diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 11:18:50 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 11:18:50 +0000 |
commit | 8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 (patch) | |
tree | a77e7fe7a93de11213032ed4ab1f33a3db51b738 /app/assets | |
parent | 00b35af3db1abfe813a778f643dad221aad51fca (diff) | |
download | gitlab-ce-8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781.tar.gz |
Add latest changes from gitlab-org/gitlab@13-1-stable-ee
Diffstat (limited to 'app/assets')
594 files changed, 16110 insertions, 4654 deletions
diff --git a/app/assets/images/aws_logo.svg b/app/assets/images/aws_logo.svg new file mode 100644 index 00000000000..e028fd1b1c0 --- /dev/null +++ b/app/assets/images/aws_logo.svg @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ viewBox="0 0 304 182" style="enable-background:new 0 0 304 182;" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#252F3E;}
+ .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FF9900;}
+</style>
+<g>
+ <path class="st0" d="M86.4,66.4c0,3.7,0.4,6.7,1.1,8.9c0.8,2.2,1.8,4.6,3.2,7.2c0.5,0.8,0.7,1.6,0.7,2.3c0,1-0.6,2-1.9,3l-6.3,4.2
+ c-0.9,0.6-1.8,0.9-2.6,0.9c-1,0-2-0.5-3-1.4C76.2,90,75,88.4,74,86.8c-1-1.7-2-3.6-3.1-5.9c-7.8,9.2-17.6,13.8-29.4,13.8
+ c-8.4,0-15.1-2.4-20-7.2c-4.9-4.8-7.4-11.2-7.4-19.2c0-8.5,3-15.4,9.1-20.6c6.1-5.2,14.2-7.8,24.5-7.8c3.4,0,6.9,0.3,10.6,0.8
+ c3.7,0.5,7.5,1.3,11.5,2.2v-7.3c0-7.6-1.6-12.9-4.7-16c-3.2-3.1-8.6-4.6-16.3-4.6c-3.5,0-7.1,0.4-10.8,1.3c-3.7,0.9-7.3,2-10.8,3.4
+ c-1.6,0.7-2.8,1.1-3.5,1.3c-0.7,0.2-1.2,0.3-1.6,0.3c-1.4,0-2.1-1-2.1-3.1v-4.9c0-1.6,0.2-2.8,0.7-3.5c0.5-0.7,1.4-1.4,2.8-2.1
+ c3.5-1.8,7.7-3.3,12.6-4.5c4.9-1.3,10.1-1.9,15.6-1.9c11.9,0,20.6,2.7,26.2,8.1c5.5,5.4,8.3,13.6,8.3,24.6V66.4z M45.8,81.6
+ c3.3,0,6.7-0.6,10.3-1.8c3.6-1.2,6.8-3.4,9.5-6.4c1.6-1.9,2.8-4,3.4-6.4c0.6-2.4,1-5.3,1-8.7v-4.2c-2.9-0.7-6-1.3-9.2-1.7
+ c-3.2-0.4-6.3-0.6-9.4-0.6c-6.7,0-11.6,1.3-14.9,4c-3.3,2.7-4.9,6.5-4.9,11.5c0,4.7,1.2,8.2,3.7,10.6
+ C37.7,80.4,41.2,81.6,45.8,81.6z M126.1,92.4c-1.8,0-3-0.3-3.8-1c-0.8-0.6-1.5-2-2.1-3.9L96.7,10.2c-0.6-2-0.9-3.3-0.9-4
+ c0-1.6,0.8-2.5,2.4-2.5h9.8c1.9,0,3.2,0.3,3.9,1c0.8,0.6,1.4,2,2,3.9l16.8,66.2l15.6-66.2c0.5-2,1.1-3.3,1.9-3.9c0.8-0.6,2.2-1,4-1
+ h8c1.9,0,3.2,0.3,4,1c0.8,0.6,1.5,2,1.9,3.9l15.8,67l17.3-67c0.6-2,1.3-3.3,2-3.9c0.8-0.6,2.1-1,3.9-1h9.3c1.6,0,2.5,0.8,2.5,2.5
+ c0,0.5-0.1,1-0.2,1.6c-0.1,0.6-0.3,1.4-0.7,2.5l-24.1,77.3c-0.6,2-1.3,3.3-2.1,3.9c-0.8,0.6-2.1,1-3.8,1h-8.6c-1.9,0-3.2-0.3-4-1
+ c-0.8-0.7-1.5-2-1.9-4L156,23l-15.4,64.4c-0.5,2-1.1,3.3-1.9,4c-0.8,0.7-2.2,1-4,1H126.1z M254.6,95.1c-5.2,0-10.4-0.6-15.4-1.8
+ c-5-1.2-8.9-2.5-11.5-4c-1.6-0.9-2.7-1.9-3.1-2.8c-0.4-0.9-0.6-1.9-0.6-2.8v-5.1c0-2.1,0.8-3.1,2.3-3.1c0.6,0,1.2,0.1,1.8,0.3
+ c0.6,0.2,1.5,0.6,2.5,1c3.4,1.5,7.1,2.7,11,3.5c4,0.8,7.9,1.2,11.9,1.2c6.3,0,11.2-1.1,14.6-3.3c3.4-2.2,5.2-5.4,5.2-9.5
+ c0-2.8-0.9-5.1-2.7-7c-1.8-1.9-5.2-3.6-10.1-5.2L246,52c-7.3-2.3-12.7-5.7-16-10.2c-3.3-4.4-5-9.3-5-14.5c0-4.2,0.9-7.9,2.7-11.1
+ c1.8-3.2,4.2-6,7.2-8.2c3-2.3,6.4-4,10.4-5.2c4-1.2,8.2-1.7,12.6-1.7c2.2,0,4.5,0.1,6.7,0.4c2.3,0.3,4.4,0.7,6.5,1.1
+ c2,0.5,3.9,1,5.7,1.6c1.8,0.6,3.2,1.2,4.2,1.8c1.4,0.8,2.4,1.6,3,2.5c0.6,0.8,0.9,1.9,0.9,3.3v4.7c0,2.1-0.8,3.2-2.3,3.2
+ c-0.8,0-2.1-0.4-3.8-1.2c-5.7-2.6-12.1-3.9-19.2-3.9c-5.7,0-10.2,0.9-13.3,2.8c-3.1,1.9-4.7,4.8-4.7,8.9c0,2.8,1,5.2,3,7.1
+ c2,1.9,5.7,3.8,11,5.5l14.2,4.5c7.2,2.3,12.4,5.5,15.5,9.6c3.1,4.1,4.6,8.8,4.6,14c0,4.3-0.9,8.2-2.6,11.6
+ c-1.8,3.4-4.2,6.4-7.3,8.8c-3.1,2.5-6.8,4.3-11.1,5.6C264.4,94.4,259.7,95.1,254.6,95.1z"/>
+ <g>
+ <path class="st1" d="M273.5,143.7c-32.9,24.3-80.7,37.2-121.8,37.2c-57.6,0-109.5-21.3-148.7-56.7c-3.1-2.8-0.3-6.6,3.4-4.4
+ c42.4,24.6,94.7,39.5,148.8,39.5c36.5,0,76.6-7.6,113.5-23.2C274.2,133.6,278.9,139.7,273.5,143.7z"/>
+ <path class="st1" d="M287.2,128.1c-4.2-5.4-27.8-2.6-38.5-1.3c-3.2,0.4-3.7-2.4-0.8-4.5c18.8-13.2,49.7-9.4,53.3-5
+ c3.6,4.5-1,35.4-18.6,50.2c-2.7,2.3-5.3,1.1-4.1-1.9C282.5,155.7,291.4,133.4,287.2,128.1z"/>
+ </g>
+</g>
+</svg>
diff --git a/app/assets/images/experienced.svg b/app/assets/images/experienced.svg new file mode 100644 index 00000000000..1c93cfcf1ee --- /dev/null +++ b/app/assets/images/experienced.svg @@ -0,0 +1 @@ +<svg height="82" viewBox="0 0 78 82" width="78" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><g fill-rule="nonzero"><path d="m2.12 42c-.08 1-.12 2-.12 3 0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3-1.53 19.03-17.46 34-36.88 34s-35.35-14.97-36.88-34z" fill="#000" fill-opacity=".03"/><path d="m39 78c-21.54 0-39-17.46-39-39s17.46-39 39-39 39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35s-15.67-35-35-35-35 15.67-35 35 15.67 35 35 35z" fill="#eee"/><path d="m44 31-2.5-3-2.5 3-2.5-3-2.5 3-2.5-3-2.5 3h-2.72c2.65-4.2 7.36-7 12.72-7s10.07 2.8 12.72 7h-2.72l-2.5-3z" fill="#e1dbf2"/><path d="m39 57c-9.4 0-17-7.6-17-17s7.6-17 17-17 17 7.6 17 17-7.6 17-17 17zm0-4c7.18 0 13-5.82 13-13s-5.82-13-13-13-13 5.82-13 13 5.82 13 13 13z" fill="#e1dbf2"/></g><g fill="#e2ba3e" stroke="#fff" transform="translate(14 18)"><path d="m8.5 12.75-4.99617464 2.6266445.95418445-5.56332227-4.0419902-3.93996668 5.58589307-.81167778 2.49808732-5.06167777 2.4980873 5.06167777 5.5858931.81167778-4.0419902 3.93996668.9541844 5.56332227z"/><path d="m24.5 12.75-4.9961746 2.6266445.9541844-5.56332227-4.0419902-3.93996668 5.5858931-.81167778 2.4980873-5.06167777 2.4980873 5.06167777 5.5858931.81167778-4.0419902 3.93996668.9541844 5.56332227z"/><path d="m40.5 12.75-4.9961746 2.6266445.9541844-5.56332227-4.0419902-3.93996668 5.5858931-.81167778 2.4980873-5.06167777 2.4980873 5.06167777 5.5858931.81167778-4.0419902 3.93996668.9541844 5.56332227z"/></g><path d="m35 45h8c0 2.2-1.8 4-4 4s-4-1.8-4-4zm-1.5-2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z" fill="#6b4fbb" fill-rule="nonzero"/></g></svg>
\ No newline at end of file diff --git a/app/assets/images/learn-gitlab-avatar.jpg b/app/assets/images/learn-gitlab-avatar.jpg Binary files differnew file mode 100644 index 00000000000..65ec29444cb --- /dev/null +++ b/app/assets/images/learn-gitlab-avatar.jpg diff --git a/app/assets/images/novice.svg b/app/assets/images/novice.svg new file mode 100644 index 00000000000..c6744fa4550 --- /dev/null +++ b/app/assets/images/novice.svg @@ -0,0 +1 @@ +<svg width="78" height="82" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M2.12 42C2.04 43 2 44 2 45c0 20.43 16.57 37 37 37s37-16.57 37-37c0-1-.04-2-.12-3C74.35 61.03 58.42 76 39 76S3.65 61.03 2.12 42z" fill-opacity=".03" fill="#000" fill-rule="nonzero"/><path 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" fill="#EEE" fill-rule="nonzero"/><path d="M44 31l-2.5-3-2.5 3-2.5-3-2.5 3-2.5-3-2.5 3h-2.72c2.65-4.2 7.36-7 12.72-7s10.07 2.8 12.72 7H49l-2.5-3-2.5 3z" fill="#E1DBF2" fill-rule="nonzero"/><path d="M39 57c-9.4 0-17-7.6-17-17s7.6-17 17-17 17 7.6 17 17-7.6 17-17 17zm0-4c7.18 0 13-5.82 13-13s-5.82-13-13-13-13 5.82-13 13 5.82 13 13 13z" fill="#E1DBF2" fill-rule="nonzero"/><g transform="translate(20 13)"><path d="M5.731 15.24V8.736H33.75v6.504c0 4.851-6.272 8.784-14.01 8.784-7.737 0-14.009-3.933-14.009-8.784z" fill="#0B2630"/><path d="M.75 7.662L18.824.182a2.4 2.4 0 0 1 1.835 0l18.072 7.48a1.2 1.2 0 0 1 0 2.218l-18.072 7.48a2.4 2.4 0 0 1-1.835 0L.75 9.88a1.2 1.2 0 0 1 0-2.218z" fill="#29424E"/><path d="M19.295 9.771a1.194 1.194 0 0 1-.66-1.557c.248-.612.948-.907 1.562-.659l11.516 4.657v11.766c0 .66-.538 1.195-1.2 1.195-.663 0-1.2-.535-1.2-1.195V13.822l-10.018-4.05z" fill="#FFCF00" fill-rule="nonzero"/><path d="M32.613 23.373v3.807h-4.2v-3.807c0-.711.353-1.34.894-1.72h2.411c.541.38.895 1.009.895 1.72z" fill="#FCA326"/><path fill="#FFCF00" d="M28.413 23.592H32.613V27.18H28.413z"/></g><path d="M35 45h8c0 2.2-1.8 4-4 4s-4-1.8-4-4zm-1.5-2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z" fill="#6B4FBB" fill-rule="nonzero"/></g></svg>
\ No newline at end of file diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue index 89db7db77d5..ed6b4b7fdb2 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -2,31 +2,32 @@ import * as Sentry from '@sentry/browser'; import { GlAlert, + GlBadge, GlIcon, GlLoadingIcon, - GlDropdown, - GlDropdownItem, GlSprintf, GlTabs, GlTab, GlButton, GlTable, } from '@gitlab/ui'; -import createFlash from '~/flash'; import { s__ } from '~/locale'; import query from '../graphql/queries/details.query.graphql'; import { fetchPolicies } from '~/lib/graphql'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { ALERTS_SEVERITY_LABELS } from '../constants'; -import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql'; +import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; +import initUserPopovers from '~/user_popovers'; +import { ALERTS_SEVERITY_LABELS, trackAlertsDetailsViewsOptions } from '../constants'; +import createIssueQuery from '../graphql/mutations/create_issue_from_alert.graphql'; +import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; +import Tracking from '~/tracking'; +import { toggleContainerClasses } from '~/lib/utils/dom_utils'; +import SystemNote from './system_notes/system_note.vue'; +import AlertSidebar from './alert_sidebar.vue'; + +const containerEl = document.querySelector('.page-with-contextual-sidebar'); export default { - statuses: { - TRIGGERED: s__('AlertManagement|Triggered'), - ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), - RESOLVED: s__('AlertManagement|Resolved'), - }, i18n: { errorMsg: s__( 'AlertManagement|There was an error displaying the alert. Please refresh the page to try again.', @@ -38,19 +39,19 @@ export default { }, severityLabels: ALERTS_SEVERITY_LABELS, components: { + GlBadge, GlAlert, GlIcon, GlLoadingIcon, GlSprintf, - GlDropdown, - GlDropdownItem, GlTab, GlTabs, GlButton, GlTable, TimeAgoTooltip, + AlertSidebar, + SystemNote, }, - mixins: [glFeatureFlagsMixin()], props: { alertId: { type: String, @@ -60,7 +61,7 @@ export default { type: String, required: true, }, - newIssuePath: { + projectIssuesPath: { type: String, required: true, }, @@ -85,7 +86,15 @@ export default { }, }, data() { - return { alert: null, errored: false, isErrorDismissed: false }; + return { + alert: null, + errored: false, + isErrorDismissed: false, + createIssueError: '', + issueCreationInProgress: false, + sidebarCollapsed: false, + sidebarErrorMessage: '', + }; }, computed: { loading() { @@ -100,38 +109,92 @@ export default { return this.errored && !this.isErrorDismissed; }, }, + mounted() { + this.trackPageViews(); + toggleContainerClasses(containerEl, { + 'issuable-bulk-update-sidebar': true, + 'right-sidebar-expanded': true, + }); + }, + updated() { + this.$nextTick(() => { + highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')); + initUserPopovers(this.$el.querySelectorAll('.js-user-link')); + }); + }, methods: { dismissError() { this.isErrorDismissed = true; + this.sidebarErrorMessage = ''; }, - updateAlertStatus(status) { + toggleSidebar() { + this.sidebarCollapsed = !this.sidebarCollapsed; + toggleContainerClasses(containerEl, { + 'right-sidebar-collapsed': this.sidebarCollapsed, + 'right-sidebar-expanded': !this.sidebarCollapsed, + }); + }, + handleAlertSidebarError(errorMessage) { + this.errored = true; + this.sidebarErrorMessage = errorMessage; + }, + createIssue() { + this.issueCreationInProgress = true; + this.$apollo .mutate({ - mutation: updateAlertStatus, + mutation: createIssueQuery, variables: { - iid: this.alertId, - status: status.toUpperCase(), + iid: this.alert.iid, projectPath: this.projectPath, }, }) - .catch(() => { - createFlash( - s__( - 'AlertManagement|There was an error while updating the status of the alert. Please try again.', - ), - ); + .then(({ data: { createAlertIssue: { errors, issue } } }) => { + if (errors?.length) { + [this.createIssueError] = errors; + this.issueCreationInProgress = false; + } else if (issue) { + visitUrl(this.issuePath(issue.iid)); + } + }) + .catch(error => { + this.createIssueError = error; + this.issueCreationInProgress = false; }); }, + issuePath(issueId) { + return joinPaths(this.projectIssuesPath, issueId); + }, + trackPageViews() { + const { category, action } = trackAlertsDetailsViewsOptions; + Tracking.event(category, action); + }, + alertRefresh() { + this.$apollo.queries.alert.refetch(); + }, }, }; </script> + <template> <div> <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError"> - {{ $options.i18n.errorMsg }} + {{ sidebarErrorMessage || $options.i18n.errorMsg }} + </gl-alert> + <gl-alert + v-if="createIssueError" + variant="danger" + data-testid="issueCreationError" + @dismiss="createIssueError = null" + > + {{ createIssueError }} </gl-alert> <div v-if="loading"><gl-loading-icon size="lg" class="gl-mt-5" /></div> - <div v-if="alert" class="alert-management-details gl-relative"> + <div + v-if="alert" + class="alert-management-details gl-relative" + :class="{ 'pr-sm-8': sidebarCollapsed }" + > <div class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid flex-column flex-sm-row" > @@ -142,32 +205,50 @@ export default { <div class="gl-display-inline-flex gl-align-items-center gl-justify-content-space-between" > - <gl-icon - class="gl-mr-3 align-middle" - :size="12" - :name="`severity-${alert.severity.toLowerCase()}`" - :class="`icon-${alert.severity.toLowerCase()}`" - /> - <strong>{{ $options.severityLabels[alert.severity] }}</strong> + <gl-badge class="gl-mr-3"> + <strong>{{ s__('AlertManagement|Alert') }}</strong> + </gl-badge> </div> - <span class="mx-2">•</span> - <gl-sprintf :message="reportedAtMessage"> - <template #when> - <time-ago-tooltip :time="alert.createdAt" class="gl-ml-3" /> - </template> - <template #tool>{{ alert.monitoringTool }}</template> - </gl-sprintf> + <span> + <gl-sprintf :message="reportedAtMessage"> + <template #when> + <time-ago-tooltip :time="alert.createdAt" /> + </template> + <template #tool>{{ alert.monitoringTool }}</template> + </gl-sprintf> + </span> </div> <gl-button - v-if="glFeatures.createIssueFromAlertEnabled" - class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-create-issue-button" + v-if="alert.issueIid" + class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-issue-button" + data-testid="viewIssueBtn" + :href="issuePath(alert.issueIid)" + category="primary" + variant="success" + > + {{ s__('AlertManagement|View issue') }} + </gl-button> + <gl-button + v-else + class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-issue-button" data-testid="createIssueBtn" - :href="newIssuePath" + :loading="issueCreationInProgress" category="primary" variant="success" + @click="createIssue()" > {{ s__('AlertManagement|Create issue') }} </gl-button> + <gl-button + :aria-label="__('Toggle sidebar')" + category="primary" + variant="default" + class="d-sm-none gl-absolute toggle-sidebar-mobile-button" + type="button" + @click="toggleSidebar" + > + <i class="fa fa-angle-double-left"></i> + </gl-button> </div> <div v-if="alert" @@ -175,44 +256,57 @@ export default { > <h2 data-testid="title">{{ alert.title }}</h2> </div> - <gl-dropdown :text="$options.statuses[alert.status]" class="gl-absolute gl-right-0" right> - <gl-dropdown-item - v-for="(label, field) in $options.statuses" - :key="field" - data-testid="statusDropdownItem" - class="gl-vertical-align-middle" - @click="updateAlertStatus(label)" - > - <span class="d-flex"> - <gl-icon - class="flex-shrink-0 append-right-4" - :class="{ invisible: label.toUpperCase() !== alert.status }" - name="mobile-issue-close" - /> - {{ label }} - </span> - </gl-dropdown-item> - </gl-dropdown> <gl-tabs v-if="alert" data-testid="alertDetailsTabs"> <gl-tab data-testid="overviewTab" :title="$options.i18n.overviewTitle"> - <ul class="pl-4 mb-n1"> - <li v-if="alert.startedAt" class="my-2"> - <strong class="bold">{{ s__('AlertManagement|Start time') }}:</strong> + <div v-if="alert.severity" class="gl-mt-3 gl-mb-5 gl-display-flex"> + <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> + {{ s__('AlertManagement|Severity') }}: + </div> + <div class="gl-pl-2" data-testid="severity"> + <span> + <gl-icon + class="gl-vertical-align-middle" + :size="12" + :name="`severity-${alert.severity.toLowerCase()}`" + :class="`icon-${alert.severity.toLowerCase()}`" + /> + </span> + {{ $options.severityLabels[alert.severity] }} + </div> + </div> + <div v-if="alert.startedAt" class="gl-my-5 gl-display-flex"> + <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> + {{ s__('AlertManagement|Start time') }}: + </div> + <div class="gl-pl-2"> <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" /> - </li> - <li v-if="alert.eventCount" class="my-2"> - <strong class="bold">{{ s__('AlertManagement|Events') }}:</strong> - <span data-testid="eventCount">{{ alert.eventCount }}</span> - </li> - <li v-if="alert.monitoringTool" class="my-2"> - <strong class="bold">{{ s__('AlertManagement|Tool') }}:</strong> - <span data-testid="monitoringTool">{{ alert.monitoringTool }}</span> - </li> - <li v-if="alert.service" class="my-2"> - <strong class="bold">{{ s__('AlertManagement|Service') }}:</strong> - <span data-testid="service">{{ alert.service }}</span> - </li> - </ul> + </div> + </div> + <div v-if="alert.eventCount" class="gl-my-5 gl-display-flex"> + <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> + {{ s__('AlertManagement|Events') }}: + </div> + <div class="gl-pl-2" data-testid="eventCount">{{ alert.eventCount }}</div> + </div> + <div v-if="alert.monitoringTool" class="gl-my-5 gl-display-flex"> + <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> + {{ s__('AlertManagement|Tool') }}: + </div> + <div class="gl-pl-2" data-testid="monitoringTool">{{ alert.monitoringTool }}</div> + </div> + <div v-if="alert.service" class="gl-my-5 gl-display-flex"> + <div class="bold gl-w-13 gl-text-right gl-pr-3"> + {{ s__('AlertManagement|Service') }}: + </div> + <div class="gl-pl-2" data-testid="service">{{ alert.service }}</div> + </div> + <template> + <div v-if="alert.notes.nodes" class="issuable-discussion py-5"> + <ul class="notes main-notes-list timeline"> + <system-note v-for="note in alert.notes.nodes" :key="note.id" :note="note" /> + </ul> + </div> + </template> </gl-tab> <gl-tab data-testid="fullDetailsTab" :title="$options.i18n.fullAlertDetailsTitle"> <gl-table @@ -231,6 +325,14 @@ export default { </gl-table> </gl-tab> </gl-tabs> + <alert-sidebar + :project-path="projectPath" + :alert="alert" + :sidebar-collapsed="sidebarCollapsed" + @alert-refresh="alertRefresh" + @toggle-sidebar="toggleSidebar" + @alert-sidebar-error="handleAlertSidebarError" + /> </div> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_list.vue index 74fc19ff3d4..37901c21f9b 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_list.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue @@ -10,23 +10,41 @@ import { GlDropdownItem, GlTabs, GlTab, + GlBadge, + GlPagination, } from '@gitlab/ui'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; +import { fetchPolicies } from '~/lib/graphql'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import getAlerts from '../graphql/queries/getAlerts.query.graphql'; -import { ALERTS_STATUS, ALERTS_STATUS_TABS, ALERTS_SEVERITY_LABELS } from '../constants'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import getAlerts from '../graphql/queries/get_alerts.query.graphql'; +import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; +import { + ALERTS_STATUS_TABS, + ALERTS_SEVERITY_LABELS, + DEFAULT_PAGE_SIZE, + trackAlertListViewsOptions, + trackAlertStatusUpdateOptions, +} from '../constants'; import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { convertToSnakeCase } from '~/lib/utils/text_utility'; +import Tracking from '~/tracking'; -const tdClass = 'table-col d-flex d-md-table-cell align-items-center'; +const tdClass = 'table-col gl-display-flex d-md-table-cell gl-align-items-center'; +const thClass = 'gl-hover-bg-blue-50'; const bodyTrClass = - 'gl-border-1 gl-border-t-solid gl-border-gray-100 hover-bg-blue-50 hover-gl-cursor-pointer hover-gl-border-b-solid hover-gl-border-blue-200'; + 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-bg-blue-50 gl-hover-cursor-pointer gl-hover-border-b-solid gl-hover-border-blue-200'; + +const initialPaginationState = { + currentPage: 1, + prevPageCursor: '', + nextPageCursor: '', + firstPageSize: DEFAULT_PAGE_SIZE, + lastPageSize: null, +}; export default { - bodyTrClass, i18n: { noAlertsMsg: s__( "AlertManagement|No alerts available to display. If you think you're seeing this message in error, refresh the page.", @@ -40,40 +58,54 @@ export default { key: 'severity', label: s__('AlertManagement|Severity'), tdClass: `${tdClass} rounded-top text-capitalize`, + thClass, + sortable: true, }, { key: 'startedAt', label: s__('AlertManagement|Start time'), + thClass: `${thClass} js-started-at`, tdClass, + sortable: true, }, { key: 'endedAt', label: s__('AlertManagement|End time'), + thClass, tdClass, + sortable: true, }, { key: 'title', label: s__('AlertManagement|Alert'), - thClass: 'w-30p', + thClass: `${thClass} w-30p gl-pointer-events-none`, tdClass, + sortable: false, }, { key: 'eventCount', label: s__('AlertManagement|Events'), - thClass: 'text-right event-count', - tdClass: `${tdClass} text-md-right event-count`, + thClass: `${thClass} text-right gl-pr-9 w-3rem`, + tdClass: `${tdClass} text-md-right`, + sortable: true, + }, + { + key: 'assignees', + label: s__('AlertManagement|Assignees'), + tdClass, }, { key: 'status', - thClass: 'w-15p', + thClass: `${thClass} w-15p`, label: s__('AlertManagement|Status'), tdClass: `${tdClass} rounded-bottom`, + sortable: true, }, ], statuses: { - [ALERTS_STATUS.TRIGGERED]: s__('AlertManagement|Triggered'), - [ALERTS_STATUS.ACKNOWLEDGED]: s__('AlertManagement|Acknowledged'), - [ALERTS_STATUS.RESOLVED]: s__('AlertManagement|Resolved'), + TRIGGERED: s__('AlertManagement|Triggered'), + ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), + RESOLVED: s__('AlertManagement|Resolved'), }, severityLabels: ALERTS_SEVERITY_LABELS, statusTabs: ALERTS_STATUS_TABS, @@ -89,8 +121,9 @@ export default { GlIcon, GlTabs, GlTab, + GlBadge, + GlPagination, }, - mixins: [glFeatureFlagsMixin()], props: { projectPath: { type: String, @@ -115,33 +148,63 @@ export default { }, apollo: { alerts: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, query: getAlerts, variables() { return { projectPath: this.projectPath, statuses: this.statusFilter, + sort: this.sort, + firstPageSize: this.pagination.firstPageSize, + lastPageSize: this.pagination.lastPageSize, + prevPageCursor: this.pagination.prevPageCursor, + nextPageCursor: this.pagination.nextPageCursor, }; }, update(data) { - return data.project.alertManagementAlerts.nodes; + const { alertManagementAlerts: { nodes: list = [], pageInfo = {} } = {} } = + data.project || {}; + + return { + list, + pageInfo, + }; }, error() { this.errored = true; }, }, + alertsCount: { + query: getAlertsCountByStatus, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + return data.project?.alertManagementAlertStatusCounts; + }, + }, }, data() { return { - alerts: null, errored: false, isAlertDismissed: false, isErrorAlertDismissed: false, - statusFilter: this.$options.statusTabs[4].filters, + sort: 'STARTED_AT_DESC', + statusFilter: [], + filteredByStatus: '', + pagination: initialPaginationState, + sortBy: 'startedAt', + sortDesc: true, + sortDirection: 'desc', }; }, computed: { showNoAlertsMsg() { - return !this.errored && !this.loading && !this.alerts?.length && !this.isAlertDismissed; + return ( + !this.errored && !this.loading && this.alertsCount?.all === 0 && !this.isAlertDismissed + ); }, showErrorMsg() { return this.errored && !this.isErrorAlertDismissed; @@ -149,12 +212,43 @@ export default { loading() { return this.$apollo.queries.alerts.loading; }, + hasAlerts() { + return this.alerts?.list?.length; + }, + tbodyTrClass() { + return !this.loading && this.hasAlerts ? bodyTrClass : ''; + }, + showPaginationControls() { + return Boolean(this.prevPage || this.nextPage); + }, + alertsForCurrentTab() { + return this.alertsCount ? this.alertsCount[this.filteredByStatus.toLowerCase()] : 0; + }, + prevPage() { + return Math.max(this.pagination.currentPage - 1, 0); + }, + nextPage() { + const nextPage = this.pagination.currentPage + 1; + return nextPage > Math.ceil(this.alertsForCurrentTab / DEFAULT_PAGE_SIZE) ? null : nextPage; + }, + }, + mounted() { + this.trackPageViews(); }, methods: { filterAlertsByStatus(tabIndex) { - this.statusFilter = this.$options.statusTabs[tabIndex].filters; + this.resetPagination(); + const { filters, status } = this.$options.statusTabs[tabIndex]; + this.statusFilter = filters; + this.filteredByStatus = status; + }, + fetchSortedData({ sortBy, sortDesc }) { + const sortingDirection = sortDesc ? 'DESC' : 'ASC'; + const sortingColumn = convertToSnakeCase(sortBy).toUpperCase(); + + this.resetPagination(); + this.sort = `${sortingColumn}_${sortingDirection}`; }, - capitalizeFirstCharacter, updateAlertStatus(status, iid) { this.$apollo .mutate({ @@ -166,7 +260,10 @@ export default { }, }) .then(() => { + this.trackStatusUpdate(status); this.$apollo.queries.alerts.refetch(); + this.$apollo.queries.alertsCount.refetch(); + this.resetPagination(); }) .catch(() => { createFlash( @@ -179,6 +276,42 @@ export default { navigateToAlertDetails({ iid }) { return visitUrl(joinPaths(window.location.pathname, iid, 'details')); }, + trackPageViews() { + const { category, action } = trackAlertListViewsOptions; + Tracking.event(category, action); + }, + trackStatusUpdate(status) { + const { category, action, label } = trackAlertStatusUpdateOptions; + Tracking.event(category, action, { label, property: status }); + }, + getAssignees(assignees) { + // TODO: Update to show list of assignee(s) after https://gitlab.com/gitlab-org/gitlab/-/issues/218405 + return assignees.nodes?.length > 0 + ? assignees.nodes[0]?.username + : s__('AlertManagement|Unassigned'); + }, + handlePageChange(page) { + const { startCursor, endCursor } = this.alerts.pageInfo; + + if (page > this.pagination.currentPage) { + this.pagination = { + ...initialPaginationState, + nextPageCursor: endCursor, + currentPage: page, + }; + } else { + this.pagination = { + lastPageSize: DEFAULT_PAGE_SIZE, + firstPageSize: null, + prevPageCursor: startCursor, + nextPageCursor: '', + currentPage: page, + }; + } + }, + resetPagination() { + this.pagination = initialPaginationState; + }, }, }; </script> @@ -192,10 +325,13 @@ export default { {{ $options.i18n.errorMsg }} </gl-alert> - <gl-tabs v-if="glFeatures.alertListStatusFilteringEnabled" @input="filterAlertsByStatus"> + <gl-tabs @input="filterAlertsByStatus"> <gl-tab v-for="tab in $options.statusTabs" :key="tab.status"> <template slot="title"> <span>{{ tab.title }}</span> + <gl-badge v-if="alertsCount" pill size="sm" class="gl-tab-counter-badge"> + {{ alertsCount[tab.status.toLowerCase()] }} + </gl-badge> </template> </gl-tab> </gl-tabs> @@ -205,13 +341,19 @@ export default { </h4> <gl-table class="alert-management-table mt-3" - :items="alerts" + :items="alerts ? alerts.list : []" :fields="$options.fields" :show-empty="true" :busy="loading" stacked="md" - :tbody-tr-class="$options.bodyTrClass" + :tbody-tr-class="tbodyTrClass" + :no-local-sorting="true" + :sort-direction="sortDirection" + :sort-desc.sync="sortDesc" + :sort-by.sync="sortBy" + sort-icon-left @row-clicked="navigateToAlertDetails" + @sort-changed="fetchSortedData" > <template #cell(severity)="{ item }"> <div @@ -236,16 +378,22 @@ export default { <time-ago v-if="item.endedAt" :time="item.endedAt" /> </template> + <template #cell(eventCount)="{ item }"> + {{ item.eventCount }} + </template> + <template #cell(title)="{ item }"> <div class="gl-max-w-full text-truncate">{{ item.title }}</div> </template> + <template #cell(assignees)="{ item }"> + <div class="gl-max-w-full text-truncate" data-testid="assigneesField"> + {{ getAssignees(item.assignees) }} + </div> + </template> + <template #cell(status)="{ item }"> - <gl-dropdown - :text="capitalizeFirstCharacter(item.status.toLowerCase())" - class="w-100" - right - > + <gl-dropdown :text="$options.statuses[item.status]" class="w-100" right> <gl-dropdown-item v-for="(label, field) in $options.statuses" :key="field" @@ -271,6 +419,16 @@ export default { <gl-loading-icon size="lg" color="dark" class="mt-3" /> </template> </gl-table> + + <gl-pagination + v-if="showPaginationControls" + :value="pagination.currentPage" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-pagination prepend-top-default" + @input="handlePageChange" + /> </div> <gl-empty-state v-else diff --git a/app/assets/javascripts/alert_management/components/alert_sidebar.vue b/app/assets/javascripts/alert_management/components/alert_sidebar.vue new file mode 100644 index 00000000000..dcd22e2062e --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_sidebar.vue @@ -0,0 +1,61 @@ +<script> +import SidebarHeader from './sidebar/sidebar_header.vue'; +import SidebarTodo from './sidebar/sidebar_todo.vue'; +import SidebarStatus from './sidebar/sidebar_status.vue'; +import SidebarAssignees from './sidebar/sidebar_assignees.vue'; + +export default { + components: { + SidebarAssignees, + SidebarHeader, + SidebarTodo, + SidebarStatus, + }, + props: { + sidebarCollapsed: { + type: Boolean, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + alert: { + type: Object, + required: true, + }, + }, + computed: { + sidebarCollapsedClass() { + return this.sidebarCollapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded'; + }, + }, +}; +</script> + +<template> + <aside :class="sidebarCollapsedClass" class="right-sidebar alert-sidebar"> + <div class="issuable-sidebar js-issuable-update"> + <sidebar-header + :sidebar-collapsed="sidebarCollapsed" + @toggle-sidebar="$emit('toggle-sidebar')" + /> + <sidebar-todo v-if="sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" /> + <sidebar-status + :project-path="projectPath" + :alert="alert" + @toggle-sidebar="$emit('toggle-sidebar')" + @alert-sidebar-error="$emit('alert-sidebar-error', $event)" + /> + <sidebar-assignees + :project-path="projectPath" + :alert="alert" + :sidebar-collapsed="sidebarCollapsed" + @alert-refresh="$emit('alert-refresh')" + @toggle-sidebar="$emit('toggle-sidebar')" + @alert-sidebar-error="$emit('alert-sidebar-error', $event)" + /> + <div class="block"></div> + </div> + </aside> +</template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue new file mode 100644 index 00000000000..df07038151e --- /dev/null +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue @@ -0,0 +1,51 @@ +<script> +import { GlDropdownItem } from '@gitlab/ui'; + +export default { + components: { + GlDropdownItem, + }, + props: { + user: { + type: Object, + required: true, + }, + active: { + type: Boolean, + required: true, + }, + }, + methods: { + isActive(name) { + return this.alert.assignees.nodes.some(({ username }) => username === name); + }, + }, +}; +</script> + +<template> + <gl-dropdown-item + :key="user.username" + data-testid="assigneeDropdownItem" + class="assignee-dropdown-item gl-vertical-align-middle" + :active="active" + active-class="is-active" + @click="$emit('update-alert-assignees', user.username)" + > + <span class="gl-relative mr-2"> + <img + :alt="user.username" + :src="user.avatar_url" + :width="32" + class="avatar avatar-inline gl-m-0 s32" + data-qa-selector="avatar_image" + /> + </span> + <span class="d-flex gl-flex-direction-column gl-overflow-hidden"> + <strong class="dropdown-menu-user-full-name"> + {{ user.name }} + </strong> + <span class="dropdown-menu-user-username"> {{ user.username }}</span> + </span> + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue new file mode 100644 index 00000000000..453a3901665 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue @@ -0,0 +1,278 @@ +<script> +import { + GlIcon, + GlDropdown, + GlDropdownDivider, + GlDropdownHeader, + GlDropdownItem, + GlLoadingIcon, + GlTooltip, + GlButton, + GlSprintf, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; +import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.graphql'; +import SidebarAssignee from './sidebar_assignee.vue'; +import { debounce } from 'lodash'; + +const DATA_REFETCH_DELAY = 250; + +export default { + FETCH_USERS_ERROR: s__( + 'AlertManagement|There was an error while updating the assignee(s) 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.', + ), + components: { + GlIcon, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlDropdownHeader, + GlLoadingIcon, + GlTooltip, + GlButton, + GlSprintf, + SidebarAssignee, + }, + props: { + projectPath: { + type: String, + required: true, + }, + alert: { + type: Object, + required: true, + }, + isEditable: { + type: Boolean, + required: false, + default: true, + }, + sidebarCollapsed: { + type: Boolean, + required: false, + }, + }, + data() { + return { + isDropdownShowing: false, + isDropdownSearching: false, + isUpdating: false, + search: '', + users: [], + }; + }, + computed: { + currentUser() { + return gon?.current_username; + }, + userName() { + return this.alert?.assignees?.nodes[0]?.username; + }, + assignedUser() { + return this.userName || s__('AlertManagement|None'); + }, + sortedUsers() { + return this.users + .map(user => ({ ...user, active: this.isActive(user.username) })) + .sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1)); // eslint-disable-line no-nested-ternary + }, + dropdownClass() { + return this.isDropdownShowing ? 'show' : 'gl-display-none'; + }, + userListValid() { + return !this.isDropdownSearching && this.users.length > 0; + }, + userListEmpty() { + return !this.isDropdownSearching && this.users.length === 0; + }, + }, + watch: { + search: debounce(function debouncedUserSearch() { + this.updateAssigneesDropdown(); + }, DATA_REFETCH_DELAY), + }, + mounted() { + this.updateAssigneesDropdown(); + }, + methods: { + hideDropdown() { + this.isDropdownShowing = false; + }, + toggleFormDropdown() { + this.isDropdownShowing = !this.isDropdownShowing; + const { dropdown } = this.$refs.dropdown.$refs; + if (dropdown && this.isDropdownShowing) { + dropdown.show(); + } + }, + isActive(name) { + return this.alert.assignees.nodes.some(({ username }) => username === name); + }, + buildUrl(urlRoot, url) { + let newUrl; + if (urlRoot != null) { + newUrl = urlRoot.replace(/\/$/, '') + url; + } + return newUrl; + }, + updateAssigneesDropdown() { + this.isDropdownSearching = true; + return axios + .get(this.buildUrl(gon.relative_url_root, '/autocomplete/users.json'), { + params: { + search: this.search, + per_page: 20, + active: true, + current_user: true, + project_id: gon?.current_project_id, + }, + }) + .then(({ data }) => { + this.users = data; + }) + .catch(() => { + this.$emit('alert-sidebar-error', this.$options.FETCH_USERS_ERROR); + }) + .finally(() => { + this.isDropdownSearching = false; + }); + }, + updateAlertAssignees(assignees) { + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: alertSetAssignees, + variables: { + iid: this.alert.iid, + assigneeUsernames: [this.isActive(assignees) ? '' : assignees], + projectPath: this.projectPath, + }, + }) + .then(() => { + this.hideDropdown(); + this.$emit('alert-refresh'); + }) + .catch(() => { + this.$emit('alert-sidebar-error', this.$options.UPDATE_ALERT_ASSIGNEES_ERROR); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + }, +}; +</script> + +<template> + <div class="block alert-status"> + <div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')"> + <gl-icon name="user" :size="14" /> + <gl-loading-icon v-if="isUpdating" /> + </div> + <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left"> + <gl-sprintf :message="s__('AlertManagement|Alert assignee(s): %{assignees}')"> + <template #assignees> + {{ assignedUser }} + </template> + </gl-sprintf> + </gl-tooltip> + + <div class="hide-collapsed"> + <p class="title gl-display-flex gl-justify-content-space-between"> + {{ s__('AlertManagement|Assignee') }} + <a + v-if="isEditable" + ref="editButton" + class="btn-link" + href="#" + @click="toggleFormDropdown" + @keydown.esc="hideDropdown" + > + {{ s__('AlertManagement|Edit') }} + </a> + </p> + + <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> + <gl-dropdown + ref="dropdown" + :text="assignedUser" + class="w-100" + toggle-class="dropdown-menu-toggle" + variant="outline-default" + @keydown.esc.native="hideDropdown" + @hide="hideDropdown" + > + <div class="dropdown-title"> + <span class="alert-title">{{ s__('AlertManagement|Assign To') }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + class="dropdown-title-button dropdown-menu-close" + icon="close" + @click="hideDropdown" + /> + </div> + <div class="dropdown-input"> + <input + v-model.trim="search" + class="dropdown-input-field" + type="search" + :placeholder="__('Search users')" + /> + <gl-icon name="search" class="dropdown-input-search ic-search" data-hidden="true" /> + </div> + <div class="dropdown-content dropdown-body"> + <template v-if="userListValid"> + <gl-dropdown-item + :active="!userName" + active-class="is-active" + @click="updateAlertAssignees('')" + > + {{ s__('AlertManagement|Unassigned') }} + </gl-dropdown-item> + <gl-dropdown-divider /> + + <gl-dropdown-header class="mt-0"> + {{ s__('AlertManagement|Assignee') }} + </gl-dropdown-header> + <sidebar-assignee + v-for="user in sortedUsers" + :key="user.username" + :user="user" + :active="user.active" + @update-alert-assignees="updateAlertAssignees" + /> + </template> + <gl-dropdown-item v-else-if="userListEmpty"> + {{ s__('AlertManagement|No Matching Results') }} + </gl-dropdown-item> + <gl-loading-icon v-else /> + </div> + </gl-dropdown> + </div> + + <gl-loading-icon v-if="isUpdating" :inline="true" /> + <p v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }"> + <span v-if="userName" class="gl-text-gray-700" data-testid="assigned-users">{{ + assignedUser + }}</span> + <span v-else class="gl-display-flex gl-align-items-center"> + {{ s__('AlertManagement|None -') }} + <gl-button + class="gl-pl-2" + href="#" + variant="link" + data-testid="unassigned-users" + @click="updateAlertAssignees(currentUser)" + > + {{ s__('AlertManagement| assign yourself') }} + </gl-button> + </span> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue new file mode 100644 index 00000000000..047793d8cee --- /dev/null +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue @@ -0,0 +1,34 @@ +<script> +import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue'; +import SidebarTodo from './sidebar_todo.vue'; + +export default { + components: { + ToggleSidebar, + SidebarTodo, + }, + props: { + sidebarCollapsed: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <div class="block d-flex justify-content-between"> + <span class="issuable-header-text hide-collapsed"> + {{ __('Quick actions') }} + </span> + <toggle-sidebar + :collapsed="sidebarCollapsed" + css-classes="ml-auto" + @toggle="$emit('toggle-sidebar')" + /> + <!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 --> + <template v-if="false"> + <sidebar-todo v-if="!sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue new file mode 100644 index 00000000000..89dbbedd9c1 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue @@ -0,0 +1,189 @@ +<script> +import { + GlIcon, + GlDropdown, + GlDropdownItem, + GlLoadingIcon, + GlTooltip, + GlButton, + GlSprintf, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { trackAlertStatusUpdateOptions } from '../../constants'; +import updateAlertStatus from '../../graphql/mutations/update_alert_status.graphql'; + +export default { + statuses: { + TRIGGERED: s__('AlertManagement|Triggered'), + ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), + RESOLVED: s__('AlertManagement|Resolved'), + }, + components: { + GlIcon, + GlDropdown, + GlDropdownItem, + GlLoadingIcon, + GlTooltip, + GlButton, + GlSprintf, + }, + props: { + projectPath: { + type: String, + required: true, + }, + alert: { + type: Object, + required: true, + }, + isEditable: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + isDropdownShowing: false, + isUpdating: false, + }; + }, + computed: { + dropdownClass() { + return this.isDropdownShowing ? 'show' : 'gl-display-none'; + }, + }, + methods: { + hideDropdown() { + this.isDropdownShowing = false; + }, + toggleFormDropdown() { + this.isDropdownShowing = !this.isDropdownShowing; + const { dropdown } = this.$refs.dropdown.$refs; + if (dropdown && this.isDropdownShowing) { + dropdown.show(); + } + }, + isSelected(status) { + return this.alert.status === status; + }, + updateAlertStatus(status) { + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: updateAlertStatus, + variables: { + iid: this.alert.iid, + status: status.toUpperCase(), + projectPath: this.projectPath, + }, + }) + .then(() => { + this.trackStatusUpdate(status); + this.hideDropdown(); + }) + .catch(() => { + this.$emit( + 'alert-sidebar-error', + s__( + 'AlertManagement|There was an error while updating the status of the alert. Please try again.', + ), + ); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + trackStatusUpdate(status) { + const { category, action, label } = trackAlertStatusUpdateOptions; + Tracking.event(category, action, { label, property: status }); + }, + }, +}; +</script> + +<template> + <div class="block alert-status"> + <div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')"> + <gl-icon name="status" :size="14" /> + <gl-loading-icon v-if="isUpdating" /> + </div> + <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left"> + <gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')"> + <template #status> + {{ alert.status.toLowerCase() }} + </template> + </gl-sprintf> + </gl-tooltip> + + <div class="hide-collapsed"> + <p class="title gl-display-flex justify-content-between"> + {{ s__('AlertManagement|Status') }} + <a + v-if="isEditable" + ref="editButton" + class="btn-link" + href="#" + @click="toggleFormDropdown" + @keydown.esc="hideDropdown" + > + {{ s__('AlertManagement|Edit') }} + </a> + </p> + + <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> + <gl-dropdown + ref="dropdown" + :text="$options.statuses[alert.status]" + class="w-100" + toggle-class="dropdown-menu-toggle" + variant="outline-default" + @keydown.esc.native="hideDropdown" + @hide="hideDropdown" + > + <div class="dropdown-title"> + <span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + class="dropdown-title-button dropdown-menu-close" + icon="close" + @click="hideDropdown" + /> + </div> + <div class="dropdown-content dropdown-body"> + <gl-dropdown-item + v-for="(label, field) in $options.statuses" + :key="field" + data-testid="statusDropdownItem" + class="gl-vertical-align-middle" + :active="label.toUpperCase() === alert.status" + :active-class="'is-active'" + @click="updateAlertStatus(label)" + > + {{ label }} + </gl-dropdown-item> + </div> + </gl-dropdown> + </div> + + <gl-loading-icon v-if="isUpdating" :inline="true" /> + <p + v-else-if="!isDropdownShowing" + class="value gl-m-0" + :class="{ 'no-value': !$options.statuses[alert.status] }" + > + <span + v-if="$options.statuses[alert.status]" + class="gl-text-gray-700" + data-testid="status" + >{{ $options.statuses[alert.status] }}</span + > + <span v-else> + {{ s__('AlertManagement|None') }} + </span> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue new file mode 100644 index 00000000000..87090165f82 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue @@ -0,0 +1,29 @@ +<script> +import Todo from '~/sidebar/components/todo_toggle/todo.vue'; + +export default { + components: { + Todo, + }, + props: { + sidebarCollapsed: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 --> +<template> + <div v-if="false" :class="{ 'block todo': sidebarCollapsed }"> + <todo + :collapsed="sidebarCollapsed" + :issuable-id="1" + :is-todo="false" + :is-action-active="false" + issuable-type="alert" + @toggleTodo="() => {}" + /> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue new file mode 100644 index 00000000000..9042d51aecf --- /dev/null +++ b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue @@ -0,0 +1,46 @@ +<script> +import NoteHeader from '~/notes/components/note_header.vue'; +import { spriteIcon } from '~/lib/utils/common_utils'; + +export default { + components: { + NoteHeader, + }, + props: { + note: { + type: Object, + required: true, + }, + }, + computed: { + noteAnchorId() { + return `note_${this.note?.id?.split('/').pop()}`; + }, + noteAuthor() { + const { + author, + author: { id }, + } = this.note; + return { ...author, id: id?.split('/').pop() }; + }, + iconHtml() { + return spriteIcon('user'); + }, + }, +}; +</script> + +<template> + <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper"> + <div class="timeline-entry-inner"> + <div class="timeline-icon" v-html="iconHtml"></div> + <div class="timeline-content"> + <div class="note-header"> + <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id"> + <span v-html="note.bodyHtml"></span> + </note-header> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js index 9df01d9d0b5..b9670466c0f 100644 --- a/app/assets/javascripts/alert_management/constants.js +++ b/app/assets/javascripts/alert_management/constants.js @@ -9,38 +9,59 @@ export const ALERTS_SEVERITY_LABELS = { UNKNOWN: s__('AlertManagement|Unknown'), }; -export const ALERTS_STATUS = { - OPEN: 'OPEN', - TRIGGERED: 'TRIGGERED', - ACKNOWLEDGED: 'ACKNOWLEDGED', - RESOLVED: 'RESOLVED', - ALL: 'ALL', -}; - export const ALERTS_STATUS_TABS = [ { title: s__('AlertManagement|Open'), - status: ALERTS_STATUS.OPEN, - filters: [ALERTS_STATUS.TRIGGERED, ALERTS_STATUS.ACKNOWLEDGED], + status: 'OPEN', + filters: ['TRIGGERED', 'ACKNOWLEDGED'], }, { title: s__('AlertManagement|Triggered'), - status: ALERTS_STATUS.TRIGGERED, - filters: [ALERTS_STATUS.TRIGGERED], + status: 'TRIGGERED', + filters: 'TRIGGERED', }, { title: s__('AlertManagement|Acknowledged'), - status: ALERTS_STATUS.ACKNOWLEDGED, - filters: [ALERTS_STATUS.ACKNOWLEDGED], + status: 'ACKNOWLEDGED', + filters: 'ACKNOWLEDGED', }, { title: s__('AlertManagement|Resolved'), - status: ALERTS_STATUS.RESOLVED, - filters: [ALERTS_STATUS.RESOLVED], + status: 'RESOLVED', + filters: 'RESOLVED', }, { title: s__('AlertManagement|All alerts'), - status: ALERTS_STATUS.ALL, - filters: [ALERTS_STATUS.TRIGGERED, ALERTS_STATUS.ACKNOWLEDGED, ALERTS_STATUS.RESOLVED], + status: 'ALL', + filters: ['TRIGGERED', 'ACKNOWLEDGED', 'RESOLVED'], }, ]; + +/* eslint-disable @gitlab/require-i18n-strings */ + +/** + * Tracks snowplow event when user views alerts list + */ +export const trackAlertListViewsOptions = { + category: 'Alert Management', + action: 'view_alerts_list', +}; + +/** + * Tracks snowplow event when user views alert details + */ +export const trackAlertsDetailsViewsOptions = { + category: 'Alert Management', + action: 'view_alert_details', +}; + +/** + * Tracks snowplow event when alert status is updated + */ +export const trackAlertStatusUpdateOptions = { + category: 'Alert Management', + action: 'update_alert_status', + label: 'Status', +}; + +export const DEFAULT_PAGE_SIZE = 10; diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js index d3523e0a29d..aa8a839ea3f 100644 --- a/app/assets/javascripts/alert_management/details.js +++ b/app/assets/javascripts/alert_management/details.js @@ -8,7 +8,7 @@ Vue.use(VueApollo); export default selector => { const domEl = document.querySelector(selector); - const { alertId, projectPath, newIssuePath } = domEl.dataset; + const { alertId, projectPath, projectIssuesPath } = domEl.dataset; const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( @@ -39,7 +39,7 @@ export default selector => { props: { alertId, projectPath, - newIssuePath, + projectIssuesPath, }, }); }, diff --git a/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql new file mode 100644 index 00000000000..c72300e9757 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/fragments/alert_note.fragment.graphql @@ -0,0 +1,16 @@ +#import "~/graphql_shared/fragments/author.fragment.graphql" + +fragment AlertNote on Note { + id + author { + id + state + ...Author + } + body + bodyHtml + createdAt + discussion { + id + } +} diff --git a/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql index df802616e97..cbe7e169be3 100644 --- a/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql +++ b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql @@ -1,4 +1,5 @@ -#import "./listItem.fragment.graphql" +#import "./list_item.fragment.graphql" +#import "./alert_note.fragment.graphql" fragment AlertDetailItem on AlertManagementAlert { ...AlertListItem @@ -8,4 +9,9 @@ fragment AlertDetailItem on AlertManagementAlert { description updatedAt details + notes { + nodes { + ...AlertNote + } + } } diff --git a/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql index fffe07b0cfd..746c4435f38 100644 --- a/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql +++ b/app/assets/javascripts/alert_management/graphql/fragments/list_item.fragment.graphql @@ -6,4 +6,10 @@ fragment AlertListItem on AlertManagementAlert { startedAt endedAt eventCount + issueIid + assignees { + nodes { + username + } + } } diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql b/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql new file mode 100644 index 00000000000..efeaf8fa372 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/alert_set_assignees.graphql @@ -0,0 +1,15 @@ +mutation($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) { + alertSetAssignees( + input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath } + ) { + errors + alert { + iid + assignees { + nodes { + username + } + } + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql new file mode 100644 index 00000000000..664596ab88f --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/create_issue_from_alert.graphql @@ -0,0 +1,8 @@ +mutation ($projectPath: ID!, $iid: String!) { + createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) { + errors + issue { + iid + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql index 009ae0b2930..09151f233f5 100644 --- a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql +++ b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql @@ -4,6 +4,7 @@ mutation ($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) { alert { iid, status, + endedAt } } } diff --git a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql index 7c77715fad2..c02b8accdd1 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql @@ -1,4 +1,4 @@ -#import "../fragments/detailItem.fragment.graphql" +#import "../fragments/detail_item.fragment.graphql" query alertDetails($fullPath: ID!, $alertId: String) { project(fullPath: $fullPath) { diff --git a/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql deleted file mode 100644 index 54b66389d5b..00000000000 --- a/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql +++ /dev/null @@ -1,11 +0,0 @@ -#import "../fragments/listItem.fragment.graphql" - -query getAlerts($projectPath: ID!, $statuses: [AlertManagementStatus!]) { - project(fullPath: $projectPath) { - alertManagementAlerts(statuses: $statuses) { - nodes { - ...AlertListItem - } - } - } -} diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql new file mode 100644 index 00000000000..1d3c3c83cc1 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql @@ -0,0 +1,32 @@ +#import "../fragments/list_item.fragment.graphql" + +query getAlerts( + $projectPath: ID!, + $statuses: [AlertManagementStatus!], + $sort: AlertManagementAlertSort, + $firstPageSize: Int, + $lastPageSize: Int, + $prevPageCursor: String = "" + $nextPageCursor: String = "" +) { + project(fullPath: $projectPath, ) { + alertManagementAlerts( + statuses: $statuses, + sort: $sort, + first: $firstPageSize + last: $lastPageSize, + after: $nextPageCursor, + before: $prevPageCursor + ) { + nodes { + ...AlertListItem + }, + pageInfo { + hasNextPage + endCursor + hasPreviousPage + startCursor + } + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql new file mode 100644 index 00000000000..1143050200c --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql @@ -0,0 +1,11 @@ +query getAlertsCount($projectPath: ID!) { + project(fullPath: $projectPath) { + alertManagementAlertStatusCounts { + all + open + acknowledged + resolved + triggered + } + } +} diff --git a/app/assets/javascripts/alert_management/services/index.js b/app/assets/javascripts/alert_management/services/index.js deleted file mode 100644 index 787603d3e7a..00000000000 --- a/app/assets/javascripts/alert_management/services/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -export default { - getAlertManagementList({ endpoint }) { - return axios.get(endpoint); - }, -}; diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue index 410c5c00e8a..ac30b086875 100644 --- a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue +++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue @@ -1,26 +1,37 @@ <script> import { - GlDeprecatedButton, + GlButton, GlFormGroup, GlFormInput, + GlLink, GlModal, GlModalDirective, + GlSprintf, } from '@gitlab/ui'; -import { escape } from 'lodash'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue'; import axios from '~/lib/utils/axios_utils'; -import { s__, __, sprintf } from '~/locale'; +import { s__, __ } from '~/locale'; import createFlash from '~/flash'; export default { + i18n: { + usageSection: s__( + 'AlertService|You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.', + ), + setupSection: s__( + "AlertService|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.", + ), + }, COPY_TO_CLIPBOARD: __('Copy'), RESET_KEY: __('Reset key'), components: { - GlDeprecatedButton, + GlButton, GlFormGroup, GlFormInput, + GlLink, GlModal, + GlSprintf, ClipboardButton, ToggleButton, }, @@ -28,6 +39,14 @@ export default { 'gl-modal': GlModalDirective, }, props: { + alertsSetupUrl: { + type: String, + required: true, + }, + alertsUsageUrl: { + type: String, + required: true, + }, initialAuthorizationKey: { type: String, required: false, @@ -41,11 +60,6 @@ export default { type: String, required: true, }, - learnMoreUrl: { - type: String, - required: false, - default: '', - }, initialActivated: { type: Boolean, required: true, @@ -59,27 +73,17 @@ export default { }; }, computed: { - learnMoreDescription() { - return sprintf( - s__( - 'AlertService|%{linkStart}Learn more%{linkEnd} about configuring this endpoint to receive alerts.', - ), + sections() { + return [ { - linkStart: `<a href="${escape( - this.learnMoreUrl, - )}" target="_blank" rel="noopener noreferrer">`, - linkEnd: '</a>', + text: this.$options.i18n.usageSection, + url: this.alertsUsageUrl, }, - false, - ); - }, - sectionDescription() { - const desc = s__( - 'AlertService|Each alert source must be authorized using the following URL and authorization key.', - ); - const learnMoreDesc = this.learnMoreDescription ? ` ${this.learnMoreDescription}` : ''; - - return `${desc}${learnMoreDesc}`; + { + text: this.$options.i18n.setupSection, + url: this.alertsSetupUrl, + }, + ]; }, }, watch: { @@ -126,7 +130,15 @@ export default { <template> <div> - <p v-html="sectionDescription"></p> + <div data-testid="description"> + <p v-for="section in sections" :key="section.text"> + <gl-sprintf :message="section.text"> + <template #link="{ content }"> + <gl-link :href="section.url" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> <gl-form-group :label="__('Active')" label-for="activated" label-class="label-bold"> <toggle-button id="activated" @@ -155,9 +167,7 @@ export default { <clipboard-button :text="authorizationKey" :title="$options.COPY_TO_CLIPBOARD" /> </span> </div> - <gl-deprecated-button v-gl-modal.authKeyModal class="mt-2">{{ - $options.RESET_KEY - }}</gl-deprecated-button> + <gl-button v-gl-modal.authKeyModal class="mt-2">{{ $options.RESET_KEY }}</gl-button> <gl-modal modal-id="authKeyModal" :title="$options.RESET_KEY" diff --git a/app/assets/javascripts/alerts_service_settings/index.js b/app/assets/javascripts/alerts_service_settings/index.js index d49725c6a4d..c26adf24a7f 100644 --- a/app/assets/javascripts/alerts_service_settings/index.js +++ b/app/assets/javascripts/alerts_service_settings/index.js @@ -7,7 +7,14 @@ export default el => { return null; } - const { activated: activatedStr, formPath, authorizationKey, url, learnMoreUrl } = el.dataset; + const { + activated: activatedStr, + alertsSetupUrl, + alertsUsageUrl, + formPath, + authorizationKey, + url, + } = el.dataset; const activated = parseBoolean(activatedStr); return new Vue({ @@ -15,9 +22,10 @@ export default el => { render(createElement) { return createElement(AlertsServiceForm, { props: { + alertsSetupUrl, + alertsUsageUrl, initialActivated: activated, formPath, - learnMoreUrl, initialAuthorizationKey: authorizationKey, url, }, diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index e527659a939..94d155840ea 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -38,6 +38,7 @@ const Api = { userPostStatusPath: '/api/:version/user/status', commitPath: '/api/:version/projects/:id/repository/commits', applySuggestionPath: '/api/:version/suggestions/:id/apply', + applySuggestionBatchPath: '/api/:version/suggestions/batch_apply', commitPipelinesPath: '/:project_id/commit/:sha/pipelines', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', createBranchPath: '/api/:version/projects/:id/repository/branches', @@ -51,8 +52,10 @@ const Api = { pipelinesPath: '/api/:version/projects/:id/pipelines/', environmentsPath: '/api/:version/projects/:id/environments', rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw', + issuePath: '/api/:version/projects/:id/issues/:issue_iid', + tagsPath: '/api/:version/projects/:id/repository/tags', - group(groupId, callback) { + group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); return axios.get(url).then(({ data }) => { callback(data); @@ -321,6 +324,12 @@ const Api = { return axios.put(url); }, + applySuggestionBatch(ids) { + const url = Api.buildUrl(Api.applySuggestionBatchPath); + + return axios.put(url, { ids }); + }, + commitPipelines(projectId, sha) { const encodedProjectId = projectId .split('/') @@ -540,6 +549,34 @@ const Api = { return axios.get(url, { params }); }, + updateIssue(project, issue, data = {}) { + const url = Api.buildUrl(Api.issuePath) + .replace(':id', encodeURIComponent(project)) + .replace(':issue_iid', encodeURIComponent(issue)); + + return axios.put(url, data); + }, + + updateMergeRequest(project, mergeRequest, data = {}) { + const url = Api.buildUrl(Api.projectMergeRequestPath) + .replace(':id', encodeURIComponent(project)) + .replace(':mrid', encodeURIComponent(mergeRequest)); + + return axios.put(url, data); + }, + + tags(id, query = '', options = {}) { + const url = Api.buildUrl(this.tagsPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url, { + params: { + search: query, + per_page: DEFAULT_PER_PAGE, + ...options, + }, + }); + }, + buildUrl(url) { return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, diff --git a/app/assets/javascripts/authentication/mount_2fa.js b/app/assets/javascripts/authentication/mount_2fa.js new file mode 100644 index 00000000000..9917151ac81 --- /dev/null +++ b/app/assets/javascripts/authentication/mount_2fa.js @@ -0,0 +1,14 @@ +import $ from 'jquery'; +import initU2F from './u2f'; +import U2FRegister from './u2f/register'; + +export const mount2faAuthentication = () => { + // Soon this will conditionally mount a webauthn app (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26692) + initU2F(); +}; + +export const mount2faRegistration = () => { + // Soon this will conditionally mount a webauthn app (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26692) + const u2fRegister = new U2FRegister($('#js-register-u2f'), gon.u2f); + u2fRegister.start(); +}; diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/authentication/u2f/authenticate.js index 6244df1180e..201cd5c2e61 100644 --- a/app/assets/javascripts/u2f/authenticate.js +++ b/app/assets/javascripts/authentication/u2f/authenticate.js @@ -40,10 +40,10 @@ export default class U2FAuthenticate { this.signRequests = u2fParams.sign_requests.map(request => omit(request, 'challenge')); this.templates = { - setup: '#js-authenticate-u2f-setup', - inProgress: '#js-authenticate-u2f-in-progress', - error: '#js-authenticate-u2f-error', - authenticated: '#js-authenticate-u2f-authenticated', + setup: '#js-authenticate-token-2fa-setup', + inProgress: '#js-authenticate-token-2fa-in-progress', + error: '#js-authenticate-token-2fa-error', + authenticated: '#js-authenticate-token-2fa-authenticated', }; } @@ -88,7 +88,7 @@ export default class U2FAuthenticate { error_message: error.message(), error_code: error.errorCode, }); - return this.container.find('#js-u2f-try-again').on('click', this.renderInProgress); + return this.container.find('#js-token-2fa-try-again').on('click', this.renderInProgress); } renderAuthenticated(deviceResponse) { diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/authentication/u2f/error.js index ca0fc0700ad..ca0fc0700ad 100644 --- a/app/assets/javascripts/u2f/error.js +++ b/app/assets/javascripts/authentication/u2f/error.js diff --git a/app/assets/javascripts/shared/sessions/u2f.js b/app/assets/javascripts/authentication/u2f/index.js index 6ae9faf1dde..f129acca1c3 100644 --- a/app/assets/javascripts/shared/sessions/u2f.js +++ b/app/assets/javascripts/authentication/u2f/index.js @@ -1,17 +1,17 @@ import $ from 'jquery'; -import U2FAuthenticate from '../../u2f/authenticate'; +import U2FAuthenticate from './authenticate'; export default () => { if (!gon.u2f) return; const u2fAuthenticate = new U2FAuthenticate( - $('#js-authenticate-u2f'), - '#js-login-u2f-form', + $('#js-authenticate-token-2fa'), + '#js-login-token-2fa-form', gon.u2f, document.querySelector('#js-login-2fa-device'), document.querySelector('.js-2fa-form'), ); u2fAuthenticate.start(); - // needed in rspec + // needed in rspec (FakeU2fDevice) gl.u2fAuthenticate = u2fAuthenticate; }; diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/authentication/u2f/register.js index f5a422727ad..52c0ce1fc04 100644 --- a/app/assets/javascripts/u2f/register.js +++ b/app/assets/javascripts/authentication/u2f/register.js @@ -78,7 +78,7 @@ export default class U2FRegister { error_message: error.message(), error_code: error.errorCode, }); - return this.container.find('#js-u2f-try-again').on('click', this.renderSetup); + return this.container.find('#js-token-2fa-try-again').on('click', this.renderSetup); } renderRegistered(deviceResponse) { diff --git a/app/assets/javascripts/u2f/util.js b/app/assets/javascripts/authentication/u2f/util.js index b706481c02f..b706481c02f 100644 --- a/app/assets/javascripts/u2f/util.js +++ b/app/assets/javascripts/authentication/u2f/util.js diff --git a/app/assets/javascripts/avatar_picker.js b/app/assets/javascripts/avatar_picker.js deleted file mode 100644 index d38e0b4abaa..00000000000 --- a/app/assets/javascripts/avatar_picker.js +++ /dev/null @@ -1,16 +0,0 @@ -import $ from 'jquery'; - -export default function initAvatarPicker() { - $('.js-choose-avatar-button').on('click', function onClickAvatar() { - const form = $(this).closest('form'); - return form.find('.js-avatar-input').click(); - }); - - $('.js-avatar-input').on('change', function onChangeAvatarInput() { - const form = $(this).closest('form'); - const filename = $(this) - .val() - .replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape - return form.find('.js-avatar-filename').text(filename); - }); -} diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index f9dd153eba0..3242993b06a 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -84,15 +84,10 @@ export default { <div v-show="hasError" class="btn-group"> <div class="btn btn-default btn-sm disabled"> - <icon - :size="16" - class="prepend-left-8 append-right-8" - name="doc-image" - aria-hidden="true" - /> + <icon :size="16" class="gl-ml-3 gl-mr-3" name="doc-image" aria-hidden="true" /> </div> <div class="btn btn-default btn-sm disabled"> - <span class="prepend-left-8 append-right-8">{{ s__('Badges|No badge image') }}</span> + <span class="gl-ml-3 gl-mr-3">{{ s__('Badges|No badge image') }}</span> </div> </div> diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue index bb363b8d85e..bad14666bb2 100644 --- a/app/assets/javascripts/badges/components/badge_list_row.vue +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -54,7 +54,7 @@ export default { <div v-if="canEditBadge" class="table-action-buttons"> <button :disabled="badge.isDeleting" - class="btn btn-default append-right-8" + class="btn btn-default gl-mr-3" type="button" @click="editBadge(badge)" > diff --git a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue new file mode 100644 index 00000000000..570954c7200 --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue @@ -0,0 +1,41 @@ +<script> +import { mapGetters } from 'vuex'; +import imageDiff from '~/diffs/mixins/image_diff'; +import DraftNote from './draft_note.vue'; + +export default { + components: { + DraftNote, + }, + mixins: [imageDiff], + props: { + fileHash: { + type: String, + required: true, + }, + }, + computed: { + ...mapGetters('batchComments', ['draftsForFile']), + drafts() { + return this.draftsForFile(this.fileHash); + }, + }, +}; +</script> + +<template> + <div> + <div + v-for="(draft, index) in drafts" + :key="draft.id" + class="discussion-notes diff-discussions position-relative" + > + <div class="notes"> + <span class="d-block btn-transparent badge badge-pill is-draft js-diff-notes-index"> + {{ toggleText(draft, index) }} + </span> + <draft-note :draft="draft" /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue new file mode 100644 index 00000000000..963d104b6b3 --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -0,0 +1,113 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import NoteableNote from '~/notes/components/noteable_note.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import PublishButton from './publish_button.vue'; + +export default { + components: { + NoteableNote, + PublishButton, + LoadingButton, + }, + props: { + draft: { + type: Object, + required: true, + }, + diffFile: { + type: Object, + required: false, + default: () => ({}), + }, + line: { + type: Object, + required: false, + default: null, + }, + }, + data() { + return { + isEditingDraft: false, + }; + }, + computed: { + ...mapState('batchComments', ['isPublishing']), + ...mapGetters('batchComments', ['isPublishingDraft']), + draftCommands() { + return this.draft.references.commands; + }, + }, + mounted() { + if (window.location.hash && window.location.hash === `#note_${this.draft.id}`) { + this.scrollToDraft(this.draft); + } + }, + methods: { + ...mapActions('batchComments', [ + 'deleteDraft', + 'updateDraft', + 'publishSingleDraft', + 'scrollToDraft', + 'toggleResolveDiscussion', + ]), + update(data) { + this.updateDraft(data); + }, + publishNow() { + this.publishSingleDraft(this.draft.id); + }, + handleEditing() { + this.isEditingDraft = true; + }, + handleNotEditing() { + this.isEditingDraft = false; + }, + }, +}; +</script> +<template> + <article class="draft-note-component note-wrapper"> + <ul class="notes draft-notes"> + <noteable-note + :note="draft" + :diff-lines="diffFile.highlighted_diff_lines" + :line="line" + class="draft-note" + @handleEdit="handleEditing" + @cancelForm="handleNotEditing" + @updateSuccess="handleNotEditing" + @handleDeleteNote="deleteDraft" + @handleUpdateNote="update" + @toggleResolveStatus="toggleResolveDiscussion(draft.id)" + > + <strong slot="note-header-info" class="badge draft-pending-label append-right-4"> + {{ __('Pending') }} + </strong> + </noteable-note> + </ul> + + <template v-if="!isEditingDraft"> + <div + v-if="draftCommands" + class="referenced-commands draft-note-commands" + v-html="draftCommands" + ></div> + + <p class="draft-note-actions d-flex"> + <publish-button + :show-count="true" + :should-publish="false" + class="btn btn-success btn-inverted gl-mr-3" + /> + <loading-button + ref="publishNowButton" + :loading="isPublishingDraft(draft.id) || isPublishing" + :label="__('Add comment now')" + container-class="btn btn-inverted" + @click="publishNow" + /> + </p> + </template> + </article> +</template> diff --git a/app/assets/javascripts/batch_comments/components/drafts_count.vue b/app/assets/javascripts/batch_comments/components/drafts_count.vue new file mode 100644 index 00000000000..f1180760c4d --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/drafts_count.vue @@ -0,0 +1,15 @@ +<script> +import { mapGetters } from 'vuex'; + +export default { + computed: { + ...mapGetters('batchComments', ['draftsCount']), + }, +}; +</script> +<template> + <span class="drafts-count-component"> + <span class="drafts-count-number">{{ draftsCount }}</span> + <span class="sr-only"> {{ n__('draft', 'drafts', draftsCount) }} </span> + </span> +</template> diff --git a/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue new file mode 100644 index 00000000000..385725cd109 --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue @@ -0,0 +1,32 @@ +<script> +import DraftNote from './draft_note.vue'; + +export default { + components: { + DraftNote, + }, + props: { + draft: { + type: Object, + required: true, + }, + diffFile: { + type: Object, + required: true, + }, + line: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> + +<template> + <tr class="notes_holder js-temp-notes-holder"> + <td class="notes-content" colspan="4"> + <div class="content"><draft-note :draft="draft" :diff-file="diffFile" :line="line" /></div> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue new file mode 100644 index 00000000000..68fd20e56bc --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue @@ -0,0 +1,45 @@ +<script> +import { mapGetters } from 'vuex'; +import DraftNote from './draft_note.vue'; + +export default { + components: { + DraftNote, + }, + props: { + line: { + type: Object, + required: true, + }, + diffFileContentSha: { + type: String, + required: true, + }, + }, + computed: { + ...mapGetters('batchComments', ['draftForLine']), + className() { + return this.leftDraft > 0 || this.rightDraft > 0 ? '' : 'js-temp-notes-holder'; + }, + leftDraft() { + return this.draftForLine(this.diffFileContentSha, this.line, 'left'); + }, + rightDraft() { + return this.draftForLine(this.diffFileContentSha, this.line, 'right'); + }, + }, +}; +</script> + +<template> + <tr :class="className" class="notes_holder"> + <td class="notes_line old"></td> + <td class="notes-content parallel old" colspan="2"> + <div v-if="leftDraft.isDraft" class="content"><draft-note :draft="leftDraft" /></div> + </td> + <td class="notes_line new"></td> + <td class="notes-content parallel new" colspan="2"> + <div v-if="rightDraft.isDraft" class="content"><draft-note :draft="rightDraft" /></div> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue new file mode 100644 index 00000000000..195e1b7ec5c --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -0,0 +1,111 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { sprintf, n__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import DraftsCount from './drafts_count.vue'; +import PublishButton from './publish_button.vue'; +import PreviewItem from './preview_item.vue'; + +export default { + components: { + GlLoadingIcon, + Icon, + DraftsCount, + PublishButton, + PreviewItem, + }, + computed: { + ...mapGetters(['isNotesFetched']), + ...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']), + ...mapState('batchComments', ['showPreviewDropdown']), + dropdownTitle() { + return sprintf( + n__('%{count} pending comment', '%{count} pending comments', this.draftsCount), + { count: this.draftsCount }, + ); + }, + }, + watch: { + showPreviewDropdown() { + if (this.showPreviewDropdown && this.$refs.dropdown) { + this.$nextTick(() => this.$refs.dropdown.focus()); + } + }, + }, + mounted() { + document.addEventListener('click', this.onClickDocument); + }, + beforeDestroy() { + document.removeEventListener('click', this.onClickDocument); + }, + methods: { + ...mapActions('batchComments', ['toggleReviewDropdown']), + isLast(index) { + return index === this.sortedDrafts.length - 1; + }, + onClickDocument({ target }) { + if ( + this.showPreviewDropdown && + !target.closest('.review-preview-dropdown, .js-publish-draft-button') + ) { + this.toggleReviewDropdown(); + } + }, + }, +}; +</script> + +<template> + <div + class="dropdown float-right review-preview-dropdown" + :class="{ + show: showPreviewDropdown, + }" + > + <button + ref="dropdown" + type="button" + class="btn btn-success review-preview-dropdown-toggle qa-review-preview-toggle" + @click="toggleReviewDropdown" + > + {{ __('Finish review') }} + <drafts-count /> + <icon name="angle-up" /> + </button> + <div + class="dropdown-menu dropdown-menu-large dropdown-menu-right dropdown-open-top" + :class="{ + show: showPreviewDropdown, + }" + > + <div class="dropdown-title"> + {{ dropdownTitle }} + <button + :aria-label="__('Close')" + type="button" + class="dropdown-title-button dropdown-menu-close" + @click="toggleReviewDropdown" + > + <icon name="close" /> + </button> + </div> + <div class="dropdown-content"> + <ul v-if="isNotesFetched"> + <li v-for="(draft, index) in sortedDrafts" :key="draft.id"> + <preview-item :draft="draft" :is-last="isLast(index)" /> + </li> + </ul> + <gl-loading-icon v-else size="lg" class="prepend-top-default append-bottom-default" /> + </div> + <div class="dropdown-footer"> + <publish-button + :show-count="false" + :should-publish="true" + :label="__('Submit review')" + class="float-right gl-mr-3" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue new file mode 100644 index 00000000000..22495eb4d7d --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -0,0 +1,143 @@ +<script> +import { mapActions, mapGetters } from 'vuex'; +import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; +import { sprintf, __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import resolvedStatusMixin from '../mixins/resolved_status'; +import { GlSprintf } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + getStartLineNumber, + getEndLineNumber, + getLineClasses, +} from '~/notes/components/multiline_comment_utils'; + +export default { + components: { + Icon, + GlSprintf, + }, + mixins: [resolvedStatusMixin, glFeatureFlagsMixin()], + props: { + draft: { + type: Object, + required: true, + }, + isLast: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapGetters('diffs', ['getDiffFileByHash']), + ...mapGetters(['getDiscussion']), + iconName() { + return this.isDiffDiscussion || this.draft.line_code ? 'doc-text' : 'comment'; + }, + discussion() { + return this.getDiscussion(this.draft.discussion_id); + }, + isDiffDiscussion() { + return this.discussion && this.discussion.diff_discussion; + }, + titleText() { + const file = this.discussion ? this.discussion.diff_file : this.draft; + + if (file) { + return file.file_path; + } + + return sprintf(__("%{authorsName}'s thread"), { + authorsName: this.discussion.notes.find(note => !note.system).author.name, + }); + }, + linePosition() { + if (this.draft.position && this.draft.position.position_type === IMAGE_DIFF_POSITION_TYPE) { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${this.draft.position.x}x ${this.draft.position.y}y`; + } + + const position = this.discussion ? this.discussion.position : this.draft.position; + + return position?.new_line || position?.old_line; + }, + content() { + const el = document.createElement('div'); + el.innerHTML = this.draft.note_html; + + return el.textContent; + }, + showLinePosition() { + return this.draft.file_hash || this.isDiffDiscussion; + }, + startLineNumber() { + return getStartLineNumber(this.draft.position?.line_range); + }, + endLineNumber() { + return getEndLineNumber(this.draft.position?.line_range); + }, + }, + methods: { + ...mapActions('batchComments', ['scrollToDraft']), + getLineClasses(lineNumber) { + return getLineClasses(lineNumber); + }, + }, + showStaysResolved: false, +}; +</script> + +<template> + <button + type="button" + class="review-preview-item menu-item" + :class="[ + componentClasses, + { + 'is-last': isLast, + }, + ]" + @click="scrollToDraft(draft)" + > + <span class="review-preview-item-header"> + <icon class="flex-shrink-0" :name="iconName" /> + <span + class="bold text-nowrap" + :class="{ 'gl-align-items-center': glFeatures.multilineComments }" + > + <span class="review-preview-item-header-text block-truncated"> + {{ titleText }} + </span> + <template v-if="showLinePosition"> + <template v-if="!glFeatures.multilineComments" + >:{{ linePosition }}</template + > + <template v-else-if="startLineNumber === endLineNumber"> + :<span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span> + </template> + <gl-sprintf v-else :message="__(':%{startLine} to %{endLine}')"> + <template #startLine> + <span class="gl-mr-2" :class="getLineClasses(startLineNumber)">{{ + startLineNumber + }}</span> + </template> + <template #endLine> + <span class="gl-ml-2" :class="getLineClasses(endLineNumber)">{{ + endLineNumber + }}</span> + </template> + </gl-sprintf> + </template> + </span> + </span> + <span class="review-preview-item-content"> + <p>{{ content }}</p> + </span> + <span + v-if="draft.discussion_id && resolvedStatusMessage" + class="review-preview-item-footer draft-note-resolution p-0" + > + <icon class="gl-mr-3" name="status_success" /> {{ resolvedStatusMessage }} + </span> + </button> +</template> diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue new file mode 100644 index 00000000000..f4dc0f04dc3 --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/publish_button.vue @@ -0,0 +1,55 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import DraftsCount from './drafts_count.vue'; + +export default { + components: { + LoadingButton, + DraftsCount, + }, + props: { + showCount: { + type: Boolean, + required: false, + default: false, + }, + label: { + type: String, + required: false, + default: __('Finish review'), + }, + shouldPublish: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapState('batchComments', ['isPublishing']), + }, + methods: { + ...mapActions('batchComments', ['publishReview', 'toggleReviewDropdown']), + onClick() { + if (this.shouldPublish) { + this.publishReview(); + } else { + this.toggleReviewDropdown(); + } + }, + }, +}; +</script> + +<template> + <loading-button + :loading="isPublishing" + container-class="btn btn-success js-publish-draft-button qa-submit-review" + @click="onClick" + > + <span> + {{ label }} + <drafts-count v-if="showCount" /> + </span> + </loading-button> +</template> diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue new file mode 100644 index 00000000000..b0e8b806701 --- /dev/null +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -0,0 +1,70 @@ +<script> +import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlModal, GlModalDirective } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import PreviewDropdown from './preview_dropdown.vue'; + +export default { + components: { + LoadingButton, + GlModal, + PreviewDropdown, + }, + directives: { + 'gl-modal': GlModalDirective, + }, + computed: { + ...mapGetters(['isNotesFetched']), + ...mapState('batchComments', ['isDiscarding']), + ...mapGetters('batchComments', ['draftsCount']), + }, + watch: { + isNotesFetched() { + if (this.isNotesFetched) { + this.expandAllDiscussions(); + } + }, + }, + methods: { + ...mapActions('batchComments', ['discardReview', 'expandAllDiscussions']), + }, + modalId: 'discard-draft-review', + text: sprintf( + s__( + `BatchComments|You're about to discard your review which will delete all of your pending comments. + The deleted comments %{strong_start}cannot%{strong_end} be restored.`, + ), + { + strong_start: '<strong>', + strong_end: '</strong>', + }, + false, + ), +}; +</script> +<template> + <div v-show="draftsCount > 0"> + <nav class="review-bar-component"> + <div class="review-bar-content qa-review-bar"> + <preview-dropdown /> + <loading-button + v-gl-modal="$options.modalId" + :loading="isDiscarding" + :label="__('Discard review')" + class="qa-discard-review float-right" + /> + </div> + </nav> + <gl-modal + :title="s__('BatchComments|Discard review?')" + :ok-title="s__('BatchComments|Delete all pending comments')" + :modal-id="$options.modalId" + title-tag="h4" + ok-variant="danger qa-modal-delete-pending-comments" + @ok="discardReview" + > + <p v-html="$options.text"></p> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/batch_comments/constants.js b/app/assets/javascripts/batch_comments/constants.js new file mode 100644 index 00000000000..b309c339fc8 --- /dev/null +++ b/app/assets/javascripts/batch_comments/constants.js @@ -0,0 +1,3 @@ +export const CHANGES_TAB = 'diffs'; +export const DISCUSSION_TAB = 'notes'; +export const SHOW_TAB = 'show'; diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js new file mode 100644 index 00000000000..e06285c0b37 --- /dev/null +++ b/app/assets/javascripts/batch_comments/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import { mapActions } from 'vuex'; +import store from '~/mr_notes/stores'; +import ReviewBar from './components/review_bar.vue'; + +// eslint-disable-next-line import/prefer-default-export +export const initReviewBar = () => { + const el = document.getElementById('js-review-bar'); + + // eslint-disable-next-line no-new + new Vue({ + el, + store, + mounted() { + this.fetchDrafts(); + }, + methods: { + ...mapActions('batchComments', ['fetchDrafts']), + }, + render(createElement) { + return createElement(ReviewBar); + }, + }); +}; diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js index 3bbbaa86b51..2517fb198f0 100644 --- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js +++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js @@ -1,9 +1,58 @@ -import { sprintf, __ } from '~/locale'; +import { mapGetters } from 'vuex'; +import { sprintf, s__, __ } from '~/locale'; export default { + props: { + discussionId: { + type: String, + required: false, + default: null, + }, + resolveDiscussion: { + type: Boolean, + required: false, + default: false, + }, + isDraft: { + type: Boolean, + required: false, + default: false, + }, + }, computed: { + ...mapGetters(['isDiscussionResolved']), + resolvedStatusMessage() { + let message; + const discussionResolved = this.isDiscussionResolved( + this.draft ? this.draft.discussion_id : this.discussionId, + ); + const discussionToBeResolved = this.draft + ? this.draft.resolve_discussion + : this.resolveDiscussion; + + if (discussionToBeResolved && discussionResolved && !this.$options.showStaysResolved) { + return undefined; + } + + if (discussionToBeResolved) { + message = discussionResolved + ? s__('MergeRequests|Thread stays resolved') + : s__('MergeRequests|Thread will be resolved'); + } else if (discussionResolved) { + message = s__('MergeRequests|Thread will be unresolved'); + } else if (this.$options.showStaysResolved) { + message = s__('MergeRequests|Thread stays unresolved'); + } + + return message; + }, + componentClasses() { + return this.resolveDiscussion ? 'is-resolving-discussion' : 'is-unresolving-discussion'; + }, resolveButtonTitle() { - let title = __('Mark comment as resolved'); + if (this.isDraft || this.discussionId) return this.resolvedStatusMessage; + + let title = __('Mark as resolved'); if (this.resolvedBy) { title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name }); @@ -12,4 +61,5 @@ export default { return title; }, }, + showStaysResolved: true, }; diff --git a/app/assets/javascripts/batch_comments/services/drafts_service.js b/app/assets/javascripts/batch_comments/services/drafts_service.js new file mode 100644 index 00000000000..36d2f8df612 --- /dev/null +++ b/app/assets/javascripts/batch_comments/services/drafts_service.js @@ -0,0 +1,33 @@ +import axios from '~/lib/utils/axios_utils'; + +export default { + createNewDraft(endpoint, data) { + const postData = { ...data, draft_note: data.note }; + delete postData.note; + + return axios.post(endpoint, postData); + }, + deleteDraft(endpoint, draftId) { + return axios.delete(`${endpoint}/${draftId}`); + }, + publishDraft(endpoint, draftId) { + return axios.post(endpoint, { id: draftId }); + }, + addDraftToDiscussion(endpoint, data) { + return axios.post(endpoint, data); + }, + fetchDrafts(endpoint) { + return axios.get(endpoint); + }, + publish(endpoint) { + return axios.post(endpoint); + }, + discard(endpoint) { + return axios.delete(endpoint); + }, + update(endpoint, { draftId, note, resolveDiscussion, position }) { + return axios.put(`${endpoint}/${draftId}`, { + draft_note: { note, resolve_discussion: resolveDiscussion, position }, + }); + }, +}; diff --git a/app/assets/javascripts/batch_comments/stores/index.js b/app/assets/javascripts/batch_comments/stores/index.js new file mode 100644 index 00000000000..08dc9ea70f8 --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import batchComments from './modules/batch_comments'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + modules: { + batchComments: batchComments(), + }, + }); + +export default createStore(); 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 new file mode 100644 index 00000000000..1ef012696c5 --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -0,0 +1,151 @@ +import flash from '~/flash'; +import { __ } from '~/locale'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import service from '../../../services/drafts_service'; +import * as types from './mutation_types'; +import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants'; + +export const saveDraft = ({ dispatch }, draft) => + dispatch('saveNote', { ...draft, isDraft: true }, { root: true }); + +export const addDraftToDiscussion = ({ commit }, { endpoint, data }) => + service + .addDraftToDiscussion(endpoint, data) + .then(res => res.data) + .then(res => { + commit(types.ADD_NEW_DRAFT, res); + return res; + }) + .catch(() => { + flash(__('An error occurred adding a draft to the thread.')); + }); + +export const createNewDraft = ({ commit }, { endpoint, data }) => + service + .createNewDraft(endpoint, data) + .then(res => res.data) + .then(res => { + commit(types.ADD_NEW_DRAFT, res); + return res; + }) + .catch(() => { + flash(__('An error occurred adding a new draft.')); + }); + +export const deleteDraft = ({ commit, getters }, draft) => + service + .deleteDraft(getters.getNotesData.draftsPath, draft.id) + .then(() => { + commit(types.DELETE_DRAFT, draft.id); + }) + .catch(() => flash(__('An error occurred while deleting the comment'))); + +export const fetchDrafts = ({ commit, getters }) => + service + .fetchDrafts(getters.getNotesData.draftsPath) + .then(res => res.data) + .then(data => commit(types.SET_BATCH_COMMENTS_DRAFTS, data)) + .catch(() => flash(__('An error occurred while fetching pending comments'))); + +export const publishSingleDraft = ({ commit, dispatch, getters }, draftId) => { + commit(types.REQUEST_PUBLISH_DRAFT, draftId); + + service + .publishDraft(getters.getNotesData.draftsPublishPath, draftId) + .then(() => dispatch('updateDiscussionsAfterPublish')) + .then(() => commit(types.RECEIVE_PUBLISH_DRAFT_SUCCESS, draftId)) + .catch(() => commit(types.RECEIVE_PUBLISH_DRAFT_ERROR, draftId)); +}; + +export const publishReview = ({ commit, dispatch, getters }) => { + commit(types.REQUEST_PUBLISH_REVIEW); + + return service + .publish(getters.getNotesData.draftsPublishPath) + .then(() => dispatch('updateDiscussionsAfterPublish')) + .then(() => commit(types.RECEIVE_PUBLISH_REVIEW_SUCCESS)) + .catch(() => commit(types.RECEIVE_PUBLISH_REVIEW_ERROR)); +}; + +export const updateDiscussionsAfterPublish = ({ dispatch, getters, rootGetters }) => + dispatch('fetchDiscussions', { path: getters.getNotesData.discussionsPath }, { root: true }).then( + () => + dispatch('diffs/assignDiscussionsToDiff', rootGetters.discussionsStructuredByLineCode, { + root: true, + }), + ); + +export const discardReview = ({ commit, getters }) => { + commit(types.REQUEST_DISCARD_REVIEW); + + return service + .discard(getters.getNotesData.draftsDiscardPath) + .then(() => commit(types.RECEIVE_DISCARD_REVIEW_SUCCESS)) + .catch(() => commit(types.RECEIVE_DISCARD_REVIEW_ERROR)); +}; + +export const updateDraft = ( + { commit, getters }, + { note, noteText, resolveDiscussion, position, callback }, +) => + service + .update(getters.getNotesData.draftsPath, { + draftId: note.id, + note: noteText, + resolveDiscussion, + position: JSON.stringify(position), + }) + .then(res => res.data) + .then(data => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data)) + .then(callback) + .catch(() => flash(__('An error occurred while updating the comment'))); + +export const scrollToDraft = ({ dispatch, rootGetters }, draft) => { + const discussion = draft.discussion_id && rootGetters.getDiscussion(draft.discussion_id); + const tab = + draft.file_hash || (discussion && discussion.diff_discussion) ? CHANGES_TAB : SHOW_TAB; + const tabEl = tab === CHANGES_TAB ? CHANGES_TAB : DISCUSSION_TAB; + const draftID = `note_${draft.id}`; + const el = document.querySelector(`#${tabEl} #${draftID}`); + + dispatch('closeReviewDropdown'); + + window.location.hash = draftID; + + if (window.mrTabs.currentAction !== tab) { + window.mrTabs.tabShown(tab); + } + + if (discussion) { + dispatch('expandDiscussion', { discussionId: discussion.id }, { root: true }); + } + + if (el) { + setTimeout(() => scrollToElement(el.closest('.draft-note-component'))); + } +}; + +export const toggleReviewDropdown = ({ dispatch, state }) => { + if (state.showPreviewDropdown) { + dispatch('closeReviewDropdown'); + } else { + dispatch('openReviewDropdown'); + } +}; + +export const openReviewDropdown = ({ commit }) => commit(types.OPEN_REVIEW_DROPDOWN); +export const closeReviewDropdown = ({ commit }) => commit(types.CLOSE_REVIEW_DROPDOWN); + +export const expandAllDiscussions = ({ dispatch, state }) => + state.drafts + .filter(draft => draft.discussion_id) + .forEach(draft => { + dispatch('expandDiscussion', { discussionId: draft.discussion_id }, { root: true }); + }); + +export const toggleResolveDiscussion = ({ commit }, draftId) => { + commit(types.TOGGLE_RESOLVE_DISCUSSION, draftId); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js new file mode 100644 index 00000000000..43f43c983aa --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js @@ -0,0 +1,87 @@ +import { parallelLineKey, showDraftOnSide } from '../../../utils'; + +export const draftsCount = state => state.drafts.length; + +export const getNotesData = (state, getters, rootState, rootGetters) => rootGetters.getNotesData; + +export const hasDrafts = state => state.drafts.length > 0; + +export const draftsPerDiscussionId = state => + state.drafts.reduce((acc, draft) => { + if (draft.discussion_id) { + acc[draft.discussion_id] = draft; + } + + return acc; + }, {}); + +export const draftsPerFileHashAndLine = state => + state.drafts.reduce((acc, draft) => { + if (draft.file_hash) { + if (!acc[draft.file_hash]) { + acc[draft.file_hash] = {}; + } + + acc[draft.file_hash][draft.line_code] = draft; + } + + return acc; + }, {}); + +export const shouldRenderDraftRow = (state, getters) => (diffFileSha, line) => + Boolean( + diffFileSha in getters.draftsPerFileHashAndLine && + getters.draftsPerFileHashAndLine[diffFileSha][line.line_code], + ); + +export const shouldRenderParallelDraftRow = (state, getters) => (diffFileSha, line) => { + const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha]; + const [lkey, rkey] = [parallelLineKey(line, 'left'), parallelLineKey(line, 'right')]; + + return draftsForFile ? Boolean(draftsForFile[lkey] || draftsForFile[rkey]) : false; +}; + +export const hasParallelDraftLeft = (state, getters) => (diffFileSha, line) => { + const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha]; + const lkey = parallelLineKey(line, 'left'); + + return draftsForFile ? Boolean(draftsForFile[lkey]) : false; +}; + +export const hasParallelDraftRight = (state, getters) => (diffFileSha, line) => { + const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha]; + const rkey = parallelLineKey(line, 'left'); + + return draftsForFile ? Boolean(draftsForFile[rkey]) : false; +}; + +export const shouldRenderDraftRowInDiscussion = (state, getters) => discussionId => + typeof getters.draftsPerDiscussionId[discussionId] !== 'undefined'; + +export const draftForDiscussion = (state, getters) => discussionId => + getters.draftsPerDiscussionId[discussionId] || {}; + +export const draftForLine = (state, getters) => (diffFileSha, line, side = null) => { + const draftsForFile = getters.draftsPerFileHashAndLine[diffFileSha]; + + const key = side !== null ? parallelLineKey(line, side) : line.line_code; + + if (draftsForFile) { + const draft = draftsForFile[key]; + if (draft && showDraftOnSide(line, side)) { + return draft; + } + } + return {}; +}; + +export const draftsForFile = state => diffFileSha => + state.drafts.filter(draft => draft.file_hash === diffFileSha); + +export const isPublishingDraft = state => draftId => + state.currentlyPublishingDrafts.indexOf(draftId) !== -1; + +export const sortedDrafts = state => [...state.drafts].sort((a, b) => a.id > b.id); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js new file mode 100644 index 00000000000..81dab0566c1 --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/index.js @@ -0,0 +1,12 @@ +import state from './state'; +import mutations from './mutations'; +import * as actions from './actions'; +import * as getters from './getters'; + +export default () => ({ + namespaced: true, + state: state(), + mutations, + actions, + getters, +}); diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js new file mode 100644 index 00000000000..c8f0658c21c --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js @@ -0,0 +1,23 @@ +export const ENABLE_BATCH_COMMENTS = 'ENABLE_BATCH_COMMENTS'; +export const ADD_NEW_DRAFT = 'ADD_NEW_DRAFT'; +export const DELETE_DRAFT = 'DELETE_DRAFT'; +export const SET_BATCH_COMMENTS_DRAFTS = 'SET_BATCH_COMMENTS_DRAFTS'; + +export const REQUEST_PUBLISH_DRAFT = 'REQUEST_PUBLISH_DRAFT'; +export const RECEIVE_PUBLISH_DRAFT_SUCCESS = 'RECEIVE_PUBLISH_DRAFT_SUCCESS'; +export const RECEIVE_PUBLISH_DRAFT_ERROR = 'RECEIVE_PUBLISH_DRAFT_ERROR'; + +export const REQUEST_PUBLISH_REVIEW = 'REQUEST_PUBLISH_REVIEW'; +export const RECEIVE_PUBLISH_REVIEW_SUCCESS = 'RECEIVE_PUBLISH_REVIEW_SUCCESS'; +export const RECEIVE_PUBLISH_REVIEW_ERROR = 'RECEIVE_PUBLISH_REVIEW_ERROR'; + +export const REQUEST_DISCARD_REVIEW = 'REQUEST_DISCARD_REVIEW'; +export const RECEIVE_DISCARD_REVIEW_SUCCESS = 'RECEIVE_DISCARD_REVIEW_SUCCESS'; +export const RECEIVE_DISCARD_REVIEW_ERROR = 'RECEIVE_DISCARD_REVIEW_ERROR'; + +export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS'; + +export const OPEN_REVIEW_DROPDOWN = 'OPEN_REVIEW_DROPDOWN'; +export const CLOSE_REVIEW_DROPDOWN = 'CLOSE_REVIEW_DROPDOWN'; + +export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION'; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js new file mode 100644 index 00000000000..81ceef7b160 --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js @@ -0,0 +1,81 @@ +import * as types from './mutation_types'; + +const processDraft = draft => ({ + ...draft, + isDraft: true, +}); + +export default { + [types.ADD_NEW_DRAFT](state, draft) { + state.drafts.push(processDraft(draft)); + }, + + [types.DELETE_DRAFT](state, draftId) { + state.drafts = state.drafts.filter(draft => draft.id !== draftId); + }, + + [types.SET_BATCH_COMMENTS_DRAFTS](state, drafts) { + state.drafts = drafts.map(processDraft); + }, + + [types.REQUEST_PUBLISH_DRAFT](state, draftId) { + state.currentlyPublishingDrafts.push(draftId); + }, + [types.RECEIVE_PUBLISH_DRAFT_SUCCESS](state, draftId) { + state.currentlyPublishingDrafts = state.currentlyPublishingDrafts.filter( + publishingDraftId => publishingDraftId !== draftId, + ); + state.drafts = state.drafts.filter(d => d.id !== draftId); + }, + [types.RECEIVE_PUBLISH_DRAFT_ERROR](state, draftId) { + state.currentlyPublishingDrafts = state.currentlyPublishingDrafts.filter( + publishingDraftId => publishingDraftId !== draftId, + ); + }, + + [types.REQUEST_PUBLISH_REVIEW](state) { + state.isPublishing = true; + }, + [types.RECEIVE_PUBLISH_REVIEW_SUCCESS](state) { + state.isPublishing = false; + state.drafts = []; + }, + [types.RECEIVE_PUBLISH_REVIEW_ERROR](state) { + state.isPublishing = false; + }, + [types.REQUEST_DISCARD_REVIEW](state) { + state.isDiscarding = true; + }, + [types.RECEIVE_DISCARD_REVIEW_SUCCESS](state) { + state.isDiscarding = false; + state.drafts = []; + }, + [types.RECEIVE_DISCARD_REVIEW_ERROR](state) { + state.isDiscarding = false; + }, + [types.RECEIVE_DRAFT_UPDATE_SUCCESS](state, data) { + const index = state.drafts.findIndex(draft => draft.id === data.id); + + if (index >= 0) { + state.drafts.splice(index, 1, processDraft(data)); + } + }, + [types.OPEN_REVIEW_DROPDOWN](state) { + state.showPreviewDropdown = true; + }, + [types.CLOSE_REVIEW_DROPDOWN](state) { + state.showPreviewDropdown = false; + }, + [types.TOGGLE_RESOLVE_DISCUSSION](state, draftId) { + state.drafts = state.drafts.map(draft => { + if (draft.id === draftId) { + return { + ...draft, + resolve_discussion: !draft.resolve_discussion, + }; + } + + return draft; + }); + }, +}; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js new file mode 100644 index 00000000000..80c710deab0 --- /dev/null +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js @@ -0,0 +1,9 @@ +export default () => ({ + withBatchComments: true, + isDraftsFetched: false, + drafts: [], + isPublishing: false, + currentlyPublishingDrafts: [], + isDiscarding: false, + showPreviewDropdown: false, +}); diff --git a/app/assets/javascripts/batch_comments/utils.js b/app/assets/javascripts/batch_comments/utils.js new file mode 100644 index 00000000000..cf4f7af0ebb --- /dev/null +++ b/app/assets/javascripts/batch_comments/utils.js @@ -0,0 +1,35 @@ +import { getFormData } from '~/diffs/store/utils'; + +export const getDraftReplyFormData = data => ({ + endpoint: data.notesData.draftsPath, + data, +}); + +export const getDraftFormData = params => ({ + endpoint: params.notesData.draftsPath, + data: getFormData(params), +}); + +export const parallelLineKey = (line, side) => (line[side] ? line[side].line_code : ''); + +export const showDraftOnSide = (line, side) => { + // inline mode + if (side === null) { + return true; + } + + // parallel + if (side === 'left' || side === 'right') { + const otherSide = side === 'left' ? 'right' : 'left'; + const thisCode = (line[side] && line[side].line_code) || ''; + const otherCode = (line[otherSide] && line[otherSide].line_code) || ''; + + // either the lineCodes are different + // or if they're the same, only show on the left side + if (thisCode !== otherCode || (side === 'left' && thisCode === otherCode)) { + return true; + } + } + + return false; +}; diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 057cdb6cc4c..e4c69a114e0 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -25,9 +25,10 @@ function importMermaidModule() { return import(/* webpackChunkName: 'mermaid' */ 'mermaid') .then(mermaid => { let theme = 'neutral'; + const ideDarkThemes = ['dark', 'solarized-dark']; if ( - window.gon?.user_color_scheme === 'dark' && + ideDarkThemes.includes(window.gon?.user_color_scheme) && // if on the Web IDE page document.querySelector('.ide') ) { diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index 4f433bd8dfd..eb7f45cba6f 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -66,6 +66,7 @@ export default { ref="contentViewer" :content="content" :type="activeViewer.fileType" + data-qa-selector="file_content" /> </template> </div> diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index e5e01caa9a5..76c5779f3ae 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -30,6 +30,11 @@ export default { required: false, default: SIMPLE_BLOB_VIEWER, }, + hasRenderError: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -75,6 +80,7 @@ export default { v-if="showDefaultActions" :raw-path="blob.rawPath" :active-viewer="viewer" + :has-render-error="hasRenderError" @copy="proxyCopyRequest" /> </div> diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue index 63ae70a37f4..62fef108b47 100644 --- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue +++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue @@ -27,6 +27,11 @@ export default { default: SIMPLE_BLOB_VIEWER, required: false, }, + hasRenderError: { + type: Boolean, + required: false, + default: false, + }, }, computed: { downloadUrl() { @@ -44,11 +49,13 @@ export default { <template> <gl-button-group> <gl-deprecated-button + v-if="!hasRenderError" v-gl-tooltip.hover :aria-label="$options.BTN_COPY_CONTENTS_TITLE" :title="$options.BTN_COPY_CONTENTS_TITLE" :disabled="copyDisabled" data-clipboard-target="#blob-code-content" + data-testid="copyContentsButton" > <gl-icon name="copy-to-clipboard" :size="14" /> </gl-deprecated-button> diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue index e9be7fbcf9b..601b694db87 100644 --- a/app/assets/javascripts/blob/components/blob_header_filepath.vue +++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue @@ -32,7 +32,7 @@ export default { <file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" /> <strong class="file-title-name mr-1 js-blob-header-filepath" - data-qa-selector="file_title_name" + data-qa-selector="file_title_content" >{{ blob.path }}</strong > </template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index fb854616a04..0ed7579e8e1 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,33 +1,22 @@ <script> -import $ from 'jquery'; import Sortable from 'sortablejs'; -import { GlButtonGroup, GlDeprecatedButton, GlLabel, GlTooltip, GlIcon } from '@gitlab/ui'; import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; -import { s__, __, sprintf } from '~/locale'; import Tooltip from '~/vue_shared/directives/tooltip'; import EmptyComponent from '~/vue_shared/components/empty_component'; -import AccessorUtilities from '../../lib/utils/accessor'; import BoardBlankState from './board_blank_state.vue'; -import BoardDelete from './board_delete'; +import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; import BoardList from './board_list.vue'; -import IssueCount from './issue_count.vue'; import boardsStore from '../stores/boards_store'; +import eventHub from '../eventhub'; import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; import { ListType } from '../constants'; -import { isScopedLabel } from '~/lib/utils/common_utils'; export default { components: { BoardPromotionState: EmptyComponent, BoardBlankState, - BoardDelete, + BoardListHeader, BoardList, - GlButtonGroup, - IssueCount, - GlDeprecatedButton, - GlLabel, - GlTooltip, - GlIcon, }, directives: { Tooltip, @@ -70,42 +59,9 @@ export default { return { detailIssue: boardsStore.detail, filter: boardsStore.filter, - weightFeatureAvailable: false, }; }, computed: { - isLoggedIn() { - return Boolean(gon.current_user_id); - }, - showListHeaderButton() { - return ( - !this.disabled && - this.list.type !== ListType.closed && - this.list.type !== ListType.blank && - this.list.type !== ListType.promotion - ); - }, - issuesTooltip() { - const { issuesSize } = this.list; - - return sprintf(__('%{issuesSize} issues'), { issuesSize }); - }, - // Only needed to make karma pass. - weightCountToolTip() {}, // eslint-disable-line vue/return-in-computed-property - caretTooltip() { - return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); - }, - isNewIssueShown() { - return this.list.type === ListType.backlog || this.showListHeaderButton; - }, - isSettingsShown() { - return ( - this.list.type !== ListType.backlog && - this.showListHeaderButton && - this.list.isExpanded && - this.isWipLimitsOn - ); - }, showBoardListAndBoardInfo() { return this.list.type !== ListType.blank && this.list.type !== ListType.promotion; }, @@ -151,41 +107,9 @@ export default { Sortable.create(this.$el.parentNode, sortableOptions); }, - created() { - if ( - this.list.isExpandable && - AccessorUtilities.isLocalStorageAccessSafe() && - !this.isLoggedIn - ) { - const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false'; - - this.list.isExpanded = !isCollapsed; - } - }, methods: { - showScopedLabels(label) { - return boardsStore.scopedLabels.enabled && isScopedLabel(label); - }, - - showNewIssueForm() { - this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; - }, - toggleExpanded() { - if (this.list.isExpandable) { - this.list.isExpanded = !this.list.isExpanded; - - if (AccessorUtilities.isLocalStorageAccessSafe() && !this.isLoggedIn) { - localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); - } - - if (this.isLoggedIn) { - this.list.update(); - } - - // When expanding/collapsing, the tooltip on the caret button sometimes stays open. - // Close all tooltips manually to prevent dangling tooltips. - $('.tooltip').tooltip('hide'); - } + showListNewIssueForm(listId) { + eventHub.$emit('showForm', listId); }, }, }; @@ -200,166 +124,18 @@ export default { 'board-type-assignee': list.type === 'assignee', }" :data-id="list.id" - class="board h-100 px-2 align-top ws-normal" + class="board gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" data-qa-selector="board_list" > - <div class="board-inner d-flex flex-column position-relative h-100 rounded"> - <header - :class="{ - 'has-border': list.label && list.label.color, - 'position-relative': list.isExpanded, - 'position-absolute position-top-0 position-left-0 w-100 h-100': !list.isExpanded, - }" - :style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }" - class="board-header" - data-qa-selector="board_list_header" - > - <h3 - :class="{ - 'user-can-drag': !disabled && !list.preset, - 'border-bottom-0': !list.isExpanded, - }" - class="board-title m-0 d-flex js-board-handle" - > - <div - v-if="list.isExpandable" - v-tooltip="" - :aria-label="caretTooltip" - :title="caretTooltip" - aria-hidden="true" - class="board-title-caret no-drag" - data-placement="bottom" - @click="toggleExpanded" - > - <i - :class="{ 'fa-caret-right': list.isExpanded, 'fa-caret-down': !list.isExpanded }" - class="fa fa-fw" - ></i> - </div> - <!-- The following is only true in EE and if it is a milestone --> - <span - v-if="list.type === 'milestone' && list.milestone" - aria-hidden="true" - class="append-right-5 milestone-icon" - > - <gl-icon name="timer" /> - </span> - - <a - v-if="list.type === 'assignee'" - :href="list.assignee.path" - class="user-avatar-link js-no-trigger" - > - <img - :alt="list.assignee.name" - :src="list.assignee.avatar" - class="avatar s20 has-tooltip" - height="20" - width="20" - /> - </a> - <div class="board-title-text"> - <span - v-if="list.type !== 'label'" - :class="{ - 'has-tooltip': !['backlog', 'closed'].includes(list.type), - 'd-block': list.type === 'milestone', - }" - :title="(list.label && list.label.description) || list.title || ''" - class="board-title-main-text block-truncated" - data-container="body" - > - {{ list.title }} - </span> - <span - v-if="list.type === 'assignee'" - :title="(list.assignee && list.assignee.username) || ''" - class="board-title-sub-text prepend-left-5 has-tooltip" - > - @{{ list.assignee.username }} - </span> - <gl-label - v-if="list.type === 'label'" - :background-color="list.label.color" - :description="list.label.description" - :scoped="showScopedLabels(list.label)" - :size="!list.isExpanded ? 'sm' : ''" - :title="list.label.title" - tooltip-placement="bottom" - /> - </div> - <board-delete - v-if="canAdminList && !list.preset && list.id" - :list="list" - inline-template="true" - > - <button - :class="{ 'd-none': !list.isExpanded }" - :aria-label="__(`Delete list`)" - class="board-delete no-drag p-0 border-0 has-tooltip float-right" - data-placement="bottom" - title="Delete list" - type="button" - @click.stop="deleteBoard" - > - <i aria-hidden="true" data-hidden="true" class="fa fa-trash"></i> - </button> - </board-delete> - <div - v-if="showBoardListAndBoardInfo" - class="issue-count-badge pr-0 no-drag text-secondary" - > - <span class="d-inline-flex"> - <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltip" /> - <span ref="issueCount" class="issue-count-badge-count"> - <gl-icon class="mr-1" name="issues" /> - <issue-count :issues-size="list.issuesSize" :max-issue-count="list.maxIssueCount" /> - </span> - <!-- The following is only true in EE. --> - <template v-if="weightFeatureAvailable"> - <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> - <span ref="weightTooltip" class="d-inline-flex ml-2"> - <gl-icon class="mr-1" name="weight" /> - {{ list.totalWeight }} - </span> - </template> - </span> - </div> - <gl-button-group - v-if="isNewIssueShown || isSettingsShown" - class="board-list-button-group pl-2" - > - <gl-deprecated-button - v-if="isNewIssueShown" - ref="newIssueBtn" - :class="{ - 'd-none': !list.isExpanded, - 'rounded-right': isNewIssueShown && !isSettingsShown, - }" - :aria-label="__(`New issue`)" - class="issue-count-badge-add-button no-drag" - type="button" - @click="showNewIssueForm" - > - <i aria-hidden="true" data-hidden="true" class="fa fa-plus"></i> - </gl-deprecated-button> - <gl-tooltip :target="() => $refs.newIssueBtn">{{ __('New Issue') }}</gl-tooltip> - - <gl-deprecated-button - v-if="isSettingsShown" - ref="settingsBtn" - :aria-label="__(`List settings`)" - class="no-drag rounded-right js-board-settings-button" - title="List settings" - type="button" - @click="openSidebarSettings" - > - <gl-icon name="settings" /> - </gl-deprecated-button> - <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip> - </gl-button-group> - </h3> - </header> + <div + class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" + > + <board-list-header + :can-admin-list="canAdminList" + :list="list" + :disabled="disabled" + :board-id="boardId" + /> <board-list v-if="showBoardListAndBoardInfo" ref="board-list" diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue new file mode 100644 index 00000000000..f0497ea0b64 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -0,0 +1,82 @@ +<script> +import { mapState } from 'vuex'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; +import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; + +export default { + components: { + BoardColumn, + EpicsSwimlanes, + }, + mixins: [glFeatureFlagMixin()], + props: { + lists: { + type: Array, + required: true, + }, + canAdminList: { + type: Boolean, + required: true, + }, + groupId: { + type: Number, + required: false, + default: null, + }, + disabled: { + type: Boolean, + required: true, + }, + issueLinkBase: { + type: String, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + boardId: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['isShowingEpicsSwimlanes']), + isSwimlanesOn() { + return this.glFeatures.boardsWithSwimlanes && this.isShowingEpicsSwimlanes; + }, + }, +}; +</script> + +<template> + <div> + <div + v-if="!isSwimlanesOn" + class="boards-list w-100 py-3 px-2 text-nowrap" + data-qa-selector="boards_list" + > + <board-column + v-for="list in lists" + :key="list.id" + ref="board" + :can-admin-list="canAdminList" + :group-id="groupId" + :list="list" + :disabled="disabled" + :issue-link-base="issueLinkBase" + :root-path="rootPath" + :board-id="boardId" + /> + </div> + <epics-swimlanes + v-else + ref="swimlanes" + :lists="lists" + :can-admin-list="canAdminList" + :disabled="disabled" + :board-id="boardId" + /> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js index cc15dc82db9..b74234a2e3c 100644 --- a/app/assets/javascripts/boards/components/board_delete.js +++ b/app/assets/javascripts/boards/components/board_delete.js @@ -1,8 +1,15 @@ import $ from 'jquery'; import Vue from 'vue'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; export default Vue.extend({ + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { list: { type: Object, diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index c4e2c398d45..4270ad5783d 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -104,7 +104,7 @@ export default { }, }, created() { - eventHub.$on(`hide-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); }, mounted() { @@ -381,7 +381,7 @@ export default { this.$refs.list.addEventListener('scroll', this.onScroll); }, beforeDestroy() { - eventHub.$off(`hide-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); this.$refs.list.removeEventListener('scroll', this.onScroll); }, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue new file mode 100644 index 00000000000..eb12617a66e --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -0,0 +1,291 @@ +<script> +import { + GlButton, + GlButtonGroup, + GlLabel, + GlTooltip, + GlIcon, + GlTooltipDirective, +} from '@gitlab/ui'; +import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; +import { s__, __, sprintf } from '~/locale'; +import AccessorUtilities from '../../lib/utils/accessor'; +import BoardDelete from './board_delete'; +import IssueCount from './issue_count.vue'; +import boardsStore from '../stores/boards_store'; +import eventHub from '../eventhub'; +import { ListType } from '../constants'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + components: { + BoardDelete, + GlButtonGroup, + GlButton, + GlLabel, + GlTooltip, + GlIcon, + IssueCount, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [isWipLimitsOn], + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + disabled: { + type: Boolean, + required: true, + }, + boardId: { + type: String, + required: true, + }, + canAdminList: { + type: Boolean, + required: false, + default: false, + }, + isSwimlanesHeader: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + weightFeatureAvailable: false, + }; + }, + computed: { + isLoggedIn() { + return Boolean(gon.current_user_id); + }, + listType() { + return this.list.type; + }, + listAssignee() { + return this.list?.assignee?.username || ''; + }, + listTitle() { + return this.list?.label?.description || this.list.title || ''; + }, + showListHeaderButton() { + return ( + !this.disabled && + this.listType !== ListType.closed && + this.listType !== ListType.blank && + this.listType !== ListType.promotion + ); + }, + issuesTooltip() { + const { issuesSize } = this.list; + + return sprintf(__('%{issuesSize} issues'), { issuesSize }); + }, + chevronTooltip() { + return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); + }, + chevronIcon() { + return this.list.isExpanded ? 'chevron-right' : 'chevron-down'; + }, + isNewIssueShown() { + return this.listType === ListType.backlog || this.showListHeaderButton; + }, + isSettingsShown() { + return ( + this.listType !== ListType.backlog && + this.showListHeaderButton && + this.list.isExpanded && + this.isWipLimitsOn + ); + }, + showBoardListAndBoardInfo() { + return this.listType !== ListType.blank && this.listType !== ListType.promotion; + }, + uniqueKey() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `boards.${this.boardId}.${this.listType}.${this.list.id}`; + }, + }, + methods: { + showScopedLabels(label) { + return boardsStore.scopedLabels.enabled && isScopedLabel(label); + }, + + showNewIssueForm() { + eventHub.$emit(`toggle-issue-form-${this.list.id}`); + }, + toggleExpanded() { + if (this.list.isExpandable) { + this.list.isExpanded = !this.list.isExpanded; + + if (AccessorUtilities.isLocalStorageAccessSafe() && !this.isLoggedIn) { + localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); + } + + if (this.isLoggedIn) { + this.list.update(); + } + + // When expanding/collapsing, the tooltip on the caret button sometimes stays open. + // Close all tooltips manually to prevent dangling tooltips. + this.$root.$emit('bv::hide::tooltip'); + } + }, + }, +}; +</script> + +<template> + <header + :class="{ + 'has-border': list.label && list.label.color, + 'gl-relative': list.isExpanded, + 'gl-h-full': !list.isExpanded, + 'board-inner gl-rounded-base gl-border-b-0': isSwimlanesHeader, + }" + :style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }" + class="board-header gl-relative" + data-qa-selector="board_list_header" + data-testid="board-list-header" + > + <h3 + :class="{ + 'user-can-drag': !disabled && !list.preset, + 'gl-border-b-0': !list.isExpanded, + }" + class="board-title gl-m-0 gl-display-flex js-board-handle" + > + <gl-button + v-if="list.isExpandable" + v-gl-tooltip.hover + :aria-label="chevronTooltip" + :title="chevronTooltip" + :icon="chevronIcon" + class="board-title-caret no-drag" + variant="link" + @click="toggleExpanded" + /> + <!-- The following is only true in EE and if it is a milestone --> + <span + v-if="list.type === 'milestone' && list.milestone" + aria-hidden="true" + class="gl-mr-2 milestone-icon" + > + <gl-icon name="timer" /> + </span> + + <a + v-if="list.type === 'assignee'" + :href="list.assignee.path" + class="user-avatar-link js-no-trigger" + > + <img + v-gl-tooltip.hover.bottom + :title="listAssignee" + :alt="list.assignee.name" + :src="list.assignee.avatar" + class="avatar s20" + height="20" + width="20" + /> + </a> + <div class="board-title-text"> + <span + v-if="list.type !== 'label'" + v-gl-tooltip.hover + :class="{ + 'gl-display-inline-block': list.type === 'milestone', + }" + :title="listTitle" + class="board-title-main-text block-truncated" + > + {{ list.title }} + </span> + <span v-if="list.type === 'assignee'" class="board-title-sub-text gl-ml-2"> + @{{ list.assignee.username }} + </span> + <gl-label + v-if="list.type === 'label'" + v-gl-tooltip.hover.bottom + :background-color="list.label.color" + :description="list.label.description" + :scoped="showScopedLabels(list.label)" + :size="!list.isExpanded ? 'sm' : ''" + :title="list.label.title" + /> + </div> + <board-delete + v-if="canAdminList && !list.preset && list.id" + :list="list" + inline-template="true" + > + <gl-button + v-gl-tooltip.hover.bottom + :class="{ 'gl-display-none': !list.isExpanded }" + :aria-label="__('Delete list')" + class="board-delete no-drag gl-pr-0 gl-shadow-none gl-mr-3" + :title="__('Delete list')" + icon="remove" + size="small" + @click.stop="deleteBoard" + /> + </board-delete> + <div + v-if="showBoardListAndBoardInfo" + class="issue-count-badge gl-pr-0 no-drag text-secondary" + > + <span class="gl-display-inline-flex"> + <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltip" /> + <span ref="issueCount" class="issue-count-badge-count"> + <gl-icon class="gl-mr-2" name="issues" /> + <issue-count :issues-size="list.issuesSize" :max-issue-count="list.maxIssueCount" /> + </span> + <!-- The following is only true in EE. --> + <template v-if="weightFeatureAvailable"> + <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> + <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3"> + <gl-icon class="gl-mr-2" name="weight" /> + {{ list.totalWeight }} + </span> + </template> + </span> + </div> + <gl-button-group + v-if="isNewIssueShown || isSettingsShown" + class="board-list-button-group pl-2" + > + <gl-button + v-if="isNewIssueShown" + ref="newIssueBtn" + v-gl-tooltip.hover + :class="{ + 'gl-display-none': !list.isExpanded, + }" + :aria-label="__('New issue')" + :title="__('New issue')" + class="issue-count-badge-add-button no-drag" + icon="plus" + @click="showNewIssueForm" + /> + + <gl-button + v-if="isSettingsShown" + ref="settingsBtn" + v-gl-tooltip.hover + :aria-label="__('List settings')" + class="no-drag js-board-settings-button" + :title="__('List settings')" + icon="settings" + @click="openSidebarSettings" + /> + <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip> + </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 deebe122109..c72fb7b30f9 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -92,7 +92,7 @@ export default { }, cancel() { this.title = ''; - eventHub.$emit(`hide-issue-form-${this.list.id}`); + eventHub.$emit(`toggle-issue-form-${this.list.id}`); }, setSelectedProject(selectedProject) { this.selectedProject = selectedProject; diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index c8953158811..056a7b48212 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -54,7 +54,7 @@ export default Vue.extend({ return this.issue.milestone ? this.issue.milestone.title : __('No milestone'); }, canRemove() { - return !this.list.preset; + return !this.list?.preset; }, hasLabels() { return this.issue.labels && this.issue.labels.length; diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index a589fb325b2..f2e198eaedb 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -147,7 +147,7 @@ export default { <template> <div> <div class="d-flex board-card-header" dir="auto"> - <h4 class="board-card-title append-bottom-0 prepend-top-0"> + <h4 class="board-card-title gl-mb-0 gl-mt-0"> <icon v-if="issue.blocked" v-gl-tooltip @@ -169,7 +169,7 @@ export default { }}</a> </h4> </div> - <div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap"> + <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 d-flex flex-wrap"> <template v-for="label in orderedLabels"> <gl-label :key="label.id" @@ -188,7 +188,7 @@ export default { > <span v-if="issue.referencePath" - class="board-card-number overflow-hidden d-flex append-right-8 prepend-top-8" + class="board-card-number overflow-hidden d-flex gl-mr-3 gl-mt-3" > <tooltip-on-truncate v-if="issueReferencePath" @@ -199,7 +199,7 @@ export default { > #{{ issue.iid }} </span> - <span class="board-info-items prepend-top-8 d-inline-block"> + <span class="board-info-items gl-mt-3 d-inline-block"> <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" :closed="issue.closed" /> <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> <issue-card-weight diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 9ff7575ae09..a882cd1cdfa 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,12 +1,15 @@ import $ from 'jquery'; import Vue from 'vue'; +import { mapActions } from 'vuex'; import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; +import BoardContent from '~/boards/components/board_content.vue'; import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar'; import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown'; import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; import toggleLabels from 'ee_else_ce/boards/toggle_labels'; +import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes'; import { setPromotionState, setWeigthFetchingState, @@ -76,6 +79,7 @@ export default () => { issueBoardsApp = new Vue({ el: $boardApp, components: { + BoardContent, Board: () => window?.gon?.features?.sfcIssueBoards ? import('ee_else_ce/boards/components/board_column.vue') @@ -114,14 +118,16 @@ export default () => { }, }, created() { - boardsStore.setEndpoints({ + const endpoints = { boardsEndpoint: this.boardsEndpoint, recentBoardsEndpoint: this.recentBoardsEndpoint, listsEndpoint: this.listsEndpoint, bulkUpdatePath: this.bulkUpdatePath, boardId: this.boardId, fullPath: $boardApp.dataset.fullPath, - }); + }; + this.setEndpoints(endpoints); + boardsStore.setEndpoints(endpoints); boardsStore.rootPath = this.boardsEndpoint; eventHub.$on('updateTokens', this.updateTokens); @@ -192,6 +198,7 @@ export default () => { } }, methods: { + ...mapActions(['setEndpoints']), updateTokens() { this.filterManager.updateTokens(); }, @@ -371,5 +378,6 @@ export default () => { toggleFocusMode(ModalStore, boardsStore); toggleLabels(); + toggleEpicsSwimlanes(); mountMultipleBoardsSwitcher(); }; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 878f49cc6be..98eac35b2ed 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -30,56 +30,43 @@ class ListIssue { } addLabel(label) { - if (!this.findLabel(label)) { - this.labels.push(new ListLabel(label)); - } + boardsStore.addIssueLabel(this, label); } findLabel(findLabel) { - return this.labels.find(label => label.id === findLabel.id); + return boardsStore.findIssueLabel(this, findLabel); } removeLabel(removeLabel) { - if (removeLabel) { - this.labels = this.labels.filter(label => removeLabel.id !== label.id); - } + boardsStore.removeIssueLabel(this, removeLabel); } removeLabels(labels) { - labels.forEach(this.removeLabel.bind(this)); + boardsStore.removeIssueLabels(this, labels); } addAssignee(assignee) { - if (!this.findAssignee(assignee)) { - this.assignees.push(new ListAssignee(assignee)); - } + boardsStore.addIssueAssignee(this, assignee); } findAssignee(findAssignee) { - return this.assignees.find(assignee => assignee.id === findAssignee.id); + return boardsStore.findIssueAssignee(this, findAssignee); } removeAssignee(removeAssignee) { - if (removeAssignee) { - this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); - } + boardsStore.removeIssueAssignee(this, removeAssignee); } removeAllAssignees() { - this.assignees = []; + boardsStore.removeAllIssueAssignees(this); } addMilestone(milestone) { - const miletoneId = this.milestone ? this.milestone.id : null; - if (IS_EE && milestone.id !== miletoneId) { - this.milestone = new ListMilestone(milestone); - } + boardsStore.addIssueMilestone(this, milestone); } removeMilestone(removeMilestone) { - if (IS_EE && removeMilestone && removeMilestone.id === this.milestone.id) { - this.milestone = {}; - } + boardsStore.removeIssueMilestone(this, removeMilestone); } getLists() { @@ -87,15 +74,15 @@ class ListIssue { } updateData(newData) { - Object.assign(this, newData); + boardsStore.updateIssueData(this, newData); } setFetchingState(key, value) { - this.isFetching[key] = value; + boardsStore.setIssueFetchingState(this, key, value); } setLoadingState(key, value) { - this.isLoading[key] = value; + boardsStore.setIssueLoadingState(this, key, value); } update() { diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 31c372b7a75..0bd606c6297 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,4 +1,4 @@ -/* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return */ +/* eslint-disable no-underscore-dangle, class-methods-use-this */ import ListIssue from 'ee_else_ce/boards/models/issue'; import { __ } from '~/locale'; @@ -8,8 +8,6 @@ import flash from '~/flash'; import boardsStore from '../stores/boards_store'; import ListMilestone from './milestone'; -const PER_PAGE = 20; - const TYPES = { backlog: { isPreset: true, @@ -83,30 +81,15 @@ class List { } destroy() { - const index = boardsStore.state.lists.indexOf(this); - boardsStore.state.lists.splice(index, 1); - boardsStore.updateNewListDropdown(this.id); - - boardsStore.destroyList(this.id).catch(() => { - // TODO: handle request error - }); + boardsStore.destroy(this); } update() { - const collapsed = !this.isExpanded; - return boardsStore.updateList(this.id, this.position, collapsed).catch(() => { - // TODO: handle request error - }); + return boardsStore.updateListFunc(this); } nextPage() { - if (this.issuesSize > this.issues.length) { - if (this.issues.length / PER_PAGE >= 1) { - this.page += 1; - } - - return this.getIssues(false); - } + return boardsStore.goToNextPage(this); } getIssues(emptyIssues = true) { @@ -114,13 +97,7 @@ class List { } newIssue(issue) { - this.addIssue(issue, null, 0); - this.issuesSize += 1; - - return boardsStore - .newIssue(this.id, issue) - .then(res => res.data) - .then(data => this.onNewIssueResponse(issue, data)); + return boardsStore.newListIssue(this, issue); } createIssues(data) { @@ -138,12 +115,7 @@ class List { } moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) { - this.issues.splice(oldIndex, 1); - this.issues.splice(newIndex, 0, issue); - - boardsStore.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => { - // TODO: handle request error - }); + boardsStore.moveListIssues(this, issue, oldIndex, newIndex, moveBeforeId, moveAfterId); } moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) { @@ -182,35 +154,15 @@ class List { } findIssue(id) { - return this.issues.find(issue => issue.id === id); + return boardsStore.findListIssue(this, id); } removeMultipleIssues(removeIssues) { - const ids = removeIssues.map(issue => issue.id); - - this.issues = this.issues.filter(issue => { - const matchesRemove = ids.includes(issue.id); - - if (matchesRemove) { - this.issuesSize -= 1; - issue.removeLabel(this.label); - } - - return !matchesRemove; - }); + return boardsStore.removeListMultipleIssues(this, removeIssues); } removeIssue(removeIssue) { - this.issues = this.issues.filter(issue => { - const matchesRemove = removeIssue.id === issue.id; - - if (matchesRemove) { - this.issuesSize -= 1; - issue.removeLabel(this.label); - } - - return !matchesRemove; - }); + return boardsStore.removeListIssues(this, removeIssue); } getTypeInfo(type) { diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 34598d66f45..08fedb14dff 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,11 +1,13 @@ +import * as types from './mutation_types'; + const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ throw new Error('Not implemented!'); }; export default { - setEndpoints: () => { - notImplemented(); + setEndpoints: ({ commit }, endpoints) => { + commit(types.SET_ENDPOINTS, endpoints); }, fetchLists: () => { diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index fdbd7e89bfb..a930f39189e 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -1,4 +1,4 @@ -/* eslint-disable no-shadow, no-param-reassign */ +/* eslint-disable no-shadow, no-param-reassign,consistent-return */ /* global List */ import $ from 'jquery'; @@ -22,6 +22,7 @@ import ListLabel from '../models/label'; import ListAssignee from '../models/assignee'; import ListMilestone from '../models/milestone'; +const PER_PAGE = 20; const boardsStore = { disabled: false, timeTracking: { @@ -42,6 +43,7 @@ const boardsStore = { }, detail: { issue: {}, + list: {}, }, moving: { issue: {}, @@ -73,6 +75,7 @@ const boardsStore = { this.filter.path = getUrlParamsArray().join('&'); this.detail = { issue: {}, + list: {}, }; }, showPage(page) { @@ -133,6 +136,21 @@ const boardsStore = { path: '', }); }, + + findIssueLabel(issue, findLabel) { + return issue.labels.find(label => label.id === findLabel.id); + }, + + goToNextPage(list) { + if (list.issuesSize > list.issues.length) { + if (list.issues.length / PER_PAGE >= 1) { + list.page += 1; + } + + return list.getIssues(false); + } + }, + addListIssue(list, issue, listFrom, newIndex) { let moveBeforeId = null; let moveAfterId = null; @@ -177,6 +195,10 @@ const boardsStore = { } } }, + findListIssue(list, id) { + return list.issues.find(issue => issue.id === id); + }, + welcomeIsHidden() { return parseBoolean(Cookies.get('issue_board_welcome_hidden')); }, @@ -243,6 +265,33 @@ const boardsStore = { } }, + removeListIssues(list, removeIssue) { + list.issues = list.issues.filter(issue => { + const matchesRemove = removeIssue.id === issue.id; + + if (matchesRemove) { + list.issuesSize -= 1; + issue.removeLabel(list.label); + } + + return !matchesRemove; + }); + }, + removeListMultipleIssues(list, removeIssues) { + const ids = removeIssues.map(issue => issue.id); + + list.issues = list.issues.filter(issue => { + const matchesRemove = ids.includes(issue.id); + + if (matchesRemove) { + list.issuesSize -= 1; + issue.removeLabel(list.label); + } + + return !matchesRemove; + }); + }, + startMoving(list, issue) { Object.assign(this.moving, { list, issue }); }, @@ -516,9 +565,25 @@ const boardsStore = { }); }, + updateListFunc(list) { + const collapsed = !list.isExpanded; + return this.updateList(list.id, list.position, collapsed).catch(() => { + // TODO: handle request error + }); + }, + destroyList(id) { return axios.delete(`${this.state.endpoints.listsEndpoint}/${id}`); }, + destroy(list) { + const index = this.state.lists.indexOf(list); + this.state.lists.splice(index, 1); + this.updateNewListDropdown(list.id); + + this.destroyList(list.id).catch(() => { + // TODO: handle request error + }); + }, saveList(list) { const entity = list.label || list.assignee || list.milestone; @@ -591,6 +656,15 @@ const boardsStore = { }); }, + moveListIssues(list, issue, oldIndex, newIndex, moveBeforeId, moveAfterId) { + list.issues.splice(oldIndex, 1); + list.issues.splice(newIndex, 0, issue); + + this.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => { + // TODO: handle request error + }); + }, + moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }) { return axios.put(this.generateMultiDragPath(this.state.endpoints.boardId), { from_list_id: fromListId, @@ -607,6 +681,15 @@ const boardsStore = { }); }, + newListIssue(list, issue) { + list.addIssue(issue, null, 0); + list.issuesSize += 1; + + return this.newIssue(list.id, issue) + .then(res => res.data) + .then(data => list.onNewIssueResponse(issue, data)); + }, + getBacklog(data) { return axios.get( mergeUrlParams( @@ -615,6 +698,21 @@ const boardsStore = { ), ); }, + removeIssueLabel(issue, removeLabel) { + if (removeLabel) { + issue.labels = issue.labels.filter(label => removeLabel.id !== label.id); + } + }, + + addIssueAssignee(issue, assignee) { + if (!issue.findAssignee(assignee)) { + issue.assignees.push(new ListAssignee(assignee)); + } + }, + + removeIssueLabels(issue, labels) { + labels.forEach(issue.removeLabel.bind(issue)); + }, bulkUpdate(issueIds, extraData = {}) { const data = { @@ -682,10 +780,49 @@ const boardsStore = { ...this.multiSelect.list.slice(index + 1), ]; }, + removeIssueAssignee(issue, removeAssignee) { + if (removeAssignee) { + issue.assignees = issue.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + }, + + findIssueAssignee(issue, findAssignee) { + return issue.assignees.find(assignee => assignee.id === findAssignee.id); + }, clearMultiSelect() { this.multiSelect.list = []; }, + + removeAllIssueAssignees(issue) { + issue.assignees = []; + }, + + addIssueMilestone(issue, milestone) { + const miletoneId = issue.milestone ? issue.milestone.id : null; + if (IS_EE && milestone.id !== miletoneId) { + issue.milestone = new ListMilestone(milestone); + } + }, + + setIssueLoadingState(issue, key, value) { + issue.isLoading[key] = value; + }, + + updateIssueData(issue, newData) { + Object.assign(issue, newData); + }, + + setIssueFetchingState(issue, key, value) { + issue.isFetching[key] = value; + }, + + removeIssueMilestone(issue, removeMilestone) { + if (IS_EE && removeMilestone && removeMilestone.id === issue.milestone.id) { + issue.milestone = {}; + } + }, + refreshIssueData(issue, obj) { issue.id = obj.id; issue.iid = obj.iid; @@ -718,6 +855,11 @@ const boardsStore = { issue.assignees = obj.assignees.map(a => new ListAssignee(a)); } }, + addIssueLabel(issue, label) { + if (!issue.findLabel(label)) { + issue.labels.push(new ListLabel(label)); + } + }, updateIssue(issue) { const data = { issue: { diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 7a287400265..e4459cdcc07 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -6,8 +6,8 @@ const notImplemented = () => { }; export default { - [mutationTypes.SET_ENDPOINTS]: () => { - notImplemented(); + [mutationTypes.SET_ENDPOINTS]: (state, endpoints) => { + state.endpoints = endpoints; }, [mutationTypes.REQUEST_ADD_LIST]: () => { diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 10aac2f649e..aca93c4d7c6 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,6 +1,7 @@ import { inactiveListId } from '~/boards/constants'; export default () => ({ + endpoints: {}, isShowingLabels: true, activeListId: inactiveListId, }); diff --git a/app/assets/javascripts/boards/toggle_epics_swimlanes.js b/app/assets/javascripts/boards/toggle_epics_swimlanes.js new file mode 100644 index 00000000000..2d1ec238274 --- /dev/null +++ b/app/assets/javascripts/boards/toggle_epics_swimlanes.js @@ -0,0 +1 @@ +export default () => {}; diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue index f5c2cc57f3f..c15d638d92b 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue @@ -154,7 +154,7 @@ export default { v-for="(result, i) in results" :key="i" role="option" - :class="{ 'gl-bg-gray-100': i === arrowCounter }" + :class="{ 'gl-bg-gray-50': i === arrowCounter }" :aria-selected="i === arrowCounter" > <gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{ diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index f6ade0867cd..6531b945212 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -1,19 +1,29 @@ <script> import { + GlAlert, + GlButton, + GlCollapse, GlDeprecatedButton, - GlModal, - GlFormSelect, + GlFormCheckbox, GlFormGroup, GlFormInput, + GlFormSelect, GlFormTextarea, - GlFormCheckbox, - GlLink, GlIcon, + GlLink, + GlModal, + GlSprintf, } from '@gitlab/ui'; +import Cookies from 'js-cookie'; import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; +import { + AWS_TOKEN_CONSTANTS, + ADD_CI_VARIABLE_MODAL_ID, + AWS_TIP_DISMISSED_COOKIE_NAME, + AWS_TIP_MESSAGE, +} from '../constants'; import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; import CiKeyField from './ci_key_field.vue'; import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; @@ -23,19 +33,29 @@ export default { components: { CiEnvironmentsDropdown, CiKeyField, + GlAlert, + GlButton, + GlCollapse, GlDeprecatedButton, - GlModal, - GlFormSelect, + GlFormCheckbox, GlFormGroup, GlFormInput, + GlFormSelect, GlFormTextarea, - GlFormCheckbox, - GlLink, GlIcon, + GlLink, + GlModal, + GlSprintf, }, mixins: [glFeatureFlagsMixin()], tokens: awsTokens, tokenList: awsTokenList, + awsTipMessage: AWS_TIP_MESSAGE, + data() { + return { + isTipDismissed: Cookies.get(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', + }; + }, computed: { ...mapState([ 'projectId', @@ -47,7 +67,16 @@ export default { 'maskableRegex', 'selectedEnvironment', 'isProtectedByDefault', + 'awsLogoSvgPath', + 'awsTipDeployLink', + 'awsTipCommandsLink', + 'awsTipLearnLink', + 'protectedEnvironmentVariablesLink', + 'maskedEnvironmentVariablesLink', ]), + isTipVisible() { + return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variableData.key); + }, canSubmit() { return ( this.variableValidationState && @@ -126,6 +155,10 @@ export default { 'setSelectedEnvironment', 'setVariableProtected', ]), + dismissTip() { + Cookies.set(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 }); + this.isTipDismissed = true; + }, deleteVarAndClose() { this.deleteVariable(this.variableBeingEdited); this.hideModal(); @@ -232,10 +265,10 @@ export default { <gl-form-group :label="__('Flags')" label-for="ci-variable-flags"> <gl-form-checkbox v-model="variableData.protected" class="mb-0"> {{ __('Protect variable') }} - <gl-link href="/help/ci/variables/README#protected-environment-variables"> + <gl-link target="_blank" :href="protectedEnvironmentVariablesLink"> <gl-icon name="question" :size="12" /> </gl-link> - <p class="prepend-top-4 text-secondary"> + <p class="gl-mt-2 text-secondary"> {{ __('Export variable to pipelines running on protected branches and tags only.') }} </p> </gl-form-checkbox> @@ -246,10 +279,10 @@ export default { data-qa-selector="ci_variable_masked_checkbox" > {{ __('Mask variable') }} - <gl-link href="/help/ci/variables/README#masked-variables"> + <gl-link target="_blank" :href="maskedEnvironmentVariablesLink"> <gl-icon name="question" :size="12" /> </gl-link> - <p class="prepend-top-4 append-bottom-0 text-secondary"> + <p class="gl-mt-2 gl-mb-0 text-secondary"> {{ __('Variable will be masked in job logs.') }} <span :class="{ @@ -258,13 +291,52 @@ export default { > {{ __('Requires values to meet regular expression requirements.') }}</span > - <gl-link href="/help/ci/variables/README#masked-variables">{{ + <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{ __('More information') }}</gl-link> </p> </gl-form-checkbox> </gl-form-group> </form> + <gl-collapse :visible="isTipVisible"> + <gl-alert + :title="__('Deploying to AWS is easy with GitLab')" + variant="tip" + data-testid="aws-guidance-tip" + @dismiss="dismissTip" + > + <div class="gl-display-flex gl-flex-direction-row"> + <div> + <p> + <gl-sprintf :message="$options.awsTipMessage"> + <template #deployLink="{ content }"> + <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link> + </template> + <template #commandsLink="{ content }"> + <gl-link :href="awsTipCommandsLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p> + <gl-button + :href="awsTipLearnLink" + target="_blank" + category="secondary" + variant="info" + class="gl-overflow-wrap-break" + >{{ __('Learn more about deploying to AWS') }}</gl-button + > + </p> + </div> + <img + class="gl-mt-3" + :alt="__('Amazon Web Services Logo')" + :src="awsLogoSvgPath" + height="32" + /> + </div> + </gl-alert> + </gl-collapse> <template #modal-footer> <gl-deprecated-button @click="hideModal">{{ __('Cancel') }}</gl-deprecated-button> <gl-deprecated-button diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index 7eb791f97e4..7b703c5ede1 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -170,7 +170,7 @@ export default { v-if="tableIsNotEmpty" ref="secret-value-reveal-button" data-qa-selector="reveal_ci_variable_value_button" - class="append-right-8" + class="gl-mr-3" @click="toggleValues(!valuesHidden)" >{{ valuesButtonText }}</gl-deprecated-button > diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index a4db6481720..ef304c7ccee 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -15,7 +15,13 @@ export const types = { allEnvironmentsType: '*', }; +export const AWS_TIP_DISMISSED_COOKIE_NAME = 'ci_variable_list_constants_aws_tip_dismissed'; +export const AWS_TIP_MESSAGE = __( + '%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.', +); + // AWS TOKEN CONSTANTS export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID'; export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION'; export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY'; +export const AWS_TOKEN_CONSTANTS = [AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY]; diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js index 2b4a56a4e6d..a28b52d6b57 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci_variable_list/index.js @@ -3,9 +3,21 @@ import CiVariableSettings from './components/ci_variable_settings.vue'; import createStore from './store'; import { parseBoolean } from '~/lib/utils/common_utils'; -export default () => { - const el = document.getElementById('js-ci-project-variables'); - const { endpoint, projectId, group, maskableRegex, protectedByDefault } = el.dataset; +export default (containerId = 'js-ci-project-variables') => { + const containerEl = document.getElementById(containerId); + const { + endpoint, + projectId, + group, + maskableRegex, + protectedByDefault, + awsLogoSvgPath, + awsTipDeployLink, + awsTipCommandsLink, + awsTipLearnLink, + protectedEnvironmentVariablesLink, + maskedEnvironmentVariablesLink, + } = containerEl.dataset; const isGroup = parseBoolean(group); const isProtectedByDefault = parseBoolean(protectedByDefault); @@ -15,10 +27,16 @@ export default () => { isGroup, maskableRegex, isProtectedByDefault, + awsLogoSvgPath, + awsTipDeployLink, + awsTipCommandsLink, + awsTipLearnLink, + protectedEnvironmentVariablesLink, + maskedEnvironmentVariablesLink, }); return new Vue({ - el, + el: containerEl, store, render(createElement) { return createElement(CiVariableSettings); diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index d8bfbdb458c..f15efb2fdeb 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -468,6 +468,11 @@ export default class Clusters { return; } + if (appId === KNATIVE && !params.hostname && !params.pages_domain_id) { + reject(s__('ClusterIntegration|You must specify a domain before you can install Knative.')); + return; + } + resolve(); }); } @@ -520,6 +525,7 @@ export default class Clusters { this.store.updateAppProperty(appId, 'isEditingDomain', true); this.store.updateAppProperty(appId, 'hostname', domain); this.store.updateAppProperty(appId, 'pagesDomain', domainId ? { id: domainId, domain } : null); + this.store.updateAppProperty(appId, 'validationError', null); } setCrossplaneProviderStack(data) { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 53bc079a4e1..ba6de41e025 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -1,23 +1,24 @@ <script> -/* eslint-disable vue/require-default-prop */ -/* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlLink, GlModalDirective } from '@gitlab/ui'; +import { GlLink, GlModalDirective, GlSprintf } from '@gitlab/ui'; import { s__, __, sprintf } from '~/locale'; import eventHub from '../event_hub'; import identicon from '../../vue_shared/components/identicon.vue'; import loadingButton from '../../vue_shared/components/loading_button.vue'; import UninstallApplicationButton from './uninstall_application_button.vue'; import UninstallApplicationConfirmationModal from './uninstall_application_confirmation_modal.vue'; +import UpdateApplicationConfirmationModal from './update_application_confirmation_modal.vue'; -import { APPLICATION_STATUS } from '../constants'; +import { APPLICATION_STATUS, ELASTIC_STACK } from '../constants'; export default { components: { loadingButton, identicon, GlLink, + GlSprintf, UninstallApplicationButton, UninstallApplicationConfirmationModal, + UpdateApplicationConfirmationModal, }, directives: { GlModalDirective, @@ -34,15 +35,17 @@ export default { titleLink: { type: String, required: false, + default: '', }, manageLink: { type: String, required: false, + default: '', }, logoUrl: { type: String, required: false, - default: null, + default: '', }, disabled: { type: Boolean, @@ -57,14 +60,17 @@ export default { status: { type: String, required: false, + default: '', }, statusReason: { type: String, required: false, + default: '', }, requestReason: { type: String, required: false, + default: '', }, installed: { type: Boolean, @@ -76,17 +82,15 @@ export default { required: false, default: false, }, - installedVia: { - type: String, - required: false, - }, version: { type: String, required: false, + default: '', }, chartRepo: { type: String, required: false, + default: '', }, updateAvailable: { type: Boolean, @@ -204,15 +208,6 @@ export default { return sprintf(errorDescription, { title: this.title }); }, - versionLabel() { - if (this.updateFailed) { - return __('Update failed'); - } else if (this.isUpdating) { - return __('Updating'); - } - - return this.updateSuccessful ? __('Updated to') : __('Updated'); - }, updateFailureDescription() { return s__('ClusterIntegration|Update failed. Please check the logs and try again.'); }, @@ -233,6 +228,17 @@ export default { return label; }, + updatingNeedsConfirmation() { + if (this.version) { + const majorVersion = parseInt(this.version.split('.')[0], 10); + + if (!Number.isNaN(majorVersion)) { + return this.id === ELASTIC_STACK && majorVersion < 3; + } + } + + return false; + }, isUpdating() { // Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend return this.status === APPLICATION_STATUS.UPDATING; @@ -248,6 +254,12 @@ export default { title: this.title, }); }, + updateModalId() { + return `update-${this.id}`; + }, + uninstallModalId() { + return `uninstall-${this.id}`; + }, }, watch: { updateSuccessful(updateSuccessful) { @@ -263,12 +275,16 @@ export default { }, methods: { installClicked() { + if (this.disabled || this.installButtonDisabled) return; + eventHub.$emit('installApplication', { id: this.id, params: this.installApplicationRequestParams, }); }, - updateClicked() { + updateConfirmed() { + if (this.isUpdating) return; + eventHub.$emit('updateApplication', { id: this.id, params: this.installApplicationRequestParams, @@ -294,7 +310,7 @@ export default { :data-qa-selector="id" > <div class="gl-responsive-table-row-layout" role="row"> - <div class="table-section append-right-8 section-align-top" role="gridcell"> + <div class="table-section gl-mr-3 section-align-top" role="gridcell"> <img v-if="hasLogo" :src="logoUrl" @@ -315,14 +331,12 @@ export default { > <span v-else class="js-cluster-application-title">{{ title }}</span> </strong> - <span - v-if="installedVia" - class="js-cluster-application-installed-via" - v-html="installedVia" - ></span> - <slot name="description"></slot> + <slot name="installedVia"></slot> + <div> + <slot name="description"></slot> + </div> <div v-if="hasError" class="cluster-application-error text-danger prepend-top-10"> - <p class="js-cluster-application-general-error-message append-bottom-0"> + <p class="js-cluster-application-general-error-message gl-mb-0"> {{ generalErrorDescription }} </p> <ul v-if="statusReason || requestReason"> @@ -340,14 +354,20 @@ export default { v-if="shouldShowUpdateDetails" class="form-text text-muted label p-0 js-cluster-application-update-details" > - {{ versionLabel }} - <gl-link - v-if="updateSuccessful" - :href="chartRepo" - target="_blank" - class="js-cluster-application-update-version" - >chart v{{ version }}</gl-link - > + <template v-if="updateFailed">{{ __('Update failed') }}</template> + <template v-else-if="isUpdating">{{ __('Updating') }}</template> + <template v-else> + <gl-sprintf :message="__('Updated to %{linkStart}chart v%{linkEnd}')"> + <template #link="{ content }"> + <gl-link + :href="chartRepo" + target="_blank" + class="js-cluster-application-update-version" + >{{ content }}{{ version }}</gl-link + > + </template> + </gl-sprintf> + </template> </div> <div @@ -356,14 +376,36 @@ export default { > {{ updateFailureDescription }} </div> - <loading-button - v-if="updateAvailable || updateFailed || isUpdating" - class="btn btn-primary js-cluster-application-update-button mt-2" - :loading="isUpdating" - :disabled="isUpdating" - :label="updateButtonLabel" - @click="updateClicked" - /> + <template v-if="updateAvailable || updateFailed || isUpdating"> + <template v-if="updatingNeedsConfirmation"> + <loading-button + v-gl-modal-directive="updateModalId" + class="btn btn-primary js-cluster-application-update-button mt-2" + :loading="isUpdating" + :disabled="isUpdating" + :label="updateButtonLabel" + data-qa-selector="update_button_with_confirmation" + :data-qa-application="id" + /> + + <update-application-confirmation-modal + :application="id" + :application-title="title" + @confirm="updateConfirmed()" + /> + </template> + + <loading-button + v-else + class="btn btn-primary js-cluster-application-update-button mt-2" + :loading="isUpdating" + :disabled="isUpdating" + :label="updateButtonLabel" + data-qa-selector="update_button" + :data-qa-application="id" + @click="updateConfirmed" + /> + </template> </div> </div> <div @@ -389,7 +431,7 @@ export default { /> <uninstall-application-button v-if="displayUninstallButton" - v-gl-modal-directive="'uninstall-' + id" + v-gl-modal-directive="uninstallModalId" :status="status" data-qa-selector="uninstall_button" :data-qa-application="id" diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index f11502a7dde..214906021ad 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -1,21 +1,16 @@ <script> -import { escape } from 'lodash'; import helmInstallIllustration from '@gitlab/svgs/dist/illustrations/kubernetes-installation.svg'; -import { GlLoadingIcon } from '@gitlab/ui'; -import elasticsearchLogo from 'images/cluster_app_logos/elasticsearch.png'; +import { GlLoadingIcon, GlSprintf, GlLink } from '@gitlab/ui'; import gitlabLogo from 'images/cluster_app_logos/gitlab.png'; import helmLogo from 'images/cluster_app_logos/helm.png'; -import jeagerLogo from 'images/cluster_app_logos/jeager.png'; import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png'; import kubernetesLogo from 'images/cluster_app_logos/kubernetes.png'; import certManagerLogo from 'images/cluster_app_logos/cert_manager.png'; import crossplaneLogo from 'images/cluster_app_logos/crossplane.png'; import knativeLogo from 'images/cluster_app_logos/knative.png'; -import meltanoLogo from 'images/cluster_app_logos/meltano.png'; import prometheusLogo from 'images/cluster_app_logos/prometheus.png'; import elasticStackLogo from 'images/cluster_app_logos/elastic_stack.png'; import fluentdLogo from 'images/cluster_app_logos/fluentd.png'; -import { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; import KnativeDomainEditor from './knative_domain_editor.vue'; @@ -30,6 +25,8 @@ export default { applicationRow, clipboardButton, GlLoadingIcon, + GlSprintf, + GlLink, KnativeDomainEditor, CrossplaneProviderStack, IngressModsecuritySettings, @@ -92,25 +89,7 @@ export default { default: false, }, }, - data: () => ({ - elasticsearchLogo, - gitlabLogo, - helmLogo, - jeagerLogo, - jupyterhubLogo, - kubernetesLogo, - certManagerLogo, - crossplaneLogo, - knativeLogo, - meltanoLogo, - prometheusLogo, - elasticStackLogo, - fluentdLogo, - }), computed: { - isProjectCluster() { - return this.type === CLUSTER_TYPE.PROJECT; - }, managedAppsLocalTillerEnabled() { return Boolean(gon.features?.managedAppsLocalTiller); }, @@ -133,84 +112,12 @@ export default { certManagerInstalled() { return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED; }, - crossplaneInstalled() { - return this.applications.crossplane.status === APPLICATION_STATUS.INSTALLED; - }, - ingressDescription() { - return sprintf( - escape( - s__( - `ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{pricingLink}.`, - ), - ), - { - pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" - target="_blank" rel="noopener noreferrer"> - ${escape(s__('ClusterIntegration|pricing'))}</a>`, - }, - false, - ); - }, - certManagerDescription() { - return sprintf( - escape( - s__( - `ClusterIntegration|Cert-Manager is a native Kubernetes certificate management controller that helps with issuing certificates. - Installing Cert-Manager on your cluster will issue a certificate by %{letsEncrypt} and ensure that certificates - are valid and up-to-date.`, - ), - ), - { - letsEncrypt: `<a href="https://letsencrypt.org/" - target="_blank" rel="noopener noreferrer"> - ${escape(s__("ClusterIntegration|Let's Encrypt"))}</a>`, - }, - false, - ); - }, - crossplaneDescription() { - return sprintf( - escape( - s__( - `ClusterIntegration|Crossplane enables declarative provisioning of managed services from your cloud of choice using %{kubectl} or %{gitlabIntegrationLink}. -Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on.`, - ), - ), - { - gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ee/user/clusters/applications.html#crossplane" - target="_blank" rel="noopener noreferrer"> - ${escape(s__('ClusterIntegration|Gitlab Integration'))}</a>`, - kubectl: `<code>kubectl</code>`, - }, - false, - ); - }, - - prometheusDescription() { - return sprintf( - escape( - s__( - `ClusterIntegration|Prometheus is an open-source monitoring system - with %{gitlabIntegrationLink} to monitor deployed applications.`, - ), - ), - { - gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" - target="_blank" rel="noopener noreferrer"> - ${escape(s__('ClusterIntegration|GitLab Integration'))}</a>`, - }, - false, - ); - }, jupyterInstalled() { return this.applications.jupyter.status === APPLICATION_STATUS.INSTALLED; }, jupyterHostname() { return this.applications.jupyter.hostname; }, - elasticStackInstalled() { - return this.applications.elastic_stack.status === APPLICATION_STATUS.INSTALLED; - }, knative() { return this.applications.knative; }, @@ -220,29 +127,10 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity cloudRun() { return this.providerType === PROVIDER_TYPE.GCP && this.preInstalledKnative; }, - installedVia() { - if (this.cloudRun) { - return sprintf( - escape(s__(`ClusterIntegration|installed via %{installed_via}`)), - { - installed_via: `<a href="${ - this.cloudRunHelpPath - }" target="_blank" rel="noopener noreferrer">${escape( - s__('ClusterIntegration|Cloud Run'), - )}</a>`, - }, - false, - ); - } - return null; - }, ingress() { return this.applications.ingress; }, }, - created() { - this.helmInstallIllustration = helmInstallIllustration; - }, methods: { saveKnativeDomain() { eventHub.$emit('saveKnativeDomain', { @@ -267,24 +155,37 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity }); }, }, + logos: { + gitlabLogo, + helmLogo, + jupyterhubLogo, + kubernetesLogo, + certManagerLogo, + crossplaneLogo, + knativeLogo, + prometheusLogo, + elasticStackLogo, + fluentdLogo, + }, + helmInstallIllustration, }; </script> <template> <section id="cluster-applications"> - <p class="append-bottom-0"> + <p class="gl-mb-0"> {{ s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster. Helm Tiller is required to install any of the following applications.`) }} - <a :href="helpPath">{{ __('More information') }}</a> + <gl-link :href="helpPath">{{ __('More information') }}</gl-link> </p> <div class="cluster-application-list prepend-top-10"> <application-row v-if="!managedAppsLocalTillerEnabled" id="helm" - :logo-url="helmLogo" + :logo-url="$options.logos.helmLogo" :title="applications.helm.title" :status="applications.helm.status" :status-reason="applications.helm.statusReason" @@ -298,17 +199,17 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity class="rounded-top" title-link="https://docs.helm.sh/" > - <div slot="description"> + <template #description> {{ s__(`ClusterIntegration|Helm streamlines installing and managing Kubernetes applications. Tiller runs inside of your Kubernetes Cluster, and manages releases of your charts.`) }} - </div> + </template> </application-row> <div v-show="!helmInstalled" class="cluster-application-warning"> - <div class="svg-container" v-html="helmInstallIllustration"></div> + <div class="svg-container" v-html="$options.helmInstallIllustration"></div> {{ s__(`ClusterIntegration|You must first install Helm Tiller before installing the applications below`) @@ -316,7 +217,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity </div> <application-row :id="ingressId" - :logo-url="kubernetesLogo" + :logo-url="$options.logos.kubernetesLogo" :title="applications.ingress.title" :status="applications.ingress.status" :status-reason="applications.ingress.statusReason" @@ -335,7 +236,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :updateable="false" title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/" > - <div slot="description"> + <template #description> <p> {{ s__(`ClusterIntegration|Ingress gives you a way to route @@ -352,27 +253,29 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity <template v-if="ingressInstalled"> <div class="form-group"> <label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label> - <div v-if="ingressExternalEndpoint" class="input-group"> - <input - id="ingress-endpoint" - :value="ingressExternalEndpoint" - type="text" - class="form-control js-endpoint" - readonly - /> - <span class="input-group-append"> - <clipboard-button - :text="ingressExternalEndpoint" - :title="s__('ClusterIntegration|Copy Ingress Endpoint')" - class="input-group-text js-clipboard-btn" + <div class="input-group"> + <template v-if="ingressExternalEndpoint"> + <input + id="ingress-endpoint" + :value="ingressExternalEndpoint" + type="text" + class="form-control js-endpoint" + readonly /> - </span> - </div> - <div v-else class="input-group"> - <input type="text" class="form-control js-endpoint" readonly /> - <gl-loading-icon - class="position-absolute align-self-center ml-2 js-ingress-ip-loading-icon" - /> + <span class="input-group-append"> + <clipboard-button + :text="ingressExternalEndpoint" + :title="s__('ClusterIntegration|Copy Ingress Endpoint')" + class="input-group-text js-clipboard-btn" + /> + </span> + </template> + <template v-else> + <input type="text" class="form-control js-endpoint" readonly /> + <gl-loading-icon + class="position-absolute align-self-center ml-2 js-ingress-ip-loading-icon" + /> + </template> </div> <p class="form-text text-muted"> {{ @@ -380,9 +283,9 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity generated endpoint in order to access your application after it has been deployed.`) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + <gl-link :href="ingressDnsHelpPath" target="_blank"> {{ __('More information') }} - </a> + </gl-link> </p> </div> @@ -392,21 +295,35 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity the process of being assigned. Please check your Kubernetes cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + <gl-link :href="ingressDnsHelpPath" target="_blank"> {{ __('More information') }} - </a> + </gl-link> </p> </template> - <template v-if="!ingressInstalled"> + <template v-else> <div class="bs-callout bs-callout-info"> - <strong v-html="ingressDescription"></strong> + <strong data-testid="ingressCostWarning"> + <gl-sprintf + :message=" + s__( + 'ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{linkStart}pricing%{linkEnd}.', + ) + " + > + <template #link="{ content }"> + <gl-link href="https://cloud.google.com/compute/pricing#lb" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </strong> </div> </template> - </div> + </template> </application-row> <application-row id="cert_manager" - :logo-url="certManagerLogo" + :logo-url="$options.logos.certManagerLogo" :title="applications.cert_manager.title" :status="applications.cert_manager.status" :status-reason="applications.cert_manager.statusReason" @@ -421,40 +338,50 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :disabled="!helmInstalled" title-link="https://cert-manager.readthedocs.io/en/latest/#" > - <template> - <div slot="description"> - <p v-html="certManagerDescription"></p> - <div class="form-group"> - <label for="cert-manager-issuer-email"> - {{ s__('ClusterIntegration|Issuer Email') }} - </label> - <div class="input-group"> - <input - v-model="applications.cert_manager.email" - :readonly="certManagerInstalled" - type="text" - class="form-control js-email" - /> - </div> - <p class="form-text text-muted"> - {{ - s__(`ClusterIntegration|Issuers represent a certificate authority. - You must provide an email address for your Issuer. `) - }} - <a - href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email" - target="_blank" - rel="noopener noreferrer" - >{{ __('More information') }}</a - > - </p> + <template #description> + <p data-testid="certManagerDescription"> + <gl-sprintf + :message=" + s__(`ClusterIntegration|Cert-Manager is a native Kubernetes certificate management controller that helps with issuing certificates. + Installing Cert-Manager on your cluster will issue a certificate by %{linkStart}Let's Encrypt%{linkEnd} and ensure that certificates + are valid and up-to-date.`) + " + > + <template #link="{ content }"> + <gl-link href="https://letsencrypt.org/" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <div class="form-group"> + <label for="cert-manager-issuer-email"> + {{ s__('ClusterIntegration|Issuer Email') }} + </label> + <div class="input-group"> + <input + id="cert-manager-issuer-email" + v-model="applications.cert_manager.email" + :readonly="certManagerInstalled" + type="text" + class="form-control js-email" + /> </div> + <p class="form-text text-muted"> + {{ + s__(`ClusterIntegration|Issuers represent a certificate authority. + You must provide an email address for your Issuer.`) + }} + <gl-link + href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email" + target="_blank" + >{{ __('More information') }}</gl-link + > + </p> </div> </template> </application-row> <application-row id="prometheus" - :logo-url="prometheusLogo" + :logo-url="$options.logos.prometheusLogo" :title="applications.prometheus.title" :manage-link="managePrometheusPath" :status="applications.prometheus.status" @@ -469,11 +396,28 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :disabled="!helmInstalled" title-link="https://prometheus.io/docs/introduction/overview/" > - <div slot="description" v-html="prometheusDescription"></div> + <template #description> + <span data-testid="prometheusDescription"> + <gl-sprintf + :message=" + s__(`ClusterIntegration|Prometheus is an open-source monitoring system + with %{linkStart}GitLab Integration%{linkEnd} to monitor deployed applications.`) + " + > + <template #link="{ content }"> + <gl-link + href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" + target="_blank" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </span> + </template> </application-row> <application-row id="runner" - :logo-url="gitlabLogo" + :logo-url="$options.logos.gitlabLogo" :title="applications.runner.title" :status="applications.runner.status" :status-reason="applications.runner.statusReason" @@ -492,18 +436,18 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :disabled="!helmInstalled" title-link="https://docs.gitlab.com/runner/" > - <div slot="description"> + <template #description> {{ s__(`ClusterIntegration|GitLab Runner connects to the repository and executes CI/CD jobs, pushing results back and deploying applications to production.`) }} - </div> + </template> </application-row> <application-row id="crossplane" - :logo-url="crossplaneLogo" + :logo-url="$options.logos.crossplaneLogo" :title="applications.crossplane.title" :status="applications.crossplane.status" :status-reason="applications.crossplane.statusReason" @@ -518,19 +462,37 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :disabled="!helmInstalled" title-link="https://crossplane.io" > - <template> - <div slot="description"> - <p v-html="crossplaneDescription"></p> - <div class="form-group"> - <CrossplaneProviderStack :crossplane="crossplane" @set="setCrossplaneProviderStack" /> - </div> + <template #description> + <p data-testid="crossplaneDescription"> + <gl-sprintf + :message=" + s__( + `ClusterIntegration|Crossplane enables declarative provisioning of managed services from your cloud of choice using %{codeStart}kubectl%{codeEnd} or %{linkStart}GitLab Integration%{linkEnd}. + Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on.`, + ) + " + > + <template #code="{content}"> + <code>{{ content }}</code> + </template> + <template #link="{ content }"> + <gl-link + href="https://docs.gitlab.com/ee/user/clusters/applications.html#crossplane" + target="_blank" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </p> + <div class="form-group"> + <CrossplaneProviderStack :crossplane="crossplane" @set="setCrossplaneProviderStack" /> </div> </template> </application-row> <application-row id="jupyter" - :logo-url="jupyterhubLogo" + :logo-url="$options.logos.jupyterhubLogo" :title="applications.jupyter.title" :status="applications.jupyter.status" :status-reason="applications.jupyter.statusReason" @@ -545,7 +507,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :disabled="!helmInstalled" title-link="https://jupyterhub.readthedocs.io/en/stable/" > - <div slot="description"> + <template #description> <p> {{ s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns, @@ -562,6 +524,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity <div class="input-group"> <input + id="jupyter-hostname" v-model="applications.jupyter.hostname" :readonly="jupyterInstalled" type="text" @@ -581,17 +544,17 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity s__(`ClusterIntegration|Replace this with your own hostname if you want. If you do so, point hostname to Ingress IP Address from above.`) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + <gl-link :href="ingressDnsHelpPath" target="_blank"> {{ __('More information') }} - </a> + </gl-link> </p> </div> </template> - </div> + </template> </application-row> <application-row id="knative" - :logo-url="knativeLogo" + :logo-url="$options.logos.knativeLogo" :title="applications.knative.title" :status="applications.knative.status" :status-reason="applications.knative.statusReason" @@ -603,7 +566,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity hostname: applications.knative.hostname, pages_domain_id: applications.knative.pagesDomain && applications.knative.pagesDomain.id, }" - :installed-via="installedVia" :uninstallable="applications.knative.uninstallable" :uninstall-successful="applications.knative.uninstallSuccessful" :uninstall-failed="applications.knative.uninstallFailed" @@ -612,19 +574,14 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity v-bind="applications.knative" title-link="https://github.com/knative/docs" > - <div slot="description"> - <span v-if="!rbac"> - <p v-if="!rbac" class="rbac-notice bs-callout bs-callout-info append-bottom-0"> - {{ - s__(`ClusterIntegration|You must have an RBAC-enabled cluster - to install Knative.`) - }} - <a :href="helpPath" target="_blank" rel="noopener noreferrer"> - {{ __('More information') }} - </a> - </p> - <br /> - </span> + <template #description> + <p v-if="!rbac" class="rbac-notice bs-callout bs-callout-info"> + {{ + s__(`ClusterIntegration|You must have an RBAC-enabled cluster + to install Knative.`) + }} + <gl-link :href="helpPath" target="_blank">{{ __('More information') }}</gl-link> + </p> <p> {{ s__(`ClusterIntegration|Knative extends Kubernetes to provide @@ -641,11 +598,22 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity @save="saveKnativeDomain" @set="setKnativeDomain" /> - </div> + </template> + <template v-if="cloudRun" #installedVia> + <span data-testid="installedVia"> + <gl-sprintf + :message="s__('ClusterIntegration|installed via %{linkStart}Cloud Run%{linkEnd}')" + > + <template #link="{ content }"> + <gl-link :href="cloudRunHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </template> </application-row> <application-row id="elastic_stack" - :logo-url="elasticStackLogo" + :logo-url="$options.logos.elasticStackLogo" :title="applications.elastic_stack.title" :status="applications.elastic_stack.status" :status-reason="applications.elastic_stack.statusReason" @@ -664,7 +632,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :disabled="!helmInstalled" title-link="https://gitlab.com/gitlab-org/charts/elastic-stack" > - <div slot="description"> + <template #description> <p> {{ s__( @@ -672,12 +640,12 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity ) }} </p> - </div> + </template> </application-row> <application-row id="fluentd" - :logo-url="fluentdLogo" + :logo-url="$options.logos.fluentdLogo" :title="applications.fluentd.title" :status="applications.fluentd.status" :status-reason="applications.fluentd.statusReason" @@ -699,7 +667,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :updateable="false" title-link="https://github.com/helm/charts/tree/master/stable/fluentd" > - <div slot="description"> + <template #description> <p> {{ s__( @@ -717,7 +685,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :status="applications.fluentd.status" :update-failed="applications.fluentd.updateFailed" /> - </div> + </template> </application-row> </div> </section> diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue index 1884b501a20..20f6210aba8 100644 --- a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue +++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue @@ -215,10 +215,10 @@ export default { </div> <div class="form-group flex flex-wrap"> <gl-form-checkbox :checked="wafLogEnabled" @input="wafLogChanged"> - <strong>{{ s__('ClusterIntegration|Send ModSecurity Logs') }}</strong> + <strong>{{ s__('ClusterIntegration|Send Web Application Firewall Logs') }}</strong> </gl-form-checkbox> <gl-form-checkbox :checked="ciliumLogEnabled" @input="ciliumLogChanged"> - <strong>{{ s__('ClusterIntegration|Send Cilium Logs') }}</strong> + <strong>{{ s__('ClusterIntegration|Send Container Network Policies Logs') }}</strong> </gl-form-checkbox> </div> <div v-if="showButtons" class="mt-3"> diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue index c2f963f0b34..54f5468bdd0 100644 --- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue +++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue @@ -13,12 +13,12 @@ import { GlIcon, } from '@gitlab/ui'; import eventHub from '~/clusters/event_hub'; -import modSecurityLogo from 'images/cluster_app_logos/modsecurity.png'; +import modSecurityLogo from 'images/cluster_app_logos/gitlab.png'; const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS; export default { - title: 'ModSecurity Web Application Firewall', + title: __('Web Application Firewall'), modsecurityUrl: 'https://modsecurity.org/about.html', components: { GlAlert, @@ -168,7 +168,7 @@ export default { }} </gl-alert> <div class="gl-responsive-table-row-layout" role="row"> - <div class="table-section append-right-8 section-align-top" role="gridcell"> + <div class="table-section gl-mr-3 section-align-top" role="gridcell"> <img :src="modSecurityLogo" :alt="`${$options.title} logo`" diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index 8136704d13b..ac61cd8e242 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -82,6 +82,9 @@ export default { showDomainsDropdown() { return this.availableDomains.length > 0; }, + validationError() { + return this.knative.validationError; + }, }, watch: { knativeUpdateSuccessful(updateSuccessful) { @@ -157,6 +160,8 @@ export default { type="text" class="form-control js-knative-domainname" /> + + <span v-if="validationError" class="gl-field-error">{{ validationError }}</span> </div> <template v-if="knativeInstalled"> diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue index 271f9f74838..c5375cbfbdc 100644 --- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue +++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue @@ -147,7 +147,7 @@ export default { ) }}</span> </template> - <template slot="modal-footer"> + <template #modal-footer> <gl-deprecated-button variant="secondary" @click="handleCancel">{{ s__('Cancel') }}</gl-deprecated-button> diff --git a/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue new file mode 100644 index 00000000000..04aa28e9b74 --- /dev/null +++ b/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue @@ -0,0 +1,65 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; +import { ELASTIC_STACK } from '../constants'; + +const CUSTOM_APP_WARNING_TEXT = { + [ELASTIC_STACK]: s__( + 'ClusterIntegration|Your Elasticsearch cluster will be re-created during this upgrade. Your logs will be re-indexed, and you will lose historical logs from hosts terminated in the last 30 days.', + ), +}; + +export default { + components: { + GlModal, + }, + props: { + application: { + type: String, + required: true, + }, + applicationTitle: { + type: String, + required: true, + }, + }, + computed: { + title() { + return sprintf(s__('ClusterIntegration|Update %{appTitle}'), { + appTitle: this.applicationTitle, + }); + }, + warningText() { + return sprintf( + s__('ClusterIntegration|You are about to update %{appTitle} on your cluster.'), + { + appTitle: this.applicationTitle, + }, + ); + }, + customAppWarningText() { + return CUSTOM_APP_WARNING_TEXT[this.application]; + }, + modalId() { + return `update-${this.application}`; + }, + }, + methods: { + confirmUpdate() { + this.$emit('confirm'); + }, + }, +}; +</script> +<template> + <gl-modal + ok-variant="danger" + cancel-variant="light" + :ok-title="title" + :modal-id="modalId" + :title="title" + @ok="confirmUpdate()" + > + {{ warningText }} <span v-html="customAppWarningText"></span> + </gl-modal> +</template> diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index af3f1437c64..a3104038c17 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -1,23 +1,34 @@ <script> +import * as Sentry from '@sentry/browser'; import { mapState, mapActions } from 'vuex'; -import { GlBadge, GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui'; +import { + GlDeprecatedBadge as GlBadge, + GlLink, + GlLoadingIcon, + GlPagination, + GlSprintf, + GlTable, +} from '@gitlab/ui'; import tooltip from '~/vue_shared/directives/tooltip'; import { CLUSTER_TYPES, STATUSES } from '../constants'; import { __, sprintf } from '~/locale'; export default { + nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'), + nodeCpuText: __('%{totalCpu} (%{freeSpacePercentage}%{percentSymbol} free)'), components: { GlBadge, GlLink, GlLoadingIcon, GlPagination, + GlSprintf, GlTable, }, directives: { tooltip, }, computed: { - ...mapState(['clusters', 'clustersPerPage', 'loading', 'page', 'totalCulsters']), + ...mapState(['clusters', 'clustersPerPage', 'loading', 'page', 'providers', 'totalCulsters']), currentPage: { get() { return this.page; @@ -37,19 +48,18 @@ export default { key: 'environment_scope', label: __('Environment scope'), }, - // Wait for backend to send these fields - // { - // key: 'size', - // label: __('Size'), - // }, - // { - // key: 'cpu', - // label: __('Total cores (vCPUs)'), - // }, - // { - // key: 'memory', - // label: __('Total memory (GB)'), - // }, + { + key: 'node_size', + label: __('Nodes'), + }, + { + key: 'total_cpu', + label: __('Total cores (CPUs)'), + }, + { + key: 'total_memory', + label: __('Total memory (GB)'), + }, { key: 'cluster_type', label: __('Cluster level'), @@ -66,14 +76,105 @@ export default { }, methods: { ...mapActions(['fetchClusters', 'setPage']), - statusClass(status) { - const iconClass = STATUSES[status] || STATUSES.default; - return iconClass.className; + k8sQuantityToGb(quantity) { + if (!quantity) { + return 0; + } else if (quantity.endsWith(__('Ki'))) { + return parseInt(quantity.substr(0, quantity.length - 2), 10) * 0.000001024; + } else if (quantity.endsWith(__('Mi'))) { + return parseInt(quantity.substr(0, quantity.length - 2), 10) * 0.001048576; + } + + // We are trying to track quantity types coming from Kubernetes. + // Sentry will notify us if we are missing types. + throw new Error(`UnknownK8sMemoryQuantity:${quantity}`); + }, + k8sQuantityToCpu(quantity) { + if (!quantity) { + return 0; + } else if (quantity.endsWith('m')) { + return parseInt(quantity.substr(0, quantity.length - 1), 10) / 1000.0; + } else if (quantity.endsWith('n')) { + return parseInt(quantity.substr(0, quantity.length - 1), 10) / 1000000000.0; + } + + // We are trying to track quantity types coming from Kubernetes. + // Sentry will notify us if we are missing types. + throw new Error(`UnknownK8sCpuQuantity:${quantity}`); + }, + selectedProvider(provider) { + return this.providers[provider] || this.providers.default; }, statusTitle(status) { const iconTitle = STATUSES[status] || STATUSES.default; return sprintf(__('Status: %{title}'), { title: iconTitle.title }, false); }, + totalMemoryAndUsage(nodes) { + try { + // For EKS node.usage will not be present unless the user manually + // install the metrics server + if (nodes && nodes[0].usage) { + let totalAllocatableMemory = 0; + let totalUsedMemory = 0; + + nodes.reduce((total, node) => { + const allocatableMemoryQuantity = node.status.allocatable.memory; + const allocatableMemoryGb = this.k8sQuantityToGb(allocatableMemoryQuantity); + totalAllocatableMemory += allocatableMemoryGb; + + const usedMemoryQuantity = node.usage.memory; + const usedMemoryGb = this.k8sQuantityToGb(usedMemoryQuantity); + totalUsedMemory += usedMemoryGb; + + return null; + }, 0); + + const freeSpacePercentage = (1 - totalUsedMemory / totalAllocatableMemory) * 100; + + return { + totalMemory: totalAllocatableMemory.toFixed(2), + freeSpacePercentage: Math.round(freeSpacePercentage), + }; + } + } catch (error) { + Sentry.captureException(error); + } + + return { totalMemory: null, freeSpacePercentage: null }; + }, + totalCpuAndUsage(nodes) { + try { + // For EKS node.usage will not be present unless the user manually + // install the metrics server + if (nodes && nodes[0].usage) { + let totalAllocatableCpu = 0; + let totalUsedCpu = 0; + + nodes.reduce((total, node) => { + const allocatableCpuQuantity = node.status.allocatable.cpu; + const allocatableCpu = this.k8sQuantityToCpu(allocatableCpuQuantity); + totalAllocatableCpu += allocatableCpu; + + const usedCpuQuantity = node.usage.cpu; + const usedCpuGb = this.k8sQuantityToCpu(usedCpuQuantity); + totalUsedCpu += usedCpuGb; + + return null; + }, 0); + + const freeSpacePercentage = (1 - totalUsedCpu / totalAllocatableCpu) * 100; + + return { + totalCpu: totalAllocatableCpu.toFixed(2), + freeSpacePercentage: Math.round(freeSpacePercentage), + }; + } + } catch (error) { + Sentry.captureException(error); + } + + return { totalCpu: null, freeSpacePercentage: null }; + }, }, }; </script> @@ -84,27 +185,68 @@ export default { <section v-else> <gl-table :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table"> <template #cell(name)="{ item }"> - <div class="d-flex flex-row-reverse flex-md-row js-status"> - <gl-link data-qa-selector="cluster" :data-qa-cluster-name="item.name" :href="item.path"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start js-status" + > + <img + :src="selectedProvider(item.provider_type).path" + :alt="selectedProvider(item.provider_type).text" + class="gl-w-6 gl-h-6 gl-display-flex gl-align-items-center" + /> + + <gl-link + data-qa-selector="cluster" + :data-qa-cluster-name="item.name" + :href="item.path" + class="gl-px-3" + > {{ item.name }} </gl-link> <gl-loading-icon - v-if="item.status === 'deleting'" + v-if="item.status === 'deleting' || item.status === 'creating'" v-tooltip :title="statusTitle(item.status)" size="sm" - class="mr-2 ml-md-2" /> - <div - v-else - v-tooltip - class="cluster-status-indicator rounded-circle align-self-center gl-w-4 gl-h-4 mr-2 ml-md-2" - :class="statusClass(item.status)" - :title="statusTitle(item.status)" - ></div> </div> </template> + + <template #cell(node_size)="{ item }"> + <span v-if="item.nodes">{{ item.nodes.length }}</span> + <small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-400">{{ + __('Unknown') + }}</small> + </template> + + <template #cell(total_cpu)="{ item }"> + <span v-if="item.nodes"> + <gl-sprintf :message="$options.nodeCpuText"> + <template #totalCpu>{{ totalCpuAndUsage(item.nodes).totalCpu }}</template> + <template #freeSpacePercentage>{{ + totalCpuAndUsage(item.nodes).freeSpacePercentage + }}</template> + <template #percentSymbol + >%</template + > + </gl-sprintf> + </span> + </template> + + <template #cell(total_memory)="{ item }"> + <span v-if="item.nodes"> + <gl-sprintf :message="$options.nodeMemoryText"> + <template #totalMemory>{{ totalMemoryAndUsage(item.nodes).totalMemory }}</template> + <template #freeSpacePercentage>{{ + totalMemoryAndUsage(item.nodes).freeSpacePercentage + }}</template> + <template #percentSymbol + >%</template + > + </gl-sprintf> + </span> + </template> + <template #cell(cluster_type)="{value}"> <gl-badge variant="light"> {{ value }} diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index eebcaa086f9..3e8ef3151a6 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -6,6 +6,8 @@ export const CLUSTER_TYPES = { instance_type: __('Instance'), }; +export const MAX_REQUESTS = 3; + export const STATUSES = { default: { className: 'bg-white', title: __('Unknown') }, disabled: { className: 'disabled', title: __('Disabled') }, @@ -13,4 +15,5 @@ export const STATUSES = { unreachable: { className: 'bg-danger', title: __('Unreachable') }, authentication_failure: { className: 'bg-warning', title: __('Authentication Failure') }, deleting: { title: __('Deleting') }, + creating: { title: __('Creating') }, }; diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js index 67d0a33030b..51ad8769250 100644 --- a/app/assets/javascripts/clusters_list/index.js +++ b/app/assets/javascripts/clusters_list/index.js @@ -9,12 +9,10 @@ export default () => { return; } - const { endpoint } = entryPoint.dataset; - // eslint-disable-next-line no-new new Vue({ el: '#js-clusters-list-app', - store: createStore({ endpoint }), + store: createStore(entryPoint.dataset), render(createElement) { return createElement(Clusters); }, diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 919625f69b4..5245c307c8c 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -2,10 +2,23 @@ import Poll from '~/lib/utils/poll'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; import { __ } from '~/locale'; +import { MAX_REQUESTS } from '../constants'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import * as Sentry from '@sentry/browser'; import * as types from './mutation_types'; +const allNodesPresent = (clusters, retryCount) => { + /* + Nodes are coming from external Kubernetes clusters. + They may fail for reasons GitLab cannot control. + MAX_REQUESTS will ensure this poll stops at some point. + */ + return retryCount > MAX_REQUESTS || clusters.every(cluster => cluster.nodes != null); +}; + export const fetchClusters = ({ state, commit }) => { + let retryCount = 0; + const poll = new Poll({ resource: { fetchClusters: paginatedEndPoint => axios.get(paginatedEndPoint), @@ -13,16 +26,40 @@ export const fetchClusters = ({ state, commit }) => { data: `${state.endpoint}?page=${state.page}`, method: 'fetchClusters', successCallback: ({ data, headers }) => { - if (data.clusters) { - const normalizedHeaders = normalizeHeaders(headers); - const paginationInformation = parseIntPagination(normalizedHeaders); + retryCount += 1; + + try { + if (data.clusters) { + const normalizedHeaders = normalizeHeaders(headers); + const paginationInformation = parseIntPagination(normalizedHeaders); + + commit(types.SET_CLUSTERS_DATA, { data, paginationInformation }); + commit(types.SET_LOADING_STATE, false); - commit(types.SET_CLUSTERS_DATA, { data, paginationInformation }); - commit(types.SET_LOADING_STATE, false); + if (allNodesPresent(data.clusters, retryCount)) { + poll.stop(); + } + } + } catch (error) { poll.stop(); + + Sentry.withScope(scope => { + scope.setTag('javascript_clusters_list', 'fetchClustersSuccessCallback'); + Sentry.captureException(error); + }); } }, - errorCallback: () => flash(__('An error occurred while loading clusters')), + errorCallback: response => { + poll.stop(); + + commit(types.SET_LOADING_STATE, false); + flash(__('Clusters|An error occurred while loading clusters')); + + Sentry.withScope(scope => { + scope.setTag('javascript_clusters_list', 'fetchClustersErrorCallback'); + Sentry.captureException(response); + }); + }, }); poll.makeRequest(); diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js index d590ea09e66..0023b43ed92 100644 --- a/app/assets/javascripts/clusters_list/store/state.js +++ b/app/assets/javascripts/clusters_list/store/state.js @@ -5,5 +5,10 @@ export default (initialState = {}) => ({ clusters: [], clustersPerPage: 0, page: 1, + providers: { + aws: { path: initialState.imgTagsAwsPath, text: initialState.imgTagsAwsText }, + default: { path: initialState.imgTagsDefaultPath, text: initialState.imgTagsDefaultText }, + gcp: { path: initialState.imgTagsGcpPath, text: initialState.imgTagsGcpText }, + }, totalCulsters: 0, }); diff --git a/app/assets/javascripts/code_navigation/components/doc_line.vue b/app/assets/javascripts/code_navigation/components/doc_line.vue new file mode 100644 index 00000000000..69d398893d9 --- /dev/null +++ b/app/assets/javascripts/code_navigation/components/doc_line.vue @@ -0,0 +1,22 @@ +<script> +export default { + props: { + language: { + type: String, + required: true, + }, + tokens: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <span class="line" :lang="language"> + <span v-for="(token, tokenIndex) in tokens" :key="tokenIndex" :class="token.class">{{ + token.value + }}</span> + </span> +</template> diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue index 7147ce227e8..df5f89e4faf 100644 --- a/app/assets/javascripts/code_navigation/components/popover.vue +++ b/app/assets/javascripts/code_navigation/components/popover.vue @@ -1,9 +1,11 @@ <script> import { GlButton } from '@gitlab/ui'; +import DocLine from './doc_line.vue'; export default { components: { GlButton, + DocLine, }, props: { position: { @@ -83,8 +85,7 @@ export default { ref="code-output" :class="$options.colorScheme" class="border-0 bg-transparent m-0 code highlight" - v-html="hover.value" - ></pre> + ><doc-line v-for="(tokens, tokenIndex) in hover.tokens" :key="tokenIndex" :language="hover.language" :tokens="tokens"/></pre> <p v-else ref="doc-output" class="p-3 m-0"> {{ hover.value }} </p> diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index ddb129f36f4..542890d9b04 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -1,6 +1,7 @@ /* eslint-disable func-names, consistent-return, one-var, no-return-assign */ import $ from 'jquery'; +import 'jquery.waitforimages'; // Width where images must fits in, for 2-up this gets divided by 2 const availWidth = 900; diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js index e5e1cbb1e62..df0fa1ae88b 100644 --- a/app/assets/javascripts/commons/bootstrap.js +++ b/app/assets/javascripts/commons/bootstrap.js @@ -70,7 +70,12 @@ whitelist.acronym = []; whitelist.blockquote = []; whitelist.del = []; whitelist.ins = []; -whitelist['gl-emoji'] = []; +whitelist['gl-emoji'] = [ + 'data-name', + 'data-unicode-version', + 'data-fallback-src', + 'data-fallback-sprite-class', +]; // Whitelisting SVG tags and attributes whitelist.svg = ['viewBox']; diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js index 25640f71af2..c4d663dfc8d 100644 --- a/app/assets/javascripts/commons/jquery.js +++ b/app/assets/javascripts/commons/jquery.js @@ -2,8 +2,6 @@ import 'jquery'; // common jQuery plugins import 'jquery-ujs'; -import 'vendor/jquery.endless-scroll'; import 'jquery.caret'; // must be imported before at.js import '@gitlab/at.js'; import 'vendor/jquery.scrollTo'; -import 'jquery.waitforimages'; 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 e5c0d1e4970..f60be52d6ca 100644 --- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue +++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue @@ -66,7 +66,7 @@ export default { </script> <template> <div class="row my-3"> - <h4 class="prepend-top-0 col-lg-8 offset-lg-2">{{ titleText }}</h4> + <h4 class="gl-mt-0 col-lg-8 offset-lg-2">{{ titleText }}</h4> <form ref="form" class="col-lg-8 offset-lg-2" :action="customMetricsPath" method="post"> <custom-metrics-form-fields :form-operation="formOperation" diff --git a/app/assets/javascripts/design_management/components/design_note_pin.vue b/app/assets/javascripts/design_management/components/design_note_pin.vue index 50ea69d52ce..0811397fbad 100644 --- a/app/assets/javascripts/design_management/components/design_note_pin.vue +++ b/app/assets/javascripts/design_management/components/design_note_pin.vue @@ -13,7 +13,7 @@ export default { required: true, }, label: { - type: String, + type: Number, required: false, default: null, }, @@ -47,7 +47,7 @@ export default { 'btn-transparent comment-indicator': isNewNote, 'js-image-badge badge badge-pill': !isNewNote, }" - class="position-absolute" + class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center" type="button" @mousedown="$emit('mousedown', $event)" @mouseup="$emit('mouseup', $event)" 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 c6c5ee88a93..7e442bb295f 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -1,14 +1,19 @@ <script> import { ApolloMutation } from 'vue-apollo'; +import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import allVersionsMixin from '../../mixins/all_versions'; import createNoteMutation from '../../graphql/mutations/createNote.mutation.graphql'; +import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql'; import getDesignQuery from '../../graphql/queries/getDesign.query.graphql'; import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql'; import DesignNote from './design_note.vue'; import DesignReplyForm from './design_reply_form.vue'; import { updateStoreAfterAddDiscussionComment } from '../../utils/cache_update'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; +import ToggleRepliesWidget from './toggle_replies_widget.vue'; export default { components: { @@ -16,6 +21,14 @@ export default { DesignNote, ReplyPlaceholder, DesignReplyForm, + GlIcon, + GlLoadingIcon, + GlLink, + ToggleRepliesWidget, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, }, mixins: [allVersionsMixin], props: { @@ -31,21 +44,28 @@ export default { type: String, required: true, }, - discussionIndex: { - type: Number, - required: true, - }, markdownPreviewPath: { type: String, required: false, default: '', }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + discussionWithOpenForm: { + type: String, + required: true, + }, }, apollo: { activeDiscussion: { query: activeDiscussionQuery, result({ data }) { const discussionId = data.activeDiscussion.id; + if (this.discussion.resolved && !this.resolvedDiscussionsExpanded) { + return; + } // We watch any changes to the active discussion from the design pins and scroll to this discussion if it exists // We don't want scrollIntoView to be triggered from the discussion click itself if ( @@ -66,6 +86,9 @@ export default { discussionComment: '', isFormRendered: false, activeDiscussion: {}, + isResolving: false, + shouldChangeResolvedStatus: false, + areRepliesCollapsed: this.discussion.resolved, }; }, computed: { @@ -87,6 +110,32 @@ export default { isDiscussionHighlighted() { return this.discussion.notes[0].id === this.activeDiscussion.id; }, + resolveCheckboxText() { + return this.discussion.resolved + ? s__('DesignManagement|Unresolve thread') + : s__('DesignManagement|Resolve thread'); + }, + firstNote() { + return this.discussion.notes[0]; + }, + discussionReplies() { + return this.discussion.notes.slice(1); + }, + areRepliesShown() { + return !this.discussion.resolved || !this.areRepliesCollapsed; + }, + resolveIconName() { + return this.discussion.resolved ? 'check-circle-filled' : 'check-circle'; + }, + isRepliesWidgetVisible() { + return this.discussion.resolved && this.discussionReplies.length > 0; + }, + isReplyPlaceholderVisible() { + return this.areRepliesShown || !this.discussionReplies.length; + }, + isFormVisible() { + return this.isFormRendered && this.discussionWithOpenForm === this.discussion.id; + }, }, methods: { addDiscussionComment( @@ -106,17 +155,40 @@ export default { onDone() { this.discussionComment = ''; this.hideForm(); + if (this.shouldChangeResolvedStatus) { + this.toggleResolvedStatus(); + } }, - onError(err) { - this.$emit('error', err); + onCreateNoteError(err) { + this.$emit('createNoteError', err); }, hideForm() { this.isFormRendered = false; this.discussionComment = ''; }, showForm() { + this.$emit('openForm', this.discussion.id); this.isFormRendered = true; }, + toggleResolvedStatus() { + this.isResolving = true; + this.$apollo + .mutate({ + mutation: toggleResolveDiscussionMutation, + variables: { id: this.discussion.id, resolve: !this.discussion.resolved }, + }) + .then(({ data }) => { + if (data.errors?.length > 0) { + this.$emit('resolveDiscussionError', data.errors[0]); + } + }) + .catch(err => { + this.$emit('resolveDiscussionError', err); + }) + .finally(() => { + this.isResolving = false; + }); + }, }, createNoteMutation, }; @@ -124,22 +196,71 @@ export default { <template> <div class="design-discussion-wrapper"> - <div class="badge badge-pill" type="button">{{ discussionIndex }}</div> <div - class="design-discussion bordered-box position-relative" + class="badge badge-pill gl-display-flex gl-align-items-center gl-justify-content-center" + :class="{ resolved: discussion.resolved }" + type="button" + > + {{ discussion.index }} + </div> + <ul + class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none" data-qa-selector="design_discussion_content" > <design-note - v-for="note in discussion.notes" + :note="firstNote" + :markdown-preview-path="markdownPreviewPath" + :is-resolving="isResolving" + :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" + @error="$emit('updateNoteError', $event)" + > + <template v-if="discussion.resolvable" #resolveDiscussion> + <button + v-gl-tooltip + :class="{ 'is-active': discussion.resolved }" + :title="resolveCheckboxText" + :aria-label="resolveCheckboxText" + type="button" + class="line-resolve-btn note-action-button gl-mr-3" + data-testid="resolve-button" + @click.stop="toggleResolvedStatus" + > + <gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" /> + <gl-loading-icon v-else inline /> + </button> + </template> + <template v-if="discussion.resolved" #resolvedStatus> + <p class="gl-text-gray-700 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message"> + {{ __('Resolved by') }} + <gl-link + class="gl-text-gray-700 gl-text-decoration-none gl-font-sm link-inherit-color" + :href="discussion.resolvedBy.webUrl" + target="_blank" + >{{ discussion.resolvedBy.name }}</gl-link + > + <time-ago-tooltip :time="discussion.resolvedAt" tooltip-placement="bottom" /> + </p> + </template> + </design-note> + <toggle-replies-widget + v-if="isRepliesWidgetVisible" + :collapsed="areRepliesCollapsed" + :replies="discussionReplies" + @toggle="areRepliesCollapsed = !areRepliesCollapsed" + /> + <design-note + v-for="note in discussionReplies" + v-show="areRepliesShown" :key="note.id" :note="note" :markdown-preview-path="markdownPreviewPath" + :is-resolving="isResolving" :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" @error="$emit('updateNoteError', $event)" /> - <div class="reply-wrapper"> + <li v-show="isReplyPlaceholderVisible" class="reply-wrapper"> <reply-placeholder - v-if="!isFormRendered" + v-if="!isFormVisible" class="qa-discussion-reply" :button-text="__('Reply...')" @onClick="showForm" @@ -153,7 +274,7 @@ export default { }" :update="addDiscussionComment" @done="onDone" - @error="onError" + @error="onCreateNoteError" > <design-reply-form v-model="discussionComment" @@ -161,9 +282,16 @@ export default { :markdown-preview-path="markdownPreviewPath" @submitForm="mutate" @cancelForm="hideForm" - /> + > + <template v-if="discussion.resolvable" #resolveCheckbox> + <label data-testid="resolve-checkbox"> + <input v-model="shouldChangeResolvedStatus" type="checkbox" /> + {{ resolveCheckboxText }} + </label> + </template> + </design-reply-form> </apollo-mutation> - </div> - </div> + </li> + </ul> </div> </template> 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 c1c19c0a597..b1f3a43a66d 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 @@ -54,6 +54,9 @@ export default { body: this.noteText, }; }, + isEditButtonVisible() { + return !this.isEditing && this.note.userPermissions.adminNote; + }, }, mounted() { if (this.isNoteLinked) { @@ -107,23 +110,28 @@ export default { </template> </span> </div> - <button - v-if="!isEditing && note.userPermissions.adminNote" - v-gl-tooltip - type="button" - title="Edit comment" - class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button" - @click="isEditing = true" - > - <gl-icon name="pencil" class="link-highlight" /> - </button> + <div class="gl-display-flex"> + <slot name="resolveDiscussion"></slot> + <button + v-if="isEditButtonVisible" + v-gl-tooltip + type="button" + :title="__('Edit comment')" + class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button" + @click="isEditing = true" + > + <gl-icon name="pencil" class="link-highlight" /> + </button> + </div> </div> - <div - v-if="!isEditing" - class="note-text js-note-text md" - data-qa-selector="note_content" - v-html="note.bodyHtml" - ></div> + <template v-if="!isEditing"> + <div + class="note-text js-note-text md" + data-qa-selector="note_content" + v-html="note.bodyHtml" + ></div> + <slot name="resolvedStatus"></slot> + </template> <apollo-mutation v-else #default="{ mutate, loading }" 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 40be9867fee..756da7f55aa 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 @@ -107,7 +107,8 @@ export default { </textarea> </template> </markdown-field> - <div class="note-form-actions d-flex justify-content-between"> + <slot name="resolveCheckbox"></slot> + <div class="note-form-actions gl-display-flex gl-justify-content-space-between"> <gl-deprecated-button ref="submitButton" :disabled="!hasValue || isSaving" diff --git a/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue b/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue new file mode 100644 index 00000000000..46c73e3eea8 --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue @@ -0,0 +1,70 @@ +<script> +import { GlIcon, GlButton, GlLink } from '@gitlab/ui'; +import { __, n__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'ToggleNotesWidget', + components: { + GlIcon, + GlButton, + GlLink, + TimeAgoTooltip, + }, + props: { + collapsed: { + type: Boolean, + required: true, + }, + replies: { + type: Array, + required: true, + }, + }, + computed: { + lastReply() { + return this.replies[this.replies.length - 1]; + }, + iconName() { + return this.collapsed ? 'chevron-right' : 'chevron-down'; + }, + toggleText() { + return this.collapsed + ? `${this.replies.length} ${n__('reply', 'replies', this.replies.length)}` + : __('Collapse replies'); + }, + }, +}; +</script> + +<template> + <li + class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3" + :class="{ expanded: !collapsed }" + data-testid="toggle-comments-wrapper" + > + <gl-icon :name="iconName" class="gl-ml-3" @click.stop="$emit('toggle')" /> + <gl-button + variant="link" + class="toggle-comments-button gl-ml-2 gl-mr-2" + @click.stop="$emit('toggle')" + > + {{ toggleText }} + </gl-button> + <template v-if="collapsed"> + <span class="gl-text-gray-700">{{ __('Last reply by') }}</span> + <gl-link + :href="lastReply.author.webUrl" + target="_blank" + class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2" + > + {{ lastReply.author.name }} + </gl-link> + <time-ago-tooltip + :time="lastReply.createdAt" + tooltip-placement="bottom" + class="gl-text-gray-700" + /> + </template> + </li> +</template> diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index beb51647821..926e7c74802 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -33,6 +33,10 @@ export default { required: false, default: false, }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, }, apollo: { activeDiscussion: { @@ -140,7 +144,7 @@ export default { }, onExistingNoteMove(e) { const note = this.notes.find(({ id }) => id === this.movingNoteStartPosition.noteId); - if (!note) return; + if (!note || !this.canMoveNote(note)) return; const { position } = note; const { width, height } = position; @@ -186,8 +190,6 @@ export default { }); }, onNoteMousedown({ clientX, clientY }, note) { - if (note && !this.canMoveNote(note)) return; - this.movingNoteStartPosition = { noteId: note?.id, discussionId: note?.discussion.id, @@ -236,6 +238,9 @@ export default { isNoteInactive(note) { return this.activeDiscussion.id && this.activeDiscussion.id !== note.id; }, + designPinClass(note) { + return { inactive: this.isNoteInactive(note), resolved: note.resolved }; + }, }, }; </script> @@ -254,20 +259,23 @@ export default { data-qa-selector="design_image_button" @mouseup="onAddCommentMouseup" ></button> - <design-note-pin - v-for="(note, index) in notes" - :key="note.id" - :label="`${index + 1}`" - :repositioning="isMovingNote(note.id)" - :position=" - isMovingNote(note.id) && movingNoteNewPosition - ? getNotePositionStyle(movingNoteNewPosition) - : getNotePositionStyle(note.position) - " - :class="{ inactive: isNoteInactive(note) }" - @mousedown.stop="onNoteMousedown($event, note)" - @mouseup.stop="onNoteMouseup(note)" - /> + <template v-for="note in notes"> + <design-note-pin + v-if="resolvedDiscussionsExpanded || !note.resolved" + :key="note.id" + :label="note.index" + :repositioning="isMovingNote(note.id)" + :position=" + isMovingNote(note.id) && movingNoteNewPosition + ? getNotePositionStyle(movingNoteNewPosition) + : getNotePositionStyle(note.position) + " + :class="designPinClass(note)" + @mousedown.stop="onNoteMousedown($event, note)" + @mouseup.stop="onNoteMouseup(note)" + /> + </template> + <design-note-pin v-if="currentCommentForm" :position="currentCommentPositionStyle" diff --git a/app/assets/javascripts/design_management/components/design_presentation.vue b/app/assets/javascripts/design_management/components/design_presentation.vue index 5c113b3dbed..84dbb2809d9 100644 --- a/app/assets/javascripts/design_management/components/design_presentation.vue +++ b/app/assets/javascripts/design_management/components/design_presentation.vue @@ -35,6 +35,10 @@ export default { required: false, default: 1, }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, }, data() { return { @@ -54,7 +58,10 @@ export default { }, computed: { discussionStartingNotes() { - return this.discussions.map(discussion => discussion.notes[0]); + return this.discussions.map(discussion => ({ + ...discussion.notes[0], + index: discussion.index, + })); }, currentCommentForm() { return (this.isAnnotating && this.currentAnnotationPosition) || null; @@ -305,6 +312,7 @@ export default { :notes="discussionStartingNotes" :current-comment-form="currentCommentForm" :disable-commenting="isDraggingDesign" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" @openCommentForm="openCommentForm" @closeCommentForm="closeCommentForm" @moveNote="moveNote" diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue new file mode 100644 index 00000000000..333ad2557e8 --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_sidebar.vue @@ -0,0 +1,178 @@ +<script> +import { s__ } from '~/locale'; +import Cookies from 'js-cookie'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui'; +import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; +import { extractDiscussions, extractParticipants } from '../utils/design_management_utils'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; +import DesignDiscussion from './design_notes/design_discussion.vue'; +import Participants from '~/sidebar/components/participants/participants.vue'; + +export default { + components: { + DesignDiscussion, + Participants, + GlCollapse, + GlButton, + GlPopover, + }, + props: { + design: { + type: Object, + required: true, + }, + resolvedDiscussionsExpanded: { + type: Boolean, + required: true, + }, + markdownPreviewPath: { + type: String, + required: true, + }, + }, + data() { + return { + isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)), + discussionWithOpenForm: '', + }; + }, + computed: { + discussions() { + return extractDiscussions(this.design.discussions); + }, + issue() { + return { + ...this.design.issue, + webPath: this.design.issue.webPath.substr(1), + }; + }, + discussionParticipants() { + return extractParticipants(this.issue.participants); + }, + resolvedDiscussions() { + return this.discussions.filter(discussion => discussion.resolved); + }, + unresolvedDiscussions() { + return this.discussions.filter(discussion => !discussion.resolved); + }, + resolvedCommentsToggleIcon() { + return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right'; + }, + }, + methods: { + handleSidebarClick() { + this.isResolvedCommentsPopoverHidden = true; + Cookies.set(this.$options.cookieKey, 'true', { expires: 365 * 10 }); + this.updateActiveDiscussion(); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion, + }, + }); + }, + closeCommentForm() { + this.comment = ''; + this.$emit('closeCommentForm'); + }, + updateDiscussionWithOpenForm(id) { + this.discussionWithOpenForm = id; + }, + }, + resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'), + cookieKey: 'hide_design_resolved_comments_popover', +}; +</script> + +<template> + <div class="image-notes" @click="handleSidebarClick"> + <h2 class="gl-font-weight-bold gl-mt-0"> + {{ issue.title }} + </h2> + <a + class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" + :href="issue.webUrl" + >{{ issue.webPath }}</a + > + <participants + :participants="discussionParticipants" + :show-participant-label="false" + class="gl-mb-4" + /> + <h2 + v-if="unresolvedDiscussions.length === 0" + class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4" + data-testid="new-discussion-disclaimer" + > + {{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }} + </h2> + <design-discussion + v-for="discussion in unresolvedDiscussions" + :key="discussion.id" + :discussion="discussion" + :design-id="$route.params.id" + :noteable-id="design.id" + :markdown-preview-path="markdownPreviewPath" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + :discussion-with-open-form="discussionWithOpenForm" + data-testid="unresolved-discussion" + @createNoteError="$emit('onDesignDiscussionError', $event)" + @updateNoteError="$emit('updateNoteError', $event)" + @resolveDiscussionError="$emit('resolveDiscussionError', $event)" + @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)" + @openForm="updateDiscussionWithOpenForm" + /> + <template v-if="resolvedDiscussions.length > 0"> + <gl-button + id="resolved-comments" + data-testid="resolved-comments" + :icon="resolvedCommentsToggleIcon" + variant="link" + class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4" + @click="$emit('toggleResolvedComments')" + >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }}) + </gl-button> + <gl-popover + v-if="!isResolvedCommentsPopoverHidden" + :show="!isResolvedCommentsPopoverHidden" + target="resolved-comments" + container="popovercontainer" + placement="top" + :title="s__('DesignManagement|Resolved Comments')" + > + <p> + {{ + s__( + 'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below', + ) + }} + </p> + <a href="#" rel="noopener noreferrer" target="_blank">{{ + s__('DesignManagement|Learn more about resolving comments') + }}</a> + </gl-popover> + <gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3"> + <design-discussion + v-for="discussion in resolvedDiscussions" + :key="discussion.id" + :discussion="discussion" + :design-id="$route.params.id" + :noteable-id="design.id" + :markdown-preview-path="markdownPreviewPath" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + :discussion-with-open-form="discussionWithOpenForm" + data-testid="resolved-discussion" + @error="$emit('onDesignDiscussionError', $event)" + @updateNoteError="$emit('updateNoteError', $event)" + @openForm="updateDiscussionWithOpenForm" + @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)" + /> + </gl-collapse> + </template> + <slot name="replyForm"></slot> + </div> +</template> diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue index e3c5e369170..68555104a3c 100644 --- a/app/assets/javascripts/design_management/components/upload/button.vue +++ b/app/assets/javascripts/design_management/components/upload/button.vue @@ -41,7 +41,7 @@ export default { variant="success" @click="openFileUpload" > - {{ s__('DesignManagement|Add designs') }} + {{ s__('DesignManagement|Upload designs') }} <gl-loading-icon v-if="isSaving" inline class="ml-1" /> </gl-deprecated-button> diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js index 59d34669ad7..21ff361a277 100644 --- a/app/assets/javascripts/design_management/constants.js +++ b/app/assets/javascripts/design_management/constants.js @@ -12,3 +12,5 @@ export const ACTIVE_DISCUSSION_SOURCE_TYPES = { pin: 'pin', discussion: 'discussion', }; + +export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0']; diff --git a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql index ca5b5a52c71..c1439c56ff5 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql @@ -1,6 +1,7 @@ #import "./designNote.fragment.graphql" #import "./designList.fragment.graphql" #import "./diffRefs.fragment.graphql" +#import "./discussion_resolved_status.fragment.graphql" fragment DesignItem on Design { ...DesignListItem @@ -12,6 +13,7 @@ fragment DesignItem on Design { nodes { id replyId + ...ResolvedStatus notes { nodes { ...DesignNote diff --git a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql index 2ad84f9cb17..cb7cfd89abf 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql @@ -10,6 +10,7 @@ fragment DesignNote on Note { body bodyHtml createdAt + resolved position { diffRefs { ...DesignDiffRefs diff --git a/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql new file mode 100644 index 00000000000..7483b508721 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql @@ -0,0 +1,9 @@ +fragment ResolvedStatus on Discussion { + resolvable + resolved + resolvedAt + resolvedBy { + name + webUrl + } +} diff --git a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql new file mode 100644 index 00000000000..d5f54ec9b58 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql @@ -0,0 +1,17 @@ +#import "../fragments/designNote.fragment.graphql" +#import "../fragments/discussion_resolved_status.fragment.graphql" + +mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) { + discussionToggleResolve(input: { id: $id, resolve: $resolve }) { + discussion { + id + ...ResolvedStatus + notes { + nodes { + ...DesignNote + } + } + } + errors + } +} diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 7ff3271394d..fe121b6530a 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -1,17 +1,16 @@ <script> -import { ApolloMutation } from 'vue-apollo'; import Mousetrap from 'mousetrap'; import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { ApolloMutation } from 'vue-apollo'; import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import allVersionsMixin from '../../mixins/all_versions'; import Toolbar from '../../components/toolbar/index.vue'; -import DesignDiscussion from '../../components/design_notes/design_discussion.vue'; -import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; import DesignDestroyer from '../../components/design_destroyer.vue'; import DesignScaler from '../../components/design_scaler.vue'; -import Participants from '~/sidebar/components/participants/participants.vue'; import DesignPresentation from '../../components/design_presentation.vue'; +import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; +import DesignSidebar from '../../components/design_sidebar.vue'; import getDesignQuery from '../../graphql/queries/getDesign.query.graphql'; import appDataQuery from '../../graphql/queries/appData.query.graphql'; import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql'; @@ -20,7 +19,6 @@ import updateActiveDiscussionMutation from '../../graphql/mutations/update_activ import { extractDiscussions, extractDesign, - extractParticipants, updateImageDiffNoteOptimisticResponse, } from '../../utils/design_management_utils'; import { @@ -43,15 +41,14 @@ import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; export default { components: { ApolloMutation, + DesignReplyForm, DesignPresentation, - DesignDiscussion, DesignScaler, DesignDestroyer, Toolbar, - DesignReplyForm, GlLoadingIcon, GlAlert, - Participants, + DesignSidebar, }, mixins: [allVersionsMixin], props: { @@ -69,6 +66,7 @@ export default { errorMessage: '', issueIid: '', scale: 1, + resolvedDiscussionsExpanded: false, }; }, apollo: { @@ -103,20 +101,17 @@ export default { return this.$apollo.queries.design.loading && !this.design.filename; }, discussions() { + if (!this.design.discussions) { + return []; + } return extractDiscussions(this.design.discussions); }, - discussionParticipants() { - return extractParticipants(this.design.issue.participants); - }, markdownPreviewPath() { return `/${this.projectPath}/preview_markdown?target_type=Issue`; }, isSubmitButtonDisabled() { return this.comment.trim().length === 0; }, - renderDiscussions() { - return this.discussions.length || this.annotationCoordinates; - }, designVariables() { return { fullPath: this.projectPath, @@ -144,18 +139,25 @@ export default { }, }; }, - issue() { - return { - ...this.design.issue, - webPath: this.design.issue.webPath.substr(1), - }; - }, isAnnotating() { return Boolean(this.annotationCoordinates); }, + resolvedDiscussions() { + return this.discussions.filter(discussion => discussion.resolved); + }, + }, + watch: { + resolvedDiscussions(val) { + if (!val.length) { + this.resolvedDiscussionsExpanded = false; + } + }, }, mounted() { Mousetrap.bind('esc', this.closeDesign); + this.trackEvent(); + // We need to reset the active discussion when opening a new design + this.updateActiveDiscussion(); }, beforeDestroy() { Mousetrap.unbind('esc', this.closeDesign); @@ -247,6 +249,9 @@ export default { onDesignDeleteError(e) { this.onError(designDeletionError({ singular: true }), e); }, + onResolveDiscussionError(e) { + this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); + }, openCommentForm(annotationCoordinates) { this.annotationCoordinates = annotationCoordinates; }, @@ -278,23 +283,9 @@ export default { }, }); }, - }, - beforeRouteEnter(to, from, next) { - next(vm => { - vm.trackEvent(); - }); - }, - beforeRouteUpdate(to, from, next) { - this.trackEvent(); - this.closeCommentForm(); - // We need to reset the active discussion when opening a new design - this.updateActiveDiscussion(); - next(); - }, - beforeRouteLeave(to, from, next) { - // We need to reset the active discussion when moving to design list view - this.updateActiveDiscussion(); - next(); + toggleResolvedComments() { + this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded; + }, }, createImageDiffNoteMutation, DESIGNS_ROUTE_NAME, @@ -337,6 +328,7 @@ export default { :discussions="discussions" :is-annotating="isAnnotating" :scale="scale" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" @openCommentForm="openCommentForm" @closeCommentForm="closeCommentForm" @moveNote="onMoveNote" @@ -346,33 +338,19 @@ export default { <design-scaler @scale="scale = $event" /> </div> </div> - <div class="image-notes" @click="updateActiveDiscussion()"> - <h2 class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0"> - {{ issue.title }} - </h2> - <a class="text-tertiary text-decoration-none mb-3 d-block" :href="issue.webUrl">{{ - issue.webPath - }}</a> - <participants - :participants="discussionParticipants" - :show-participant-label="false" - class="mb-4" - /> - <template v-if="renderDiscussions"> - <design-discussion - v-for="(discussion, index) in discussions" - :key="discussion.id" - :discussion="discussion" - :design-id="id" - :noteable-id="design.id" - :discussion-index="index + 1" - :markdown-preview-path="markdownPreviewPath" - @error="onDesignDiscussionError" - @updateNoteError="onUpdateNoteError" - @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)" - /> + <design-sidebar + :design="design" + :resolved-discussions-expanded="resolvedDiscussionsExpanded" + :markdown-preview-path="markdownPreviewPath" + @onDesignDiscussionError="onDesignDiscussionError" + @onCreateImageDiffNoteError="onCreateImageDiffNoteError" + @updateNoteError="onUpdateNoteError" + @resolveDiscussionError="onResolveDiscussionError" + @toggleResolvedComments="toggleResolvedComments" + > + <template #replyForm> <apollo-mutation - v-if="annotationCoordinates" + v-if="isAnnotating" #default="{ mutate, loading }" :mutation="$options.createImageDiffNoteMutation" :variables="{ @@ -388,13 +366,9 @@ export default { :markdown-preview-path="markdownPreviewPath" @submitForm="mutate" @cancelForm="closeCommentForm" - /> - </apollo-mutation> - </template> - <h2 v-else class="new-discussion-disclaimer gl-font-base m-0"> - {{ __("Click the image where you'd like to start a new discussion") }} - </h2> - </div> + /> </apollo-mutation + ></template> + </design-sidebar> </template> </div> </template> diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 7d419bc3ded..922c800009f 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -318,6 +318,6 @@ export default { </li> </ol> </div> - <router-view /> + <router-view :key="$route.fullPath" /> </div> </template> diff --git a/app/assets/javascripts/design_management/router/index.js b/app/assets/javascripts/design_management/router/index.js index 7dc92f55d47..7494da002c8 100644 --- a/app/assets/javascripts/design_management/router/index.js +++ b/app/assets/javascripts/design_management/router/index.js @@ -2,6 +2,9 @@ import $ from 'jquery'; import Vue from 'vue'; import VueRouter from 'vue-router'; import routes from './routes'; +import { DESIGN_ROUTE_NAME } from './constants'; +import { getPageLayoutElement } from '~/design_management/utils/design_management_utils'; +import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants'; Vue.use(VueRouter); @@ -11,10 +14,20 @@ export default function createRouter(base) { mode: 'history', routes, }); + const pageEl = getPageLayoutElement(); - router.beforeEach(({ meta: { el } }, from, next) => { + router.beforeEach(({ meta: { el }, name }, _, next) => { $(`#${el}`).tab('show'); + // apply a fullscreen layout style in Design View (a.k.a design detail) + if (pageEl) { + if (name === DESIGN_ROUTE_NAME) { + pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + } else { + pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + } + } + next(); }); diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index 01c073bddc2..24b374b79fd 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -95,6 +95,10 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) = __typename: 'Discussion', id: createImageDiffNote.note.discussion.id, replyId: createImageDiffNote.note.discussion.replyId, + resolvable: true, + resolved: false, + resolvedAt: null, + resolvedBy: null, notes: { __typename: 'NoteConnection', nodes: [createImageDiffNote.note], diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js index e6d8796ffa4..22705cf67a1 100644 --- a/app/assets/javascripts/design_management/utils/design_management_utils.js +++ b/app/assets/javascripts/design_management/utils/design_management_utils.js @@ -21,8 +21,9 @@ export const extractNodes = elements => elements.edges.map(({ node }) => node); */ export const extractDiscussions = discussions => - discussions.nodes.map(discussion => ({ + discussions.nodes.map((discussion, index) => ({ ...discussion, + index: index + 1, notes: discussion.notes.nodes, })); @@ -123,3 +124,5 @@ const normalizeAuthor = author => ({ }); export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node)); + +export const getPageLayoutElement = () => document.querySelector('.layout-page'); diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js index 0c521fa29bd..0991f5282a8 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, guard-for-in, no-restricted-syntax, no-lonely-if, no-continue */ +/* eslint-disable func-names, no-continue */ /* global CommentsStore */ import $ from 'jquery'; @@ -42,13 +42,13 @@ const JumpToDiscussion = Vue.extend({ }, lastResolvedId() { let lastId; - for (const discussionId in this.discussions) { + Object.keys(this.discussions).forEach(discussionId => { const discussion = this.discussions[discussionId]; if (!discussion.isResolved()) { lastId = discussion.id; } - } + }); return lastId; }, }, @@ -95,12 +95,10 @@ const JumpToDiscussion = Vue.extend({ if (unresolvedDiscussionCount === 1) { hasDiscussionsToJumpTo = false; } - } else { + } else if (unresolvedDiscussionCount === 0) { // If there are no unresolved discussions on the diffs tab at all, // there are no discussions to jump to. - if (unresolvedDiscussionCount === 0) { - hasDiscussionsToJumpTo = false; - } + hasDiscussionsToJumpTo = false; } } else if (activeTab !== 'show') { // If we are on the commits or builds tabs, diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index ee93ca020e8..99bc1b5c040 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -144,7 +144,7 @@ export default { <pre v-if="commit.description_html" - class="commit-row-description js-toggle-content append-bottom-8" + class="commit-row-description js-toggle-content gl-mb-3" v-html="commit.description_html" ></pre> </div> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index c680c3f4600..6f6fa312865 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -86,7 +86,7 @@ export default { <button v-gl-tooltip.hover type="button" - class="btn btn-default append-right-8 js-toggle-tree-list" + class="btn btn-default gl-mr-3 js-toggle-tree-list" :class="{ active: showTreeList, }" @@ -98,20 +98,20 @@ export default { <gl-sprintf v-if="showDropdowns" class="d-flex align-items-center compare-versions-container" - :message="s__('MergeRequest|Compare %{source} and %{target}')" + :message="s__('MergeRequest|Compare %{target} and %{source}')" > - <template #source> - <compare-dropdown-layout - :versions="diffCompareDropdownSourceVersions" - class="mr-version-dropdown" - /> - </template> <template #target> <compare-dropdown-layout :versions="diffCompareDropdownTargetVersions" class="mr-version-compare-dropdown" /> </template> + <template #source> + <compare-dropdown-layout + :versions="diffCompareDropdownSourceVersions" + class="mr-version-dropdown" + /> + </template> </gl-sprintf> <div v-else-if="commit"> {{ __('Viewing commit') }} @@ -126,15 +126,11 @@ export default { <gl-deprecated-button v-if="commit || startVersion" :href="latestVersionPath" - class="append-right-8 js-latest-version" + class="gl-mr-3 js-latest-version" > {{ __('Show latest version') }} </gl-deprecated-button> - <gl-deprecated-button - v-show="hasCollapsedFile" - class="append-right-8" - @click="expandAllFiles" - > + <gl-deprecated-button v-show="hasCollapsedFile" class="gl-mr-3" @click="expandAllFiles"> {{ __('Expand all') }} </gl-deprecated-button> <settings-dropdown /> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 5656bfc4707..741462a849c 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -1,11 +1,12 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; -import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; -import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments'; +import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; +import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue'; import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue'; +import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; import InlineDiffView from './inline_diff_view.vue'; import ParallelDiffView from './parallel_diff_view.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -29,7 +30,7 @@ export default { NotDiffableViewer, NoPreviewViewer, userAvatarLink, - DiffFileDrafts: () => import('ee_component/batch_comments/components/diff_file_drafts.vue'), + DiffFileDrafts, }, mixins: [diffLineNoteFormMixin, draftCommentsMixin], props: { @@ -128,6 +129,7 @@ export default { <no-preview-viewer v-else-if="noPreview" /> <diff-viewer v-else + :diff-file="diffFile" :diff-mode="diffMode" :diff-viewer-mode="diffViewerMode" :new-path="diffFile.new_path" 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 f81f50f8490..74305ee69bc 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -1,18 +1,22 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; +import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__ } from '~/locale'; import noteForm from '../../notes/components/note_form.vue'; +import MultilineCommentForm from '../../notes/components/multiline_comment_form.vue'; import autosave from '../../notes/mixins/autosave'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import { DIFF_NOTE_TYPE } from '../constants'; +import { commentLineOptions } from '../../notes/components/multiline_comment_utils'; export default { components: { noteForm, userAvatarLink, + MultilineCommentForm, }, - mixins: [autosave, diffLineNoteFormMixin], + mixins: [autosave, diffLineNoteFormMixin, glFeatureFlagsMixin()], props: { diffFileHash: { type: String, @@ -37,6 +41,14 @@ export default { default: '', }, }, + data() { + return { + commentLineStart: { + lineCode: this.line.line_code, + type: this.line.type, + }, + }; + }, computed: { ...mapState({ noteableData: state => state.notes.noteableData, @@ -62,11 +74,20 @@ export default { diffViewType: this.diffViewType, diffFile: this.diffFile, linePosition: this.linePosition, + lineRange: { + start_line_code: this.commentLineStart.lineCode, + start_line_type: this.commentLineStart.type, + end_line_code: this.line.line_code, + end_line_type: this.line.type, + }, }; }, diffFile() { return this.getDiffFileByHash(this.diffFileHash); }, + commentLineOptions() { + return commentLineOptions(this.diffFile.highlighted_diff_lines, this.line.line_code); + }, }, mounted() { if (this.isLoggedIn) { @@ -83,7 +104,6 @@ export default { methods: { ...mapActions('diffs', [ 'cancelCommentForm', - 'assignDiscussionsToDiff', 'saveDiffDiscussion', 'setSuggestPopoverDismissed', ]), @@ -116,6 +136,16 @@ export default { <template> <div class="content discussion-form discussion-form-container discussion-notes"> + <div + v-if="glFeatures.multilineComments" + class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" + > + <multiline-comment-form + v-model="commentLineStart" + :line="line" + :comment-line-options="commentLineOptions" + /> + </div> <user-avatar-link v-if="author" :link-href="author.path" @@ -133,7 +163,7 @@ export default { :diff-file="diffFile" :show-suggest-popover="showSuggestPopover" save-button-title="Comment" - class="diff-comment-form" + class="diff-comment-form prepend-top-10" @handleFormUpdateAddToReview="addToReview" @cancelForm="handleCancelCommentForm" @handleFormUpdate="handleSaveNote" diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index 8b25cdc2887..ad72016f03b 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -1,6 +1,7 @@ <script> import { mapGetters } from 'vuex'; -import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments'; +import draftCommentsMixin from '~/diffs/mixins/draft_comments'; +import InlineDraftCommentRow from '~/batch_comments/components/inline_draft_comment_row.vue'; import inlineDiffTableRow from './inline_diff_table_row.vue'; import inlineDiffCommentRow from './inline_diff_comment_row.vue'; import inlineDiffExpansionRow from './inline_diff_expansion_row.vue'; @@ -9,8 +10,7 @@ export default { components: { inlineDiffCommentRow, inlineDiffTableRow, - InlineDraftCommentRow: () => - import('ee_component/batch_comments/components/inline_draft_comment_row.vue'), + InlineDraftCommentRow, inlineDiffExpansionRow, }, mixins: [draftCommentsMixin], @@ -80,6 +80,8 @@ export default { v-if="shouldRenderDraftRow(diffFile.file_hash, line)" :key="`draft_${index}`" :draft="draftForLine(diffFile.file_hash, line)" + :diff-file="diffFile" + :line="line" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index d796aad9d06..b5fcc50ce26 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -1,6 +1,7 @@ <script> import { mapGetters } from 'vuex'; -import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments'; +import draftCommentsMixin from '~/diffs/mixins/draft_comments'; +import ParallelDraftCommentRow from '~/batch_comments/components/parallel_draft_comment_row.vue'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; import parallelDiffExpansionRow from './parallel_diff_expansion_row.vue'; @@ -10,8 +11,7 @@ export default { parallelDiffExpansionRow, parallelDiffTableRow, parallelDiffCommentRow, - ParallelDraftCommentRow: () => - import('ee_component/batch_comments/components/parallel_draft_comment_row.vue'), + ParallelDraftCommentRow, }, mixins: [draftCommentsMixin], props: { diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index eca9091f92f..52611f3c82a 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -64,7 +64,7 @@ export default { <template> <div class="tree-list-holder d-flex flex-column"> - <div class="append-bottom-8 position-relative tree-list-search d-flex"> + <div class="gl-mb-3 position-relative tree-list-search d-flex"> <div class="flex-fill d-flex"> <icon name="search" class="position-absolute tree-list-icon" /> <label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 40e1aec42ed..9269dacd582 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -61,3 +61,22 @@ export const DIFFS_PER_PAGE = 20; export const DIFF_COMPARE_BASE_VERSION_INDEX = -1; export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2; + +// State machine states +export const STATE_IDLING = 'idle'; +export const STATE_LOADING = 'loading'; +export const STATE_ERRORED = 'errored'; + +// State machine transitions +export const TRANSITION_LOAD_START = 'LOAD_START'; +export const TRANSITION_LOAD_ERROR = 'LOAD_ERROR'; +export const TRANSITION_LOAD_SUCCEED = 'LOAD_SUCCEED'; +export const TRANSITION_ACKNOWLEDGE_ERROR = 'ACKNOWLEDGE_ERROR'; + +export const RENAMED_DIFF_TRANSITIONS = { + [`${STATE_IDLING}:${TRANSITION_LOAD_START}`]: STATE_LOADING, + [`${STATE_LOADING}:${TRANSITION_LOAD_ERROR}`]: STATE_ERRORED, + [`${STATE_LOADING}:${TRANSITION_LOAD_SUCCEED}`]: STATE_IDLING, + [`${STATE_ERRORED}:${TRANSITION_LOAD_START}`]: STATE_LOADING, + [`${STATE_ERRORED}:${TRANSITION_ACKNOWLEDGE_ERROR}`]: STATE_IDLING, +}; diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js index b6c9b132aeb..693b4a84694 100644 --- a/app/assets/javascripts/diffs/mixins/draft_comments.js +++ b/app/assets/javascripts/diffs/mixins/draft_comments.js @@ -1,12 +1,17 @@ +import { mapGetters } from 'vuex'; + export default { computed: { - shouldRenderDraftRow: () => () => false, - shouldRenderParallelDraftRow: () => () => false, - draftForLine: () => () => ({}), + ...mapGetters('batchComments', [ + 'shouldRenderDraftRow', + 'shouldRenderParallelDraftRow', + 'draftForLine', + 'draftsForFile', + 'hasParallelDraftLeft', + 'hasParallelDraftRight', + ]), imageDiscussions() { - return this.diffFile.discussions; + return this.diffFile.discussions.concat(this.draftsForFile(this.diffFile.file_hash)); }, - hasParallelDraftLeft: () => () => false, - hasParallelDraftRight: () => () => false, }, }; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 1975d6996a5..a8d348e1836 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -118,12 +118,7 @@ export const fetchDiffFilesBatch = ({ commit, state }) => { const getBatch = (page = 1) => axios - .get(state.endpointBatch, { - params: { - ...urlParams, - page, - }, - }) + .get(mergeUrlParams({ ...urlParams, page }, state.endpointBatch)) .then(({ data: { pagination, diff_files } }) => { commit(types.SET_DIFF_DATA_BATCH, { diff_files }); commit(types.SET_BATCH_LOADING, false); @@ -507,9 +502,6 @@ export const cacheTreeListWidth = (_, size) => { localStorage.setItem(TREE_LIST_WIDTH_STORAGE_KEY, size); }; -export const requestFullDiff = ({ commit }, filePath) => commit(types.REQUEST_FULL_DIFF, filePath); -export const receiveFullDiffSucess = ({ commit }, { filePath }) => - commit(types.RECEIVE_FULL_DIFF_SUCCESS, { filePath }); export const receiveFullDiffError = ({ commit }, filePath) => { commit(types.RECEIVE_FULL_DIFF_ERROR, filePath); createFlash(s__('MergeRequest|Error loading full diff. Please try again.')); @@ -600,7 +592,7 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => { } }; -export const fetchFullDiff = ({ dispatch }, file) => +export const fetchFullDiff = ({ commit, dispatch }, file) => axios .get(file.context_lines_path, { params: { @@ -609,15 +601,16 @@ export const fetchFullDiff = ({ dispatch }, file) => }, }) .then(({ data }) => { - dispatch('receiveFullDiffSucess', { filePath: file.file_path }); + commit(types.RECEIVE_FULL_DIFF_SUCCESS, { filePath: file.file_path }); + dispatch('setExpandedDiffLines', { file, data }); }) .catch(() => dispatch('receiveFullDiffError', file.file_path)); -export const toggleFullDiff = ({ dispatch, getters, state }, filePath) => { +export const toggleFullDiff = ({ dispatch, commit, getters, state }, filePath) => { const file = state.diffFiles.find(f => f.file_path === filePath); - dispatch('requestFullDiff', filePath); + commit(types.REQUEST_FULL_DIFF, filePath); if (file.isShowingFullFile) { dispatch('loadCollapsedDiff', file) @@ -656,11 +649,6 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines }); dispatch('startRenderDiffsQueue'); - }) - .catch(error => { - dispatch('receiveFullDiffError', diffFile.file_path); - - throw error; }); } diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 2be71c77087..d261be1b550 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -40,6 +40,7 @@ export function getFormData(params) { diffViewType, linePosition, positionType, + lineRange, } = params; const position = JSON.stringify({ @@ -55,6 +56,7 @@ export function getFormData(params) { y: params.y, width: params.width, height: params.height, + line_range: lineRange, }); const postData = { diff --git a/app/assets/javascripts/diffs/utils/uuids.js b/app/assets/javascripts/diffs/utils/uuids.js new file mode 100644 index 00000000000..1a529c07ccc --- /dev/null +++ b/app/assets/javascripts/diffs/utils/uuids.js @@ -0,0 +1,79 @@ +/** + * @module uuids + */ + +/** + * A string or number representing a start state for a random generator + * @typedef {(Number|String)} Seed + */ +/** + * A UUIDv4 string in the format <code>Hex{8}-Hex{4}-4Hex{3}-[89ab]Hex{3}-Hex{12}</code> + * @typedef {String} UUIDv4 + */ + +// https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20 +/* eslint-disable import/prefer-default-export */ + +import MersenneTwister from 'mersenne-twister'; +import stringHash from 'string-hash'; +import { isString } from 'lodash'; +import { v4 } from 'uuid'; + +function getSeed(seeds) { + return seeds.reduce((seedling, seed, i) => { + let thisSeed = 0; + + if (Number.isInteger(seed)) { + thisSeed = seed; + } else if (isString(seed)) { + thisSeed = stringHash(seed); + } + + return seedling + (seeds.length - i) * thisSeed; + }, 0); +} + +function getPseudoRandomNumberGenerator(...seeds) { + let seedNumber; + + if (seeds.length) { + seedNumber = getSeed(seeds); + } else { + seedNumber = Math.floor(Math.random() * 10 ** 15); + } + + return new MersenneTwister(seedNumber); +} + +function randomValuesForUuid(prng) { + const randomValues = []; + + for (let i = 0; i <= 3; i += 1) { + const buffer = new ArrayBuffer(4); + const view = new DataView(buffer); + + view.setUint32(0, prng.random_int()); + + randomValues.push(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)); + } + + return randomValues; +} + +/** + * Get an array of UUIDv4s + * @param {Object} [options={}] + * @param {Seed[]} [options.seeds=[]] - A list of mixed strings or numbers to seed the UUIDv4 generator + * @param {Number} [options.count=1] - A total number of UUIDv4s to generate + * @returns {UUIDv4[]} An array of UUIDv4s + */ +export function uuids({ seeds = [], count = 1 } = {}) { + const rng = getPseudoRandomNumberGenerator(...seeds); + return ( + // Create an array the same size as the number of UUIDs requested + Array(count) + .fill(0) + // Replace each slot in the array with a UUID which needs 16 (pseudo)random values to generate + .map(() => v4({ random: randomValuesForUuid(rng) })) + ); +} diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 0a5538237f9..b8bcca814cd 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -158,13 +158,13 @@ export default { :deploy-boards-help-path="deployBoardsHelpPath" @onChangePage="onChangePage" > - <empty-state - v-if="!isLoading && state.environments.length === 0" - slot="emptyState" - :new-path="newEnvironmentPath" - :help-path="helpPagePath" - :can-create-environment="canCreateEnvironment" - /> + <template v-if="!isLoading && state.environments.length === 0" #emptyState> + <empty-state + :new-path="newEnvironmentPath" + :help-path="helpPagePath" + :can-create-environment="canCreateEnvironment" + /> + </template> </container> </div> </template> diff --git a/app/assets/javascripts/error_tracking/components/constants.js b/app/assets/javascripts/error_tracking/components/constants.js index 60b217443de..41b952e26d8 100644 --- a/app/assets/javascripts/error_tracking/components/constants.js +++ b/app/assets/javascripts/error_tracking/components/constants.js @@ -8,10 +8,10 @@ export const severityLevel = { export const severityLevelVariant = { [severityLevel.FATAL]: 'danger', - [severityLevel.ERROR]: 'dark', + [severityLevel.ERROR]: 'neutral', [severityLevel.WARNING]: 'warning', [severityLevel.INFO]: 'info', - [severityLevel.DEBUG]: 'light', + [severityLevel.DEBUG]: 'muted', }; export const errorStatus = { diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 148edfe3a51..1e8f5a26125 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -1,6 +1,5 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import dateFormat from 'dateformat'; import createFlash from '~/flash'; import { GlDeprecatedButton, @@ -19,9 +18,14 @@ import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import Stacktrace from './stacktrace.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { trackClickErrorLinkToSentryOptions } from '../utils'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { severityLevel, severityLevelVariant, errorStatus } from './constants'; +import Tracking from '~/tracking'; +import { + trackClickErrorLinkToSentryOptions, + trackErrorDetailsViewsOptions, + trackErrorStatusUpdateOptions, +} from '../utils'; import query from '../queries/details.query.graphql'; @@ -42,11 +46,11 @@ export default { GlDropdown, GlDropdownItem, GlDropdownDivider, + TimeAgoTooltip, }, directives: { TrackEvent: TrackEventDirective, }, - mixins: [timeagoMixin], props: { issueUpdatePath: { type: String, @@ -172,6 +176,7 @@ export default { }, }, mounted() { + this.trackPageViews(); this.startPollingStacktrace(this.issueStackTracePath); this.errorPollTimeout = Date.now() + SENTRY_TIMEOUT; this.$apollo.queries.error.setOptions({ @@ -194,7 +199,10 @@ export default { onIgnoreStatusUpdate() { const status = this.errorStatus === errorStatus.IGNORED ? errorStatus.UNRESOLVED : errorStatus.IGNORED; - this.updateIgnoreStatus({ endpoint: this.issueUpdatePath, status }); + // eslint-disable-next-line promise/catch-or-return + this.updateIgnoreStatus({ endpoint: this.issueUpdatePath, status }).then(() => { + this.trackStatusUpdate(status); + }); }, onResolveStatusUpdate() { const status = @@ -206,6 +214,7 @@ export default { if (this.closedIssueId) { this.isAlertVisible = true; } + this.trackStatusUpdate(status); }); }, onNoApolloResult() { @@ -215,8 +224,13 @@ export default { createFlash(__('Could not connect to Sentry. Refresh the page to try again.'), 'warning'); } }, - formatDate(date) { - return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; + trackPageViews() { + const { category, action } = trackErrorDetailsViewsOptions; + Tracking.event(category, action); + }, + trackStatusUpdate(status) { + const { category, action } = trackErrorStatusUpdateOptions(status); + Tracking.event(category, action); }, }, }; @@ -251,7 +265,7 @@ export default { <strong class="error-details-meta-culprit">{{ error.culprit }}</strong> </template> <template #timeAgo> - {{ timeFormatted(stacktraceData.date_received) }} + <time-ago-tooltip :time="stacktraceData.date_received" /> </template> </gl-sprintf> </div> @@ -259,7 +273,7 @@ export default { <div class="d-inline-flex bv-d-sm-down-none"> <gl-deprecated-button :loading="updatingIgnoreStatus" - data-qa-selector="update_ignore_status_button" + data-testid="update-ignore-status-btn" @click="onIgnoreStatusUpdate" > {{ ignoreBtnLabel }} @@ -267,7 +281,7 @@ export default { <gl-deprecated-button class="btn-outline-info ml-2" :loading="updatingResolveStatus" - data-qa-selector="update_resolve_status_button" + data-testid="update-resolve-status-btn" @click="onResolveStatusUpdate" > {{ resolveBtnLabel }} @@ -275,7 +289,7 @@ export default { <gl-deprecated-button v-if="error.gitlabIssuePath" class="ml-2" - data-qa-selector="view_issue_button" + data-testid="view_issue_button" :href="error.gitlabIssuePath" variant="success" > @@ -345,16 +359,10 @@ export default { <h2 class="text-truncate">{{ error.title }}</h2> </tooltip-on-truncate> <template v-if="error.tags"> - <gl-badge - v-if="error.tags.level" - :variant="errorSeverityVariant" - class="rounded-pill mr-2" - > + <gl-badge v-if="error.tags.level" :variant="errorSeverityVariant" class="mr-2"> {{ errorLevel }} </gl-badge> - <gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill" - >{{ error.tags.logger }} - </gl-badge> + <gl-badge v-if="error.tags.logger" variant="muted">{{ error.tags.logger }} </gl-badge> </template> <ul> <li v-if="error.gitlabCommit"> @@ -375,6 +383,7 @@ export default { v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)" :href="error.externalUrl" target="_blank" + data-testid="external-url-link" > <span class="text-truncate">{{ error.externalUrl }}</span> <icon name="external-link" class="ml-1 flex-shrink-0" /> @@ -382,14 +391,14 @@ export default { </li> <li v-if="error.firstReleaseShortVersion"> <strong class="bold">{{ __('First seen') }}:</strong> - {{ formatDate(error.firstSeen) }} + <time-ago-tooltip :time="error.firstSeen" /> <gl-link :href="firstReleaseLink" target="_blank"> <span>{{ __('Release') }}: {{ error.firstReleaseShortVersion.substr(0, 10) }}</span> </gl-link> </li> <li v-if="error.lastReleaseShortVersion"> <strong class="bold">{{ __('Last seen') }}:</strong> - {{ formatDate(error.lastSeen) }} + <time-ago-tooltip :time="error.lastSeen" /> <gl-link :href="lastReleaseLink" target="_blank"> <span>{{ __('Release') }}: {{ error.lastReleaseShortVersion.substr(0, 10) }}</span> </gl-link> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 45432e8ebd8..62a73e21096 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -19,6 +19,8 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { __ } from '~/locale'; import { isEmpty } from 'lodash'; import ErrorTrackingActions from './error_tracking_actions.vue'; +import Tracking from '~/tracking'; +import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '../utils'; export const tableDataClass = 'table-col d-flex d-md-table-cell align-items-center'; @@ -150,6 +152,9 @@ export default { this.startPolling(); } }, + mounted() { + this.trackPageViews(); + }, methods: { ...mapActions('list', [ 'startPolling', @@ -197,13 +202,25 @@ export default { this.filterValue = label; return this.filterByStatus(status); }, - updateIssueStatus({ errorId, status }) { + updateErrosStatus({ errorId, status }) { + // eslint-disable-next-line promise/catch-or-return this.updateStatus({ endpoint: this.getIssueUpdatePath(errorId), status, + }).then(() => { + this.trackStatusUpdate(status); }); + this.removeIgnoredResolvedErrors(errorId); }, + trackPageViews() { + const { category, action } = trackErrorListViewsOptions; + Tracking.event(category, action); + }, + trackStatusUpdate(status) { + const { category, action } = trackErrorStatusUpdateOptions(status); + Tracking.event(category, action); + }, }, }; </script> @@ -359,7 +376,7 @@ export default { </div> </template> <template #cell(status)="errors"> - <error-tracking-actions :error="errors.item" @update-issue-status="updateIssueStatus" /> + <error-tracking-actions :error="errors.item" @update-issue-status="updateErrosStatus" /> </template> <template #empty> {{ __('No errors to display.') }} diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index 4170c1bf759..94cf444d2e4 100644 --- a/app/assets/javascripts/error_tracking/store/list/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -2,7 +2,7 @@ import Service from '../../services'; import * as types from './mutation_types'; import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; let eTagPoll; @@ -31,17 +31,9 @@ export function startPolling({ state, commit, dispatch }) { commit(types.SET_LOADING, false); dispatch('stopPolling'); }, - errorCallback: ({ response }) => { - let errorMessage = ''; - if (response && response.data && response.data.message) { - errorMessage = response.data.message; - } + errorCallback: () => { commit(types.SET_LOADING, false); - createFlash( - sprintf(__(`Failed to load errors from Sentry. Error message: %{errorMessage}`), { - errorMessage, - }), - ); + createFlash(__('Failed to load errors from Sentry.')); }, }); diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js index d1cd70a72fa..5b705cc5510 100644 --- a/app/assets/javascripts/error_tracking/utils.js +++ b/app/assets/javascripts/error_tracking/utils.js @@ -1,4 +1,4 @@ -/* eslint-disable @gitlab/require-i18n-strings, import/prefer-default-export */ +/* eslint-disable @gitlab/require-i18n-strings */ /** * Tracks snowplow event when User clicks on error link to Sentry @@ -10,3 +10,27 @@ export const trackClickErrorLinkToSentryOptions = url => ({ label: 'Error Link', property: url, }); + +/** + * Tracks snowplow event when user views error list + */ +export const trackErrorListViewsOptions = { + category: 'Error Tracking', + action: 'view_errors_list', +}; + +/** + * Tracks snowplow event when user views error details + */ +export const trackErrorDetailsViewsOptions = { + category: 'Error Tracking', + action: 'view_error_details', +}; + +/** + * Tracks snowplow event when error status is updated + */ +export const trackErrorStatusUpdateOptions = status => ({ + category: 'Error Tracking', + action: `update_${status}_status`, +}); diff --git a/app/assets/javascripts/file_pickers.js b/app/assets/javascripts/file_pickers.js new file mode 100644 index 00000000000..956a4954afb --- /dev/null +++ b/app/assets/javascripts/file_pickers.js @@ -0,0 +1,21 @@ +export default function initFilePickers() { + const filePickers = document.querySelectorAll('.js-filepicker'); + + filePickers.forEach(filePicker => { + const button = filePicker.querySelector('.js-filepicker-button'); + + button.addEventListener('click', () => { + const form = button.closest('form'); + form.querySelector('.js-filepicker-input').click(); + }); + + const input = filePicker.querySelector('.js-filepicker-input'); + + input.addEventListener('change', () => { + const form = input.closest('form'); + const filename = input.value.replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape + + form.querySelector('.js-filepicker-filename').textContent = filename; + }); + }); +} diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index d41d5a543b0..5298e20557d 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -95,7 +95,7 @@ export default class FilteredSearchVisualTokens { const tokenType = tokenName.toLowerCase(); const tokenValueContainer = parentElement.querySelector('.value-container'); const tokenValueElement = tokenValueContainer.querySelector('.value'); - tokenValueElement.innerText = tokenValue; + tokenValueElement.textContent = tokenValue; const visualTokenValue = new VisualTokenValue(tokenValue, tokenType, tokenOperator); @@ -140,9 +140,9 @@ export default class FilteredSearchVisualTokens { li.innerHTML = nameHTML + operatorHTML; } - li.querySelector('.name').innerText = name; + li.querySelector('.name').textContent = name; if (hasOperator) { - li.querySelector('.operator').innerText = operator; + li.querySelector('.operator').textContent = operator; } const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); @@ -162,8 +162,8 @@ export default class FilteredSearchVisualTokens { lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({ hasOperator: Boolean(operator), }); - lastVisualToken.querySelector('.name').innerText = name; - lastVisualToken.querySelector('.operator').innerText = operator; + lastVisualToken.querySelector('.name').textContent = name; + lastVisualToken.querySelector('.operator').textContent = operator; FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value, operator); } } @@ -208,8 +208,8 @@ export default class FilteredSearchVisualTokens { }, }); } else { - const previousTokenName = lastVisualToken.querySelector('.name').innerText; - const previousTokenOperator = lastVisualToken.querySelector('.operator').innerText; + const previousTokenName = lastVisualToken.querySelector('.name').textContent; + const previousTokenOperator = lastVisualToken.querySelector('.operator').textContent; const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); tokensContainer.removeChild(lastVisualToken); @@ -234,7 +234,7 @@ export default class FilteredSearchVisualTokens { const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) { - lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`; + lastVisualToken.querySelector('.name').textContent += ` ${searchTerm}`; } else { FilteredSearchVisualTokens.addVisualTokenElement({ name: searchTerm, @@ -261,12 +261,12 @@ export default class FilteredSearchVisualTokens { const value = lastVisualToken.querySelector('.value'); const name = lastVisualToken.querySelector('.name'); - const valueText = value ? value.innerText : ''; - const nameText = name ? name.innerText : ''; + const valueText = value ? value.textContent : ''; + const nameText = name ? name.textContent : ''; if (includeOperator) { const operator = lastVisualToken.querySelector('.operator'); - const operatorText = operator ? operator.innerText : ''; + const operatorText = operator ? operator.textContent : ''; return valueText || operatorText || nameText; } @@ -278,7 +278,7 @@ export default class FilteredSearchVisualTokens { const operator = lastVisualToken && lastVisualToken.querySelector('.operator'); - return operator?.innerText; + return operator?.textContent; } static removeLastTokenPartial() { @@ -346,8 +346,8 @@ export default class FilteredSearchVisualTokens { if (token.classList.contains('filtered-search-token')) { FilteredSearchVisualTokens.addFilterVisualToken( - nameElement.innerText, - operatorElement.innerText, + nameElement.textContent, + operatorElement.textContent, null, { uppercaseTokenName: nameElement.classList.contains('text-uppercase'), @@ -359,13 +359,13 @@ export default class FilteredSearchVisualTokens { if (!value) { const valueElement = valueContainerElement.querySelector('.value'); - value = valueElement.innerText; + value = valueElement.textContent; } } // token is a search term if (!value) { - value = nameElement.innerText; + value = nameElement.textContent; } input.value = value; diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js index 011b37e218d..d8c2c6d79c6 100644 --- a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js @@ -1,13 +1,10 @@ import { __ } from '~/locale'; -class RecentSearchesServiceError { +class RecentSearchesServiceError extends Error { constructor(message) { + super(message || __('Recent Searches Service is unavailable')); this.name = 'RecentSearchesServiceError'; - this.message = message || __('Recent Searches Service is unavailable'); } } -// Can't use `extends` for builtin prototypes and get true inheritance yet -RecentSearchesServiceError.prototype = Error.prototype; - export default RecentSearchesServiceError; diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js index 6263acbab8e..c074f173776 100644 --- a/app/assets/javascripts/frequent_items/index.js +++ b/app/assets/javascripts/frequent_items/index.js @@ -1,8 +1,7 @@ import $ from 'jquery'; import Vue from 'vue'; import Translate from '~/vue_shared/translate'; -import eventHub from '~/frequent_items/event_hub'; -import frequentItems from './components/app.vue'; +import eventHub from './event_hub'; Vue.use(Translate); @@ -17,7 +16,7 @@ const frequentItemDropdowns = [ }, ]; -const initFrequentItemDropdowns = () => { +export default function initFrequentItemDropdowns() { frequentItemDropdowns.forEach(dropdown => { const { namespace, key } = dropdown; const el = document.getElementById(`js-${namespace}-dropdown`); @@ -29,45 +28,40 @@ const initFrequentItemDropdowns = () => { return; } - $(navEl).on('shown.bs.dropdown', () => { - eventHub.$emit(`${namespace}-dropdownOpen`); - }); + $(navEl).on('shown.bs.dropdown', () => + import('./components/app.vue').then(({ default: FrequentItems }) => { + // eslint-disable-next-line no-new + new Vue({ + el, + data() { + const { dataset } = this.$options.el; + const item = { + id: Number(dataset[`${key}Id`]), + name: dataset[`${key}Name`], + namespace: dataset[`${key}Namespace`], + webUrl: dataset[`${key}WebUrl`], + avatarUrl: dataset[`${key}AvatarUrl`] || null, + lastAccessedOn: Date.now(), + }; - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - frequentItems, - }, - data() { - const { dataset } = this.$options.el; - const item = { - id: Number(dataset[`${key}Id`]), - name: dataset[`${key}Name`], - namespace: dataset[`${key}Namespace`], - webUrl: dataset[`${key}WebUrl`], - avatarUrl: dataset[`${key}AvatarUrl`] || null, - lastAccessedOn: Date.now(), - }; - - return { - currentUserName: dataset.userName, - currentItem: item, - }; - }, - render(createElement) { - return createElement('frequent-items', { - props: { - namespace, - currentUserName: this.currentUserName, - currentItem: this.currentItem, + return { + currentUserName: dataset.userName, + currentItem: item, + }; + }, + render(createElement) { + return createElement(FrequentItems, { + props: { + namespace, + currentUserName: this.currentUserName, + currentItem: this.currentItem, + }, + }); }, }); - }, - }); - }); -}; -document.addEventListener('DOMContentLoaded', () => { - requestIdleCallback(initFrequentItemDropdowns); -}); + eventHub.$emit(`${namespace}-dropdownOpen`); + }), + ); + }); +} diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index be4b4b5f87d..ec0d0cf6aef 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import { escape } from 'lodash'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import axios from './lib/utils/axios_utils'; -import { visitUrl } from './lib/utils/url_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; import { isObject } from './lib/utils/type_utility'; import renderItem from './gl_dropdown/render'; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/global_search_input.js index d8eb981c106..a7c121259d4 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/global_search_input.js @@ -1,10 +1,8 @@ /* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; -import { escape, throttle } from 'lodash'; -import { s__, __ } from '~/locale'; -import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; -import axios from './lib/utils/axios_utils'; +import { throttle } from 'lodash'; +import { s__, __, sprintf } from '~/locale'; import { isInGroupsPage, isInProjectPage, @@ -67,15 +65,11 @@ function setSearchOptions() { } } -export class SearchAutocomplete { - constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) { +export class GlobalSearchInput { + constructor({ wrap } = {}) { setSearchOptions(); this.bindEventContext(); this.wrap = wrap || $('.search'); - this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts'); - this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath'); - this.projectId = projectId || (this.optsEl.data('autocompleteProjectId') || ''); - this.projectRef = projectRef || (this.optsEl.data('autocompleteProjectRef') || ''); this.dropdown = this.wrap.find('.dropdown'); this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle'); this.dropdownMenu = this.dropdown.find('.dropdown-menu'); @@ -92,7 +86,7 @@ export class SearchAutocomplete { // Only when user is logged in if (gon.current_user_id) { - this.createAutocomplete(); + this.createGlobalSearchInput(); } this.bindEvents(); @@ -117,7 +111,7 @@ export class SearchAutocomplete { return (this.originalState = this.serializeState()); } - createAutocomplete() { + createGlobalSearchInput() { return this.searchInput.glDropdown({ filterInputBlur: false, filterable: true, @@ -149,156 +143,75 @@ export class SearchAutocomplete { if (glDropdownInstance) { glDropdownInstance.filter.options.callback(contents); } - this.enableAutocomplete(); + this.enableDropdown(); } return; } - // Prevent multiple ajax calls - if (this.loadingSuggestions) { - return; - } - - this.loadingSuggestions = true; - - return axios - .get(this.autocompletePath, { - params: { - project_id: this.projectId, - project_ref: this.projectRef, - term, - }, - }) - .then(response => { - // Hide dropdown menu if no suggestions returns - if (!response.data.length) { - this.disableAutocomplete(); - return; - } - - const data = []; - // List results - let firstCategory = true; - let lastCategory; - for (let i = 0, len = response.data.length; i < len; i += 1) { - const suggestion = response.data[i]; - // Add group header before list each group - if (lastCategory !== suggestion.category) { - if (!firstCategory) { - data.push({ type: 'separator' }); - } - if (firstCategory) { - firstCategory = false; - } - data.push({ - type: 'header', - content: suggestion.category, - }); - lastCategory = suggestion.category; - } - data.push({ - id: `${suggestion.category.toLowerCase()}-${suggestion.id}`, - icon: this.getAvatar(suggestion), - category: suggestion.category, - text: suggestion.label, - url: suggestion.url, - }); - } - // Add option to proceed with the search - if (data.length) { - const icon = spriteIcon('search', 's16 inline-search-icon'); - let template; - - if (this.projectInputEl.val()) { - template = s__('SearchAutocomplete|in this project'); - } - if (this.groupInputEl.val()) { - template = s__('SearchAutocomplete|in this group'); - } - - data.unshift({ type: 'separator' }); - data.unshift({ - icon, - text: term, - template: s__('SearchAutocomplete|in all GitLab'), - url: `${gon.relative_url_root}/search?search=${term}`, - }); - - if (template) { - data.unshift({ - icon, - text: term, - template, - url: `${ - gon.relative_url_root - }/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`, - }); - } - } + const options = this.scopedSearchOptions(term); - callback(data); + callback(options); - this.loadingSuggestions = false; - this.highlightFirstRow(); - this.setScrollFade(); - }) - .catch(() => { - this.loadingSuggestions = false; - }); + this.highlightFirstRow(); + this.setScrollFade(); } - getCategoryContents() { - const userName = gon.current_username; - const { projectOptions, groupOptions, dashboardOptions } = gl; - - // Get options - let options; - if (isInGroupsPage() && groupOptions) { - options = groupOptions[getGroupSlug()]; - } else if (isInProjectPage() && projectOptions) { - options = projectOptions[getProjectSlug()]; - } else if (dashboardOptions) { - options = dashboardOptions; + // Add option to proceed with the search for each + // scope that is currently available, namely: + // + // - Search in this project + // - Search in this group (or project's group) + // - Search in all GitLab + scopedSearchOptions(term) { + const icon = spriteIcon('search', 's16 inline-search-icon'); + const projectId = this.projectInputEl.val(); + const groupId = this.groupInputEl.val(); + const options = []; + + if (projectId) { + const projectOptions = gl.projectOptions[getProjectSlug()]; + const url = groupId + ? `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&group_id=${groupId}` + : `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}`; + + options.push({ + icon, + text: term, + template: sprintf( + s__(`SearchAutocomplete|in project %{projectName}`), + { + projectName: `<i>${projectOptions.name}</i>`, + }, + false, + ), + url, + }); } - const { issuesPath, mrPath, name, issuesDisabled } = options; - const baseItems = []; - - if (name) { - baseItems.push({ - type: 'header', - content: `${name}`, + if (groupId) { + const groupOptions = gl.groupOptions[getGroupSlug()]; + options.push({ + icon, + text: term, + template: sprintf( + s__(`SearchAutocomplete|in group %{groupName}`), + { + groupName: `<i>${groupOptions.name}</i>`, + }, + false, + ), + url: `${gon.relative_url_root}/search?search=${term}&group_id=${groupId}`, }); } - const issueItems = [ - { - text: s__('SearchAutocomplete|Issues assigned to me'), - url: `${issuesPath}/?assignee_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Issues I've created"), - url: `${issuesPath}/?author_username=${userName}`, - }, - ]; - const mergeRequestItems = [ - { - text: s__('SearchAutocomplete|Merge requests assigned to me'), - url: `${mrPath}/?assignee_username=${userName}`, - }, - { - text: s__("SearchAutocomplete|Merge requests I've created"), - url: `${mrPath}/?author_username=${userName}`, - }, - ]; + options.push({ + icon, + text: term, + template: s__('SearchAutocomplete|in all GitLab'), + url: `${gon.relative_url_root}/search?search=${term}`, + }); - let items; - if (issuesDisabled) { - items = baseItems.concat(mergeRequestItems); - } else { - items = baseItems.concat(...issueItems, ...mergeRequestItems); - } - return items; + return options; } serializeState() { @@ -325,7 +238,7 @@ export class SearchAutocomplete { }); } - enableAutocomplete() { + enableDropdown() { this.setScrollFade(); // No need to enable anything if user is not logged in @@ -342,7 +255,7 @@ export class SearchAutocomplete { } onSearchInputChange() { - this.enableAutocomplete(); + this.enableDropdown(); } onSearchInputKeyUp(e) { @@ -351,7 +264,7 @@ export class SearchAutocomplete { this.restoreOriginalState(); break; case KEYCODE.ENTER: - this.disableAutocomplete(); + this.disableDropdown(); break; default: } @@ -404,7 +317,7 @@ export class SearchAutocomplete { return results; } - disableAutocomplete() { + disableDropdown() { if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) { this.searchInput.addClass('js-autocomplete-disabled'); this.dropdownToggle.dropdown('toggle'); @@ -420,16 +333,8 @@ export class SearchAutocomplete { onClick(item, $el, e) { if (window.location.pathname.indexOf(item.url) !== -1) { if (!e.metaKey) e.preventDefault(); - /* eslint-disable-next-line @gitlab/require-i18n-strings */ - if (item.category === 'Projects') { - this.projectInputEl.val(item.id); - } - // eslint-disable-next-line @gitlab/require-i18n-strings - if (item.category === 'Groups') { - this.groupInputEl.val(item.id); - } $el.removeClass('is-active'); - this.disableAutocomplete(); + this.disableDropdown(); return this.searchInput.val('').focus(); } } @@ -438,20 +343,58 @@ export class SearchAutocomplete { this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0); } - getAvatar(item) { - if (!Object.hasOwnProperty.call(item, 'avatar_url')) { - return false; + getCategoryContents() { + const userName = gon.current_username; + const { projectOptions, groupOptions, dashboardOptions } = gl; + + // Get options + let options; + if (isInProjectPage() && projectOptions) { + options = projectOptions[getProjectSlug()]; + } else if (isInGroupsPage() && groupOptions) { + options = groupOptions[getGroupSlug()]; + } else if (dashboardOptions) { + options = dashboardOptions; + } + + const { issuesPath, mrPath, name, issuesDisabled } = options; + const baseItems = []; + + if (name) { + baseItems.push({ + type: 'header', + content: `${name}`, + }); } - const { label, id } = item; - const avatarUrl = item.avatar_url; - const avatar = avatarUrl - ? `<img class="search-item-avatar" src="${avatarUrl}" />` - : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle( - escape(label), - )}</div>`; + const issueItems = [ + { + text: s__('SearchAutocomplete|Issues assigned to me'), + url: `${issuesPath}/?assignee_username=${userName}`, + }, + { + text: s__("SearchAutocomplete|Issues I've created"), + url: `${issuesPath}/?author_username=${userName}`, + }, + ]; + const mergeRequestItems = [ + { + text: s__('SearchAutocomplete|Merge requests assigned to me'), + url: `${mrPath}/?assignee_username=${userName}`, + }, + { + text: s__("SearchAutocomplete|Merge requests I've created"), + url: `${mrPath}/?author_username=${userName}`, + }, + ]; - return avatar; + let items; + if (issuesDisabled) { + items = baseItems.concat(mergeRequestItems); + } else { + items = baseItems.concat(...issueItems, ...mergeRequestItems); + } + return items; } isScrolledUp() { @@ -477,6 +420,6 @@ export class SearchAutocomplete { } } -export default function initSearchAutocomplete(opts) { - return new SearchAutocomplete(opts); +export default function initGlobalSearchInput(opts) { + return new GlobalSearchInput(opts); } diff --git a/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql index 7403fd6d3c2..e6f5d7db11a 100644 --- a/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql @@ -1,4 +1,6 @@ fragment PageInfo on PageInfo { hasNextPage + hasPreviousPage + startCursor endCursor } diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index eda0f5d1d23..ec8a238192a 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import { slugify } from './lib/utils/text_utility'; import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability'; import flash from '~/flash'; @@ -6,44 +5,69 @@ import { __ } from '~/locale'; export default class Group { constructor() { - this.groupPath = $('#group_path'); - this.groupName = $('#group_name'); - this.parentId = $('#group_parent_id'); + this.groupPaths = Array.from(document.querySelectorAll('.js-autofill-group-path')); + this.groupNames = Array.from(document.querySelectorAll('.js-autofill-group-name')); + this.parentId = document.getElementById('group_parent_id'); this.updateHandler = this.update.bind(this); this.resetHandler = this.reset.bind(this); this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this); - if (this.groupName.val() === '') { - this.groupName.on('keyup', this.updateHandler); - this.groupPath.on('keydown', this.resetHandler); - if (!this.parentId.val()) { - this.groupName.on('blur', this.updateGroupPathSlugHandler); + + this.groupNames.forEach(groupName => { + if (groupName.value === '') { + groupName.addEventListener('keyup', this.updateHandler); + + if (!this.parentId.value) { + groupName.addEventListener('blur', this.updateGroupPathSlugHandler); + } } - } + }); + + this.groupPaths.forEach(groupPath => { + groupPath.addEventListener('keydown', this.resetHandler); + }); } - update() { - const slug = slugify(this.groupName.val()); - this.groupPath.val(slug); + update({ currentTarget: { value: updatedValue } }) { + const slug = slugify(updatedValue); + + this.groupNames.forEach(element => { + element.value = updatedValue; + }); + this.groupPaths.forEach(element => { + element.value = slug; + }); } reset() { - this.groupName.off('keyup', this.updateHandler); - this.groupPath.off('keydown', this.resetHandler); - this.groupName.off('blur', this.checkPathHandler); + this.groupNames.forEach(groupName => { + groupName.removeEventListener('keyup', this.updateHandler); + groupName.removeEventListener('blur', this.checkPathHandler); + }); + + this.groupPaths.forEach(groupPath => { + groupPath.removeEventListener('keydown', this.resetHandler); + }); } - updateGroupPathSlug() { - const slug = this.groupPath.val() || slugify(this.groupName.val()); + updateGroupPathSlug({ currentTarget: { value } = '' } = {}) { + const slug = this.groupPaths[0]?.value || slugify(value); if (!slug) return; fetchGroupPathAvailability(slug) .then(({ data }) => data) - .then(data => { - if (data.exists && data.suggests.length > 0) { - const suggestedSlug = data.suggests[0]; - this.groupPath.val(suggestedSlug); + .then(({ exists, suggests }) => { + if (exists && suggests.length) { + const [suggestedSlug] = suggests; + + this.groupPaths.forEach(element => { + element.value = suggestedSlug; + }); + } else if (exists && !suggests.length) { + flash(__('Unable to suggest a path. Please refresh and try again.')); } }) - .catch(() => flash(__('An error occurred while checking group path'))); + .catch(() => + flash(__('An error occurred while checking group path. Please refresh and try again.')), + ); } } diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 1b8c75202fb..6b9748bb725 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -12,6 +12,8 @@ import itemStats from './item_stats.vue'; import itemStatsValue from './item_stats_value.vue'; import itemActions from './item_actions.vue'; +import { showLearnGitLabGroupItemPopover } from '~/onboarding_issues'; + export default { directives: { tooltip, @@ -73,6 +75,11 @@ export default { return GROUP_VISIBILITY_TYPE[this.group.visibility]; }, }, + mounted() { + if (this.group.name === 'Learn GitLab') { + showLearnGitLabGroupItemPopover(this.group.id); + } + }, methods: { onClickRowGroup(e) { const NO_EXPAND_CLS = 'no-expand'; @@ -104,7 +111,7 @@ export default { <gl-loading-icon v-if="group.isChildrenLoading" size="lg" - class="d-none d-sm-inline-flex flex-shrink-0 append-right-8" + class="d-none d-sm-inline-flex flex-shrink-0 gl-mr-3" /> <div :class="{ 'd-sm-flex': !group.isChildrenLoading }" @@ -117,12 +124,12 @@ export default { </div> <div class="group-text-container d-flex flex-fill align-items-center"> <div class="group-text flex-grow-1 flex-shrink-1"> - <div class="d-flex align-items-center flex-wrap title namespace-title append-right-8"> + <div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3"> <a v-tooltip :href="group.relativePath" :title="group.fullName" - class="no-expand prepend-top-8 append-right-8" + class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!" data-placement="bottom" >{{ // ending bracket must be by closing tag to prevent @@ -133,9 +140,9 @@ export default { <item-stats-value :icon-name="visibilityIcon" :title="visibilityTooltip" - css-class="item-visibility d-inline-flex align-items-center prepend-top-8 append-right-4 text-secondary" + css-class="item-visibility d-inline-flex align-items-center gl-mt-3 append-right-4 text-secondary" /> - <span v-if="group.permission" class="user-access-role prepend-top-8"> + <span v-if="group.permission" class="user-access-role gl-mt-3"> {{ group.permission }} </span> </div> @@ -150,7 +157,7 @@ export default { class="metadata align-items-md-center d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between" > <item-actions v-if="isGroup" :group="group" :parent-group="parentGroup" /> - <item-stats :item="group" class="group-stats prepend-top-2 d-none d-md-flex" /> + <item-stats :item="group" class="group-stats gl-mt-2 d-none d-md-flex" /> </div> </div> </div> diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index 5454480e61a..985ea5a9019 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -53,7 +53,7 @@ export default { :aria-label="leaveBtnTitle" data-container="body" data-placement="bottom" - class="leave-group btn btn-xs no-expand" + class="leave-group btn btn-xs no-expand gl-text-gray-700 gl-ml-5" @click.prevent="onLeaveGroup" > <icon name="leave" class="position-top-0" /> @@ -66,7 +66,7 @@ export default { :aria-label="editBtnTitle" data-container="body" data-placement="bottom" - class="edit-group btn btn-xs no-expand" + class="edit-group btn btn-xs no-expand gl-text-gray-700 gl-ml-5" > <icon name="settings" class="position-top-0 align-middle" /> </a> diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index 53da3f7b2ee..ffe4b18dea1 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -44,26 +44,26 @@ export default { </script> <template> - <div class="stats"> + <div class="stats gl-text-gray-700"> <item-stats-value v-if="isGroup" :title="__('Subgroups')" :value="item.subgroupCount" - css-class="number-subgroups" + css-class="number-subgroups gl-ml-5" icon-name="folder-o" /> <item-stats-value v-if="isGroup" :title="__('Projects')" :value="item.projectCount" - css-class="number-projects" + css-class="number-projects gl-ml-5" icon-name="bookmark" /> <item-stats-value v-if="isGroup" :title="__('Members')" :value="item.memberCount" - css-class="number-users" + css-class="number-users gl-ml-5" icon-name="users" /> <item-stats-value diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 67b068f1c6b..d151cecf5be 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -86,7 +86,7 @@ function trackShowUserDropdownLink(trackEvent, elToTrack, el) { } export function initNavUserDropdownTracking() { const el = document.querySelector('.js-nav-user-dropdown'); - const buyEl = document.querySelector('.js-buy-ci-minutes-link'); + const buyEl = document.querySelector('.js-buy-pipeline-minutes-link'); const upgradeEl = document.querySelector('.js-upgrade-plan-link'); if (el && buyEl) { diff --git a/app/assets/javascripts/ide/commit_icon.js b/app/assets/javascripts/ide/commit_icon.js new file mode 100644 index 00000000000..4984b5bb91d --- /dev/null +++ b/app/assets/javascripts/ide/commit_icon.js @@ -0,0 +1,11 @@ +import { commitItemIconMap } from './constants'; + +export default file => { + if (file.deleted) { + return commitItemIconMap.deleted; + } else if (file.tempFile && !file.prevPath) { + return commitItemIconMap.addition; + } + + return commitItemIconMap.modified; +}; diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 186d4b6d7d2..a65af55fcac 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { mapActions, mapGetters, mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import { leftSidebarViews } from '../constants'; @@ -13,7 +13,6 @@ export default { tooltip, }, computed: { - ...mapGetters(['hasChanges']), ...mapState(['currentActivityView']), }, methods: { @@ -23,6 +22,8 @@ export default { this.updateActivityBarView(view); + // TODO: We must use JQuery here to interact with the Bootstrap tooltip API + // https://gitlab.com/gitlab-org/gitlab/-/issues/217577 $(e.currentTarget).tooltip('hide'); }, }, @@ -67,7 +68,7 @@ export default { <icon name="file-modified" /> </button> </li> - <li v-show="hasChanges"> + <li> <button v-tooltip :class="{ diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue index 58a0631ee0d..e7f4cd796b5 100644 --- a/app/assets/javascripts/ide/components/branches/item.vue +++ b/app/assets/javascripts/ide/components/branches/item.vue @@ -2,7 +2,6 @@ /* eslint-disable @gitlab/vue-require-i18n-strings */ import Icon from '~/vue_shared/components/icon.vue'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; -import router from '../../ide_router'; export default { components: { @@ -26,7 +25,7 @@ export default { }, computed: { branchHref() { - return router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href; + return this.$router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href; }, }, }; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index 24499fb9f6d..59a32dd477e 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -29,7 +29,7 @@ export default { }, }, methods: { - ...mapActions(['stageChange', 'unstageChange', 'discardFileChanges']), + ...mapActions(['unstageChange', 'discardFileChanges']), showDiscardModal() { this.$refs.discardModal.show(); }, @@ -56,7 +56,7 @@ export default { v-if="canDiscard" ref="discardButton" type="button" - class="btn btn-remove btn-inverted append-right-8" + class="btn btn-remove btn-inverted gl-mr-3" @click="showDiscardModal" > {{ __('Discard changes') }} diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue index a23bae8e4c7..a13ca0cd138 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue @@ -9,10 +9,7 @@ export default { </script> <template> - <div - v-if="!lastCommitMsg" - class="multi-file-commit-panel-section ide-commit-empty-state js-empty-state" - > + <div v-if="!lastCommitMsg" class="multi-file-commit-panel-section ide-commit-empty-state"> <div class="ide-commit-empty-state-container"> <div class="svg-content svg-80"><img :src="noChangesStateSvgPath" /></div> <div class="append-right-default prepend-left-default"> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 4cbd33e6ed6..3bba4fbc906 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -26,7 +26,7 @@ export default { computed: { ...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), - ...mapGetters(['hasChanges']), + ...mapGetters(['someUncommittedChanges']), ...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']), overviewText() { return n__('%d changed file', '%d changed files', this.stagedFiles.length); @@ -40,20 +40,9 @@ export default { }, }, watch: { - currentActivityView() { - if (this.lastCommitMsg) { - this.isCompact = false; - } else { - this.isCompact = !( - this.currentViewIsCommitView && window.innerHeight >= MAX_WINDOW_HEIGHT_COMPACT - ); - } - }, - - lastCommitMsg() { - this.isCompact = - this.currentActivityView !== leftSidebarViews.commit.name && this.lastCommitMsg === ''; - }, + currentActivityView: 'handleCompactState', + someUncommittedChanges: 'handleCompactState', + lastCommitMsg: 'handleCompactState', }, methods: { ...mapActions(['updateActivityBarView']), @@ -71,19 +60,24 @@ export default { forceCreateNewBranch() { return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commit()); }, - toggleIsCompact() { - if (this.currentViewIsCommitView) { - this.isCompact = !this.isCompact; + handleCompactState() { + if (this.lastCommitMsg) { + this.isCompact = false; } else { - this.updateActivityBarView(leftSidebarViews.commit.name) - .then(() => { - this.isCompact = false; - }) - .catch(e => { - throw e; - }); + this.isCompact = + !this.someUncommittedChanges || + !this.currentViewIsCommitView || + window.innerHeight < MAX_WINDOW_HEIGHT_COMPACT; } }, + toggleIsCompact() { + this.isCompact = !this.isCompact; + }, + beginCommit() { + return this.updateActivityBarView(leftSidebarViews.commit.name).then(() => { + this.isCompact = false; + }); + }, beforeEnterTransition() { const elHeight = this.isCompact ? this.$refs.formEl && this.$refs.formEl.offsetHeight @@ -126,16 +120,17 @@ export default { > <div v-if="isCompact" ref="compactEl" class="commit-form-compact"> <button - :disabled="!hasChanges" + :disabled="!someUncommittedChanges" type="button" class="btn btn-primary btn-sm btn-block qa-begin-commit-button" - @click="toggleIsCompact" + data-testid="begin-commit-button" + @click="beginCommit" > {{ __('Commit…') }} </button> <p class="text-center bold">{{ overviewText }}</p> </div> - <form v-if="!isCompact" ref="formEl" @submit.prevent.stop="commit"> + <form v-else ref="formEl" @submit.prevent.stop="commit"> <transition name="fade"> <success-message v-show="lastCommitMsg" /> </transition> <commit-message-field :text="commitMessage" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index e6a1a1ba73c..5cff1079eb0 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -55,7 +55,7 @@ export default { }, }, methods: { - ...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']), + ...mapActions(['unstageAllChanges', 'discardAllChanges']), openDiscardModal() { this.$refs.discardAllModal.show(); }, @@ -74,7 +74,7 @@ export default { <div class="ide-commit-list-container"> <header class="multi-file-commit-panel-header d-flex mb-0"> <div class="d-flex align-items-center flex-fill"> - <icon v-once :name="iconName" :size="18" class="append-right-8" /> + <icon v-once :name="iconName" :size="18" class="gl-mr-3" /> <strong> {{ titleText }} </strong> <div class="d-flex ml-auto"> <button @@ -98,7 +98,7 @@ export default { </div> </div> </header> - <ul v-if="filesLength" class="multi-file-commit-list list-unstyled append-bottom-0"> + <ul v-if="filesLength" class="multi-file-commit-list list-unstyled gl-mb-0"> <li v-for="file in fileList" :key="file.key"> <list-item :file="file" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index e70e251c117..c65169f5d31 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -4,7 +4,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { viewerTypes } from '../../constants'; -import { getCommitIconMap } from '../../utils'; +import getCommitIconMap from '../../commit_icon'; export default { components: { @@ -87,7 +87,7 @@ export default { @click="openFileInEditor" > <span class="multi-file-commit-list-file-path d-flex align-items-center"> - <file-icon :file-name="file.name" class="append-right-8" /> + <file-icon :file-name="file.name" class="gl-mr-3" /> <template v-if="file.prevName && file.prevName !== file.name"> {{ file.prevName }} → </template> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 32822a75772..51509cd5fe6 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -89,7 +89,7 @@ export default { :type="file.type" :path="file.path" :is-open="dropdownOpen" - class="prepend-left-8" + class="gl-ml-3" v-on="$listeners" /> </div> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 36c8b18e205..e9f84eb8648 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,5 +1,4 @@ <script> -import Vue from 'vue'; import { mapActions, mapGetters, mapState } from 'vuex'; import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; @@ -27,20 +26,13 @@ export default { CommitEditorHeader, GlDeprecatedButton, GlLoadingIcon, + RightPane, }, mixins: [glFeatureFlagsMixin()], - props: { - rightPaneComponent: { - type: Vue.Component, - required: false, - default: () => RightPane, - }, - }, computed: { ...mapState([ 'openFiles', 'viewer', - 'currentMergeRequestId', 'fileFindVisible', 'emptyStateSvgPath', 'currentProjectId', @@ -49,7 +41,6 @@ export default { ]), ...mapGetters([ 'activeFile', - 'hasChanges', 'someUncommittedChanges', 'isCommitModeActive', 'allBlobs', @@ -108,14 +99,7 @@ export default { <div class="multi-file-edit-pane"> <template v-if="activeFile"> <commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" /> - <repo-tabs - v-else - :active-file="activeFile" - :files="openFiles" - :viewer="viewer" - :has-changes="hasChanges" - :merge-request-id="currentMergeRequestId" - /> + <repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" /> <repo-editor :file="activeFile" class="multi-file-edit-pane-content" /> </template> <template v-else> @@ -141,6 +125,7 @@ export default { variant="success" :title="__('New file')" :aria-label="__('New file')" + data-qa-selector="first_file_button" @click="createNewFile()" > {{ __('New file') }} @@ -160,7 +145,7 @@ export default { </div> </template> </div> - <component :is="rightPaneComponent" v-if="currentProjectId" /> + <right-pane v-if="currentProjectId" /> </div> <ide-status-bar /> <new-modal ref="newModal" /> diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index 7cb31df85ce..1eb89b41495 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -9,7 +9,7 @@ import CommitForm from './commit_sidebar/form.vue'; import IdeReview from './ide_review.vue'; import SuccessMessage from './commit_sidebar/success_message.vue'; import IdeProjectHeader from './ide_project_header.vue'; -import { leftSidebarViews } from '../constants'; +import { leftSidebarViews, SIDEBAR_INIT_WIDTH } from '../constants'; export default { components: { @@ -33,11 +33,16 @@ export default { ); }, }, + SIDEBAR_INIT_WIDTH, }; </script> <template> - <resizable-panel :initial-width="340" side="left" class="flex-column"> + <resizable-panel + :initial-width="$options.SIDEBAR_INIT_WIDTH" + side="left" + class="multi-file-commit-panel flex-column" + > <template v-if="loading"> <div class="multi-file-commit-panel-inner"> <div v-for="n in 3" :key="n" class="multi-file-loading-container"> diff --git a/app/assets/javascripts/ide/components/ide_sidebar_nav.vue b/app/assets/javascripts/ide/components/ide_sidebar_nav.vue new file mode 100644 index 00000000000..966c36d6e71 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_sidebar_nav.vue @@ -0,0 +1,83 @@ +<script> +import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { otherSide } from '../utils'; +import { SIDE_RIGHT } from '../constants'; + +export default { + directives: { + tooltip: GlTooltipDirective, + }, + components: { + GlIcon, + }, + props: { + tabs: { + type: Array, + required: true, + }, + side: { + type: String, + required: true, + }, + currentView: { + type: String, + required: false, + default: '', + }, + isOpen: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + otherSide() { + return otherSide(this.side); + }, + }, + methods: { + isActiveTab(tab) { + return this.isOpen && tab.views.some(view => view.name === this.currentView); + }, + buttonClasses(tab) { + return [ + { + 'is-right': this.side === SIDE_RIGHT, + active: this.isActiveTab(tab), + }, + ...(tab.buttonClasses || []), + ]; + }, + clickTab(e, tab) { + e.currentTarget.blur(); + this.$root.$emit('bv::hide::tooltip'); + + if (this.isActiveTab(tab)) { + this.$emit('close'); + } else { + this.$emit('open', tab.views[0]); + } + }, + }, +}; +</script> +<template> + <nav class="ide-activity-bar"> + <ul class="list-unstyled"> + <li v-for="tab of tabs" :key="tab.title"> + <button + v-tooltip="{ container: 'body', placement: otherSide }" + :title="tab.title" + :aria-label="tab.title" + :class="buttonClasses(tab)" + :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`" + class="ide-sidebar-link" + type="button" + @click="clickTab($event, tab)" + > + <gl-icon :size="16" :name="tab.icon" /> + </button> + </li> + </ul> + </nav> +</template> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 5585343f367..ddc126c3d77 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ import { mapActions, mapState, mapGetters } from 'vuex'; -import IdeStatusList from 'ee_else_ce/ide/components/ide_status_list.vue'; +import IdeStatusList from './ide_status_list.vue'; import IdeStatusMr from './ide_status_mr.vue'; import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue index 364e3f081a1..92d25709bd5 100644 --- a/app/assets/javascripts/ide/components/ide_status_list.vue +++ b/app/assets/javascripts/ide/components/ide_status_list.vue @@ -1,9 +1,17 @@ <script> import { mapGetters } from 'vuex'; +import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue'; +import { getFileEOL } from '../utils'; export default { + components: { + TerminalSyncStatusSafe, + }, computed: { ...mapGetters(['activeFile']), + activeFileEOL() { + return getFileEOL(this.activeFile.content); + }, }, }; </script> @@ -12,12 +20,12 @@ export default { <div class="ide-status-list d-flex"> <template v-if="activeFile"> <div class="ide-status-file">{{ activeFile.name }}</div> - <div class="ide-status-file">{{ activeFile.eol }}</div> + <div class="ide-status-file">{{ activeFileEOL }}</div> <div v-if="!activeFile.binary" class="ide-status-file"> {{ activeFile.editorRow }}:{{ activeFile.editorColumn }} </div> <div class="ide-status-file">{{ activeFile.fileLanguage }}</div> </template> - <slot></slot> + <terminal-sync-status-safe /> </div> </template> diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue index 9c0c97bc5ae..f1ba102fffe 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/description.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue @@ -24,7 +24,7 @@ export default { <template> <div class="d-flex align-items-center"> <ci-icon :status="job.status" :borderless="true" :size="24" class="d-flex" /> - <span class="prepend-left-8"> + <span class="gl-ml-3"> {{ job.name }} <a :href="job.path" target="_blank" class="ide-external-link position-relative"> {{ jobId }} <icon :size="12" name="external-link" /> diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index ba8407382f4..169a948c2da 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -71,11 +71,11 @@ export default { v-tooltip="showTooltip" :title="showTooltip ? stage.name : null" data-container="body" - class="prepend-left-8 text-truncate" + class="gl-ml-3 text-truncate" > {{ stage.name }} </strong> - <div v-if="!stage.isLoading || stage.jobs.length" class="append-right-8 prepend-left-4"> + <div v-if="!stage.isLoading || stage.jobs.length" class="gl-mr-3 gl-ml-2"> <span class="badge badge-pill"> {{ jobsCount }} </span> </div> <icon :name="collapseIcon" class="ide-stage-collapse-icon" /> diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue index 60889c893cf..3f060392686 100644 --- a/app/assets/javascripts/ide/components/merge_requests/item.vue +++ b/app/assets/javascripts/ide/components/merge_requests/item.vue @@ -1,6 +1,5 @@ <script> import Icon from '../../../vue_shared/components/icon.vue'; -import router from '../../ide_router'; export default { components: { @@ -33,7 +32,7 @@ export default { mergeRequestHref() { const path = `/project/${this.item.projectPathWithNamespace}/merge_requests/${this.item.iid}`; - return router.resolve(path).href; + return this.$router.resolve(path).href; }, }, }; diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue index cf8a1abbde4..4fab57b6f3e 100644 --- a/app/assets/javascripts/ide/components/mr_file_icon.vue +++ b/app/assets/javascripts/ide/components/mr_file_icon.vue @@ -18,6 +18,6 @@ export default { :title="__('Part of merge request changes')" :size="12" name="git-merge" - class="append-right-8" + class="gl-mr-3" /> </template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 4766a2fe6ae..586d6867ab4 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -4,7 +4,7 @@ import flash from '~/flash'; import { __, sprintf, s__ } from '~/locale'; import { GlModal } from '@gitlab/ui'; import { modalTypes } from '../../constants'; -import { trimPathComponents } from '../../utils'; +import { trimPathComponents, getPathParent } from '../../utils'; export default { components: { @@ -85,8 +85,10 @@ export default { } }, createFromTemplate(template) { + const parent = getPathParent(this.entryName); + const name = parent ? `${parent}/${template.name}` : template.name; this.createTempEntry({ - name: template.name, + name, type: this.modalType, }); @@ -133,7 +135,7 @@ export default { <gl-modal ref="modal" modal-id="ide-new-entry" - modal-class="qa-new-file-modal" + data-qa-selector="new_file_modal" :title="modalTitle" :ok-title="buttonLabel" ok-variant="success" @@ -148,7 +150,8 @@ export default { ref="fieldName" v-model.trim="entryName" type="text" - class="form-control qa-full-file-path" + class="form-control" + data-qa-selector="file_name_field" :placeholder="placeholder" /> <ul diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 7261e0590c8..b2141c13d9f 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -35,7 +35,6 @@ export default { name: `${this.path ? `${this.path}/` : ''}${name}`, type: 'blob', content, - base64: !isText, binary: !isText, rawPath: !isText ? target.result : '', }); diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue index 91e80be7d18..4e8e1e3a470 100644 --- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue +++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue @@ -2,8 +2,7 @@ import { mapActions, mapState } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; -import ResizablePanel from '../resizable_panel.vue'; -import { GlSkeletonLoading } from '@gitlab/ui'; +import IdeSidebarNav from '../ide_sidebar_nav.vue'; export default { name: 'CollapsibleSidebar', @@ -12,8 +11,7 @@ export default { }, components: { Icon, - ResizablePanel, - GlSkeletonLoading, + IdeSidebarNav, }, props: { extensionTabs: { @@ -25,13 +23,8 @@ export default { type: String, required: true, }, - width: { - type: Number, - required: true, - }, }, computed: { - ...mapState(['loading']), ...mapState({ isOpen(state) { return state[this.namespace].isOpen; @@ -39,9 +32,6 @@ export default { currentView(state) { return state[this.namespace].currentView; }, - isActiveView(state, getters) { - return getters[`${this.namespace}/isActiveView`]; - }, isAliveView(_state, getters) { return getters[`${this.namespace}/isAliveView`]; }, @@ -59,9 +49,6 @@ export default { aliveTabViews() { return this.tabViews.filter(view => this.isAliveView(view.name)); }, - otherSide() { - return this.side === 'right' ? 'left' : 'right'; - }, }, methods: { ...mapActions({ @@ -72,25 +59,6 @@ export default { return dispatch(`${this.namespace}/open`, view); }, }), - clickTab(e, tab) { - e.target.blur(); - - if (this.isActiveTab(tab)) { - this.toggleOpen(); - } else { - this.open(tab.views[0]); - } - }, - isActiveTab(tab) { - return tab.views.some(view => this.isActiveView(view.name)); - }, - buttonClasses(tab) { - return [ - this.side === 'right' ? 'is-right' : '', - this.isActiveTab(tab) && this.isOpen ? 'active' : '', - ...(tab.buttonClasses || []), - ]; - }, }, }; </script> @@ -101,49 +69,27 @@ export default { :data-qa-selector="`ide_${side}_sidebar`" class="multi-file-commit-panel ide-sidebar" > - <resizable-panel + <div v-show="isOpen" - :initial-width="width" - :min-size="width" :class="`ide-${side}-sidebar-${currentView}`" - :side="side" class="multi-file-commit-panel-inner" > - <div class="h-100 d-flex flex-column align-items-stretch"> - <slot v-if="isOpen" name="header"></slot> - <div - v-for="tabView in aliveTabViews" - v-show="isActiveView(tabView.name)" - :key="tabView.name" - class="flex-fill gl-overflow-hidden js-tab-view" - > - <component :is="tabView.component" /> - </div> - <slot name="footer"></slot> + <div + v-for="tabView in aliveTabViews" + v-show="tabView.name === currentView" + :key="tabView.name" + class="flex-fill gl-overflow-hidden js-tab-view gl-h-full" + > + <component :is="tabView.component" /> </div> - </resizable-panel> - <nav class="ide-activity-bar"> - <ul class="list-unstyled"> - <li> - <slot name="header-icon"></slot> - </li> - <li v-for="tab of tabs" :key="tab.title"> - <button - v-tooltip - :title="tab.title" - :aria-label="tab.title" - :class="buttonClasses(tab)" - data-container="body" - :data-placement="otherSide" - :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`" - class="ide-sidebar-link" - type="button" - @click="clickTab($event, tab)" - > - <icon :size="16" :name="tab.icon" /> - </button> - </li> - </ul> - </nav> + </div> + <ide-sidebar-nav + :tabs="tabs" + :side="side" + :current-view="currentView" + :is-open="isOpen" + @open="open" + @close="toggleOpen" + /> </div> </template> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index 4a9de9e0c03..46ef08a45a9 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -2,26 +2,27 @@ import { mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; import CollapsibleSidebar from './collapsible_sidebar.vue'; -import { rightSidebarViews } from '../../constants'; +import ResizablePanel from '../resizable_panel.vue'; +import { rightSidebarViews, SIDEBAR_INIT_WIDTH, SIDEBAR_NAV_WIDTH } from '../../constants'; import PipelinesList from '../pipelines/list.vue'; import JobsDetail from '../jobs/detail.vue'; import Clientside from '../preview/clientside.vue'; +import TerminalView from '../terminal/view.vue'; + +// Need to add the width of the nav buttons since the resizable container contains those as well +const WIDTH = SIDEBAR_INIT_WIDTH + SIDEBAR_NAV_WIDTH; export default { name: 'RightPane', components: { CollapsibleSidebar, - }, - props: { - extensionTabs: { - type: Array, - required: false, - default: () => [], - }, + ResizablePanel, }, computed: { + ...mapState('terminal', { isTerminalVisible: 'isVisible' }), ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']), ...mapGetters(['packageJson']), + ...mapState('rightPane', ['isOpen']), showLivePreview() { return this.packageJson && this.clientsidePreviewEnabled; }, @@ -42,13 +43,27 @@ export default { views: [{ component: Clientside, ...rightSidebarViews.clientSidePreview }], icon: 'live-preview', }, - ...this.extensionTabs, + { + show: this.isTerminalVisible, + title: __('Terminal'), + views: [{ component: TerminalView, ...rightSidebarViews.terminal }], + icon: 'terminal', + }, ]; }, }, + WIDTH, }; </script> <template> - <collapsible-sidebar :extension-tabs="rightExtensionTabs" side="right" :width="350" /> + <resizable-panel + class="gl-display-flex gl-overflow-hidden" + side="right" + :initial-width="$options.WIDTH" + :min-size="$options.WIDTH" + :resizable="isOpen" + > + <collapsible-sidebar class="gl-w-full" :extension-tabs="rightExtensionTabs" side="right" /> + </resizable-panel> </template> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index cf6d01b6351..6958a5d2526 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -63,7 +63,7 @@ export default { <template v-else-if="hasLoadedPipeline"> <header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header"> <ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" /> - <span class="prepend-left-8"> + <span class="gl-ml-3"> <strong> {{ __('Pipeline') }} </strong> <a :href="latestPipeline.path" @@ -82,9 +82,9 @@ export default { class="mb-auto mt-auto" /> <div v-else-if="latestPipeline.yamlError" class="bs-callout bs-callout-danger"> - <p class="append-bottom-0">{{ __('Found errors in your .gitlab-ci.yml:') }}</p> - <p class="append-bottom-0 break-word">{{ latestPipeline.yamlError }}</p> - <p class="append-bottom-0" v-html="ciLintText"></p> + <p class="gl-mb-0">{{ __('Found errors in your .gitlab-ci.yml:') }}</p> + <p class="gl-mb-0 break-word">{{ latestPipeline.yamlError }}</p> + <p class="gl-mb-0" v-html="ciLintText"></p> </div> <tabs v-else class="ide-pipeline-list"> <tab :active="!pipelineFailed"> diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue index ff23485f0f0..0de9dfd8827 100644 --- a/app/assets/javascripts/ide/components/preview/navigator.vue +++ b/app/assets/javascripts/ide/components/preview/navigator.vue @@ -119,7 +119,7 @@ export default { > <icon :size="18" name="retry" class="m-auto" /> </button> - <div class="position-relative w-100 prepend-left-4"> + <div class="position-relative w-100 gl-ml-2"> <input :value="path || '/'" type="text" diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 530fba49df2..5eed57bb6c5 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -3,7 +3,7 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; -import { leftSidebarViews, stageKeys } from '../constants'; +import { stageKeys } from '../constants'; export default { components: { @@ -14,39 +14,37 @@ export default { tooltip, }, computed: { - ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg', 'unusedSeal']), + ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), - ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommittedChanges', 'activeFile']), + ...mapGetters(['lastOpenedFile', 'someUncommittedChanges', 'activeFile']), ...mapGetters('commit', ['discardDraftButtonDisabled']), showStageUnstageArea() { - return Boolean(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal); + return Boolean(this.someUncommittedChanges || this.lastCommitMsg); }, activeFileKey() { return this.activeFile ? this.activeFile.key : null; }, }, - watch: { - hasChanges() { - if (!this.hasChanges) { - this.updateActivityBarView(leftSidebarViews.edit.name); - } - }, - }, mounted() { - if (this.lastOpenedFile && this.lastOpenedFile.type !== 'tree') { - this.openPendingTab({ - file: this.lastOpenedFile, - keyPrefix: this.lastOpenedFile.staged ? stageKeys.staged : stageKeys.unstaged, + const file = + this.lastOpenedFile && this.lastOpenedFile.type !== 'tree' + ? this.lastOpenedFile + : this.activeFile; + + if (!file) return; + + this.openPendingTab({ + file, + keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged, + }) + .then(changeViewer => { + if (changeViewer) { + this.updateViewer('diff'); + } }) - .then(changeViewer => { - if (changeViewer) { - this.updateViewer('diff'); - } - }) - .catch(e => { - throw e; - }); - } + .catch(e => { + throw e; + }); }, methods: { ...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']), @@ -67,6 +65,6 @@ export default { icon-name="unstaged" /> </template> - <empty-state v-if="unusedSeal" /> + <empty-state v-else /> </div> </template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index c72a8b2b0d0..a7646083428 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -14,6 +14,9 @@ import Editor from '../lib/editor'; import FileTemplatesBar from './file_templates/bar.vue'; import { __ } from '~/locale'; import { extractMarkdownImagesFromEntries } from '../stores/utils'; +import { getPathParent, readFileAsDataURL } from '../utils'; +import { getRulesWithTraversal } from '../lib/editorconfig/parser'; +import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; export default { components: { @@ -31,6 +34,7 @@ export default { return { content: '', images: {}, + rules: {}, }; }, computed: { @@ -50,7 +54,6 @@ export default { 'getStagedFile', 'isEditModeActive', 'isCommitModeActive', - 'isReviewModeActive', 'currentBranch', ]), ...mapGetters('fileTemplates', ['showFileTemplatesBar']), @@ -82,10 +85,6 @@ export default { active: this.isPreviewViewMode, }; }, - fileType() { - const info = viewerInformationForPath(this.file.path); - return (info && info.id) || ''; - }, showEditor() { return !this.shouldHideEditor && this.isEditorViewMode; }, @@ -98,6 +97,12 @@ export default { currentBranchCommit() { return this.currentBranch?.commit.id; }, + previewMode() { + return viewerInformationForPath(this.file.path); + }, + fileType() { + return this.previewMode?.id || ''; + }, }, watch: { file(newVal, oldVal) { @@ -165,6 +170,12 @@ export default { this.editor = Editor.create(this.editorOptions); } this.initEditor(); + + // listen in capture phase to be able to override Monaco's behaviour. + window.addEventListener('paste', this.onPaste, true); + }, + destroyed() { + window.removeEventListener('paste', this.onPaste, true); }, methods: { ...mapActions([ @@ -174,10 +185,10 @@ export default { 'setFileLanguage', 'setEditorPosition', 'setFileViewMode', - 'setFileEOL', 'updateViewer', 'removePendingTab', 'triggerFilesChange', + 'addTempImage', ]), initEditor() { if (this.shouldHideEditor && (this.file.content || this.file.raw)) { @@ -186,7 +197,7 @@ export default { this.editor.clearEditor(); - this.fetchFileData() + Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()]) .then(() => { this.createEditorInstance(); }) @@ -223,7 +234,7 @@ export default { if (this.viewer === viewerTypes.edit) { this.editor.createInstance(this.$refs.editor); } else { - this.editor.createDiffInstance(this.$refs.editor, !this.isReviewModeActive); + this.editor.createDiffInstance(this.$refs.editor); } this.setupEditor(); @@ -245,15 +256,15 @@ export default { this.editor.attachModel(this.model); } + this.model.updateOptions(this.rules); + this.model.onChange(model => { const { file } = model; + if (!file.active) return; - if (file.active) { - this.changeFileContent({ - path: file.path, - content: model.getModel().getValue(), - }); - } + const monacoModel = model.getModel(); + const content = monacoModel.getValue(); + this.changeFileContent({ path: file.path, content }); }); // Handle Cursor Position @@ -274,16 +285,51 @@ export default { fileLanguage: this.model.language, }); - // Get File eol - this.setFileEOL({ - eol: this.model.eol, - }); + this.$emit('editorSetup'); }, refreshEditorDimensions() { if (this.showEditor) { this.editor.updateDimensions(); } }, + fetchEditorconfigRules() { + return getRulesWithTraversal(this.file.path, path => { + const entry = this.entries[path]; + if (!entry) return Promise.resolve(null); + + const content = entry.content || entry.raw; + if (content) return Promise.resolve(content); + + return this.getFileData({ path: entry.path, makeFileActive: false }).then(() => + this.getRawFileData({ path: entry.path }), + ); + }).then(rules => { + this.rules = mapRulesToMonaco(rules); + }); + }, + onPaste(event) { + const editor = this.editor.instance; + const reImage = /^image\/(png|jpg|jpeg|gif)$/; + const file = event.clipboardData.files[0]; + + if (editor.hasTextFocus() && this.fileType === 'markdown' && reImage.test(file?.type)) { + // don't let the event be passed on to Monaco. + event.preventDefault(); + event.stopImmediatePropagation(); + + return readFileAsDataURL(file).then(content => { + const parentPath = getPathParent(this.file.path); + const path = `${parentPath ? `${parentPath}/` : ''}${file.name}`; + + return this.addTempImage({ name: path, rawPath: content }).then(({ name: fileName }) => { + this.editor.replaceSelectedText(`![${fileName}](./${fileName})`); + }); + }); + } + + // do nothing if no image is found in the clipboard + return Promise.resolve(); + }, }, viewerTypes, FILE_VIEW_MODE_EDITOR, @@ -301,16 +347,15 @@ export default { role="button" @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_EDITOR })" > - <template v-if="viewer === $options.viewerTypes.edit">{{ __('Edit') }}</template> - <template v-else>{{ __('Review') }}</template> + {{ __('Edit') }} </a> </li> - <li v-if="file.previewMode" :class="previewTabCSS"> + <li v-if="previewMode" :class="previewTabCSS"> <a href="javascript:void(0);" role="button" @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_PREVIEW })" - >{{ file.previewMode.previewTitle }}</a + >{{ previewMode.previewTitle }}</a > </li> </ul> diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index 1b7f149097b..47c75be3f7c 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -1,7 +1,6 @@ <script> import { mapActions } from 'vuex'; import RepoTab from './repo_tab.vue'; -import router from '../ide_router'; export default { components: { @@ -20,15 +19,6 @@ export default { type: String, required: true, }, - hasChanges: { - type: Boolean, - required: true, - }, - mergeRequestId: { - type: String, - required: false, - default: '', - }, }, methods: { ...mapActions(['updateViewer', 'removePendingTab']), @@ -37,7 +27,7 @@ export default { if (this.activeFile.pending) { return this.removePendingTab(this.activeFile).then(() => { - router.push(`/project${this.activeFile.url}`); + this.$router.push(`/project${this.activeFile.url}`); }); } @@ -49,7 +39,7 @@ export default { <template> <div class="multi-file-tabs"> - <ul ref="tabsScroller" class="list-unstyled append-bottom-0"> + <ul ref="tabsScroller" class="list-unstyled gl-mb-0"> <repo-tab v-for="tab in files" :key="tab.key" :tab="tab" /> </ul> </div> diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue index 86a4622401c..b49d743d877 100644 --- a/app/assets/javascripts/ide/components/resizable_panel.vue +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -1,6 +1,7 @@ <script> import { mapActions } from 'vuex'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import { SIDEBAR_MIN_WIDTH } from '../constants'; export default { components: { @@ -14,12 +15,17 @@ export default { minSize: { type: Number, required: false, - default: 340, + default: SIDEBAR_MIN_WIDTH, }, side: { type: String, required: true, }, + resizable: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -28,7 +34,7 @@ export default { }, computed: { panelStyle() { - if (!this.collapsed) { + if (this.resizable) { return { width: `${this.width}px`, }; @@ -45,9 +51,10 @@ export default { </script> <template> - <div :style="panelStyle" class="multi-file-commit-panel"> + <div class="gl-relative" :style="panelStyle"> <slot></slot> <panel-resizer + v-show="resizable" :size.sync="width" :start-size="initialWidth" :min-size="minSize" diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue new file mode 100644 index 00000000000..9841f1ece48 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue @@ -0,0 +1,71 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; + +export default { + components: { + GlLoadingIcon, + }, + props: { + isLoading: { + type: Boolean, + required: false, + default: true, + }, + isValid: { + type: Boolean, + required: false, + default: false, + }, + message: { + type: String, + required: false, + default: '', + }, + helpPath: { + type: String, + required: false, + default: '', + }, + illustrationPath: { + type: String, + required: false, + default: '', + }, + }, + methods: { + onStart() { + this.$emit('start'); + }, + }, +}; +</script> +<template> + <div class="text-center p-3"> + <div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div> + <h4>{{ __('Web Terminal') }}</h4> + <gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default" /> + <template v-else> + <p>{{ __('Run tests against your code live using the Web Terminal') }}</p> + <p> + <button + :disabled="!isValid" + class="btn btn-info" + type="button" + data-qa-selector="start_web_terminal_button" + @click="onStart" + > + {{ __('Start Web Terminal') }} + </button> + </p> + <div v-if="!isValid && message" class="bs-callout text-left" v-html="message"></div> + <p v-else> + <a + v-if="helpPath" + :href="helpPath" + target="_blank" + v-text="__('Learn more about Web Terminal')" + ></a> + </p> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal/session.vue b/app/assets/javascripts/ide/components/terminal/session.vue new file mode 100644 index 00000000000..a8fe9ea6866 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/session.vue @@ -0,0 +1,53 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; +import Terminal from './terminal.vue'; +import { isEndingStatus } from '../../stores/modules/terminal/utils'; + +export default { + components: { + Terminal, + }, + computed: { + ...mapState('terminal', ['session']), + actionButton() { + if (isEndingStatus(this.session.status)) { + return { + action: () => this.restartSession(), + text: __('Restart Terminal'), + class: 'btn-primary', + }; + } + + return { + action: () => this.stopSession(), + text: __('Stop Terminal'), + class: 'btn-inverted btn-remove', + }; + }, + }, + methods: { + ...mapActions('terminal', ['restartSession', 'stopSession']), + }, +}; +</script> + +<template> + <div v-if="session" class="ide-terminal d-flex flex-column"> + <header class="ide-job-header d-flex align-items-center"> + <h5>{{ __('Web Terminal') }}</h5> + <div class="ml-auto align-self-center"> + <button + v-if="actionButton" + type="button" + class="btn btn-sm" + :class="actionButton.class" + @click="actionButton.action" + > + {{ actionButton.text }} + </button> + </div> + </header> + <terminal :terminal-path="session.terminalPath" :status="session.status" /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal/terminal.vue b/app/assets/javascripts/ide/components/terminal/terminal.vue new file mode 100644 index 00000000000..0ee4107f9ab --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/terminal.vue @@ -0,0 +1,117 @@ +<script> +import { mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import GLTerminal from '~/terminal/terminal'; +import TerminalControls from './terminal_controls.vue'; +import { RUNNING, STOPPING } from '../../stores/modules/terminal/constants'; +import { isStartingStatus } from '../../stores/modules/terminal/utils'; + +export default { + components: { + GlLoadingIcon, + TerminalControls, + }, + props: { + terminalPath: { + type: String, + required: false, + default: '', + }, + status: { + type: String, + required: true, + }, + }, + data() { + return { + glterminal: null, + canScrollUp: false, + canScrollDown: false, + }; + }, + computed: { + ...mapState(['panelResizing']), + loadingText() { + if (isStartingStatus(this.status)) { + return __('Starting...'); + } else if (this.status === STOPPING) { + return __('Stopping...'); + } + + return ''; + }, + }, + watch: { + panelResizing() { + if (!this.panelResizing && this.glterminal) { + this.glterminal.fit(); + } + }, + status() { + this.refresh(); + }, + terminalPath() { + this.refresh(); + }, + }, + beforeDestroy() { + this.destroyTerminal(); + }, + methods: { + refresh() { + if (this.status === RUNNING && this.terminalPath) { + this.createTerminal(); + } else if (this.status === STOPPING) { + this.stopTerminal(); + } + }, + createTerminal() { + this.destroyTerminal(); + this.glterminal = new GLTerminal(this.$refs.terminal); + this.glterminal.addScrollListener(({ canScrollUp, canScrollDown }) => { + this.canScrollUp = canScrollUp; + this.canScrollDown = canScrollDown; + }); + }, + destroyTerminal() { + if (this.glterminal) { + this.glterminal.dispose(); + this.glterminal = null; + } + }, + stopTerminal() { + if (this.glterminal) { + this.glterminal.disable(); + } + }, + }, +}; +</script> + +<template> + <div class="d-flex flex-column flex-fill min-height-0 pr-3"> + <div class="top-bar d-flex border-left-0 align-items-center"> + <div v-if="loadingText" data-qa-selector="loading_container"> + <gl-loading-icon :inline="true" /> + <span>{{ loadingText }}</span> + </div> + <terminal-controls + v-if="glterminal" + class="ml-auto" + :can-scroll-up="canScrollUp" + :can-scroll-down="canScrollDown" + @scroll-up="glterminal.scrollToTop()" + @scroll-down="glterminal.scrollToBottom()" + /> + </div> + <div class="terminal-wrapper d-flex flex-fill min-height-0"> + <div + ref="terminal" + class="ide-terminal-trace flex-fill min-height-0 w-100" + :data-project-path="terminalPath" + data-qa-selector="terminal_screen" + ></div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal/terminal_controls.vue b/app/assets/javascripts/ide/components/terminal/terminal_controls.vue new file mode 100644 index 00000000000..4c13b4ef103 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/terminal_controls.vue @@ -0,0 +1,27 @@ +<script> +import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue'; + +export default { + components: { + ScrollButton, + }, + props: { + canScrollUp: { + type: Boolean, + required: false, + default: false, + }, + canScrollDown: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> +<template> + <div class="controllers"> + <scroll-button :disabled="!canScrollUp" direction="up" @click="$emit('scroll-up')" /> + <scroll-button :disabled="!canScrollDown" direction="down" @click="$emit('scroll-down')" /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal/view.vue b/app/assets/javascripts/ide/components/terminal/view.vue new file mode 100644 index 00000000000..db97e95eed9 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/view.vue @@ -0,0 +1,41 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import EmptyState from './empty_state.vue'; +import TerminalSession from './session.vue'; + +export default { + components: { + EmptyState, + TerminalSession, + }, + computed: { + ...mapState('terminal', ['isShowSplash', 'paths']), + ...mapGetters('terminal', ['allCheck']), + }, + methods: { + ...mapActions('terminal', ['startSession', 'hideSplash']), + start() { + this.startSession(); + this.hideSplash(); + }, + }, +}; +</script> + +<template> + <div class="h-100"> + <div v-if="isShowSplash" class="h-100 d-flex flex-column justify-content-center"> + <empty-state + :is-loading="allCheck.isLoading" + :is-valid="allCheck.isValid" + :message="allCheck.message" + :help-path="paths.webTerminalHelpPath" + :illustration-path="paths.webTerminalSvgPath" + @start="start()" + /> + </div> + <template v-else> + <terminal-session /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue new file mode 100644 index 00000000000..deb13b5615e --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue @@ -0,0 +1,76 @@ +<script> +import { throttle } from 'lodash'; +import { GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; +import { mapState } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import { + MSG_TERMINAL_SYNC_CONNECTING, + MSG_TERMINAL_SYNC_UPLOADING, + MSG_TERMINAL_SYNC_RUNNING, +} from '../../stores/modules/terminal_sync/messages'; + +export default { + components: { + Icon, + GlLoadingIcon, + }, + directives: { + 'gl-tooltip': GlTooltipDirective, + }, + data() { + return { isLoading: false }; + }, + computed: { + ...mapState('terminalSync', ['isError', 'isStarted', 'message']), + ...mapState('terminalSync', { + isLoadingState: 'isLoading', + }), + status() { + if (this.isLoading) { + return { + icon: '', + text: this.isStarted ? MSG_TERMINAL_SYNC_UPLOADING : MSG_TERMINAL_SYNC_CONNECTING, + }; + } else if (this.isError) { + return { + icon: 'warning', + text: this.message, + }; + } else if (this.isStarted) { + return { + icon: 'mobile-issue-close', + text: MSG_TERMINAL_SYNC_RUNNING, + }; + } + + return null; + }, + }, + watch: { + // We want to throttle the `isLoading` updates so that + // the user actually sees an indicator that changes are sent. + isLoadingState: throttle(function watchIsLoadingState(val) { + this.isLoading = val; + }, 150), + }, + created() { + this.isLoading = this.isLoadingState; + }, +}; +</script> + +<template> + <div + v-if="status" + v-gl-tooltip + :title="status.text" + role="note" + class="d-flex align-items-center" + > + <span>{{ __('Terminal') }}:</span> + <span class="square s16 d-flex-center ml-1" :aria-label="status.text"> + <gl-loading-icon v-if="isLoading" inline size="sm" class="d-flex-center" /> + <icon v-else-if="status.icon" :name="status.icon" :size="16" /> + </span> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue new file mode 100644 index 00000000000..afaf06f7f68 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue @@ -0,0 +1,22 @@ +<script> +import { mapState } from 'vuex'; +import TerminalSyncStatus from './terminal_sync_status.vue'; + +/** + * It is possible that the vuex module is not registered. + * + * This component will gracefully handle this so the actual one can simply use `mapState(moduleName, ...)`. + */ +export default { + components: { + TerminalSyncStatus, + }, + computed: { + ...mapState(['terminalSync']), + }, +}; +</script> + +<template> + <terminal-sync-status v-if="terminalSync" /> +</template> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index ae8550cba76..59b1969face 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -4,6 +4,10 @@ export const MAX_WINDOW_HEIGHT_COMPACT = 750; export const MAX_TITLE_LENGTH = 50; export const MAX_BODY_LENGTH = 72; +export const SIDEBAR_INIT_WIDTH = 340; +export const SIDEBAR_MIN_WIDTH = 340; +export const SIDEBAR_NAV_WIDTH = 60; + // File view modes export const FILE_VIEW_MODE_EDITOR = 'editor'; export const FILE_VIEW_MODE_PREVIEW = 'preview'; @@ -53,6 +57,7 @@ export const rightSidebarViews = { jobsDetail: { name: 'jobs-detail', keepAlive: false }, mergeRequestInfo: { name: 'merge-request-info', keepAlive: true }, clientSidePreview: { name: 'clientside', keepAlive: false }, + terminal: { name: 'terminal', keepAlive: true }, }; export const stageKeys = { @@ -89,3 +94,6 @@ export const commitActionTypes = { }; export const packageJsonPath = 'package.json'; + +export const SIDE_LEFT = 'left'; +export const SIDE_RIGHT = 'right'; diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 0fab3ee0f3b..152f77effa3 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -2,8 +2,8 @@ import Vue from 'vue'; import IdeRouter from '~/ide/ide_router_extension'; import { joinPaths } from '~/lib/utils/url_utility'; import flash from '~/flash'; -import store from './stores'; import { __ } from '~/locale'; +import { syncRouterAndStore } from './sync_router_and_store'; Vue.use(IdeRouter); @@ -33,80 +33,85 @@ const EmptyRouterComponent = { }, }; -const router = new IdeRouter({ - mode: 'history', - base: joinPaths(gon.relative_url_root || '', '/-/ide/'), - routes: [ - { - path: '/project/:namespace+/:project', - component: EmptyRouterComponent, - children: [ - { - path: ':targetmode(edit|tree|blob)/:branchid+/-/*', - component: EmptyRouterComponent, - }, - { - path: ':targetmode(edit|tree|blob)/:branchid+/', - redirect: to => joinPaths(to.path, '/-/'), - }, - { - path: ':targetmode(edit|tree|blob)', - redirect: to => joinPaths(to.path, '/master/-/'), - }, - { - path: 'merge_requests/:mrid', - component: EmptyRouterComponent, - }, - { - path: '', - redirect: to => joinPaths(to.path, '/edit/master/-/'), - }, - ], - }, - ], -}); +// eslint-disable-next-line import/prefer-default-export +export const createRouter = store => { + const router = new IdeRouter({ + mode: 'history', + base: joinPaths(gon.relative_url_root || '', '/-/ide/'), + routes: [ + { + path: '/project/:namespace+/:project', + component: EmptyRouterComponent, + children: [ + { + path: ':targetmode(edit|tree|blob)/:branchid+/-/*', + component: EmptyRouterComponent, + }, + { + path: ':targetmode(edit|tree|blob)/:branchid+/', + redirect: to => joinPaths(to.path, '/-/'), + }, + { + path: ':targetmode(edit|tree|blob)', + redirect: to => joinPaths(to.path, '/master/-/'), + }, + { + path: 'merge_requests/:mrid', + component: EmptyRouterComponent, + }, + { + path: '', + redirect: to => joinPaths(to.path, '/edit/master/-/'), + }, + ], + }, + ], + }); -router.beforeEach((to, from, next) => { - if (to.params.namespace && to.params.project) { - store - .dispatch('getProjectData', { - namespace: to.params.namespace, - projectId: to.params.project, - }) - .then(() => { - const basePath = to.params.pathMatch || ''; - const projectId = `${to.params.namespace}/${to.params.project}`; - const branchId = to.params.branchid; - const mergeRequestId = to.params.mrid; + router.beforeEach((to, from, next) => { + if (to.params.namespace && to.params.project) { + store + .dispatch('getProjectData', { + namespace: to.params.namespace, + projectId: to.params.project, + }) + .then(() => { + const basePath = to.params.pathMatch || ''; + const projectId = `${to.params.namespace}/${to.params.project}`; + const branchId = to.params.branchid; + const mergeRequestId = to.params.mrid; - if (branchId) { - store.dispatch('openBranch', { - projectId, - branchId, - basePath, - }); - } else if (mergeRequestId) { - store.dispatch('openMergeRequest', { - projectId, - mergeRequestId, - targetProjectId: to.query.target_project, - }); - } - }) - .catch(e => { - flash( - __('Error while loading the project data. Please try again.'), - 'alert', - document, - null, - false, - true, - ); - throw e; - }); - } + if (branchId) { + store.dispatch('openBranch', { + projectId, + branchId, + basePath, + }); + } else if (mergeRequestId) { + store.dispatch('openMergeRequest', { + projectId, + mergeRequestId, + targetProjectId: to.query.target_project, + }); + } + }) + .catch(e => { + flash( + __('Error while loading the project data. Please try again.'), + 'alert', + document, + null, + false, + true, + ); + throw e; + }); + } - next(); -}); + next(); + }); -export default router; + syncRouterAndStore(router, store); + + return router; +}; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 55a0dd848c8..850cfcb05e3 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -4,7 +4,7 @@ import Translate from '~/vue_shared/translate'; import { identity } from 'lodash'; import ide from './components/ide.vue'; import store from './stores'; -import router from './ide_router'; +import { createRouter } from './ide_router'; import { parseBoolean } from '../lib/utils/common_utils'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import { DEFAULT_THEME } from './lib/themes'; @@ -32,6 +32,7 @@ export function initIde(el, options = {}) { if (!el) return null; const { rootComponent = ide, extendStore = identity } = options; + const router = createRouter(store); return new Vue({ el, diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index a15f04075d9..c5bb00c3dee 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -1,6 +1,8 @@ import { editor as monacoEditor, Uri } from 'monaco-editor'; import Disposable from './disposable'; import eventHub from '../../eventhub'; +import { trimTrailingWhitespace, insertFinalNewline } from '../../utils'; +import { defaultModelOptions } from '../editor_options'; export default class Model { constructor(file, head = null) { @@ -8,6 +10,7 @@ export default class Model { this.file = file; this.head = head; this.content = file.content !== '' || file.deleted ? file.content : file.raw; + this.options = { ...defaultModelOptions }; this.disposable.add( (this.originalModel = monacoEditor.createModel( @@ -50,10 +53,6 @@ export default class Model { return this.model.getModeId(); } - get eol() { - return this.model.getEOL() === '\n' ? 'LF' : 'CRLF'; - } - get path() { return this.file.key; } @@ -94,8 +93,32 @@ export default class Model { this.getModel().setValue(content); } + updateOptions(obj = {}) { + Object.assign(this.options, obj); + this.model.updateOptions(obj); + this.applyCustomOptions(); + } + + applyCustomOptions() { + this.updateNewContent( + Object.entries(this.options).reduce((content, [key, value]) => { + switch (key) { + case 'endOfLine': + this.model.pushEOL(value); + return this.model.getValue(); + case 'insertFinalNewline': + return value ? insertFinalNewline(content) : content; + case 'trimTrailingWhitespace': + return value ? trimTrailingWhitespace(content) : content; + default: + return content; + } + }, this.model.getValue()), + ); + } + dispose() { - this.disposable.dispose(); + if (!this.model.isDisposed()) this.applyCustomOptions(); this.events.forEach(cb => { if (typeof cb === 'function') cb(); @@ -106,5 +129,7 @@ export default class Model { eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose); eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent); eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent); + + this.disposable.dispose(); } } diff --git a/app/assets/javascripts/ide/lib/create_diff.js b/app/assets/javascripts/ide/lib/create_diff.js new file mode 100644 index 00000000000..3e915afdbcb --- /dev/null +++ b/app/assets/javascripts/ide/lib/create_diff.js @@ -0,0 +1,85 @@ +import { commitActionForFile } from '~/ide/stores/utils'; +import { commitActionTypes } from '~/ide/constants'; +import createFileDiff from './create_file_diff'; + +const getDeletedParents = (entries, file) => { + const parent = file.parentPath && entries[file.parentPath]; + + if (parent && parent.deleted) { + return [parent, ...getDeletedParents(entries, parent)]; + } + + return []; +}; + +const filesWithChanges = ({ stagedFiles = [], changedFiles = [], entries = {} }) => { + // We need changed files to overwrite staged, so put them at the end. + const changes = stagedFiles.concat(changedFiles).reduce((acc, file) => { + const key = file.path; + const action = commitActionForFile(file); + const prev = acc[key]; + + // If a file was deleted, which was previously added, then we should do nothing. + if (action === commitActionTypes.delete && prev && prev.action === commitActionTypes.create) { + delete acc[key]; + } else { + acc[key] = { action, file }; + } + + return acc; + }, {}); + + // We need to clean "move" actions, because we can only support 100% similarity moves at the moment. + // This is because the previous file's content might not be loaded. + Object.values(changes) + .filter(change => change.action === commitActionTypes.move) + .forEach(change => { + const prev = changes[change.file.prevPath]; + + if (!prev) { + return; + } + + if (change.file.content === prev.file.content) { + // If content is the same, continue with the move but don't do the prevPath's delete. + delete changes[change.file.prevPath]; + } else { + // Otherwise, treat the move as a delete / create. + Object.assign(change, { action: commitActionTypes.create }); + } + }); + + // Next, we need to add deleted directories by looking at the parents + Object.values(changes) + .filter(change => change.action === commitActionTypes.delete && change.file.parentPath) + .forEach(({ file }) => { + // Do nothing if we've already visited this directory. + if (changes[file.parentPath]) { + return; + } + + getDeletedParents(entries, file).forEach(parent => { + changes[parent.path] = { action: commitActionTypes.delete, file: parent }; + }); + }); + + return Object.values(changes); +}; + +const createDiff = state => { + const changes = filesWithChanges(state); + + const toDelete = changes.filter(x => x.action === commitActionTypes.delete).map(x => x.file.path); + + const patch = changes + .filter(x => x.action !== commitActionTypes.delete) + .map(({ file, action }) => createFileDiff(file, action)) + .join(''); + + return { + patch, + toDelete, + }; +}; + +export default createDiff; diff --git a/app/assets/javascripts/ide/lib/create_file_diff.js b/app/assets/javascripts/ide/lib/create_file_diff.js new file mode 100644 index 00000000000..5ae4993321c --- /dev/null +++ b/app/assets/javascripts/ide/lib/create_file_diff.js @@ -0,0 +1,112 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import { createTwoFilesPatch } from 'diff'; +import { commitActionTypes } from '~/ide/constants'; + +const DEV_NULL = '/dev/null'; +const DEFAULT_MODE = '100644'; +const NO_NEW_LINE = '\\ No newline at end of file'; +const NEW_LINE = '\n'; + +/** + * Cleans patch generated by `diff` package. + * + * - Removes "=======" separator added at the beginning + */ +const cleanTwoFilesPatch = text => text.replace(/^(=+\s*)/, ''); + +const endsWithNewLine = val => !val || val[val.length - 1] === NEW_LINE; + +const addEndingNewLine = val => (endsWithNewLine(val) ? val : val + NEW_LINE); + +const removeEndingNewLine = val => (endsWithNewLine(val) ? val.substr(0, val.length - 1) : val); + +const diffHead = (prevPath, newPath = '') => + `diff --git "a/${prevPath}" "b/${newPath || prevPath}"`; + +const createDiffBody = (path, content, isCreate) => { + if (!content) { + return ''; + } + + const prefix = isCreate ? '+' : '-'; + const fromPath = isCreate ? DEV_NULL : `a/${path}`; + const toPath = isCreate ? `b/${path}` : DEV_NULL; + + const hasNewLine = endsWithNewLine(content); + const lines = removeEndingNewLine(content).split(NEW_LINE); + + const chunkHead = isCreate ? `@@ -0,0 +1,${lines.length} @@` : `@@ -1,${lines.length} +0,0 @@`; + const chunk = lines + .map(line => `${prefix}${line}`) + .concat(!hasNewLine ? [NO_NEW_LINE] : []) + .join(NEW_LINE); + + return `--- ${fromPath} ++++ ${toPath} +${chunkHead} +${chunk}`; +}; + +const createMoveFileDiff = (prevPath, newPath) => `${diffHead(prevPath, newPath)} +rename from ${prevPath} +rename to ${newPath}`; + +const createNewFileDiff = (path, content) => { + const diff = createDiffBody(path, content, true); + + return `${diffHead(path)} +new file mode ${DEFAULT_MODE} +${diff}`; +}; + +const createDeleteFileDiff = (path, content) => { + const diff = createDiffBody(path, content, false); + + return `${diffHead(path)} +deleted file mode ${DEFAULT_MODE} +${diff}`; +}; + +const createUpdateFileDiff = (path, oldContent, newContent) => { + const patch = createTwoFilesPatch(`a/${path}`, `b/${path}`, oldContent, newContent); + + return `${diffHead(path)} +${cleanTwoFilesPatch(patch)}`; +}; + +const createFileDiffRaw = (file, action) => { + switch (action) { + case commitActionTypes.move: + return createMoveFileDiff(file.prevPath, file.path); + case commitActionTypes.create: + return createNewFileDiff(file.path, file.content); + case commitActionTypes.delete: + return createDeleteFileDiff(file.path, file.content); + case commitActionTypes.update: + return createUpdateFileDiff(file.path, file.raw || '', file.content); + default: + return ''; + } +}; + +/** + * Create a git diff for a single IDE file. + * + * ## Notes: + * When called with `commitActionType.move`, it assumes that the move + * is a 100% similarity move. No diff will be generated. This is because + * generating a move with changes is not support by the current IDE, since + * the source file might not have it's content loaded yet. + * + * When called with `commitActionType.delete`, it does not support + * deleting files with a mode different than 100644. For the IDE mirror, this + * isn't needed because deleting is handled outside the unified patch. + * + * ## References: + * - https://git-scm.com/docs/git-diff#_generating_patches_with_p + */ +const createFileDiff = (file, action) => + // It's important that the file diff ends in a new line - git expects this. + addEndingNewLine(createFileDiffRaw(file, action)); + +export default createFileDiff; diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index 234a7f903a1..35fcda6a6c5 100644 --- a/app/assets/javascripts/ide/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -50,10 +50,15 @@ export default class DirtyDiffController { } computeDiff(model) { + const originalModel = model.getOriginalModel(); + const newModel = model.getModel(); + + if (originalModel.isDisposed() || newModel.isDisposed()) return; + this.dirtyDiffWorker.postMessage({ path: model.path, - originalContent: model.getOriginalModel().getValue(), - newContent: model.getModel().getValue(), + originalContent: originalModel.getValue(), + newContent: newModel.getValue(), }); } diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js index 29e29d7fcd3..3a456b7c4d6 100644 --- a/app/assets/javascripts/ide/lib/diff/diff.js +++ b/app/assets/javascripts/ide/lib/diff/diff.js @@ -1,8 +1,15 @@ import { diffLines } from 'diff'; +import { defaultDiffOptions } from '../editor_options'; +// See: https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20 // eslint-disable-next-line import/prefer-default-export export const computeDiff = (originalContent, newContent) => { - const changes = diffLines(originalContent, newContent); + // prevent EOL changes from highlighting the entire file + const changes = diffLines( + originalContent.replace(/\r\n/g, '\n'), + newContent.replace(/\r\n/g, '\n'), + defaultDiffOptions, + ); let lineNumber = 1; return changes.reduce((acc, change) => { diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 25224abd77c..4dfc27117c0 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -1,11 +1,11 @@ import { debounce } from 'lodash'; -import { editor as monacoEditor, KeyCode, KeyMod } from 'monaco-editor'; +import { editor as monacoEditor, KeyCode, KeyMod, Range } from 'monaco-editor'; import store from '../stores'; import DecorationsController from './decorations/controller'; import DirtyDiffController from './diff/controller'; import Disposable from './common/disposable'; import ModelManager from './common/model_manager'; -import editorOptions, { defaultEditorOptions } from './editor_options'; +import { editorOptions, defaultEditorOptions, defaultDiffEditorOptions } from './editor_options'; import { themes } from './themes'; import languages from './languages'; import keymap from './keymap.json'; @@ -37,6 +37,10 @@ export default class Editor { ...defaultEditorOptions, ...options, }; + this.diffOptions = { + ...defaultDiffEditorOptions, + ...options, + }; setupThemes(); registerLanguages(...languages); @@ -66,19 +70,14 @@ export default class Editor { } } - createDiffInstance(domElement, readOnly = true) { + createDiffInstance(domElement) { if (!this.instance) { clearDomElement(domElement); this.disposable.add( (this.instance = monacoEditor.createDiffEditor(domElement, { - ...this.options, - quickSuggestions: false, - occurrencesHighlight: false, + ...this.diffOptions, renderSideBySide: Editor.renderSideBySide(domElement), - readOnly, - renderLineHighlight: readOnly ? 'all' : 'none', - hideCursorInOverviewRuler: !readOnly, })), ); @@ -187,6 +186,21 @@ export default class Editor { }); } + replaceSelectedText(text) { + let selection = this.instance.getSelection(); + const range = new Range( + selection.startLineNumber, + selection.startColumn, + selection.endLineNumber, + selection.endColumn, + ); + + this.instance.executeEdits('', [{ range, text }]); + + selection = this.instance.getSelection(); + this.instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn }); + } + get isDiffEditorType() { return this.instance.getEditorType() === 'vs.editor.IDiffEditor'; } diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index dac2a8e8b51..f182a1ec50e 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -9,7 +9,27 @@ export const defaultEditorOptions = { wordWrap: 'on', }; -export default [ +export const defaultDiffOptions = { + ignoreWhitespace: false, +}; + +export const defaultDiffEditorOptions = { + ...defaultEditorOptions, + quickSuggestions: false, + occurrencesHighlight: false, + ignoreTrimWhitespace: false, + readOnly: false, + renderLineHighlight: 'none', + hideCursorInOverviewRuler: true, +}; + +export const defaultModelOptions = { + endOfLine: 0, + insertFinalNewline: true, + trimTrailingWhitespace: false, +}; + +export const editorOptions = [ { readOnly: model => Boolean(model.file.file_lock), quickSuggestions: model => !(model.language === 'markdown'), diff --git a/app/assets/javascripts/ide/lib/editorconfig/parser.js b/app/assets/javascripts/ide/lib/editorconfig/parser.js new file mode 100644 index 00000000000..a30a8cb868d --- /dev/null +++ b/app/assets/javascripts/ide/lib/editorconfig/parser.js @@ -0,0 +1,55 @@ +import { parseString } from 'editorconfig/src/lib/ini'; +import minimatch from 'minimatch'; +import { getPathParents } from '../../utils'; + +const dirname = path => path.replace(/\.editorconfig$/, ''); + +function isRootConfig(config) { + return config.some(([pattern, rules]) => !pattern && rules?.root === 'true'); +} + +function getRulesForSection(path, [pattern, rules]) { + if (!pattern) { + return {}; + } + if (minimatch(path, pattern, { matchBase: true })) { + return rules; + } + + return {}; +} + +function getRulesWithConfigs(filePath, configFiles = [], rules = {}) { + if (!configFiles.length) return rules; + + const [{ content, path: configPath }, ...nextConfigs] = configFiles; + const configDir = dirname(configPath); + + if (!filePath.startsWith(configDir)) return rules; + + const parsed = parseString(content); + const isRoot = isRootConfig(parsed); + const relativeFilePath = filePath.slice(configDir.length); + + const sectionRules = parsed.reduce( + (acc, section) => Object.assign(acc, getRulesForSection(relativeFilePath, section)), + {}, + ); + + // prefer existing rules by overwriting to section rules + const result = Object.assign(sectionRules, rules); + + return isRoot ? result : getRulesWithConfigs(filePath, nextConfigs, result); +} + +// eslint-disable-next-line import/prefer-default-export +export function getRulesWithTraversal(filePath, getFileContent) { + const editorconfigPaths = [ + ...getPathParents(filePath).map(x => `${x}/.editorconfig`), + '.editorconfig', + ]; + + return Promise.all( + editorconfigPaths.map(path => getFileContent(path).then(content => ({ path, content }))), + ).then(results => getRulesWithConfigs(filePath, results.filter(x => x.content))); +} diff --git a/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js b/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js new file mode 100644 index 00000000000..f9d5579511a --- /dev/null +++ b/app/assets/javascripts/ide/lib/editorconfig/rules_mapper.js @@ -0,0 +1,33 @@ +import { isBoolean, isNumber } from 'lodash'; + +const map = (key, validValues) => value => + value in validValues ? { [key]: validValues[value] } : {}; + +const bool = key => value => (isBoolean(value) ? { [key]: value } : {}); + +const int = (key, isValid) => value => + isNumber(value) && isValid(value) ? { [key]: Math.trunc(value) } : {}; + +const rulesMapper = { + indent_style: map('insertSpaces', { tab: false, space: true }), + indent_size: int('tabSize', n => n > 0), + tab_width: int('tabSize', n => n > 0), + trim_trailing_whitespace: bool('trimTrailingWhitespace'), + end_of_line: map('endOfLine', { crlf: 1, lf: 0 }), + insert_final_newline: bool('insertFinalNewline'), +}; + +const parseValue = x => { + let value = typeof x === 'string' ? x.toLowerCase() : x; + if (/^[0-9.-]+$/.test(value)) value = Number(value); + if (value === 'true') value = true; + if (value === 'false') value = false; + + return value; +}; + +export default function mapRulesToMonaco(rules) { + return Object.entries(rules).reduce((obj, [key, value]) => { + return Object.assign(obj, rulesMapper[key]?.(parseValue(value)) || {}); + }, {}); +} diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js index 26518a2abac..6d85e225fd5 100644 --- a/app/assets/javascripts/ide/lib/files.js +++ b/app/assets/javascripts/ide/lib/files.js @@ -19,7 +19,6 @@ export const decorateFiles = ({ branchId, tempFile = false, content = '', - base64 = false, binary = false, rawPath = '', }) => { @@ -49,7 +48,6 @@ export const decorateFiles = ({ path, url: `/${projectId}/tree/${branchId}/-/${path}/`, type: 'tree', - parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`, tempFile, changed: tempFile, opened: tempFile, @@ -86,14 +84,11 @@ export const decorateFiles = ({ path, url: `/${projectId}/blob/${branchId}/-/${path}`, type: 'blob', - parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`, tempFile, changed: tempFile, content, - base64, binary: (previewMode && previewMode.binary) || binary, rawPath, - previewMode, parentPath, }); diff --git a/app/assets/javascripts/ide/lib/languages/README.md b/app/assets/javascripts/ide/lib/languages/README.md new file mode 100644 index 00000000000..e4d1a4c7818 --- /dev/null +++ b/app/assets/javascripts/ide/lib/languages/README.md @@ -0,0 +1,21 @@ +# Web IDE Languages + +The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting. +The Web IDE currently supports all langauges defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository. + +## Adding New Languages + +While Monaco supports a wide variety of languages, there's always the chance that it's missing something. +You'll find a list of [unsupported languages in this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), which is the right place to add more if needed. + +Should you be willing to help us and add support to GitLab for any missing languages, here are the steps to do so: + +1. Create a new issue and add it to [this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), if it doesn't already exist. +2. Create a new file in this folder called `{languageName}.js`, where `{languageName}` is the name of the language you want to add support for. +3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language. + - Example: The [`vue.js`](./vue.js) file in the current directory adds support for Vue.js Syntax Highlighting. +4. Add tests for the new langauge implementation in `spec/frontend/ide/lib/languages/{langaugeName}.js`. + - Example: See [`vue_spec.js`](spec/frontend/ide/lib/languages/vue_spec.js). +5. Create a [Merge Request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language. + +Thank you! diff --git a/app/assets/javascripts/ide/lib/mirror.js b/app/assets/javascripts/ide/lib/mirror.js new file mode 100644 index 00000000000..a516c28ad7a --- /dev/null +++ b/app/assets/javascripts/ide/lib/mirror.js @@ -0,0 +1,154 @@ +import createDiff from './create_diff'; +import { getWebSocketUrl, mergeUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; + +export const SERVICE_NAME = 'webide-file-sync'; +export const PROTOCOL = 'webfilesync.gitlab.com'; +export const MSG_CONNECTION_ERROR = __('Could not connect to Web IDE file mirror service.'); + +// Before actually connecting to the service, we must delay a bit +// so that the service has sufficiently started. + +const noop = () => {}; +export const SERVICE_DELAY = 8000; + +const cancellableWait = time => { + let timeoutId = 0; + + const cancel = () => clearTimeout(timeoutId); + + const promise = new Promise(resolve => { + timeoutId = setTimeout(resolve, time); + }); + + return [promise, cancel]; +}; + +const isErrorResponse = error => error && error.code !== 0; + +const isErrorPayload = payload => payload && payload.status_code !== 200; + +const getErrorFromResponse = data => { + if (isErrorResponse(data.error)) { + return { message: data.error.Message }; + } else if (isErrorPayload(data.payload)) { + return { message: data.payload.error_message }; + } + + return null; +}; + +const getFullPath = path => mergeUrlParams({ service: SERVICE_NAME }, getWebSocketUrl(path)); + +const createWebSocket = fullPath => + new Promise((resolve, reject) => { + const socket = new WebSocket(fullPath, [PROTOCOL]); + const resetCallbacks = () => { + socket.onopen = null; + socket.onerror = null; + }; + + socket.onopen = () => { + resetCallbacks(); + resolve(socket); + }; + + socket.onerror = () => { + resetCallbacks(); + reject(new Error(MSG_CONNECTION_ERROR)); + }; + }); + +export const canConnect = ({ services = [] }) => services.some(name => name === SERVICE_NAME); + +export const createMirror = () => { + let socket = null; + let cancelHandler = noop; + let nextMessageHandler = noop; + + const cancelConnect = () => { + cancelHandler(); + cancelHandler = noop; + }; + + const onCancelConnect = fn => { + cancelHandler = fn; + }; + + const receiveMessage = ev => { + const handle = nextMessageHandler; + nextMessageHandler = noop; + handle(JSON.parse(ev.data)); + }; + + const onNextMessage = fn => { + nextMessageHandler = fn; + }; + + const waitForNextMessage = () => + new Promise((resolve, reject) => { + onNextMessage(data => { + const err = getErrorFromResponse(data); + + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + + const uploadDiff = ({ toDelete, patch }) => { + if (!socket) { + return Promise.resolve(); + } + + const response = waitForNextMessage(); + + const msg = { + code: 'EVENT', + namespace: '/files', + event: 'PATCH', + payload: { diff: patch, delete_files: toDelete }, + }; + + socket.send(JSON.stringify(msg)); + + return response; + }; + + return { + upload(state) { + return uploadDiff(createDiff(state)); + }, + connect(path) { + if (socket) { + this.disconnect(); + } + + const fullPath = getFullPath(path); + const [wait, cancelWait] = cancellableWait(SERVICE_DELAY); + + onCancelConnect(cancelWait); + + return wait + .then(() => createWebSocket(fullPath)) + .then(newSocket => { + socket = newSocket; + socket.onmessage = receiveMessage; + }); + }, + disconnect() { + cancelConnect(); + + if (!socket) { + return; + } + + socket.close(); + socket = null; + }, + }; +}; + +export default createMirror(); diff --git a/app/assets/javascripts/ide/services/terminals.js b/app/assets/javascripts/ide/services/terminals.js new file mode 100644 index 00000000000..17b4329037d --- /dev/null +++ b/app/assets/javascripts/ide/services/terminals.js @@ -0,0 +1,15 @@ +import axios from '~/lib/utils/axios_utils'; + +export const baseUrl = projectPath => `/${projectPath}/ide_terminals`; + +export const checkConfig = (projectPath, branch) => + axios.post(`${baseUrl(projectPath)}/check_config`, { + branch, + format: 'json', + }); + +export const create = (projectPath, branch) => + axios.post(baseUrl(projectPath), { + branch, + format: 'json', + }); diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index e32b5ac7bdc..c881f1221e5 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -7,7 +7,6 @@ import * as types from './mutation_types'; import { decorateFiles } from '../lib/files'; import { stageKeys } from '../constants'; import service from '../services'; -import router from '../ide_router'; import eventHub from '../eventhub'; export const redirectToUrl = (self, url) => visitUrl(url); @@ -20,21 +19,25 @@ export const discardAllChanges = ({ state, commit, dispatch }) => { commit(types.REMOVE_ALL_CHANGES_FILES); }; -export const closeAllFiles = ({ state, dispatch }) => { - state.openFiles.forEach(file => dispatch('closeFile', file)); -}; - export const setResizingStatus = ({ commit }, resizing) => { commit(types.SET_RESIZING_STATUS, resizing); }; export const createTempEntry = ( { state, commit, dispatch, getters }, - { name, type, content = '', base64 = false, binary = false, rawPath = '' }, + { + name, + type, + content = '', + binary = false, + rawPath = '', + openFile = true, + makeFileActive = true, + }, ) => { const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; - if (state.entries[name] && !state.entries[name].deleted) { + if (getters.entryExists(name)) { flash( sprintf(__('The name "%{name}" is already taken in this directory.'), { name: name.split('/').pop(), @@ -46,7 +49,7 @@ export const createTempEntry = ( true, ); - return; + return undefined; } const data = decorateFiles({ @@ -56,7 +59,6 @@ export const createTempEntry = ( type, tempFile: true, content, - base64, binary, rawPath, }); @@ -69,18 +71,31 @@ export const createTempEntry = ( }); if (type === 'blob') { - commit(types.TOGGLE_FILE_OPEN, file.path); + if (openFile) commit(types.TOGGLE_FILE_OPEN, file.path); commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }); - dispatch('setFileActive', file.path); + if (openFile && makeFileActive) dispatch('setFileActive', file.path); dispatch('triggerFilesChange'); } if (parentPath && !state.entries[parentPath].opened) { commit(types.TOGGLE_TREE_OPEN, parentPath); } + + return file; }; +export const addTempImage = ({ dispatch, getters }, { name, rawPath = '' }) => + dispatch('createTempEntry', { + name: getters.getAvailableFileName(name), + type: 'blob', + content: rawPath.split('base64,')[1], + binary: true, + rawPath, + openFile: false, + makeFileActive: false, + }); + export const scrollToTab = () => { Vue.nextTick(() => { const tabs = document.getElementById('tabs'); @@ -239,7 +254,7 @@ export const renameEntry = ({ dispatch, commit, state, getters }, { path, name, } if (newEntry.opened) { - router.push(`/project${newEntry.url}`); + dispatch('router/push', `/project${newEntry.url}`, { root: true }); } } @@ -297,6 +312,3 @@ export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; export * from './actions/merge_request'; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index da7d4a44bde..47f9337a288 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -3,8 +3,7 @@ import { __ } from '~/locale'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; -import router from '../../ide_router'; -import { addFinalNewlineIfNeeded, setPageTitleForFile } from '../utils'; +import { setPageTitleForFile } from '../utils'; import { viewerTypes, stageKeys } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { @@ -30,10 +29,10 @@ export const closeFile = ({ commit, state, dispatch }, file) => { keyPrefix: nextFileToOpen.staged ? 'staged' : 'unstaged', }); } else { - router.push(`/project${nextFileToOpen.url}`); + dispatch('router/push', `/project${nextFileToOpen.url}`, { root: true }); } } else if (!state.openFiles.length) { - router.push(`/project/${file.projectId}/tree/${file.branchId}/`); + dispatch('router/push', `/project/${file.projectId}/tree/${file.branchId}/`, { root: true }); } eventHub.$emit(`editor.update.model.dispose.${file.key}`); @@ -152,7 +151,7 @@ export const changeFileContent = ({ commit, state, getters }, { path, content }) const file = state.entries[path]; commit(types.UPDATE_FILE_CONTENT, { path, - content: addFinalNewlineIfNeeded(content), + content, }); const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path); @@ -170,12 +169,6 @@ export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => { } }; -export const setFileEOL = ({ getters, commit }, { eol }) => { - if (getters.activeFile) { - commit(types.SET_FILE_EOL, { file: getters.activeFile, eol }); - } -}; - export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn }) => { if (getters.activeFile) { commit(types.SET_FILE_POSITION, { @@ -226,7 +219,7 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) = if (!isDestructiveDiscard && file.path === getters.activeFile?.path) { dispatch('updateDelayViewerUpdated', true) .then(() => { - router.push(`/project${file.url}`); + dispatch('router/push', `/project${file.url}`, { root: true }); }) .catch(e => { throw e; @@ -275,14 +268,16 @@ export const unstageChange = ({ commit, dispatch, getters }, path) => { } }; -export const openPendingTab = ({ commit, getters, state }, { file, keyPrefix }) => { +export const openPendingTab = ({ commit, dispatch, getters, state }, { file, keyPrefix }) => { if (getters.activeFile && getters.activeFile.key === `${keyPrefix}-${file.key}`) return false; state.openFiles.forEach(f => eventHub.$emit(`editor.update.model.dispose.${f.key}`)); commit(types.ADD_PENDING_TAB, { file, keyPrefix }); - router.push(`/project/${file.projectId}/tree/${state.currentBranchId}/`); + dispatch('router/push', `/project/${file.projectId}/tree/${state.currentBranchId}/`, { + root: true, + }); return true; }; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 6c8fb9f90aa..d172bb31ae5 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -4,7 +4,6 @@ import { __, sprintf } from '~/locale'; import service from '../../services'; import api from '../../../api'; import * as types from '../mutation_types'; -import router from '../../ide_router'; export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) => new Promise((resolve, reject) => { @@ -57,7 +56,7 @@ export const createNewBranchFromDefault = ({ state, dispatch, getters }, branch) }) .then(() => { dispatch('setErrorMessage', null); - router.push(`${router.currentRoute.path}?${Date.now()}`); + window.location.reload(); }) .catch(() => { dispatch('setErrorMessage', { diff --git a/app/assets/javascripts/ide/stores/extend.js b/app/assets/javascripts/ide/stores/extend.js new file mode 100644 index 00000000000..1c1636cf6ca --- /dev/null +++ b/app/assets/javascripts/ide/stores/extend.js @@ -0,0 +1,14 @@ +import terminal from './plugins/terminal'; +import terminalSync from './plugins/terminal_sync'; + +const plugins = () => [ + terminal, + ...(gon.features && gon.features.buildServiceProxy ? [terminalSync] : []), +]; + +export default (store, el) => { + // plugins is actually an array of plugin factories, so we have to create first then call + plugins().forEach(plugin => plugin(el)(store)); + + return store; +}; diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 5d0a8570906..53734fa626b 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -50,9 +50,6 @@ export const emptyRepo = state => export const currentTree = state => state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; -export const hasChanges = state => - Boolean(state.changedFiles.length) || Boolean(state.stagedFiles.length); - export const hasMergeRequest = state => Boolean(state.currentMergeRequestId); export const allBlobs = state => @@ -162,5 +159,18 @@ export const canCreateMergeRequests = (state, getters) => export const canPushCode = (state, getters) => Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_PUSH_CODE]); -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; +export const entryExists = state => path => + Boolean(state.entries[path] && !state.entries[path].deleted); + +export const getAvailableFileName = (state, getters) => path => { + let newPath = path; + + while (getters.entryExists(newPath)) { + newPath = newPath.replace( + /([ _-]?)(\d*)(\..+?$|$)/, + (_, before, number, after) => `${before || '_'}${Number(number) + 1}${after}`, + ); + } + + return newPath; +}; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index 85550578e94..18c466cc93d 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -11,24 +11,27 @@ import branches from './modules/branches'; import fileTemplates from './modules/file_templates'; import paneModule from './modules/pane'; import clientsideModule from './modules/clientside'; +import routerModule from './modules/router'; Vue.use(Vuex); -export const createStore = () => - new Vuex.Store({ - state: state(), - actions, - mutations, - getters, - modules: { - commit: commitModule, - pipelines, - mergeRequests, - branches, - fileTemplates: fileTemplates(), - rightPane: paneModule(), - clientside: clientsideModule(), - }, - }); +export const createStoreOptions = () => ({ + state: state(), + actions, + mutations, + getters, + modules: { + commit: commitModule, + pipelines, + mergeRequests, + branches, + fileTemplates: fileTemplates(), + rightPane: paneModule(), + clientside: clientsideModule(), + router: routerModule, + }, +}); + +export const createStore = () => new Vuex.Store(createStoreOptions()); export default createStore(); diff --git a/app/assets/javascripts/ide/stores/modules/branches/index.js b/app/assets/javascripts/ide/stores/modules/branches/index.js index 04e7e0f08f1..deda95cd0c9 100644 --- a/app/assets/javascripts/ide/stores/modules/branches/index.js +++ b/app/assets/javascripts/ide/stores/modules/branches/index.js @@ -4,7 +4,7 @@ import mutations from './mutations'; export default { namespaced: true, - state: state(), + state, actions, mutations, }; diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js index eb3bcdff2ae..2bebf8b90ce 100644 --- a/app/assets/javascripts/ide/stores/modules/clientside/actions.js +++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js @@ -8,5 +8,4 @@ export const pingUsage = ({ rootGetters }) => { return axios.post(url); }; -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; +export default pingUsage; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 592c7e15918..005bd0240e2 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -3,7 +3,6 @@ import flash from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import * as rootTypes from '../../mutation_types'; import { createCommitPayload, createNewMergeRequestUrl } from '../../utils'; -import router from '../../../ide_router'; import service from '../../../services'; import * as types from './mutation_types'; import consts from './constants'; @@ -196,8 +195,10 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo dispatch('updateViewer', 'editor', { root: true }); if (rootGetters.activeFile) { - router.push( + dispatch( + 'router/push', `/project/${rootState.currentProjectId}/blob/${branchName}/-/${rootGetters.activeFile.path}`, + { root: true }, ); } } @@ -234,6 +235,3 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo window.dispatchEvent(new Event('resize')); }); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index 413c4b0110d..37f887bcf0a 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -59,6 +59,3 @@ export const shouldDisableNewMrOption = (state, getters, rootState, rootGetters) export const shouldCreateMR = (state, getters) => state.shouldCreateMR && !getters.shouldDisableNewMrOption; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/index.js b/app/assets/javascripts/ide/stores/modules/commit/index.js index 3bf65b02847..5cec73bde2e 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/index.js +++ b/app/assets/javascripts/ide/stores/modules/commit/index.js @@ -5,7 +5,7 @@ import * as getters from './getters'; export default { namespaced: true, - state: state(), + state, mutations, actions, getters, diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js index 59ead8a3dcf..6b2c929cd44 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js @@ -117,6 +117,3 @@ export const undoFileTemplate = ({ dispatch, commit, rootGetters }) => { dispatch('discardFileChanges', file.path, { root: true }); } }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/pane/actions.js b/app/assets/javascripts/ide/stores/modules/pane/actions.js index a8fcdf539ec..b7cff368fe4 100644 --- a/app/assets/javascripts/ide/stores/modules/pane/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pane/actions.js @@ -25,6 +25,3 @@ export const open = ({ state, commit }, view) => { export const close = ({ commit }) => { commit(types.SET_OPEN, false); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/pane/getters.js b/app/assets/javascripts/ide/stores/modules/pane/getters.js index c346cf13689..7816172bb6f 100644 --- a/app/assets/javascripts/ide/stores/modules/pane/getters.js +++ b/app/assets/javascripts/ide/stores/modules/pane/getters.js @@ -1,4 +1,3 @@ -export const isActiveView = state => view => state.currentView === view; - -export const isAliveView = (state, getters) => view => - state.keepAliveViews[view] || (state.isOpen && getters.isActiveView(view)); +// eslint-disable-next-line import/prefer-default-export +export const isAliveView = state => view => + state.keepAliveViews[view] || (state.isOpen && state.currentView === view); diff --git a/app/assets/javascripts/ide/stores/modules/router/actions.js b/app/assets/javascripts/ide/stores/modules/router/actions.js new file mode 100644 index 00000000000..849067599f2 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/router/actions.js @@ -0,0 +1,6 @@ +import * as types from './mutation_types'; + +// eslint-disable-next-line import/prefer-default-export +export const push = ({ commit }, fullPath) => { + commit(types.PUSH, fullPath); +}; diff --git a/app/assets/javascripts/ide/stores/modules/router/index.js b/app/assets/javascripts/ide/stores/modules/router/index.js new file mode 100644 index 00000000000..68c81bb4509 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/router/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import mutations from './mutations'; +import * as actions from './actions'; + +export default { + namespaced: true, + state, + mutations, + actions, +}; diff --git a/app/assets/javascripts/ide/stores/modules/router/mutation_types.js b/app/assets/javascripts/ide/stores/modules/router/mutation_types.js new file mode 100644 index 00000000000..ae99073cc4c --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/router/mutation_types.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const PUSH = 'PUSH'; diff --git a/app/assets/javascripts/ide/stores/modules/router/mutations.js b/app/assets/javascripts/ide/stores/modules/router/mutations.js new file mode 100644 index 00000000000..471cace314c --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/router/mutations.js @@ -0,0 +1,7 @@ +import * as types from './mutation_types'; + +export default { + [types.PUSH](state, fullPath) { + state.fullPath = fullPath; + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/router/state.js b/app/assets/javascripts/ide/stores/modules/router/state.js new file mode 100644 index 00000000000..abb6c5239e4 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/router/state.js @@ -0,0 +1,3 @@ +export default () => ({ + fullPath: '', +}); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js new file mode 100644 index 00000000000..43b6650b241 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js @@ -0,0 +1,98 @@ +import Api from '~/api'; +import httpStatus from '~/lib/utils/http_status'; +import * as types from '../mutation_types'; +import * as messages from '../messages'; +import { CHECK_CONFIG, CHECK_RUNNERS, RETRY_RUNNERS_INTERVAL } from '../constants'; +import * as terminalService from '../../../../services/terminals'; + +export const requestConfigCheck = ({ commit }) => { + commit(types.REQUEST_CHECK, CHECK_CONFIG); +}; + +export const receiveConfigCheckSuccess = ({ commit }) => { + commit(types.SET_VISIBLE, true); + commit(types.RECEIVE_CHECK_SUCCESS, CHECK_CONFIG); +}; + +export const receiveConfigCheckError = ({ commit, state }, e) => { + const { status } = e.response; + const { paths } = state; + + const isVisible = status !== httpStatus.FORBIDDEN && status !== httpStatus.NOT_FOUND; + commit(types.SET_VISIBLE, isVisible); + + const message = messages.configCheckError(status, paths.webTerminalConfigHelpPath); + commit(types.RECEIVE_CHECK_ERROR, { type: CHECK_CONFIG, message }); +}; + +export const fetchConfigCheck = ({ dispatch, rootState, rootGetters }) => { + dispatch('requestConfigCheck'); + + const { currentBranchId } = rootState; + const { currentProject } = rootGetters; + + terminalService + .checkConfig(currentProject.path_with_namespace, currentBranchId) + .then(() => { + dispatch('receiveConfigCheckSuccess'); + }) + .catch(e => { + dispatch('receiveConfigCheckError', e); + }); +}; + +export const requestRunnersCheck = ({ commit }) => { + commit(types.REQUEST_CHECK, CHECK_RUNNERS); +}; + +export const receiveRunnersCheckSuccess = ({ commit, dispatch, state }, data) => { + if (data.length) { + commit(types.RECEIVE_CHECK_SUCCESS, CHECK_RUNNERS); + } else { + const { paths } = state; + + commit(types.RECEIVE_CHECK_ERROR, { + type: CHECK_RUNNERS, + message: messages.runnersCheckEmpty(paths.webTerminalRunnersHelpPath), + }); + + dispatch('retryRunnersCheck'); + } +}; + +export const receiveRunnersCheckError = ({ commit }) => { + commit(types.RECEIVE_CHECK_ERROR, { + type: CHECK_RUNNERS, + message: messages.UNEXPECTED_ERROR_RUNNERS, + }); +}; + +export const retryRunnersCheck = ({ dispatch, state }) => { + // if the overall check has failed, don't worry about retrying + const check = state.checks[CHECK_CONFIG]; + if (!check.isLoading && !check.isValid) { + return; + } + + setTimeout(() => { + dispatch('fetchRunnersCheck', { background: true }); + }, RETRY_RUNNERS_INTERVAL); +}; + +export const fetchRunnersCheck = ({ dispatch, rootGetters }, options = {}) => { + const { background = false } = options; + + if (!background) { + dispatch('requestRunnersCheck'); + } + + const { currentProject } = rootGetters; + + Api.projectRunners(currentProject.id, { params: { scope: 'active' } }) + .then(({ data }) => { + dispatch('receiveRunnersCheckSuccess', data); + }) + .catch(e => { + dispatch('receiveRunnersCheckError', e); + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js new file mode 100644 index 00000000000..112b3794114 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js @@ -0,0 +1,5 @@ +export * from './setup'; +export * from './checks'; +export * from './session_controls'; +export * from './session_status'; +export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js new file mode 100644 index 00000000000..d3dcb9dd125 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js @@ -0,0 +1,118 @@ +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; +import flash from '~/flash'; +import * as types from '../mutation_types'; +import * as messages from '../messages'; +import * as terminalService from '../../../../services/terminals'; +import { STARTING, STOPPING, STOPPED } from '../constants'; + +export const requestStartSession = ({ commit }) => { + commit(types.SET_SESSION_STATUS, STARTING); +}; + +export const receiveStartSessionSuccess = ({ commit, dispatch }, data) => { + commit(types.SET_SESSION, { + id: data.id, + status: data.status, + showPath: data.show_path, + cancelPath: data.cancel_path, + retryPath: data.retry_path, + terminalPath: data.terminal_path, + proxyWebsocketPath: data.proxy_websocket_path, + services: data.services, + }); + + dispatch('pollSessionStatus'); +}; + +export const receiveStartSessionError = ({ dispatch }) => { + flash(messages.UNEXPECTED_ERROR_STARTING); + dispatch('killSession'); +}; + +export const startSession = ({ state, dispatch, rootGetters, rootState }) => { + if (state.session && state.session.status === STARTING) { + return; + } + + const { currentProject } = rootGetters; + const { currentBranchId } = rootState; + + dispatch('requestStartSession'); + + terminalService + .create(currentProject.path_with_namespace, currentBranchId) + .then(({ data }) => { + dispatch('receiveStartSessionSuccess', data); + }) + .catch(error => { + dispatch('receiveStartSessionError', error); + }); +}; + +export const requestStopSession = ({ commit }) => { + commit(types.SET_SESSION_STATUS, STOPPING); +}; + +export const receiveStopSessionSuccess = ({ dispatch }) => { + dispatch('killSession'); +}; + +export const receiveStopSessionError = ({ dispatch }) => { + flash(messages.UNEXPECTED_ERROR_STOPPING); + dispatch('killSession'); +}; + +export const stopSession = ({ state, dispatch }) => { + const { cancelPath } = state.session; + + dispatch('requestStopSession'); + + axios + .post(cancelPath) + .then(() => { + dispatch('receiveStopSessionSuccess'); + }) + .catch(err => { + dispatch('receiveStopSessionError', err); + }); +}; + +export const killSession = ({ commit, dispatch }) => { + dispatch('stopPollingSessionStatus'); + commit(types.SET_SESSION_STATUS, STOPPED); +}; + +export const restartSession = ({ state, dispatch, rootState }) => { + const { status, retryPath } = state.session; + const { currentBranchId } = rootState; + + if (status !== STOPPED) { + return; + } + + if (!retryPath) { + dispatch('startSession'); + return; + } + + dispatch('requestStartSession'); + + axios + .post(retryPath, { branch: currentBranchId, format: 'json' }) + .then(({ data }) => { + dispatch('receiveStartSessionSuccess', data); + }) + .catch(error => { + const responseStatus = error.response && error.response.status; + // We may have removed the build, in this case we'll just create a new session + if ( + responseStatus === httpStatus.NOT_FOUND || + responseStatus === httpStatus.UNPROCESSABLE_ENTITY + ) { + dispatch('startSession'); + } else { + dispatch('receiveStartSessionError', error); + } + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js new file mode 100644 index 00000000000..59ba1605c47 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js @@ -0,0 +1,64 @@ +import axios from '~/lib/utils/axios_utils'; +import flash from '~/flash'; +import * as types from '../mutation_types'; +import * as messages from '../messages'; +import { isEndingStatus } from '../utils'; + +export const pollSessionStatus = ({ state, dispatch, commit }) => { + dispatch('stopPollingSessionStatus'); + dispatch('fetchSessionStatus'); + + const interval = setInterval(() => { + if (!state.session) { + dispatch('stopPollingSessionStatus'); + } else { + dispatch('fetchSessionStatus'); + } + }, 5000); + + commit(types.SET_SESSION_STATUS_INTERVAL, interval); +}; + +export const stopPollingSessionStatus = ({ state, commit }) => { + const { sessionStatusInterval } = state; + + if (!sessionStatusInterval) { + return; + } + + clearInterval(sessionStatusInterval); + + commit(types.SET_SESSION_STATUS_INTERVAL, 0); +}; + +export const receiveSessionStatusSuccess = ({ commit, dispatch }, data) => { + const status = data && data.status; + + commit(types.SET_SESSION_STATUS, status); + + if (isEndingStatus(status)) { + dispatch('killSession'); + } +}; + +export const receiveSessionStatusError = ({ dispatch }) => { + flash(messages.UNEXPECTED_ERROR_STATUS); + dispatch('killSession'); +}; + +export const fetchSessionStatus = ({ dispatch, state }) => { + if (!state.session) { + return; + } + + const { showPath } = state.session; + + axios + .get(showPath) + .then(({ data }) => { + dispatch('receiveSessionStatusSuccess', data); + }) + .catch(error => { + dispatch('receiveSessionStatusError', error); + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js new file mode 100644 index 00000000000..78ad94f8a91 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js @@ -0,0 +1,14 @@ +import * as types from '../mutation_types'; + +export const init = ({ dispatch }) => { + dispatch('fetchConfigCheck'); + dispatch('fetchRunnersCheck'); +}; + +export const hideSplash = ({ commit }) => { + commit(types.HIDE_SPLASH); +}; + +export const setPaths = ({ commit }, paths) => { + commit(types.SET_PATHS, paths); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/constants.js b/app/assets/javascripts/ide/stores/modules/terminal/constants.js new file mode 100644 index 00000000000..f7ae9d8f4ea --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/constants.js @@ -0,0 +1,9 @@ +export const CHECK_CONFIG = 'config'; +export const CHECK_RUNNERS = 'runners'; +export const RETRY_RUNNERS_INTERVAL = 10000; + +export const STARTING = 'starting'; +export const PENDING = 'pending'; +export const RUNNING = 'running'; +export const STOPPING = 'stopping'; +export const STOPPED = 'stopped'; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/getters.js b/app/assets/javascripts/ide/stores/modules/terminal/getters.js new file mode 100644 index 00000000000..6d64ee4ab6e --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/getters.js @@ -0,0 +1,19 @@ +export const allCheck = state => { + const checks = Object.values(state.checks); + + if (checks.some(check => check.isLoading)) { + return { isLoading: true }; + } + + const invalidCheck = checks.find(check => !check.isValid); + const isValid = !invalidCheck; + const message = !invalidCheck ? '' : invalidCheck.message; + + return { + isLoading: false, + isValid, + message, + }; +}; + +export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/index.js b/app/assets/javascripts/ide/stores/modules/terminal/index.js new file mode 100644 index 00000000000..ef1289e1722 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/index.js @@ -0,0 +1,12 @@ +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +export default () => ({ + namespaced: true, + actions, + getters, + mutations, + state: state(), +}); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/messages.js b/app/assets/javascripts/ide/stores/modules/terminal/messages.js new file mode 100644 index 00000000000..38c5a8a28d8 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/messages.js @@ -0,0 +1,55 @@ +import { escape } from 'lodash'; +import { __, sprintf } from '~/locale'; +import httpStatus from '~/lib/utils/http_status'; + +export const UNEXPECTED_ERROR_CONFIG = __( + 'An unexpected error occurred while checking the project environment.', +); +export const UNEXPECTED_ERROR_RUNNERS = __( + 'An unexpected error occurred while checking the project runners.', +); +export const UNEXPECTED_ERROR_STATUS = __( + 'An unexpected error occurred while communicating with the Web Terminal.', +); +export const UNEXPECTED_ERROR_STARTING = __( + 'An unexpected error occurred while starting the Web Terminal.', +); +export const UNEXPECTED_ERROR_STOPPING = __( + 'An unexpected error occurred while stopping the Web Terminal.', +); +export const EMPTY_RUNNERS = __( + 'Configure GitLab runners to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}', +); +export const ERROR_CONFIG = __( + 'Configure a <code>.gitlab-webide.yml</code> file in the <code>.gitlab</code> directory to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}', +); +export const ERROR_PERMISSION = __( + 'You do not have permission to run the Web Terminal. Please contact a project administrator.', +); + +export const configCheckError = (status, helpUrl) => { + if (status === httpStatus.UNPROCESSABLE_ENTITY) { + return sprintf( + ERROR_CONFIG, + { + helpStart: `<a href="${escape(helpUrl)}" target="_blank">`, + helpEnd: '</a>', + }, + false, + ); + } else if (status === httpStatus.FORBIDDEN) { + return ERROR_PERMISSION; + } + + return UNEXPECTED_ERROR_CONFIG; +}; + +export const runnersCheckEmpty = helpUrl => + sprintf( + EMPTY_RUNNERS, + { + helpStart: `<a href="${escape(helpUrl)}" target="_blank">`, + helpEnd: '</a>', + }, + false, + ); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js b/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js new file mode 100644 index 00000000000..b6a6f28abfa --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js @@ -0,0 +1,11 @@ +export const SET_VISIBLE = 'SET_VISIBLE'; +export const HIDE_SPLASH = 'HIDE_SPLASH'; +export const SET_PATHS = 'SET_PATHS'; + +export const REQUEST_CHECK = 'REQUEST_CHECK'; +export const RECEIVE_CHECK_SUCCESS = 'RECEIVE_CHECK_SUCCESS'; +export const RECEIVE_CHECK_ERROR = 'RECEIVE_CHECK_ERROR'; + +export const SET_SESSION = 'SET_SESSION'; +export const SET_SESSION_STATUS = 'SET_SESSION_STATUS'; +export const SET_SESSION_STATUS_INTERVAL = 'SET_SESSION_STATUS_INTERVAL'; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/mutations.js b/app/assets/javascripts/ide/stores/modules/terminal/mutations.js new file mode 100644 index 00000000000..37f40af9c2e --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/mutations.js @@ -0,0 +1,64 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_VISIBLE](state, isVisible) { + Object.assign(state, { + isVisible, + }); + }, + [types.HIDE_SPLASH](state) { + Object.assign(state, { + isShowSplash: false, + }); + }, + [types.SET_PATHS](state, paths) { + Object.assign(state, { + paths, + }); + }, + [types.REQUEST_CHECK](state, type) { + Object.assign(state.checks, { + [type]: { + isLoading: true, + }, + }); + }, + [types.RECEIVE_CHECK_ERROR](state, { type, message }) { + Object.assign(state.checks, { + [type]: { + isLoading: false, + isValid: false, + message, + }, + }); + }, + [types.RECEIVE_CHECK_SUCCESS](state, type) { + Object.assign(state.checks, { + [type]: { + isLoading: false, + isValid: true, + message: null, + }, + }); + }, + [types.SET_SESSION](state, session) { + Object.assign(state, { + session, + }); + }, + [types.SET_SESSION_STATUS](state, status) { + const session = { + ...(state.session || {}), + status, + }; + + Object.assign(state, { + session, + }); + }, + [types.SET_SESSION_STATUS_INTERVAL](state, sessionStatusInterval) { + Object.assign(state, { + sessionStatusInterval, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/state.js b/app/assets/javascripts/ide/stores/modules/terminal/state.js new file mode 100644 index 00000000000..f35a10ed2fe --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/state.js @@ -0,0 +1,13 @@ +import { CHECK_CONFIG, CHECK_RUNNERS } from './constants'; + +export default () => ({ + checks: { + [CHECK_CONFIG]: { isLoading: true }, + [CHECK_RUNNERS]: { isLoading: true }, + }, + isVisible: false, + isShowSplash: true, + paths: {}, + session: null, + sessionStatusInterval: 0, +}); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/utils.js b/app/assets/javascripts/ide/stores/modules/terminal/utils.js new file mode 100644 index 00000000000..c30136b5277 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/utils.js @@ -0,0 +1,5 @@ +import { STARTING, PENDING, RUNNING } from './constants'; + +export const isStartingStatus = status => status === STARTING || status === PENDING; +export const isRunningStatus = status => status === RUNNING; +export const isEndingStatus = status => !isStartingStatus(status) && !isRunningStatus(status); diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js new file mode 100644 index 00000000000..2fee6b4e974 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js @@ -0,0 +1,41 @@ +import * as types from './mutation_types'; +import mirror, { canConnect } from '../../../lib/mirror'; + +export const upload = ({ rootState, commit }) => { + commit(types.START_LOADING); + + return mirror + .upload(rootState) + .then(() => { + commit(types.SET_SUCCESS); + }) + .catch(err => { + commit(types.SET_ERROR, err); + }); +}; + +export const stop = ({ commit }) => { + mirror.disconnect(); + + commit(types.STOP); +}; + +export const start = ({ rootState, commit }) => { + const { session } = rootState.terminal; + const path = session && session.proxyWebsocketPath; + if (!path || !canConnect(session)) { + return Promise.reject(); + } + + commit(types.START_LOADING); + + return mirror + .connect(path) + .then(() => { + commit(types.SET_SUCCESS); + }) + .catch(err => { + commit(types.SET_ERROR, err); + throw err; + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js new file mode 100644 index 00000000000..795c2fad724 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +export default () => ({ + namespaced: true, + actions, + mutations, + state: state(), +}); diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js new file mode 100644 index 00000000000..e50e1a1406b --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js @@ -0,0 +1,5 @@ +import { __ } from '~/locale'; + +export const MSG_TERMINAL_SYNC_CONNECTING = __('Connecting to terminal sync service'); +export const MSG_TERMINAL_SYNC_UPLOADING = __('Uploading changes to terminal'); +export const MSG_TERMINAL_SYNC_RUNNING = __('Terminal sync service is running'); diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js new file mode 100644 index 00000000000..ec809540c18 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js @@ -0,0 +1,4 @@ +export const START_LOADING = 'START_LOADING'; +export const SET_ERROR = 'SET_ERROR'; +export const SET_SUCCESS = 'SET_SUCCESS'; +export const STOP = 'STOP'; diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js new file mode 100644 index 00000000000..70ed137776a --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js @@ -0,0 +1,22 @@ +import * as types from './mutation_types'; + +export default { + [types.START_LOADING](state) { + state.isLoading = true; + state.isError = false; + }, + [types.SET_ERROR](state, { message }) { + state.isLoading = false; + state.isError = true; + state.message = message; + }, + [types.SET_SUCCESS](state) { + state.isLoading = false; + state.isError = false; + state.isStarted = true; + }, + [types.STOP](state) { + state.isLoading = false; + state.isStarted = false; + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js new file mode 100644 index 00000000000..7ec3e38f675 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js @@ -0,0 +1,6 @@ +export default () => ({ + isLoading: false, + isStarted: false, + isError: false, + message: '', +}); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 5c78bfefa04..d94adc3760f 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -27,7 +27,6 @@ export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; export const SET_TREE_OPEN = 'SET_TREE_OPEN'; -export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL'; export const CREATE_TREE = 'CREATE_TREE'; export const REMOVE_ALL_CHANGES_FILES = 'REMOVE_ALL_CHANGES_FILES'; @@ -41,7 +40,6 @@ export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; export const SET_FILE_POSITION = 'SET_FILE_POSITION'; export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE'; -export const SET_FILE_EOL = 'SET_FILE_EOL'; export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 12ac10df206..e827aacac13 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -65,14 +65,10 @@ export default { // NOTE: We can't clone `entry` in any of the below assignments because // we need `state.entries` and the `entry.tree` to reference the same object. - if (!foundEntry) { + if (!foundEntry || foundEntry.deleted) { Object.assign(state.entries, { [key]: entry, }); - } else if (foundEntry.deleted) { - Object.assign(state.entries, { - [key]: Object.assign(entry, { replaces: true }), - }); } else { const tree = entry.tree.filter( f => foundEntry.tree.find(e => e.path === f.path) === undefined, @@ -147,7 +143,6 @@ export default { raw: file.content, changed: Boolean(changedFile), staged: false, - replaces: false, lastCommitSha: lastCommit.commit.id, prevId: undefined, @@ -164,9 +159,6 @@ export default { Object.assign(state.entries[file.path], { rawPath: file.rawPath.replace(regex, file.path), - permalink: file.permalink.replace(regex, file.path), - commitsPath: file.commitsPath.replace(regex, file.path), - blamePath: file.blamePath.replace(regex, file.path), }); } }, @@ -207,8 +199,6 @@ export default { state.changedFiles = state.changedFiles.concat(entry); } } - - state.unusedSeal = false; }, [types.RENAME_ENTRY](state, { path, name, parentPath }) { const oldEntry = state.entries[path]; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 5c5920a3027..c90bc2a3320 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -99,11 +99,6 @@ export default { fileLanguage, }); }, - [types.SET_FILE_EOL](state, { file, eol }) { - Object.assign(state.entries[file.path], { - eol, - }); - }, [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) { Object.assign(state.entries[file.path], { editorRow, @@ -153,13 +148,11 @@ export default { [types.ADD_FILE_TO_CHANGED](state, path) { Object.assign(state, { changedFiles: state.changedFiles.concat(state.entries[path]), - unusedSeal: false, }); }, [types.REMOVE_FILE_FROM_CHANGED](state, path) { Object.assign(state, { changedFiles: state.changedFiles.filter(f => f.path !== path), - unusedSeal: false, }); }, [types.STAGE_CHANGE](state, { path, diffInfo }) { @@ -175,7 +168,6 @@ export default { deleted: diffInfo.deleted, }), }), - unusedSeal: false, }); if (stagedFile) { diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js index c8f14a680c2..cce43a99bd9 100644 --- a/app/assets/javascripts/ide/stores/mutations/tree.js +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -34,11 +34,6 @@ export default { Object.assign(selectedTree, { tree }); }, - [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) { - Object.assign(tree, { - lastCommitPath: url, - }); - }, [types.REMOVE_ALL_CHANGES_FILES](state) { Object.assign(state, { changedFiles: [], diff --git a/app/assets/javascripts/ide/stores/plugins/terminal.js b/app/assets/javascripts/ide/stores/plugins/terminal.js new file mode 100644 index 00000000000..66539c7bd4f --- /dev/null +++ b/app/assets/javascripts/ide/stores/plugins/terminal.js @@ -0,0 +1,25 @@ +import * as mutationTypes from '~/ide/stores/mutation_types'; +import terminalModule from '../modules/terminal'; + +function getPathsFromData(el) { + return { + webTerminalSvgPath: el.dataset.eeWebTerminalSvgPath, + webTerminalHelpPath: el.dataset.eeWebTerminalHelpPath, + webTerminalConfigHelpPath: el.dataset.eeWebTerminalConfigHelpPath, + webTerminalRunnersHelpPath: el.dataset.eeWebTerminalRunnersHelpPath, + }; +} + +export default function createTerminalPlugin(el) { + return store => { + store.registerModule('terminal', terminalModule()); + + store.dispatch('terminal/setPaths', getPathsFromData(el)); + + store.subscribe(({ type }) => { + if (type === mutationTypes.SET_BRANCH_WORKING_REFERENCE) { + store.dispatch('terminal/init'); + } + }); + }; +} diff --git a/app/assets/javascripts/ide/stores/plugins/terminal_sync.js b/app/assets/javascripts/ide/stores/plugins/terminal_sync.js new file mode 100644 index 00000000000..c60bba4293a --- /dev/null +++ b/app/assets/javascripts/ide/stores/plugins/terminal_sync.js @@ -0,0 +1,49 @@ +import { debounce } from 'lodash'; +import eventHub from '~/ide/eventhub'; +import terminalSyncModule from '../modules/terminal_sync'; +import { isEndingStatus, isRunningStatus } from '../modules/terminal/utils'; + +const UPLOAD_DEBOUNCE = 200; + +/** + * Registers and controls the terminalSync vuex module based on IDE events. + * + * - Watches the terminal session status state to control start/stop. + * - Listens for file change event to control upload. + */ +export default function createMirrorPlugin() { + return store => { + store.registerModule('terminalSync', terminalSyncModule()); + + const upload = debounce(() => { + store.dispatch(`terminalSync/upload`); + }, UPLOAD_DEBOUNCE); + + const stop = () => { + store.dispatch(`terminalSync/stop`); + eventHub.$off('ide.files.change', upload); + }; + + const start = () => { + store + .dispatch(`terminalSync/start`) + .then(() => { + eventHub.$on('ide.files.change', upload); + }) + .catch(() => { + // error is handled in store + }); + }; + + store.watch( + x => x.terminal && x.terminal.session && x.terminal.session.status, + val => { + if (isRunningStatus(val)) { + start(); + } else if (isEndingStatus(val)) { + stop(); + } + }, + ); + }; +} diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 0c95c22e8f8..c1a83bf0726 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -9,10 +9,8 @@ export default () => ({ stagedFiles: [], endpoints: {}, lastCommitMsg: '', - lastCommitPath: '', loading: false, openFiles: [], - parentTreeUrl: '', trees: {}, projects: {}, panelResizing: false, @@ -20,7 +18,6 @@ export default () => ({ viewer: viewerTypes.edit, delayViewerUpdated: false, currentActivityView: leftSidebarViews.edit.name, - unusedSeal: true, fileFindVisible: false, links: {}, errorMessage: null, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 56671142bd4..1c5fe9fe9a5 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,5 +1,10 @@ import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants'; -import { relativePathToAbsolute, isAbsolute, isRootRelative } from '~/lib/utils/url_utility'; +import { + relativePathToAbsolute, + isAbsolute, + isRootRelative, + isBase64DataUrl, +} from '~/lib/utils/url_utility'; export const dataStructure = () => ({ id: '', @@ -19,8 +24,6 @@ export const dataStructure = () => ({ active: false, changed: false, staged: false, - replaces: false, - lastCommitPath: '', lastCommitSha: '', lastCommit: { id: '', @@ -29,23 +32,14 @@ export const dataStructure = () => ({ updatedAt: '', author: '', }, - blamePath: '', - commitsPath: '', - permalink: '', rawPath: '', binary: false, - html: '', raw: '', content: '', - parentTreeUrl: '', - renderError: false, - base64: false, editorRow: 1, editorColumn: 1, fileLanguage: '', - eol: '', viewMode: FILE_VIEW_MODE_EDITOR, - previewMode: null, size: 0, parentPath: null, lastOpenedAt: 0, @@ -63,19 +57,14 @@ export const decorateData = entity => { url, name, path, - renderError, content = '', tempFile = false, active = false, opened = false, changed = false, - parentTreeUrl = '', - base64 = false, binary = false, rawPath = '', - previewMode, file_lock, - html, parentPath = '', } = entity; @@ -91,25 +80,15 @@ export const decorateData = entity => { tempFile, opened, active, - parentTreeUrl, changed, - renderError, content, - base64, binary, rawPath, - previewMode, file_lock, - html, parentPath, }); }; -export const findEntry = (tree, type, name, prop = 'name') => - tree.find(f => f.type === type && f[prop] === name); - -export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); - export const setPageTitle = title => { document.title = title; }; @@ -124,7 +103,7 @@ export const commitActionForFile = file => { return commitActionTypes.move; } else if (file.deleted) { return commitActionTypes.delete; - } else if (file.tempFile && !file.replaces) { + } else if (file.tempFile) { return commitActionTypes.create; } @@ -155,9 +134,8 @@ export const createCommitPayload = ({ file_path: f.path, previous_path: f.prevPath || undefined, content: f.prevPath && !f.changed ? null : f.content || undefined, - encoding: f.base64 ? 'base64' : 'text', - last_commit_id: - newBranch || f.deleted || f.prevPath || f.replaces ? undefined : f.lastCommitSha, + encoding: isBase64DataUrl(f.rawPath) ? 'base64' : 'text', + last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha, })), start_sha: newBranch ? rootGetters.lastCommit.id : undefined, }); @@ -272,10 +250,6 @@ export const pathsAreEqual = (a, b) => { return cleanA === cleanB; }; -// if the contents of a file dont end with a newline, this function adds a newline -export const addFinalNewlineIfNeeded = content => - content.charAt(content.length - 1) !== '\n' ? `${content}\n` : content; - export function extractMarkdownImagesFromEntries(mdFile, entries) { /** * Regex to identify an image tag in markdown, like: diff --git a/app/assets/javascripts/ide/sync_router_and_store.js b/app/assets/javascripts/ide/sync_router_and_store.js new file mode 100644 index 00000000000..1782c32b3b2 --- /dev/null +++ b/app/assets/javascripts/ide/sync_router_and_store.js @@ -0,0 +1,55 @@ +/* eslint-disable import/prefer-default-export */ +/** + * This method adds listeners to the given router and store and syncs their state with eachother + * + * ### Why? + * + * Previously the IDE had a circular dependency between a singleton router and a singleton store. + * This causes some integration testing headaches... + * + * At the time, the most effecient way to break this ciruclar dependency was to: + * + * - Replace the router with a factory function that receives a store reference + * - Have the store write to a certain state that can be watched by the router + * + * Hence... This helper function... + */ +export const syncRouterAndStore = (router, store) => { + const disposables = []; + + let currentPath = ''; + + // sync store to router + disposables.push( + store.watch( + state => state.router.fullPath, + fullPath => { + if (currentPath === fullPath) { + return; + } + + currentPath = fullPath; + + router.push(fullPath); + }, + ), + ); + + // sync router to store + disposables.push( + router.afterEach(to => { + if (currentPath === to.fullPath) { + return; + } + + currentPath = to.fullPath; + store.dispatch('router/push', currentPath, { root: true }); + }), + ); + + const unsync = () => { + disposables.forEach(fn => fn()); + }; + + return unsync; +}; diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index 1ea2b199237..c28a2bd9f1d 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -1,4 +1,4 @@ -import { commitItemIconMap } from './constants'; +import { SIDE_LEFT, SIDE_RIGHT } from './constants'; import { languages } from 'monaco-editor'; import { flatten } from 'lodash'; @@ -53,16 +53,6 @@ export function isTextFile(content, mimeType, fileName) { return asciiRegex.test(content); } -export const getCommitIconMap = file => { - if (file.deleted) { - return commitItemIconMap.deleted; - } else if (file.tempFile && !file.prevPath) { - return commitItemIconMap.addition; - } - - return commitItemIconMap.modified; -}; - export const createPathWithExt = p => { const ext = p.lastIndexOf('.') >= 0 ? p.substring(p.lastIndexOf('.') + 1) : ''; @@ -84,3 +74,52 @@ export function registerLanguages(def, ...defs) { languages.setMonarchTokensProvider(languageId, def.language); languages.setLanguageConfiguration(languageId, def.conf); } + +export const otherSide = side => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT); + +export function trimTrailingWhitespace(content) { + return content.replace(/[^\S\r\n]+$/gm, ''); +} + +export function insertFinalNewline(content, eol = '\n') { + return content.slice(-eol.length) !== eol ? `${content}${eol}` : content; +} + +export function getPathParents(path, maxDepth = Infinity) { + const pathComponents = path.split('/'); + const paths = []; + + let depth = 0; + while (pathComponents.length && depth < maxDepth) { + pathComponents.pop(); + + let parentPath = pathComponents.join('/'); + if (parentPath.startsWith('/')) parentPath = parentPath.slice(1); + if (parentPath) paths.push(parentPath); + + depth += 1; + } + + return paths; +} + +export function getPathParent(path) { + return getPathParents(path, 1)[0]; +} + +/** + * Takes a file object and returns a data uri of its contents. + * + * @param {File} file + */ +export function readFileAsDataURL(file) { + return new Promise(resolve => { + const reader = new FileReader(); + reader.addEventListener('load', e => resolve(e.target.result), { once: true }); + reader.readAsDataURL(file); + }); +} + +export function getFileEOL(content = '') { + return content.includes('\r\n') ? 'CRLF' : 'LF'; +} diff --git a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue new file mode 100644 index 00000000000..1a9974db727 --- /dev/null +++ b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue @@ -0,0 +1,74 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import ImportProjectsTable from './import_projects_table.vue'; + +export default { + components: { + ImportProjectsTable, + GlAlert, + GlSprintf, + GlLink, + }, + props: { + providerTitle: { + type: String, + required: true, + }, + }, + data() { + return { + isWarningDismissed: false, + }; + }, + computed: { + currentPage() { + return window.location.href; + }, + }, +}; +</script> +<template> + <import-projects-table provider-title="providerTitle"> + <template #actions> + <slot name="actions"></slot> + </template> + <template #incompatible-repos-warning> + <gl-alert + v-if="!isWarningDismissed" + variant="warning" + class="gl-my-2" + @dismiss="isWarningDismissed = true" + > + <gl-sprintf + :message=" + __( + 'One or more of your %{provider} projects cannot be imported into GitLab directly because they use Subversion or Mercurial for version control, rather than Git.', + ) + " + > + <template #provider> + {{ providerTitle }} + </template> + </gl-sprintf> + <gl-sprintf + :message=" + __( + 'Please convert %{linkStart}them to Git%{linkEnd}, and go through the %{linkToImportFlow} again.', + ) + " + > + <template #link="{ content }"> + <gl-link + href="https://www.atlassian.com/git/tutorials/migrating-overview" + target="_blank" + >{{ content }}</gl-link + > + </template> + <template #linkToImportFlow> + <gl-link :href="currentPage">{{ __('import flow') }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + </template> + </import-projects-table> +</template> diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue index 849bda28d03..6a467fb8c6a 100644 --- a/app/assets/javascripts/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue @@ -1,11 +1,11 @@ <script> import { throttle } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import ImportedProjectTableRow from './imported_project_table_row.vue'; import ProviderRepoTableRow from './provider_repo_table_row.vue'; +import IncompatibleRepoTableRow from './incompatible_repo_table_row.vue'; import eventHub from '../event_hub'; const reposFetchThrottleDelay = 1000; @@ -15,19 +15,42 @@ export default { components: { ImportedProjectTableRow, ProviderRepoTableRow, - LoadingButton, + IncompatibleRepoTableRow, GlLoadingIcon, + GlButton, }, props: { providerTitle: { type: String, required: true, }, + filterable: { + type: Boolean, + required: false, + default: true, + }, }, computed: { - ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos', 'filter']), - ...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']), + ...mapState([ + 'importedProjects', + 'providerRepos', + 'incompatibleRepos', + 'isLoadingRepos', + 'filter', + ]), + ...mapGetters([ + 'isImportingAnyRepo', + 'hasProviderRepos', + 'hasImportedProjects', + 'hasIncompatibleRepos', + ]), + + importAllButtonText() { + return this.hasIncompatibleRepos + ? __('Import all compatible repositories') + : __('Import all repositories'); + }, emptyStateText() { return sprintf(__('No %{providerTitle} repositories found'), { @@ -68,7 +91,6 @@ export default { }, throttledFetchRepos: throttle(function fetch() { - eventHub.$off('importAll'); this.fetchRepos(); }, reposFetchThrottleDelay), }, @@ -80,17 +102,24 @@ export default { <p class="light text-nowrap mt-2"> {{ s__('ImportProjects|Select the projects you want to import') }} </p> - - <div class="d-flex justify-content-between align-items-end flex-wrap mb-3"> - <loading-button - container-class="btn btn-success js-import-all" + <template v-if="hasIncompatibleRepos"> + <slot name="incompatible-repos-warning"> </slot> + </template> + <div + v-if="!isLoadingRepos" + class="d-flex justify-content-between align-items-end flex-wrap mb-3" + > + <gl-button + variant="success" :loading="isImportingAnyRepo" - :label="__('Import all repositories')" :disabled="!hasProviderRepos" type="button" @click="importAll" - /> - <form novalidate @submit.prevent> + > + {{ importAllButtonText }} + </gl-button> + <slot name="actions"></slot> + <form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent> <input :value="filter" data-qa-selector="githubish_import_filter_field" @@ -109,7 +138,10 @@ export default { class="js-loading-button-icon import-projects-loading-icon" size="md" /> - <div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive"> + <div + v-else-if="hasProviderRepos || hasImportedProjects || hasIncompatibleRepos" + class="table-responsive" + > <table class="table import-table"> <thead> <th class="import-jobs-from-col">{{ fromHeaderText }}</th> @@ -124,6 +156,11 @@ export default { :project="project" /> <provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" /> + <incompatible-repo-table-row + v-for="repo in incompatibleRepos" + :key="repo.id" + :repo="repo" + /> </tbody> </table> </div> diff --git a/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue b/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue new file mode 100644 index 00000000000..fa2fb439eac --- /dev/null +++ b/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue @@ -0,0 +1,30 @@ +<script> +import { GlBadge } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + }, + props: { + repo: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <tr class="import-row"> + <td> + <a :href="repo.providerLink" rel="noreferrer noopener" target="_blank"> + {{ repo.fullName }} + </a> + </td> + <td></td> + <td></td> + <td> + <gl-badge variant="danger">{{ __('Incompatible project') }}</gl-badge> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue index 6e227ab3d82..63524d61146 100644 --- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue @@ -53,7 +53,11 @@ export default { }, created() { - eventHub.$on('importAll', () => this.importRepo()); + eventHub.$on('importAll', this.importRepo); + }, + + beforeDestroy() { + eventHub.$off('importAll', this.importRepo); }, methods: { diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js index b069dcb7766..68ba04aa9dd 100644 --- a/app/assets/javascripts/import_projects/index.js +++ b/app/assets/javascripts/import_projects/index.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import { mapActions } from 'vuex'; import Translate from '../vue_shared/translate'; import ImportProjectsTable from './components/import_projects_table.vue'; import { parseBoolean } from '../lib/utils/common_utils'; @@ -7,42 +6,45 @@ import createStore from './store'; Vue.use(Translate); -export default function mountImportProjectsTable(mountElement) { - if (!mountElement) return undefined; - +export function initStoreFromElement(element) { const { reposPath, provider, - providerTitle, canSelectNamespace, jobsPath, importPath, ciCdOnly, - } = mountElement.dataset; + } = element.dataset; - const store = createStore(); - return new Vue({ - el: mountElement, - store, + return createStore({ + reposPath, + provider, + jobsPath, + importPath, + defaultTargetNamespace: gon.current_username, + ciCdOnly: parseBoolean(ciCdOnly), + canSelectNamespace: parseBoolean(canSelectNamespace), + }); +} - created() { - this.setInitialData({ - reposPath, - provider, - jobsPath, - importPath, - defaultTargetNamespace: gon.current_username, - ciCdOnly: parseBoolean(ciCdOnly), - canSelectNamespace: parseBoolean(canSelectNamespace), - }); - }, +export function initPropsFromElement(element) { + return { + providerTitle: element.dataset.providerTitle, + filterable: parseBoolean(element.dataset.filterable), + }; +} - methods: { - ...mapActions(['setInitialData', 'setFilter']), - }, +export default function mountImportProjectsTable(mountElement) { + if (!mountElement) return undefined; + + const store = initStoreFromElement(mountElement); + const props = initPropsFromElement(mountElement); + return new Vue({ + el: mountElement, + store, render(createElement) { - return createElement(ImportProjectsTable, { props: { providerTitle } }); + return createElement(ImportProjectsTable, { props }); }, }); } diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js index 0fb9a4cdfd4..2422a1ed2e4 100644 --- a/app/assets/javascripts/import_projects/store/actions.js +++ b/app/assets/javascripts/import_projects/store/actions.js @@ -2,6 +2,7 @@ import Visibility from 'visibilityjs'; import * as types from './mutation_types'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; +import { visitUrl } from '~/lib/utils/url_utility'; import createFlash from '~/flash'; import { s__, sprintf } from '~/locale'; import axios from '~/lib/utils/axios_utils'; @@ -9,6 +10,9 @@ import { jobsPathWithFilter, reposPathWithFilter } from './getters'; let eTagPoll; +const hasRedirectInError = e => e?.response?.data?.error?.redirect; +const redirectToUrlInError = e => visitUrl(e.response.data.error.redirect); + export const clearJobsEtagPoll = () => { eTagPoll = null; }; @@ -19,45 +23,39 @@ export const restartJobsPolling = () => { if (eTagPoll) eTagPoll.restart(); }; -export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); export const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter); -export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos); -export const receiveReposSuccess = ({ commit }, repos) => - commit(types.RECEIVE_REPOS_SUCCESS, repos); -export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR); -export const fetchRepos = ({ state, dispatch }) => { +export const fetchRepos = ({ state, dispatch, commit }) => { dispatch('stopJobsPolling'); - dispatch('requestRepos'); + commit(types.REQUEST_REPOS); const { provider } = state; return axios .get(reposPathWithFilter(state)) .then(({ data }) => - dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })), + commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), ) .then(() => dispatch('fetchJobs')) - .catch(() => { - createFlash( - sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), { - provider, - }), - ); - - dispatch('receiveReposError'); + .catch(e => { + if (hasRedirectInError(e)) { + redirectToUrlInError(e); + } else { + createFlash( + sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), { + provider, + }), + ); + + commit(types.RECEIVE_REPOS_ERROR); + } }); }; -export const requestImport = ({ commit, state }, repoId) => { - if (!state.reposBeingImported.includes(repoId)) commit(types.REQUEST_IMPORT, repoId); -}; -export const receiveImportSuccess = ({ commit }, { importedProject, repoId }) => - commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject, repoId }); -export const receiveImportError = ({ commit }, repoId) => - commit(types.RECEIVE_IMPORT_ERROR, repoId); -export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, repo }) => { - dispatch('requestImport', repo.id); +export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo }) => { + if (!state.reposBeingImported.includes(repo.id)) { + commit(types.REQUEST_IMPORT, repo.id); + } return axios .post(state.importPath, { @@ -67,7 +65,7 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep target_namespace: targetNamespace, }) .then(({ data }) => - dispatch('receiveImportSuccess', { + commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject: convertObjectPropsToCamelCase(data, { deep: true }), repoId: repo.id, }), @@ -75,13 +73,14 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep .catch(() => { createFlash(s__('ImportProjects|Importing the project failed')); - dispatch('receiveImportError', { repoId: repo.id }); + commit(types.RECEIVE_IMPORT_ERROR, repo.id); }); }; export const receiveJobsSuccess = ({ commit }, updatedProjects) => commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects); -export const fetchJobs = ({ state, dispatch }) => { + +export const fetchJobs = ({ state, commit, dispatch }) => { const { filter } = state; if (eTagPoll) { @@ -95,9 +94,14 @@ export const fetchJobs = ({ state, dispatch }) => { }, method: 'fetchJobs', successCallback: ({ data }) => - dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })), - errorCallback: () => - createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')), + commit(types.RECEIVE_JOBS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), + errorCallback: e => { + if (hasRedirectInError(e)) { + redirectToUrlInError(e); + } else { + createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')); + } + }, data: { filter }, }); diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js index b107c293181..e6eb8f523de 100644 --- a/app/assets/javascripts/import_projects/store/getters.js +++ b/app/assets/javascripts/import_projects/store/getters.js @@ -21,6 +21,8 @@ export const hasProviderRepos = state => state.providerRepos.length > 0; export const hasImportedProjects = state => state.importedProjects.length > 0; +export const hasIncompatibleRepos = state => state.incompatibleRepos.length > 0; + export const reposPathWithFilter = ({ reposPath, filter = '' }) => filter ? `${reposPath}?filter=${filter}` : reposPath; export const jobsPathWithFilter = ({ jobsPath, filter = '' }) => diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js index ff1fd1e598e..29deb7868ba 100644 --- a/app/assets/javascripts/import_projects/store/index.js +++ b/app/assets/javascripts/import_projects/store/index.js @@ -9,9 +9,9 @@ Vue.use(Vuex); export { state, actions, getters, mutations }; -export default () => +export default initialState => new Vuex.Store({ - state: state(), + state: { ...state(), ...initialState }, actions, mutations, getters, diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_projects/store/mutation_types.js index 16574f4450f..a23b7eef986 100644 --- a/app/assets/javascripts/import_projects/store/mutation_types.js +++ b/app/assets/javascripts/import_projects/store/mutation_types.js @@ -1,5 +1,3 @@ -export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; - export const REQUEST_REPOS = 'REQUEST_REPOS'; export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS'; export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR'; diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js index 6c56cfa8298..ec62d0640ef 100644 --- a/app/assets/javascripts/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_projects/store/mutations.js @@ -2,10 +2,6 @@ import Vue from 'vue'; import * as types from './mutation_types'; export default { - [types.SET_INITIAL_DATA](state, data) { - Object.assign(state, data); - }, - [types.SET_FILTER](state, filter) { state.filter = filter; }, @@ -14,11 +10,15 @@ export default { state.isLoadingRepos = true; }, - [types.RECEIVE_REPOS_SUCCESS](state, { importedProjects, providerRepos, namespaces }) { + [types.RECEIVE_REPOS_SUCCESS]( + state, + { importedProjects, providerRepos, incompatibleRepos, namespaces }, + ) { state.isLoadingRepos = false; state.importedProjects = importedProjects; state.providerRepos = providerRepos; + state.incompatibleRepos = incompatibleRepos ?? []; state.namespaces = namespaces; }, diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js index 829f3aa4fbb..0418d735b1d 100644 --- a/app/assets/javascripts/import_projects/store/state.js +++ b/app/assets/javascripts/import_projects/store/state.js @@ -7,6 +7,7 @@ export default () => ({ currentUsername: '', importedProjects: [], providerRepos: [], + incompatibleRepos: [], namespaces: [], reposBeingImported: [], isLoadingRepos: false, diff --git a/app/assets/javascripts/integrations/edit/components/active_toggle.vue b/app/assets/javascripts/integrations/edit/components/active_toggle.vue index 8b95b04d93c..dc89e139320 100644 --- a/app/assets/javascripts/integrations/edit/components/active_toggle.vue +++ b/app/assets/javascripts/integrations/edit/components/active_toggle.vue @@ -1,12 +1,15 @@ <script> import eventHub from '../event_hub'; -import { GlToggle } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { GlFormGroup, GlToggle } from '@gitlab/ui'; export default { name: 'ActiveToggle', components: { + GlFormGroup, GlToggle, }, + mixins: [glFeatureFlagsMixin()], props: { initialActivated: { type: Boolean, @@ -33,7 +36,17 @@ export default { </script> <template> - <div> + <div v-if="glFeatures.integrationFormRefactor"> + <gl-form-group :label="__('Enable integration')" label-for="service[active]"> + <gl-toggle + v-model="activated" + name="service[active]" + class="gl-display-block gl-line-height-0" + @change="onToggle" + /> + </gl-form-group> + </div> + <div v-else> <div class="form-group row" role="group"> <label for="service[active]" class="col-form-label col-sm-2">{{ __('Active') }}</label> <div class="col-sm-10 pt-1"> diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue new file mode 100644 index 00000000000..29318d6aaa8 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -0,0 +1,172 @@ +<script> +import eventHub from '../event_hub'; +import { capitalize, lowerCase, isEmpty } from 'lodash'; +import { __, sprintf } from '~/locale'; +import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui'; + +export default { + name: 'DynamicField', + components: { + GlFormGroup, + GlFormCheckbox, + GlFormInput, + GlFormSelect, + GlFormTextarea, + }, + props: { + choices: { + type: Array, + required: false, + default: null, + }, + help: { + type: String, + required: false, + default: null, + }, + name: { + type: String, + required: true, + }, + placeholder: { + type: String, + required: false, + default: null, + }, + required: { + type: Boolean, + required: false, + }, + title: { + type: String, + required: false, + default: null, + }, + type: { + type: String, + required: true, + }, + value: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + model: this.value, + validated: false, + }; + }, + computed: { + isCheckbox() { + return this.type === 'checkbox'; + }, + isPassword() { + return this.type === 'password'; + }, + isSelect() { + return this.type === 'select'; + }, + isTextarea() { + return this.type === 'textarea'; + }, + isNonEmptyPassword() { + return this.isPassword && !isEmpty(this.value); + }, + label() { + if (this.isNonEmptyPassword) { + return sprintf(__('Enter new %{field_title}'), { + field_title: this.humanizedTitle, + }); + } + return this.humanizedTitle; + }, + humanizedTitle() { + return this.title || capitalize(lowerCase(this.name)); + }, + passwordRequired() { + return isEmpty(this.value) && this.required; + }, + options() { + return this.choices.map(choice => { + return { + value: choice[1], + text: choice[0], + }; + }); + }, + fieldId() { + return `service_${this.name}`; + }, + fieldName() { + return `service[${this.name}]`; + }, + sharedProps() { + return { + id: this.fieldId, + name: this.fieldName, + }; + }, + valid() { + return !this.required || !isEmpty(this.model) || !this.validated; + }, + }, + created() { + if (this.isNonEmptyPassword) { + this.model = null; + } + eventHub.$on('validateForm', this.validateForm); + }, + beforeDestroy() { + eventHub.$off('validateForm', this.validateForm); + }, + methods: { + validateForm() { + this.validated = true; + }, + }, +}; +</script> + +<template> + <gl-form-group + :label="label" + :label-for="fieldId" + :invalid-feedback="__('This field is required.')" + :state="valid" + :description="help" + > + <template v-if="isCheckbox"> + <input :name="fieldName" type="hidden" value="false" /> + <gl-form-checkbox v-model="model" v-bind="sharedProps"> + {{ humanizedTitle }} + </gl-form-checkbox> + </template> + <gl-form-select v-else-if="isSelect" v-model="model" v-bind="sharedProps" :options="options" /> + <gl-form-textarea + v-else-if="isTextarea" + v-model="model" + v-bind="sharedProps" + :placeholder="placeholder" + :required="required" + /> + <gl-form-input + v-else-if="isPassword" + v-model="model" + v-bind="sharedProps" + :type="type" + autocomplete="new-password" + :placeholder="placeholder" + :required="passwordRequired" + /> + <gl-form-input + v-else + v-model="model" + v-bind="sharedProps" + :type="type" + :placeholder="placeholder" + :required="required" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index fbe58c30b13..ef7a4d44b20 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -2,6 +2,7 @@ import ActiveToggle from './active_toggle.vue'; import JiraTriggerFields from './jira_trigger_fields.vue'; import TriggerFields from './trigger_fields.vue'; +import DynamicField from './dynamic_field.vue'; export default { name: 'IntegrationForm', @@ -9,6 +10,7 @@ export default { ActiveToggle, JiraTriggerFields, TriggerFields, + DynamicField, }, props: { activeToggleProps: { @@ -28,6 +30,11 @@ export default { required: false, default: () => [], }, + fields: { + type: Array, + required: false, + default: () => [], + }, type: { type: String, required: true, @@ -46,5 +53,6 @@ export default { <active-toggle v-if="showActive" v-bind="activeToggleProps" /> <jira-trigger-fields v-if="isJira" v-bind="triggerFieldsProps" /> <trigger-fields v-else-if="triggerEvents.length" :events="triggerEvents" :type="type" /> + <dynamic-field v-for="field in fields" :key="field.name" v-bind="field" /> </div> </template> diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index 70278e401ce..64e5789764f 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -1,12 +1,31 @@ <script> -import { GlFormCheckbox, GlFormRadio } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { s__ } from '~/locale'; +import { GlFormGroup, GlFormCheckbox, GlFormRadio } from '@gitlab/ui'; + +const commentDetailOptions = [ + { + value: 'standard', + label: s__('Integrations|Standard'), + help: s__('Integrations|Includes commit title and branch'), + }, + { + value: 'all_details', + label: s__('Integrations|All details'), + help: s__( + 'Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs', + ), + }, +]; export default { name: 'JiraTriggerFields', components: { + GlFormGroup, GlFormCheckbox, GlFormRadio, }, + mixins: [glFeatureFlagsMixin()], props: { initialTriggerCommit: { type: Boolean, @@ -32,13 +51,71 @@ export default { triggerMergeRequest: this.initialTriggerMergeRequest, enableComments: this.initialEnableComments, commentDetail: this.initialCommentDetail, + commentDetailOptions, }; }, + computed: { + showEnableComments() { + return this.triggerCommit || this.triggerMergeRequest; + }, + }, }; </script> <template> - <div class="form-group row pt-2" role="group"> + <div v-if="glFeatures.integrationFormRefactor"> + <gl-form-group + :label="__('Trigger')" + label-for="service[trigger]" + :description=" + s__( + 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) will be created.', + ) + " + > + <input name="service[commit_events]" type="hidden" value="false" /> + <gl-form-checkbox v-model="triggerCommit" name="service[commit_events]"> + {{ __('Commit') }} + </gl-form-checkbox> + + <input name="service[merge_requests_events]" type="hidden" value="false" /> + <gl-form-checkbox v-model="triggerMergeRequest" name="service[merge_requests_events]"> + {{ __('Merge request') }} + </gl-form-checkbox> + </gl-form-group> + + <gl-form-group + v-show="showEnableComments" + :label="s__('Integrations|Comment settings:')" + data-testid="comment-settings" + > + <input name="service[comment_on_event_enabled]" type="hidden" value="false" /> + <gl-form-checkbox v-model="enableComments" name="service[comment_on_event_enabled]"> + {{ s__('Integrations|Enable comments') }} + </gl-form-checkbox> + </gl-form-group> + + <gl-form-group + v-show="showEnableComments && enableComments" + :label="s__('Integrations|Comment detail:')" + data-testid="comment-detail" + > + <gl-form-radio + v-for="commentDetailOption in commentDetailOptions" + :key="commentDetailOption.value" + v-model="commentDetail" + :value="commentDetailOption.value" + name="service[comment_detail]" + > + {{ commentDetailOption.label }} + <template #help> + {{ commentDetailOption.help }} + </template> + </gl-form-radio> + </gl-form-group> + </div> + + <div v-else class="form-group row pt-2" role="group"> <label for="service[trigger]" class="col-form-label col-sm-2 pt-0">{{ __('Trigger') }}</label> <div class="col-sm-10"> <label class="weight-normal mb-2"> @@ -76,20 +153,16 @@ export default { <label> {{ s__('Integrations|Comment detail:') }} </label> - <gl-form-radio v-model="commentDetail" value="standard" name="service[comment_detail]"> - {{ s__('Integrations|Standard') }} - <template #help> - {{ s__('Integrations|Includes commit title and branch') }} - </template> - </gl-form-radio> - <gl-form-radio v-model="commentDetail" value="all_details" name="service[comment_detail]"> - {{ s__('Integrations|All details') }} + <gl-form-radio + v-for="commentDetailOption in commentDetailOptions" + :key="commentDetailOption.value" + v-model="commentDetail" + :value="commentDetailOption.value" + name="service[comment_detail]" + > + {{ commentDetailOption.label }} <template #help> - {{ - s__( - 'Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs', - ) - }} + {{ commentDetailOption.help }} </template> </gl-form-radio> </div> diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 2ae1342a558..21b5ca17951 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -15,7 +15,7 @@ export default el => { return result; } - const { type, commentDetail, triggerEvents, ...booleanAttributes } = el.dataset; + const { type, commentDetail, triggerEvents, fields, ...booleanAttributes } = el.dataset; const { showActive, activated, @@ -41,6 +41,7 @@ export default el => { initialCommentDetail: commentDetail, }, triggerEvents: JSON.parse(triggerEvents), + fields: JSON.parse(fields), }, }); }, diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index 3067f4090b1..8844cbebe85 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -45,10 +45,15 @@ export default class IntegrationSettingsForm { // 2) If this service can be tested // If both conditions are true, we override form submission // and test the service using provided configuration. - if (this.$form.get(0).checkValidity() && this.canTestService) { + if (this.$form.get(0).checkValidity()) { + if (this.canTestService) { + e.preventDefault(); + // eslint-disable-next-line no-jquery/no-serialize + this.testSettings(this.$form.serialize()); + } + } else { e.preventDefault(); - // eslint-disable-next-line no-jquery/no-serialize - this.testSettings(this.$form.serialize()); + eventHub.$emit('validateForm'); } } diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index 95e10cc75cc..01ea3eee16e 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -99,8 +99,11 @@ export default { setOriginalDropdownData() { const $labelSelect = $('.bulk-update .js-label-select'); + const dirtyLabelIds = $labelSelect.data('marked') || []; + const chosenLabelIds = [...this.getOriginalMarkedIds(), ...dirtyLabelIds]; + $labelSelect.data('common', this.getOriginalCommonIds()); - $labelSelect.data('marked', this.getOriginalMarkedIds()); + $labelSelect.data('marked', chosenLabelIds); $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds()); }, diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue index 76e4fac5107..51904c64085 100644 --- a/app/assets/javascripts/issuable_suggestions/components/item.vue +++ b/app/assets/javascripts/issuable_suggestions/components/item.vue @@ -128,7 +128,7 @@ export default { :key="id" v-gl-tooltip.bottom :title="tooltipTitle" - class="suggestion-help-hover prepend-left-8 text-tertiary" + class="suggestion-help-hover gl-ml-3 text-tertiary" > <icon :name="icon" /> {{ count }} </span> diff --git a/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue index 27a04da9541..49a89d15c35 100644 --- a/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue +++ b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue @@ -1,7 +1,11 @@ <script> import { GlAlert, GlLabel } from '@gitlab/ui'; import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql'; -import { calculateJiraImportLabel, isFinished, isInProgress } from '~/jira_import/utils'; +import { + calculateJiraImportLabel, + isFinished, + isInProgress, +} from '~/jira_import/utils/jira_import_utils'; export default { name: 'IssuableListRoot', diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue index 640827fe564..1c395fd9795 100644 --- a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue @@ -245,7 +245,7 @@ export default { <template> <ul v-if="loading" class="content-list"> - <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue"> + <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue gl-px-5! gl-py-5!"> <gl-skeleton-loading /> </li> </ul> diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 4b53225e100..252e8e92f5e 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -6,6 +6,7 @@ import { addDelimiter } from './lib/utils/text_utility'; import flash from './flash'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; import IssuablesHelper from './helpers/issuables_helper'; +import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from './locale'; export default class Issue { @@ -14,6 +15,16 @@ export default class Issue { if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener(); + if ($('.js-alert-moved-from-service-desk-warning').length) { + const trimmedPathname = window.location.pathname.slice(1); + this.alertMovedFromServiceDeskDismissedKey = joinPaths( + trimmedPathname, + 'alert-issue-moved-from-service-desk-dismissed', + ); + + this.initIssueMovedFromServiceDeskDismissHandler(); + } + Issue.$btnNewBranch = $('#new-branch'); Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); @@ -169,6 +180,21 @@ export default class Issue { }); } + initIssueMovedFromServiceDeskDismissHandler() { + const alertMovedFromServiceDeskWarning = $('.js-alert-moved-from-service-desk-warning'); + + if (!localStorage.getItem(this.alertMovedFromServiceDeskDismissedKey)) { + alertMovedFromServiceDeskWarning.show(); + } + + alertMovedFromServiceDeskWarning.on('click', '.js-close', e => { + e.preventDefault(); + e.stopImmediatePropagation(); + alertMovedFromServiceDeskWarning.remove(); + localStorage.setItem(this.alertMovedFromServiceDeskDismissedKey, true); + }); + } + static submitNoteForm(form) { const noteText = form.find('textarea.js-note-text').val(); if (noteText && noteText.trim().length > 0) { diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 8cf2cda64a4..09acfd1cfae 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -1,9 +1,10 @@ <script> +import { GlIcon, GlIntersectionObserver } from '@gitlab/ui'; import Visibility from 'visibilityjs'; import { __, s__, sprintf } from '~/locale'; import createFlash from '~/flash'; -import { visitUrl } from '../../lib/utils/url_utility'; -import Poll from '../../lib/utils/poll'; +import { visitUrl } from '~/lib/utils/url_utility'; +import Poll from '~/lib/utils/poll'; import eventHub from '../event_hub'; import Service from '../services/index'; import Store from '../stores'; @@ -12,10 +13,13 @@ import descriptionComponent from './description.vue'; import editedComponent from './edited.vue'; import formComponent from './form.vue'; import PinnedLinks from './pinned_links.vue'; -import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; +import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor'; +import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants'; export default { components: { + GlIcon, + GlIntersectionObserver, descriptionComponent, titleComponent, editedComponent, @@ -58,12 +62,22 @@ export default { zoomMeetingUrl: { type: String, required: false, - default: null, + default: '', + }, + publishedIncidentUrl: { + type: String, + required: false, + default: '', }, issuableRef: { type: String, required: true, }, + issuableStatus: { + type: String, + required: false, + default: '', + }, initialTitleHtml: { type: String, required: true, @@ -157,6 +171,7 @@ export default { state: store.state, showForm: false, templatesRequested: false, + isStickyHeaderShowing: false, }; }, computed: { @@ -191,6 +206,18 @@ export default { defaultErrorMessage() { return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType }); }, + isOpenStatus() { + return this.issuableStatus === IssuableStatus.Open; + }, + statusIcon() { + return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close'; + }, + statusText() { + return IssuableStatusText[this.issuableStatus]; + }, + shouldShowStickyHeader() { + return this.isStickyHeaderShowing && this.issuableType === IssuableType.Issue; + }, }, created() { this.service = new Service(this.endpoint); @@ -344,6 +371,14 @@ export default { ); }); }, + + hideStickyHeader() { + this.isStickyHeaderShowing = false; + }, + + showStickyHeader() { + this.isStickyHeaderShowing = true; + }, }, }; </script> @@ -380,7 +415,40 @@ export default { :title-text="state.titleText" :show-inline-edit-button="showInlineEditButton" /> - <pinned-links :zoom-meeting-url="zoomMeetingUrl" /> + + <gl-intersection-observer @appear="hideStickyHeader" @disappear="showStickyHeader"> + <transition name="issuable-header-slide"> + <div + v-if="shouldShowStickyHeader" + class="issue-sticky-header gl-fixed gl-z-index-2 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-200 gl-py-3" + data-testid="issue-sticky-header" + > + <div + class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5" + > + <p + class="issuable-status-box status-box gl-my-0" + :class="[isOpenStatus ? 'status-box-open' : 'status-box-issue-closed']" + > + <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" /> + <span class="gl-display-none d-sm-block">{{ statusText }}</span> + </p> + <p + class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" + :title="state.titleText" + > + {{ state.titleText }} + </p> + </div> + </div> + </transition> + </gl-intersection-observer> + + <pinned-links + :zoom-meeting-url="zoomMeetingUrl" + :published-incident-url="publishedIncidentUrl" + /> + <description-component v-if="state.descriptionHtml" :can-update="canUpdate" @@ -393,6 +461,7 @@ export default { :lock-version="state.lock_version" @taskListUpdateFailed="updateStoreState" /> + <edited-component v-if="hasUpdated" :updated-at="state.updatedAt" diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issue_show/components/pinned_links.vue index 965e8a3d751..4b50acceb62 100644 --- a/app/assets/javascripts/issue_show/components/pinned_links.vue +++ b/app/assets/javascripts/issue_show/components/pinned_links.vue @@ -11,21 +11,40 @@ export default { zoomMeetingUrl: { type: String, required: false, - default: null, + default: '', + }, + publishedIncidentUrl: { + type: String, + required: false, + default: '', }, }, }; </script> <template> - <div v-if="zoomMeetingUrl" class="border-bottom mb-3 mt-n2"> - <gl-link - :href="zoomMeetingUrl" - target="_blank" - class="btn btn-inverted btn-secondary btn-sm text-dark mb-3" - > - <icon name="brand-zoom" :size="14" /> - <strong class="vertical-align-top">{{ __('Join Zoom meeting') }}</strong> - </gl-link> + <div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start"> + <div v-if="publishedIncidentUrl" class="gl-pr-3"> + <gl-link + :href="publishedIncidentUrl" + target="_blank" + class="btn btn-inverted btn-secondary btn-sm text-dark mb-3" + data-testid="publishedIncidentUrl" + > + <icon name="tanuki" :size="14" /> + <strong class="vertical-align-top">{{ __('Published on status page') }}</strong> + </gl-link> + </div> + <div v-if="zoomMeetingUrl"> + <gl-link + :href="zoomMeetingUrl" + target="_blank" + class="btn btn-inverted btn-secondary btn-sm text-dark mb-3" + data-testid="zoomMeetingUrl" + > + <icon name="brand-zoom" :size="14" /> + <strong class="vertical-align-top">{{ __('Join Zoom meeting') }}</strong> + </gl-link> + </div> </div> </template> diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js new file mode 100644 index 00000000000..d73cc8cf007 --- /dev/null +++ b/app/assets/javascripts/issue_show/constants.js @@ -0,0 +1,17 @@ +import { __ } from '~/locale'; + +export const IssuableStatus = { + Open: 'opened', + Closed: 'closed', +}; + +export const IssuableStatusText = { + [IssuableStatus.Open]: __('Open'), + [IssuableStatus.Closed]: __('Closed'), +}; + +export const IssuableType = { + Issue: 'issue', + Epic: 'epic', + MergeRequest: 'merge_request', +}; diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue index d1570f52c8c..ef0fc4716dd 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_app.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue @@ -4,7 +4,8 @@ import { last } from 'lodash'; import { __ } from '~/locale'; import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql'; import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql'; -import { IMPORT_STATE, isInProgress } from '../utils'; +import { addInProgressImportToStore } from '../utils/cache_update'; +import { isInProgress, extractJiraProjectsOptions } from '../utils/jira_import_utils'; import JiraImportForm from './jira_import_form.vue'; import JiraImportProgress from './jira_import_progress.vue'; import JiraImportSetup from './jira_import_setup.vue'; @@ -20,14 +21,14 @@ export default { JiraImportSetup, }, props: { - isJiraConfigured: { - type: Boolean, - required: true, - }, inProgressIllustration: { type: String, required: true, }, + isJiraConfigured: { + type: Boolean, + required: true, + }, issuesPath: { type: String, required: true, @@ -36,10 +37,6 @@ export default { type: String, required: true, }, - jiraProjects: { - type: Array, - required: true, - }, projectPath: { type: String, required: true, @@ -51,6 +48,7 @@ export default { }, data() { return { + jiraImportDetails: {}, errorMessage: '', showAlert: false, selectedProject: undefined, @@ -65,8 +63,10 @@ export default { }; }, update: ({ project }) => ({ - status: project.jiraImportStatus, imports: project.jiraImports.nodes, + isInProgress: isInProgress(project.jiraImportStatus), + mostRecentImport: last(project.jiraImports.nodes), + projects: extractJiraProjectsOptions(project.services.nodes[0].projects.nodes), }), skip() { return !this.isJiraConfigured; @@ -74,35 +74,22 @@ export default { }, }, computed: { - isImportInProgress() { - return isInProgress(this.jiraImportDetails?.status); - }, - jiraProjectsOptions() { - return this.jiraProjects.map(([text, value]) => ({ text, value })); - }, - mostRecentImport() { - // The backend returns JiraImports ordered by created_at asc in app/models/project.rb - return last(this.jiraImportDetails?.imports); - }, - numberOfPreviousImportsForProject() { - return this.jiraImportDetails?.imports?.reduce?.( + numberOfPreviousImports() { + return this.jiraImportDetails.imports?.reduce?.( (acc, jiraProject) => (jiraProject.jiraProjectKey === this.selectedProject ? acc + 1 : acc), 0, ); }, + hasPreviousImports() { + return this.numberOfPreviousImports > 0; + }, importLabel() { return this.selectedProject - ? `jira-import::${this.selectedProject}-${this.numberOfPreviousImportsForProject + 1}` + ? `jira-import::${this.selectedProject}-${this.numberOfPreviousImports + 1}` : 'jira-import::KEY-1'; }, - hasPreviousImports() { - return this.numberOfPreviousImportsForProject > 0; - }, }, methods: { - dismissAlert() { - this.showAlert = false; - }, initiateJiraImport(project) { this.$apollo .mutate({ @@ -113,39 +100,8 @@ export default { jiraProjectKey: project, }, }, - update: (store, { data }) => { - if (data.jiraImportStart.errors.length) { - return; - } - - const cacheData = store.readQuery({ - query: getJiraImportDetailsQuery, - variables: { - fullPath: this.projectPath, - }, - }); - - store.writeQuery({ - query: getJiraImportDetailsQuery, - variables: { - fullPath: this.projectPath, - }, - data: { - project: { - jiraImportStatus: IMPORT_STATE.SCHEDULED, - jiraImports: { - nodes: [ - ...cacheData.project.jiraImports.nodes, - data.jiraImportStart.jiraImport, - ], - __typename: 'JiraImportConnection', - }, - // eslint-disable-next-line @gitlab/require-i18n-strings - __typename: 'Project', - }, - }, - }); - }, + update: (store, { data }) => + addInProgressImportToStore(store, data.jiraImportStart, this.projectPath), }) .then(({ data }) => { if (data.jiraImportStart.errors.length) { @@ -160,7 +116,13 @@ export default { this.errorMessage = message; this.showAlert = true; }, + dismissAlert() { + this.showAlert = false; + }, }, + previousImportsMessage: __( + 'You have imported from this project %{numberOfPreviousImports} times before. Each new import will create duplicate issues.', + ), }; </script> @@ -170,16 +132,8 @@ export default { {{ errorMessage }} </gl-alert> <gl-alert v-if="hasPreviousImports" variant="warning" :dismissible="false"> - <gl-sprintf - :message=" - __( - 'You have imported from this project %{numberOfPreviousImportsForProject} times before. Each new import will create duplicate issues.', - ) - " - > - <template #numberOfPreviousImportsForProject>{{ - numberOfPreviousImportsForProject - }}</template> + <gl-sprintf :message="$options.previousImportsMessage"> + <template #numberOfPreviousImports>{{ numberOfPreviousImports }}</template> </gl-sprintf> </gl-alert> @@ -190,11 +144,11 @@ export default { /> <gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" /> <jira-import-progress - v-else-if="isImportInProgress" + v-else-if="jiraImportDetails.isInProgress" :illustration="inProgressIllustration" - :import-initiator="mostRecentImport.scheduledBy.name" - :import-project="mostRecentImport.jiraProjectKey" - :import-time="mostRecentImport.scheduledAt" + :import-initiator="jiraImportDetails.mostRecentImport.scheduledBy.name" + :import-project="jiraImportDetails.mostRecentImport.jiraProjectKey" + :import-time="jiraImportDetails.mostRecentImport.scheduledAt" :issues-path="issuesPath" /> <jira-import-form @@ -202,7 +156,7 @@ export default { v-model="selectedProject" :import-label="importLabel" :issues-path="issuesPath" - :jira-projects="jiraProjectsOptions" + :jira-projects="jiraImportDetails.projects" @initiateJiraImport="initiateJiraImport" /> </div> diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index b576668fe7c..924cc7e6864 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -28,7 +28,6 @@ export default function mountJiraImportApp() { isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured), issuesPath: el.dataset.issuesPath, jiraIntegrationPath: el.dataset.jiraIntegrationPath, - jiraProjects: el.dataset.jiraProjects ? JSON.parse(el.dataset.jiraProjects) : [], projectPath: el.dataset.projectPath, setupIllustration: el.dataset.setupIllustration, }, diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql index aa8d03c7f17..2aacc5cf668 100644 --- a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql +++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql @@ -8,5 +8,17 @@ query($fullPath: ID!) { ...JiraImport } } + services(active: true, type: JIRA_SERVICE) { + nodes { + ... on JiraService { + projects { + nodes { + key + name + } + } + } + } + } } } diff --git a/app/assets/javascripts/jira_import/utils/cache_update.js b/app/assets/javascripts/jira_import/utils/cache_update.js new file mode 100644 index 00000000000..6aaf2010866 --- /dev/null +++ b/app/assets/javascripts/jira_import/utils/cache_update.js @@ -0,0 +1,37 @@ +import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql'; +import { IMPORT_STATE } from './jira_import_utils'; + +export const addInProgressImportToStore = (store, jiraImportStart, fullPath) => { + if (jiraImportStart.errors.length) { + return; + } + + const queryDetails = { + query: getJiraImportDetailsQuery, + variables: { + fullPath, + }, + }; + + const cacheData = store.readQuery({ + ...queryDetails, + }); + + store.writeQuery({ + ...queryDetails, + data: { + project: { + ...cacheData.project, + jiraImportStatus: IMPORT_STATE.SCHEDULED, + jiraImports: { + ...cacheData.project.jiraImports, + nodes: cacheData.project.jiraImports.nodes.concat(jiraImportStart.jiraImport), + }, + }, + }, + }); +}; + +export default { + addInProgressImportToStore, +}; diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils/jira_import_utils.js index aa10dfc8099..e82a3f44a29 100644 --- a/app/assets/javascripts/jira_import/utils.js +++ b/app/assets/javascripts/jira_import/utils/jira_import_utils.js @@ -14,6 +14,17 @@ export const isInProgress = state => export const isFinished = state => state === IMPORT_STATE.FINISHED; /** + * Converts the list of Jira projects into a format consumable by GlFormSelect. + * + * @param {Object[]} projects - List of Jira projects + * @param {string} projects[].key - Jira project key + * @param {string} projects[].name - Jira project name + * @returns {Object[]} - List of Jira projects in a format consumable by GlFormSelect + */ +export const extractJiraProjectsOptions = projects => + projects.map(({ key, name }) => ({ text: `${name} (${key})`, value: key })); + +/** * Calculates the label title for the most recent Jira import. * * @param {Object[]} jiraImports - List of Jira imports diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/artifacts_block.vue index 0bce860df91..b2f9bf2a348 100644 --- a/app/assets/javascripts/jobs/components/artifacts_block.vue +++ b/app/assets/javascripts/jobs/components/artifacts_block.vue @@ -17,11 +17,14 @@ export default { }, computed: { isExpired() { - return this.artifact.expired; + return this.artifact?.expired && !this.isLocked; + }, + isLocked() { + return this.artifact?.locked; }, // Only when the key is `false` we can render this block willExpire() { - return this.artifact.expired === false; + return this.artifact?.expired === false && !this.isLocked; }, }, }; @@ -29,42 +32,45 @@ export default { <template> <div class="block"> <div class="title font-weight-bold">{{ s__('Job|Job artifacts') }}</div> - <p v-if="isExpired || willExpire" - :class="{ - 'js-artifacts-removed': isExpired, - 'js-artifacts-will-be-removed': willExpire, - }" class="build-detail-row" + data-testid="artifacts-remove-timeline" > <span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span> <span v-if="willExpire">{{ s__('Job|The artifacts will be removed') }}</span> <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" /> </p> - + <p v-else-if="isLocked" class="build-detail-row"> + <span data-testid="job-locked-message">{{ + s__( + 'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.', + ) + }}</span> + </p> <div class="btn-group d-flex prepend-top-10" role="group"> <gl-link v-if="artifact.keep_path" :href="artifact.keep_path" - class="js-keep-artifacts btn btn-sm btn-default" + class="btn btn-sm btn-default" data-method="post" + data-testid="keep-artifacts" >{{ s__('Job|Keep') }}</gl-link > - <gl-link v-if="artifact.download_path" :href="artifact.download_path" - class="js-download-artifacts btn btn-sm btn-default" + class="btn btn-sm btn-default" download rel="nofollow" + data-testid="download-artifacts" >{{ s__('Job|Download') }}</gl-link > - <gl-link v-if="artifact.browse_path" :href="artifact.browse_path" - class="js-browse-artifacts btn btn-sm btn-default" + class="btn btn-sm btn-default" + data-testid="browse-artifacts" >{{ s__('Job|Browse') }}</gl-link > </div> diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index 88649ddbdb7..72a5ff01672 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -53,6 +53,6 @@ export default { </span> </p> - <p class="append-bottom-0">{{ commit.title }}</p> + <p class="gl-mb-0">{{ commit.title }}</p> </div> </template> diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index 28cc03c88cb..c34a3488dbd 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -277,7 +277,7 @@ export default { <div class="prepend-top-default append-bottom-default js-environment-container"> <div class="environment-information"> <ci-icon :status="iconStatus" /> - <p class="inline append-bottom-0" v-html="environment"></p> + <p class="inline gl-mb-0" v-html="environment"></p> </div> </div> </template> diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index ddcfc3d6db6..116331d9549 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -81,7 +81,7 @@ export default { <button type="button" data-toggle="dropdown" - class="js-selected-stage dropdown-menu-toggle prepend-top-8" + class="js-selected-stage dropdown-menu-toggle gl-mt-3" > {{ selectedStage }} <i class="fa fa-chevron-down"></i> </button> diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue index ec52d272168..da01269a50c 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/stuck_block.vue @@ -26,31 +26,31 @@ export default { </script> <template> <div class="bs-callout bs-callout-warning"> - <p v-if="tags.length" class="js-stuck-with-tags append-bottom-0"> + <p v-if="tags.length" class="js-stuck-with-tags gl-mb-0"> {{ s__(`This job is stuck because you don't have - any active runners online with any of these tags assigned to them:`) + any active runners online or available with any of these tags assigned to them:`) }} <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary append-right-4"> {{ tag }} </span> </p> - <p v-else-if="hasNoRunnersForProject" class="js-stuck-no-runners append-bottom-0"> + <p v-else-if="hasNoRunnersForProject" class="js-stuck-no-runners gl-mb-0"> {{ s__(`Job|This job is stuck because the project doesn't have any runners online assigned to it.`) }} </p> - <p v-else class="js-stuck-no-active-runner append-bottom-0"> + <p v-else class="js-stuck-no-active-runner gl-mb-0"> {{ s__(`This job is stuck because you don't have any active runners that can run this job.`) }} </p> - {{ __('Go to') }} + {{ __('Go to project') }} <gl-link v-if="runnersPath" :href="runnersPath" class="js-runners-path"> - {{ __('Runners page') }} + {{ __('CI settings') }} </gl-link> </div> </template> diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index 7c9b2824a43..1a076249fe7 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -46,7 +46,7 @@ export default { <p v-if="trigger.short_token" class="js-short-token" - :class="{ 'append-bottom-5': hasVariables, 'append-bottom-0': !hasVariables }" + :class="{ 'append-bottom-5': hasVariables, 'gl-mb-0': !hasVariables }" > <span class="font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }} </p> diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue index 25a8da84873..633561c879e 100644 --- a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue +++ b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue @@ -17,7 +17,7 @@ export default { </script> <template> <div class="bs-callout bs-callout-danger"> - <p class="js-failed-unmet-prerequisites append-bottom-0"> + <p class="js-failed-unmet-prerequisites gl-mb-0"> {{ s__(`Job|This job failed because the necessary resources were not successfully created.`) }} diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index a464290ffb5..75542267f37 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -15,7 +15,7 @@ export default class LazyLoader { } static supportsIntersectionObserver() { - return 'IntersectionObserver' in window; + return Boolean(window.IntersectionObserver); } searchLazyImages() { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 4a48852159a..a60748215ab 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -31,7 +31,7 @@ export const getProjectSlug = () => { }; export const getGroupSlug = () => { - if (isInGroupsPage()) { + if (isInProjectPage() || isInGroupsPage()) { return $('body').data('group'); } return null; @@ -244,22 +244,28 @@ export const contentTop = () => { ); }; -export const scrollToElement = element => { +export const scrollToElement = (element, options = {}) => { let $el = element; if (!(element instanceof $)) { $el = $(element); } const { top } = $el.offset(); + const { offset = 0 } = options; // eslint-disable-next-line no-jquery/no-animate return $('body, html').animate( { - scrollTop: top - contentTop(), + scrollTop: top - contentTop() + offset, }, 200, ); }; +export const scrollToElementWithContext = element => { + const offsetMultiplier = -0.1; + return scrollToElement(element, { offset: window.innerHeight * offsetMultiplier }); +}; + /** * Returns a function that can only be invoked once between * each browser screen repaint. @@ -718,6 +724,8 @@ export const convertObjectProps = (conversionFunction, obj = {}, options = {}) = } else { acc[conversionFunction(prop)] = convertObjectProps(conversionFunction, val, options); } + } else if (isObjParameterArray) { + acc[prop] = val; } else { acc[conversionFunction(prop)] = val; } diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 9a61003ef30..eb6c9bf7eb6 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,2 +1,10 @@ export const BYTES_IN_KIB = 1024; export const HIDDEN_CLASS = 'hidden'; + +export const DATETIME_RANGE_TYPES = { + fixed: 'fixed', + anchored: 'anchored', + rolling: 'rolling', + open: 'open', + invalid: 'invalid', +}; diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js index 9275b9e74e1..8efbcb89607 100644 --- a/app/assets/javascripts/lib/utils/datetime_range.js +++ b/app/assets/javascripts/lib/utils/datetime_range.js @@ -1,5 +1,6 @@ import dateformat from 'dateformat'; import { pick, omit, isEqual, isEmpty } from 'lodash'; +import { DATETIME_RANGE_TYPES } from './constants'; import { secondsToMilliseconds } from './datetime_utility'; const MINIMUM_DATE = new Date(0); @@ -153,18 +154,22 @@ export function getRangeType(range) { const { start, end, anchor, duration } = range; if ((start || end) && !anchor && !duration) { - return isValidDateString(start) && isValidDateString(end) ? 'fixed' : 'invalid'; + return isValidDateString(start) && isValidDateString(end) + ? DATETIME_RANGE_TYPES.fixed + : DATETIME_RANGE_TYPES.invalid; } if (anchor && duration) { - return isValidDateString(anchor) && isValidDuration(duration) ? 'anchored' : 'invalid'; + return isValidDateString(anchor) && isValidDuration(duration) + ? DATETIME_RANGE_TYPES.anchored + : DATETIME_RANGE_TYPES.invalid; } if (duration && !anchor) { - return isValidDuration(duration) ? 'rolling' : 'invalid'; + return isValidDuration(duration) ? DATETIME_RANGE_TYPES.rolling : DATETIME_RANGE_TYPES.invalid; } if (anchor && !duration) { - return isValidDateString(anchor) ? 'open' : 'invalid'; + return isValidDateString(anchor) ? DATETIME_RANGE_TYPES.open : DATETIME_RANGE_TYPES.invalid; } - return 'invalid'; + return DATETIME_RANGE_TYPES.invalid; } /** diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 7933c234384..8fa235f8afb 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -12,3 +12,16 @@ export const canScrollUp = ({ scrollTop }, margin = 0) => scrollTop > margin; export const canScrollDown = ({ scrollTop, offsetHeight, scrollHeight }, margin = 0) => scrollTop + offsetHeight < scrollHeight - margin; + +export const toggleContainerClasses = (containerEl, classList) => { + if (containerEl) { + // eslint-disable-next-line array-callback-return + Object.entries(classList).map(([key, value]) => { + if (value) { + containerEl.classList.add(key); + } else { + containerEl.classList.remove(key); + } + }); + } +}; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 86714471823..be3fe1ed620 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -90,6 +90,13 @@ export const truncatePathMiddleToLength = (text, maxWidth) => { while (returnText.length >= maxWidth) { const textSplit = returnText.split('/').filter(s => s !== ELLIPSIS_CHAR); + + if (textSplit.length === 0) { + // There are n - 1 path separators for n segments, so 2n - 1 <= maxWidth + const maxSegments = Math.floor((maxWidth + 1) / 2); + return new Array(maxSegments).fill(ELLIPSIS_CHAR).join('/'); + } + const middleIndex = Math.floor(textSplit.length / 2); returnText = textSplit @@ -168,7 +175,7 @@ export const convertToCamelCase = string => * @param {*} string */ export const convertToSnakeCase = string => - slugifyWithUnderscore(string.match(/([a-zA-Z][^A-Z]*)/g).join(' ')); + slugifyWithUnderscore((string.match(/([a-zA-Z][^A-Z]*)/g) || [string]).join(' ')); /** * Converts a sentence to lower case from the second word onwards diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 966e6d42b80..0472b8cf51f 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -109,9 +109,10 @@ export function mergeUrlParams(params, url) { * * @param {string[]} params - the query param names to remove * @param {string} [url=windowLocation().href] - url from which the query param will be removed + * @param {boolean} skipEncoding - set to true when the url does not require encoding * @returns {string} A copy of the original url but without the query param */ -export function removeParams(params, url = window.location.href) { +export function removeParams(params, url = window.location.href, skipEncoding = false) { const [rootAndQuery, fragment] = url.split('#'); const [root, query] = rootAndQuery.split('?'); @@ -119,12 +120,13 @@ export function removeParams(params, url = window.location.href) { return url; } - const encodedParams = params.map(param => encodeURIComponent(param)); + const removableParams = skipEncoding ? params : params.map(param => encodeURIComponent(param)); + const updatedQuery = query .split('&') .filter(paramPair => { const [foundParam] = paramPair.split('='); - return encodedParams.indexOf(foundParam) < 0; + return removableParams.indexOf(foundParam) < 0; }) .join('&'); @@ -242,6 +244,15 @@ export function isRootRelative(url) { } /** + * Returns true if url is a base64 data URL + * + * @param {String} url + */ +export function isBase64DataUrl(url) { + return /^data:[.\w+-]+\/[.\w+-]+;base64,/.test(url); +} + +/** * Returns true if url is an absolute or root-relative URL * * @param {String} url diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index 838652f7210..01a4cbd41f6 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -91,7 +91,7 @@ export default { 'setInitData', 'showEnvironment', 'fetchEnvironments', - 'fetchLogs', + 'refreshPodLogs', 'fetchMoreLogsPrepend', 'dismissRequestEnvironmentsError', 'dismissInvalidTimeRangeWarning', @@ -204,7 +204,7 @@ export default { ref="scrollButtons" class="flex-grow-0 pr-2 mb-2 controllers" :scroll-down-button-disabled="scrollDownButtonDisabled" - @refresh="fetchLogs()" + @refresh="refreshPodLogs()" @scrollDown="scrollDown" /> </div> diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js index 51770aa7a1c..f83d369c6b8 100644 --- a/app/assets/javascripts/logs/constants.js +++ b/app/assets/javascripts/logs/constants.js @@ -1,3 +1,11 @@ export const dateFormatMask = 'mmm dd HH:MM:ss.l'; export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME'; + +export const tracking = { + USED_SEARCH_BAR: 'used_search_bar', + POD_LOG_CHANGED: 'pod_log_changed', + TIME_RANGE_SET: 'time_range_set', + ENVIRONMENT_SELECTED: 'environment_selected', + REFRESH_POD_LOGS: 'refresh_pod_logs', +}; diff --git a/app/assets/javascripts/logs/logs_tracking_helper.js b/app/assets/javascripts/logs/logs_tracking_helper.js new file mode 100644 index 00000000000..91b0392f71f --- /dev/null +++ b/app/assets/javascripts/logs/logs_tracking_helper.js @@ -0,0 +1,18 @@ +import Tracking from '~/tracking'; + +/** + * The value of 1 in count, means there was one action performed + * related to the tracked action, in either of the following categories + * 1. Refreshing the logs + * 2. Select an environment + * 3. Change the time range + * 4. Use the search bar + */ +const trackLogs = label => + Tracking.event(document.body.dataset.page, 'logs_view', { + label, + property: 'count', + value: 1, + }); + +export default trackLogs; diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js index a86d3c775a9..d828e8f8a3e 100644 --- a/app/assets/javascripts/logs/stores/actions.js +++ b/app/assets/javascripts/logs/stores/actions.js @@ -2,7 +2,8 @@ import { backOff } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { TOKEN_TYPE_POD_NAME } from '../constants'; +import { TOKEN_TYPE_POD_NAME, tracking } from '../constants'; +import trackLogs from '../logs_tracking_helper'; import * as types from './mutation_types'; @@ -81,22 +82,27 @@ export const showFilteredLogs = ({ dispatch, commit }, filters = []) => { commit(types.SET_CURRENT_POD_NAME, podName); commit(types.SET_SEARCH, search); - dispatch('fetchLogs'); + dispatch('fetchLogs', tracking.USED_SEARCH_BAR); }; export const showPodLogs = ({ dispatch, commit }, podName) => { commit(types.SET_CURRENT_POD_NAME, podName); - dispatch('fetchLogs'); + dispatch('fetchLogs', tracking.POD_LOG_CHANGED); }; export const setTimeRange = ({ dispatch, commit }, timeRange) => { commit(types.SET_TIME_RANGE, timeRange); - dispatch('fetchLogs'); + dispatch('fetchLogs', tracking.TIME_RANGE_SET); }; export const showEnvironment = ({ dispatch, commit }, environmentName) => { commit(types.SET_PROJECT_ENVIRONMENT, environmentName); - dispatch('fetchLogs'); + dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED); +}; + +export const refreshPodLogs = ({ dispatch, commit }) => { + commit(types.REFRESH_POD_LOGS); + dispatch('fetchLogs', tracking.REFRESH_POD_LOGS); }; /** @@ -111,19 +117,22 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => { .get(environmentsPath) .then(({ data }) => { commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments); - dispatch('fetchLogs'); + dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED); }) .catch(() => { commit(types.RECEIVE_ENVIRONMENTS_DATA_ERROR); }); }; -export const fetchLogs = ({ commit, state }) => { +export const fetchLogs = ({ commit, state }, trackingLabel) => { commit(types.REQUEST_LOGS_DATA); return requestLogsUntilData({ commit, state }) .then(({ data }) => { const { pod_name, pods, logs, cursor } = data; + if (logs && logs.length > 0) { + trackLogs(trackingLabel); + } commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor }); commit(types.SET_CURRENT_POD_NAME, pod_name); commit(types.RECEIVE_PODS_DATA_SUCCESS, pods); diff --git a/app/assets/javascripts/logs/stores/mutation_types.js b/app/assets/javascripts/logs/stores/mutation_types.js index c1cc7eca52e..9010ec51817 100644 --- a/app/assets/javascripts/logs/stores/mutation_types.js +++ b/app/assets/javascripts/logs/stores/mutation_types.js @@ -19,6 +19,7 @@ export const REQUEST_LOGS_DATA_PREPEND = 'REQUEST_LOGS_DATA_PREPEND'; export const RECEIVE_LOGS_DATA_PREPEND_SUCCESS = 'RECEIVE_LOGS_DATA_PREPEND_SUCCESS'; export const RECEIVE_LOGS_DATA_PREPEND_ERROR = 'RECEIVE_LOGS_DATA_PREPEND_ERROR'; export const HIDE_REQUEST_LOGS_ERROR = 'HIDE_REQUEST_LOGS_ERROR'; +export const REFRESH_POD_LOGS = 'REFRESH_POD_LOGS'; export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS'; export const RECEIVE_PODS_DATA_ERROR = 'RECEIVE_PODS_DATA_ERROR'; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 713f57a2b27..5f5fd790f67 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -28,15 +28,15 @@ import initLayoutNav from './layout_nav'; import './feature_highlight/feature_highlight_options'; import LazyLoader from './lazy_loader'; import initLogoAnimation from './logo'; -import './frequent_items'; +import initFrequentItemDropdowns from './frequent_items'; import initBreadcrumbs from './breadcrumb'; import initUsagePingConsent from './usage_ping_consent'; import initPerformanceBar from './performance_bar'; -import initSearchAutocomplete from './search_autocomplete'; +import initGlobalSearchInput from './global_search_input'; import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; -import PersistentUserCallout from './persistent_user_callout'; +import initPersistentUserCallouts from './persistent_user_callouts'; import { initUserTracking } from './tracking'; import { __ } from './locale'; @@ -107,14 +107,10 @@ function deferredInitialisation() { initUsagePingConsent(); initUserPopovers(); initBroadcastNotifications(); + initFrequentItemDropdowns(); + initPersistentUserCallouts(); - const recoverySettingsCallout = document.querySelector('.js-recovery-settings-callout'); - PersistentUserCallout.factory(recoverySettingsCallout); - - const usersOverLicenseCallout = document.querySelector('.js-users-over-license-callout'); - PersistentUserCallout.factory(usersOverLicenseCallout); - - if (document.querySelector('.search')) initSearchAutocomplete(); + if (document.querySelector('.search')) initGlobalSearchInput(); addSelectOnFocusBehaviour('.js-select-on-focus'); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 1795a0dbdf8..6c63ab7cf95 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -126,6 +126,13 @@ export default class MergeRequestTabs { bindEvents() { $('.merge-request-tabs a[data-toggle="tabvue"]').on('click', this.clickTab); + window.addEventListener('popstate', event => { + if (event.state && event.state.action) { + this.tabShown(event.state.action, event.target.location); + this.currentAction = event.state.action; + this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction()); + } + }); } // Used in tests @@ -155,6 +162,12 @@ export default class MergeRequestTabs { } else if (action) { const href = e.currentTarget.getAttribute('href'); this.tabShown(action, href); + + if (this.setUrl) { + this.setCurrentAction(action); + } + + this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction()); } } } @@ -213,11 +226,6 @@ export default class MergeRequestTabs { this.resetViewContainer(); this.destroyPipelinesView(); } - if (this.setUrl) { - this.setCurrentAction(action); - } - - this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction()); } else if (action === this.currentAction) { // ContentTop is used to handle anything at the top of the page before the main content const mainContentContainer = document.querySelector('.content-wrapper'); @@ -287,19 +295,25 @@ export default class MergeRequestTabs { // Ensure parameters and hash come along for the ride newState += location.search + location.hash; - // TODO: Consider refactoring in light of turbolinks removal. - - // Replace the current history state with the new one without breaking - // Turbolinks' history. - // - // See https://github.com/rails/turbolinks/issues/363 - window.history.replaceState( - { - url: newState, - }, - document.title, - newState, - ); + if (window.history.state && window.history.state.url && window.location.pathname !== newState) { + window.history.pushState( + { + url: newState, + action: this.currentAction, + }, + document.title, + newState, + ); + } else { + window.history.replaceState( + { + url: window.location.href, + action, + }, + document.title, + window.location.href, + ); + } return newState; } diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index e14212254a8..caa45184bfc 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -130,10 +130,13 @@ export default class MilestoneSelect { fieldName: $dropdown.data('fieldName'), text: milestone => escape(milestone.title), id: milestone => { - if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) { - return milestone.name; + if (milestone !== undefined) { + if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) { + return milestone.name; + } + + return milestone.id; } - return milestone.id; }, hidden: () => { $selectBox.hide(); diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue index 86a793c854e..5562981fe1c 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget.vue @@ -234,11 +234,7 @@ export default { class="alert-current-setting cursor-pointer d-flex" @click="showModal" > - <gl-badge - :variant="isFiring ? 'danger' : 'secondary'" - pill - class="d-flex-center text-truncate" - > + <gl-badge :variant="isFiring ? 'danger' : 'neutral'" class="d-flex-center text-truncate"> <gl-icon name="warning" :size="16" class="flex-shrink-0" /> <span class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me"> <gl-sprintf diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue index 74324daa1e3..b2d7ca0c4e0 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue @@ -238,7 +238,7 @@ export default { <icon v-gl-tooltip="$options.alertQueryText.descriptionTooltip" name="question" - class="prepend-left-4" + class="gl-ml-2" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue index 7a2e3e1b511..d7d01def45e 100644 --- a/app/assets/javascripts/monitoring/components/charts/column.vue +++ b/app/assets/javascripts/monitoring/components/charts/column.vue @@ -5,7 +5,8 @@ import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { chartHeight } from '../../constants'; import { makeDataSeries } from '~/helpers/monitor_helper'; import { graphDataValidatorForValues } from '../../utils'; -import { getYAxisOptions, getChartGrid } from './options'; +import { getTimeAxisOptions, getYAxisOptions, getChartGrid } from './options'; +import { timezones } from '../../format_date'; export default { components: { @@ -20,6 +21,11 @@ export default { required: true, validator: graphDataValidatorForValues.bind(null, false), }, + timezone: { + type: String, + required: false, + default: timezones.LOCAL, + }, }, data() { return { @@ -43,6 +49,8 @@ export default { }; }, chartOptions() { + const xAxis = getTimeAxisOptions({ timezone: this.timezone }); + const yAxis = { ...getYAxisOptions(this.graphData.yAxis), scale: false, @@ -50,8 +58,9 @@ export default { return { grid: getChartGrid(), + xAxis, yAxis, - dataZoom: this.dataZoomConfig, + dataZoom: [this.dataZoomConfig], }; }, xAxisTitle() { diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue index e015ef32d8c..ad176637538 100644 --- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue +++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue @@ -23,10 +23,10 @@ export default { <template> <div class="d-flex flex-column justify-content-center"> <div - class="prepend-top-8 svg-w-100 d-flex align-items-center" + class="gl-mt-3 svg-w-100 d-flex align-items-center" :style="svgContainerStyle" v-html="chartEmptyStateIllustration" ></div> - <h5 class="text-center prepend-top-8">{{ __('No data to display') }}</h5> + <h5 class="text-center gl-mt-3">{{ __('No data to display') }}</h5> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue index 55a25ee09fd..f6f266dacf3 100644 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -1,8 +1,8 @@ <script> import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlHeatmap } from '@gitlab/ui/dist/charts'; -import dateformat from 'dateformat'; import { graphDataValidatorForValues } from '../../utils'; +import { formatDate, timezones, formats } from '../../format_date'; export default { components: { @@ -17,6 +17,11 @@ export default { required: true, validator: graphDataValidatorForValues.bind(null, false), }, + timezone: { + type: String, + required: false, + default: timezones.LOCAL, + }, }, data() { return { @@ -43,7 +48,7 @@ export default { return this.result.values.map(val => { const [yLabel] = val; - return dateformat(new Date(yLabel), 'HH:MM:ss'); + return formatDate(new Date(yLabel), { format: formats.shortTime, timezone: this.timezone }); }); }, result() { diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js index 09b03774580..f7822e69b1d 100644 --- a/app/assets/javascripts/monitoring/components/charts/options.js +++ b/app/assets/javascripts/monitoring/components/charts/options.js @@ -1,5 +1,6 @@ import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { formatDate, timezones, formats } from '../../format_date'; const yAxisBoundaryGap = [0.1, 0.1]; /** @@ -21,6 +22,21 @@ const chartGridLeft = 75; // 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 */ @@ -58,6 +74,17 @@ export const getYAxisOptions = ({ }; }; +export const getTimeAxisOptions = ({ timezone = timezones.LOCAL } = {}) => ({ + name: __('Time'), + type: axisTypes.time, + axisLabel: { + formatter: date => formatDate(date, { format: formats.shortTime, timezone }), + }, + axisPointer: { + snap: false, + }, +}); + // Chart grid /** diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue index 66ba20c125f..ac31d107e63 100644 --- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue +++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue @@ -2,8 +2,11 @@ import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlStackedColumnChart } from '@gitlab/ui/dist/charts'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; -import { chartHeight } from '../../constants'; +import { chartHeight, legendLayoutTypes } from '../../constants'; +import { s__ } from '~/locale'; import { graphDataValidatorForValues } from '../../utils'; +import { getTimeAxisOptions, axisTypes } from './options'; +import { timezones } from '../../format_date'; export default { components: { @@ -18,6 +21,36 @@ export default { 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 { @@ -28,7 +61,14 @@ export default { }, computed: { chartData() { - return this.graphData.metrics.map(metric => metric.result[0].values.map(val => val[1])); + return this.graphData.metrics.map(({ 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 result[0].values.map(val => val[1]); + }); }, xAxisTitle() { return this.graphData.x_label !== undefined ? this.graphData.x_label : ''; @@ -37,10 +77,17 @@ export default { return this.graphData.y_label !== undefined ? this.graphData.y_label : ''; }, xAxisType() { - return this.graphData.x_type !== undefined ? this.graphData.x_type : 'category'; + // stacked-column component requires the x-axis to be of type `category` + return axisTypes.category; }, groupBy() { - return this.graphData.metrics[0].result[0].values.map(val => val[0]); + // 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']; @@ -49,11 +96,15 @@ export default { }, chartOptions() { return { - dataZoom: this.dataZoomConfig, + xAxis: { + ...getTimeAxisOptions({ timezone: this.timezone }), + type: this.xAxisType, + }, + dataZoom: [this.dataZoomConfig], }; }, seriesNames() { - return this.graphData.metrics.map(metric => metric.series_name); + return this.graphData.metrics.map(metric => metric.label); }, }, created() { @@ -94,6 +145,11 @@ export default { :width="width" :height="height" :series-names="seriesNames" + :legend-layout="legendLayout" + :legend-average-text="legendAverageText" + :legend-current-text="legendCurrentText" + :legend-max-text="legendMaxText" + :legend-min-text="legendMinText" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 8f37a12af75..28af2d8ba77 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -2,18 +2,19 @@ import { omit, throttle } from 'lodash'; import { GlLink, GlDeprecatedButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; -import dateFormat from 'dateformat'; -import { s__, __ } from '~/locale'; +import { s__ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; -import { panelTypes, chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants'; -import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options'; +import { panelTypes, chartHeight, lineTypes, lineWidths, legendLayoutTypes } from '../../constants'; +import { getYAxisOptions, getTimeAxisOptions, getChartGrid, getTooltipFormatter } from './options'; import { annotationsYAxis, generateAnnotationsSeries } from './annotations'; import { makeDataSeries } from '~/helpers/monitor_helper'; import { graphDataValidatorForValues } from '../../utils'; +import { formatDate, timezones } from '../../format_date'; + +export const timestampToISODate = timestamp => new Date(timestamp).toISOString(); const THROTTLED_DATAZOOM_WAIT = 1000; // milliseconds -const timestampToISODate = timestamp => new Date(timestamp).toISOString(); const events = { datazoom: 'datazoom', @@ -74,21 +75,41 @@ export default { required: false, default: () => [], }, + 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 { @@ -154,23 +175,16 @@ export default { const { yAxis, xAxis } = this.option; const option = omit(this.option, ['series', 'yAxis', 'xAxis']); + const timeXAxis = { + ...getTimeAxisOptions({ timezone: this.timezone }), + ...xAxis, + }; + const dataYAxis = { ...getYAxisOptions(this.graphData.yAxis), ...yAxis, }; - const timeXAxis = { - name: __('Time'), - type: 'time', - axisLabel: { - formatter: date => dateFormat(date, dateFormats.timeOfDay), - }, - axisPointer: { - snap: true, - }, - ...xAxis, - }; - return { series: this.chartOptionSeries, xAxis: timeXAxis, @@ -271,12 +285,13 @@ export default { */ formatAnnotationsTooltipText(params) { return { - title: dateFormat(params.data?.tooltipData?.title, dateFormats.default), + title: formatDate(params.data?.tooltipData?.title, { timezone: this.timezone }), content: params.data?.tooltipData?.content, }; }, formatTooltipText(params) { - this.tooltip.title = dateFormat(params.value, dateFormats.default); + this.tooltip.title = formatDate(params.value, { timezone: this.timezone }); + this.tooltip.content = []; params.seriesData.forEach(dataPoint => { @@ -368,8 +383,11 @@ export default { :thresholds="thresholds" :width="width" :height="height" - :average-text="legendAverageText" - :max-text="legendMaxText" + :legend-layout="legendLayout" + :legend-average-text="legendAverageText" + :legend-current-text="legendCurrentText" + :legend-max-text="legendMaxText" + :legend-min-text="legendMinText" @created="onChartCreated" @updated="onChartUpdated" > diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 2018c706b11..f54319d283e 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,73 +1,45 @@ <script> -import { debounce } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import VueDraggable from 'vuedraggable'; -import { - GlIcon, - GlButton, - GlDeprecatedButton, - GlDropdown, - GlDropdownItem, - GlDropdownHeader, - GlDropdownDivider, - GlModal, - GlLoadingIcon, - GlSearchBoxByType, - GlModalDirective, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlIcon, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import DashboardHeader from './dashboard_header.vue'; import DashboardPanel from './dashboard_panel.vue'; import { s__ } from '~/locale'; import createFlash from '~/flash'; import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys'; -import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; -import { mergeUrlParams, redirectTo, updateHistory } from '~/lib/utils/url_utility'; +import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import Icon from '~/vue_shared/components/icon.vue'; -import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; import GroupEmptyState from './group_empty_state.vue'; -import DashboardsDropdown from './dashboards_dropdown.vue'; import VariablesSection from './variables_section.vue'; +import LinksSection from './links_section.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { - getAddMetricTrackingOptions, - timeRangeToUrl, timeRangeFromUrl, panelToUrl, expandedPanelPayloadFromUrl, convertVariablesForURL, } from '../utils'; import { metricStates } from '../constants'; -import { defaultTimeRange, timeRanges } from '~/vue_shared/constants'; +import { defaultTimeRange } from '~/vue_shared/constants'; export default { components: { VueDraggable, + DashboardHeader, DashboardPanel, Icon, GlIcon, GlButton, - GlDeprecatedButton, - GlDropdown, - GlLoadingIcon, - GlDropdownItem, - GlDropdownHeader, - GlDropdownDivider, - GlSearchBoxByType, - GlModal, - CustomMetricsFormFields, - - DateTimePicker, GraphGroup, EmptyState, GroupEmptyState, - DashboardsDropdown, - VariablesSection, + LinksSection, }, directives: { GlModal: GlModalDirective, @@ -111,27 +83,10 @@ export default { type: String, required: true, }, - projectPath: { - type: String, - required: true, - }, - logsPath: { - type: String, - required: false, - default: invalidUrl, - }, defaultBranch: { type: String, - required: true, - }, - metricsEndpoint: { - type: String, - required: true, - }, - deploymentsEndpoint: { - type: String, required: false, - default: null, + default: '', }, emptyGettingStartedSvgPath: { type: String, @@ -153,10 +108,6 @@ export default { type: String, required: true, }, - currentEnvironmentName: { - type: String, - required: true, - }, customMetricsAvailable: { type: Boolean, required: false, @@ -172,21 +123,6 @@ export default { required: false, default: invalidUrl, }, - dashboardEndpoint: { - type: String, - required: false, - default: invalidUrl, - }, - dashboardsEndpoint: { - type: String, - required: false, - default: invalidUrl, - }, - currentDashboard: { - type: String, - required: false, - default: '', - }, smallEmptyState: { type: Boolean, required: false, @@ -210,11 +146,9 @@ export default { }, data() { return { - formIsValid: null, selectedTimeRange: timeRangeFromUrl() || defaultTimeRange, - hasValidDates: true, - timeRanges, isRearrangingPanels: false, + originalDocumentTitle: document.title, }; }, computed: { @@ -222,36 +156,17 @@ export default { 'dashboard', 'emptyState', 'showEmptyState', - 'useDashboardEndpoint', - 'allDashboards', - 'environmentsLoading', 'expandedPanel', - 'promVariables', - 'isUpdatingStarredValue', - ]), - ...mapGetters('monitoringDashboard', [ - 'selectedDashboard', - 'getMetricStates', - 'filteredEnvironments', + 'variables', + 'links', + 'currentDashboard', ]), - showRearrangePanelsBtn() { - return !this.showEmptyState && this.rearrangePanelsAvailable; - }, - addingMetricsAvailable() { - return ( - this.customMetricsAvailable && - !this.showEmptyState && - // Custom metrics only avaialble 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?.system_dashboard - ); - }, - shouldShowEnvironmentsDropdownNoMatchedMsg() { - return !this.environmentsLoading && this.filteredEnvironments.length === 0; - }, + ...mapGetters('monitoringDashboard', ['selectedDashboard', 'getMetricStates']), shouldShowVariablesSection() { - return Object.keys(this.promVariables).length > 0; + return Object.keys(this.variables).length > 0; + }, + shouldShowLinksSection() { + return Object.keys(this.links).length > 0; }, }, watch: { @@ -273,24 +188,17 @@ export default { handler({ group, panel }) { const dashboardPath = this.currentDashboard || this.selectedDashboard?.path; updateHistory({ - url: panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), group, panel), + url: panelToUrl(dashboardPath, convertVariablesForURL(this.variables), group, panel), title: document.title, }); }, deep: true, }, + selectedDashboard(dashboard) { + this.prependToDocumentTitle(dashboard?.display_name); + }, }, created() { - this.setInitialState({ - metricsEndpoint: this.metricsEndpoint, - deploymentsEndpoint: this.deploymentsEndpoint, - dashboardEndpoint: this.dashboardEndpoint, - dashboardsEndpoint: this.dashboardsEndpoint, - currentDashboard: this.currentDashboard, - projectPath: this.projectPath, - logsPath: this.logsPath, - currentEnvironmentName: this.currentEnvironmentName, - }); window.addEventListener('keyup', this.onKeyup); }, destroyed() { @@ -308,14 +216,10 @@ export default { ...mapActions('monitoringDashboard', [ 'setTimeRange', 'fetchData', - 'fetchDashboardData', 'setGettingStartedEmptyState', - 'setInitialState', 'setPanelGroupMetrics', - 'filterEnvironments', 'setExpandedPanel', 'clearExpandedPanel', - 'toggleStarredValue', ]), updatePanels(key, panels) { this.setPanelGroupMetrics({ @@ -329,37 +233,9 @@ export default { key, }); }, - - onDateTimePickerInput(timeRange) { - redirectTo(timeRangeToUrl(timeRange)); - }, - onDateTimePickerInvalid() { - createFlash( - 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; - }, generatePanelUrl(groupKey, panel) { const dashboardPath = this.currentDashboard || this.selectedDashboard?.path; - return panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), groupKey, panel); - }, - hideAddMetricModal() { - this.$refs.addMetricModal.hide(); - }, - toggleRearrangingPanels() { - this.isRearrangingPanels = !this.isRearrangingPanels; - }, - setFormValidity(isValid) { - this.formIsValid = isValid; - }, - debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) { - this.filterEnvironments(searchTerm); - }, 500), - submitCustomMetricsForm() { - this.$refs.customMetricsForm.submit(); + return panelToUrl(dashboardPath, convertVariablesForURL(this.variables), groupKey, panel); }, /** * Return a single empty state for a group. @@ -387,25 +263,20 @@ export default { // Collapse group if no data is available return !this.getMetricStates(groupKey).includes(metricStates.OK); }, - getAddMetricTrackingOptions, - - selectDashboard(dashboard) { - const params = { - dashboard: dashboard.path, - }; - redirectTo(mergeUrlParams(params, window.location.href)); - }, - - refreshDashboard() { - this.fetchDashboardData(); + 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 }); @@ -419,213 +290,45 @@ export default { this.clearExpandedPanel(); } }, - }, - addMetric: { - title: s__('Metrics|Add metric'), - modalId: 'add-metric', + onSetRearrangingPanels(isRearrangingPanels) { + this.isRearrangingPanels = isRearrangingPanels; + }, + onDateTimePickerInvalid() { + createFlash( + 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; + }, }, i18n: { goBackLabel: s__('Metrics|Go back (Esc)'), - starDashboard: s__('Metrics|Star dashboard'), - unstarDashboard: s__('Metrics|Unstar dashboard'), }, }; </script> <template> <div class="prometheus-graphs" data-qa-selector="prometheus_graphs"> - <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" - > - <div class="mb-2 pr-2 d-flex d-sm-block"> - <dashboards-dropdown - id="monitor-dashboards-dropdown" - data-qa-selector="dashboards_filter_dropdown" - class="flex-grow-1" - toggle-class="dropdown-menu-toggle" - :default-branch="defaultBranch" - @selectDashboard="selectDashboard($event)" - /> - </div> - - <div class="mb-2 pr-2 d-flex d-sm-block"> - <gl-dropdown - id="monitor-environments-dropdown" - ref="monitorEnvironmentsDropdown" - class="flex-grow-1" - data-qa-selector="environments_dropdown" - toggle-class="dropdown-menu-toggle" - menu-class="monitor-environment-dropdown-menu" - :text="currentEnvironmentName" - > - <div class="d-flex flex-column overflow-hidden"> - <gl-dropdown-header class="monitor-environment-dropdown-header text-center"> - {{ __('Environment') }} - </gl-dropdown-header> - <gl-dropdown-divider /> - <gl-search-box-by-type - ref="monitorEnvironmentsDropdownSearch" - class="m-2" - @input="debouncedEnvironmentsSearch" - /> - <gl-loading-icon - v-if="environmentsLoading" - ref="monitorEnvironmentsDropdownLoading" - :inline="true" - /> - <div v-else class="flex-fill overflow-auto"> - <gl-dropdown-item - v-for="environment in filteredEnvironments" - :key="environment.id" - :active="environment.name === currentEnvironmentName" - active-class="is-active" - :href="environment.metrics_path" - >{{ 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" - data-qa-selector="range_picker_dropdown" - :value="selectedTimeRange" - :options="timeRanges" - @input="onDateTimePickerInput" - @invalid="onDateTimePickerInvalid" - /> - </div> - - <div class="mb-2 pr-2 d-flex d-sm-block"> - <gl-deprecated-button - ref="refreshDashboardBtn" - v-gl-tooltip - class="flex-grow-1" - variant="default" - :title="s__('Metrics|Refresh dashboard')" - @click="refreshDashboard" - > - <icon name="retry" /> - </gl-deprecated-button> - </div> - - <div class="flex-grow-1"></div> - - <div class="d-sm-flex"> - <div v-if="selectedDashboard" class="mb-2 mr-2 d-flex"> - <!-- - wrapper for tooltip as button can be `disabled` - https://bootstrap-vue.org/docs/components/tooltip#disabled-elements - --> - <div - v-gl-tooltip - class="flex-grow-1" - :title=" - selectedDashboard.starred - ? $options.i18n.unstarDashboard - : $options.i18n.starDashboard - " - > - <gl-deprecated-button - ref="toggleStarBtn" - class="w-100" - :disabled="isUpdatingStarredValue" - variant="default" - @click="toggleStarredValue()" - > - <gl-icon :name="selectedDashboard.starred ? 'star' : 'star-o'" /> - </gl-deprecated-button> - </div> - </div> - - <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex"> - <gl-deprecated-button - :pressed="isRearrangingPanels" - variant="default" - class="flex-grow-1 js-rearrange-button" - @click="toggleRearrangingPanels" - > - {{ __('Arrange charts') }} - </gl-deprecated-button> - </div> - <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block"> - <gl-deprecated-button - ref="addMetricBtn" - v-gl-modal="$options.addMetric.modalId" - variant="outline-success" - data-qa-selector="add_metric_button" - class="flex-grow-1" - > - {{ $options.addMetric.title }} - </gl-deprecated-button> - <gl-modal - ref="addMetricModal" - :modal-id="$options.addMetric.modalId" - :title="$options.addMetric.title" - > - <form ref="customMetricsForm" :action="customMetricsPath" method="post"> - <custom-metrics-form-fields - :validate-query-path="validateQueryPath" - form-operation="post" - @formValidation="setFormValidity" - /> - </form> - <div slot="modal-footer"> - <gl-deprecated-button @click="hideAddMetricModal"> - {{ __('Cancel') }} - </gl-deprecated-button> - <gl-deprecated-button - ref="submitCustomMetricsFormBtn" - v-track-event="getAddMetricTrackingOptions()" - :disabled="!formIsValid" - variant="success" - @click="submitCustomMetricsForm" - > - {{ __('Save changes') }} - </gl-deprecated-button> - </div> - </gl-modal> - </div> - - <div - v-if="selectedDashboard && selectedDashboard.can_edit" - class="mb-2 mr-2 d-flex d-sm-block" - > - <gl-deprecated-button - class="flex-grow-1 js-edit-link" - :href="selectedDashboard.project_blob_path" - data-qa-selector="edit_dashboard_button" - > - {{ __('Edit dashboard') }} - </gl-deprecated-button> - </div> - - <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block"> - <gl-deprecated-button - class="flex-grow-1 js-external-dashboard-link" - variant="primary" - :href="externalDashboardUrl" - target="_blank" - rel="noopener noreferrer" - > - {{ __('View full dashboard') }} <icon name="external-link" /> - </gl-deprecated-button> - </div> - </div> - </div> + :default-branch="defaultBranch" + :rearrange-panels-available="rearrangePanelsAvailable" + :custom-metrics-available="customMetricsAvailable" + :custom-metrics-path="customMetricsPath" + :validate-query-path="validateQueryPath" + :external-dashboard-url="externalDashboardUrl" + :has-metrics="hasMetrics" + :is-rearranging-panels="isRearrangingPanels" + :selected-time-range="selectedTimeRange" + @dateTimePickerInvalid="onDateTimePickerInvalid" + @setRearrangingPanels="onSetRearrangingPanels" + /> <variables-section v-if="shouldShowVariablesSection && !showEmptyState" /> + <links-section v-if="shouldShowLinksSection && !showEmptyState" /> <div v-if="!showEmptyState"> <dashboard-panel v-show="expandedPanel.panel" diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue new file mode 100644 index 00000000000..16a21ae0d3c --- /dev/null +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -0,0 +1,369 @@ +<script> +import { debounce } from 'lodash'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { + GlIcon, + GlDeprecatedButton, + GlDropdown, + GlDropdownItem, + GlDropdownHeader, + GlDropdownDivider, + GlModal, + GlLoadingIcon, + GlSearchBoxByType, + GlModalDirective, + GlTooltipDirective, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; +import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; +import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; +import invalidUrl from '~/lib/utils/invalid_url'; +import Icon from '~/vue_shared/components/icon.vue'; +import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; + +import DashboardsDropdown from './dashboards_dropdown.vue'; + +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import { getAddMetricTrackingOptions, timeRangeToUrl } from '../utils'; +import { timeRanges } from '~/vue_shared/constants'; +import { timezones } from '../format_date'; + +export default { + components: { + Icon, + GlIcon, + GlDeprecatedButton, + GlDropdown, + GlLoadingIcon, + GlDropdownItem, + GlDropdownHeader, + GlDropdownDivider, + GlSearchBoxByType, + GlModal, + CustomMetricsFormFields, + + DateTimePicker, + DashboardsDropdown, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, + TrackEvent: TrackEventDirective, + }, + 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, + }, + externalDashboardUrl: { + type: String, + required: false, + default: '', + }, + hasMetrics: { + type: Boolean, + required: false, + default: true, + }, + isRearrangingPanels: { + type: Boolean, + required: true, + }, + selectedTimeRange: { + type: Object, + required: true, + }, + }, + data() { + return { + formIsValid: null, + }; + }, + computed: { + ...mapState('monitoringDashboard', [ + 'environmentsLoading', + 'currentEnvironmentName', + 'isUpdatingStarredValue', + 'showEmptyState', + 'dashboardTimezone', + ]), + ...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']), + shouldShowEnvironmentsDropdownNoMatchedMsg() { + return !this.environmentsLoading && this.filteredEnvironments.length === 0; + }, + addingMetricsAvailable() { + return ( + this.customMetricsAvailable && + !this.showEmptyState && + // Custom metrics only avaialble 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?.system_dashboard + ); + }, + showRearrangePanelsBtn() { + return !this.showEmptyState && this.rearrangePanelsAvailable; + }, + displayUtc() { + return this.dashboardTimezone === timezones.UTC; + }, + }, + methods: { + ...mapActions('monitoringDashboard', [ + 'filterEnvironments', + 'fetchDashboardData', + 'toggleStarredValue', + ]), + selectDashboard(dashboard) { + const params = { + dashboard: dashboard.path, + }; + redirectTo(mergeUrlParams(params, window.location.href)); + }, + debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) { + this.filterEnvironments(searchTerm); + }, 500), + onDateTimePickerInput(timeRange) { + redirectTo(timeRangeToUrl(timeRange)); + }, + onDateTimePickerInvalid() { + this.$emit('dateTimePickerInvalid'); + }, + refreshDashboard() { + this.fetchDashboardData(); + }, + + toggleRearrangingPanels() { + this.$emit('setRearrangingPanels', !this.isRearrangingPanels); + }, + setFormValidity(isValid) { + this.formIsValid = isValid; + }, + hideAddMetricModal() { + this.$refs.addMetricModal.hide(); + }, + getAddMetricTrackingOptions, + submitCustomMetricsForm() { + this.$refs.customMetricsForm.submit(); + }, + }, + addMetric: { + title: s__('Metrics|Add metric'), + modalId: 'add-metric', + }, + i18n: { + starDashboard: s__('Metrics|Star dashboard'), + unstarDashboard: s__('Metrics|Unstar dashboard'), + }, + timeRanges, +}; +</script> + +<template> + <div ref="prometheusGraphsHeader"> + <div class="mb-2 pr-2 d-flex d-sm-block"> + <dashboards-dropdown + id="monitor-dashboards-dropdown" + data-qa-selector="dashboards_filter_dropdown" + class="flex-grow-1" + toggle-class="dropdown-menu-toggle" + :default-branch="defaultBranch" + @selectDashboard="selectDashboard" + /> + </div> + + <div class="mb-2 pr-2 d-flex d-sm-block"> + <gl-dropdown + id="monitor-environments-dropdown" + ref="monitorEnvironmentsDropdown" + class="flex-grow-1" + data-qa-selector="environments_dropdown" + toggle-class="dropdown-menu-toggle" + menu-class="monitor-environment-dropdown-menu" + :text="currentEnvironmentName" + > + <div class="d-flex flex-column overflow-hidden"> + <gl-dropdown-header class="monitor-environment-dropdown-header text-center"> + {{ __('Environment') }} + </gl-dropdown-header> + <gl-dropdown-divider /> + <gl-search-box-by-type + ref="monitorEnvironmentsDropdownSearch" + class="m-2" + @input="debouncedEnvironmentsSearch" + /> + <gl-loading-icon + v-if="environmentsLoading" + ref="monitorEnvironmentsDropdownLoading" + :inline="true" + /> + <div v-else class="flex-fill overflow-auto"> + <gl-dropdown-item + v-for="environment in filteredEnvironments" + :key="environment.id" + :active="environment.name === currentEnvironmentName" + active-class="is-active" + :href="environment.metrics_path" + >{{ 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" + data-qa-selector="range_picker_dropdown" + :value="selectedTimeRange" + :options="$options.timeRanges" + :utc="displayUtc" + @input="onDateTimePickerInput" + @invalid="onDateTimePickerInvalid" + /> + </div> + + <div class="mb-2 pr-2 d-flex d-sm-block"> + <gl-deprecated-button + ref="refreshDashboardBtn" + v-gl-tooltip + class="flex-grow-1" + variant="default" + :title="s__('Metrics|Refresh dashboard')" + @click="refreshDashboard" + > + <icon name="retry" /> + </gl-deprecated-button> + </div> + + <div class="flex-grow-1"></div> + + <div class="d-sm-flex"> + <div v-if="selectedDashboard" class="mb-2 mr-2 d-flex"> + <!-- + wrapper for tooltip as button can be `disabled` + https://bootstrap-vue.org/docs/components/tooltip#disabled-elements + --> + <div + v-gl-tooltip + class="flex-grow-1" + :title=" + selectedDashboard.starred ? $options.i18n.unstarDashboard : $options.i18n.starDashboard + " + > + <gl-deprecated-button + ref="toggleStarBtn" + class="w-100" + :disabled="isUpdatingStarredValue" + variant="default" + @click="toggleStarredValue()" + > + <gl-icon :name="selectedDashboard.starred ? 'star' : 'star-o'" /> + </gl-deprecated-button> + </div> + </div> + + <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex"> + <gl-deprecated-button + :pressed="isRearrangingPanels" + variant="default" + class="flex-grow-1 js-rearrange-button" + @click="toggleRearrangingPanels" + > + {{ __('Arrange charts') }} + </gl-deprecated-button> + </div> + <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + ref="addMetricBtn" + v-gl-modal="$options.addMetric.modalId" + variant="outline-success" + data-qa-selector="add_metric_button" + class="flex-grow-1" + > + {{ $options.addMetric.title }} + </gl-deprecated-button> + <gl-modal + ref="addMetricModal" + :modal-id="$options.addMetric.modalId" + :title="$options.addMetric.title" + > + <form ref="customMetricsForm" :action="customMetricsPath" method="post"> + <custom-metrics-form-fields + :validate-query-path="validateQueryPath" + form-operation="post" + @formValidation="setFormValidity" + /> + </form> + <div slot="modal-footer"> + <gl-deprecated-button @click="hideAddMetricModal"> + {{ __('Cancel') }} + </gl-deprecated-button> + <gl-deprecated-button + ref="submitCustomMetricsFormBtn" + v-track-event="getAddMetricTrackingOptions()" + :disabled="!formIsValid" + variant="success" + @click="submitCustomMetricsForm" + > + {{ __('Save changes') }} + </gl-deprecated-button> + </div> + </gl-modal> + </div> + + <div + v-if="selectedDashboard && selectedDashboard.can_edit" + class="mb-2 mr-2 d-flex d-sm-block" + > + <gl-deprecated-button + class="flex-grow-1 js-edit-link" + :href="selectedDashboard.project_blob_path" + data-qa-selector="edit_dashboard_button" + > + {{ __('Edit dashboard') }} + </gl-deprecated-button> + </div> + + <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + class="flex-grow-1 js-external-dashboard-link" + variant="primary" + :href="externalDashboardUrl" + target="_blank" + rel="noopener noreferrer" + > + {{ __('View full dashboard') }} <icon name="external-link" /> + </gl-deprecated-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 48825fda5c8..9545a211bbd 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -6,8 +6,9 @@ import { GlResizeObserverDirective, GlIcon, GlLoadingIcon, - GlDropdown, - GlDropdownItem, + GlNewDropdown as GlDropdown, + GlNewDropdownItem as GlDropdownItem, + GlNewDropdownDivider as GlDropdownDivider, GlModal, GlModalDirective, GlTooltip, @@ -28,6 +29,7 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import AlertWidget from './alert_widget.vue'; import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; +import { isSafeURL } from '~/lib/utils/url_utility'; const events = { timeRangeZoom: 'timerangezoom', @@ -43,6 +45,7 @@ export default { GlTooltip, GlDropdown, GlDropdownItem, + GlDropdownDivider, GlModal, }, directives: { @@ -115,9 +118,15 @@ export default { 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`]; + }, }), title() { return this.graphData?.title || ''; @@ -266,6 +275,9 @@ export default { this.$delete(this.allAlerts, alertPath); } }, + safeUrl(url) { + return isSafeURL(url) ? url : '#'; + }, }, panelTypes, }; @@ -276,7 +288,8 @@ export default { <slot name="topLeft"></slot> <h5 ref="graphTitle" - class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate append-right-8" + class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate gl-mr-3" + tabindex="0" > {{ title }} </h5> @@ -304,14 +317,13 @@ export default { <div class="d-flex align-items-center"> <gl-dropdown v-gl-tooltip - toggle-class="btn btn-transparent border-0" + toggle-class="shadow-none border-0" data-qa-selector="prometheus_widgets_dropdown" right - no-caret :title="__('More actions')" > <template slot="button-content"> - <gl-icon name="ellipsis_v" class="text-secondary" /> + <gl-icon name="ellipsis_v" class="dropdown-icon text-secondary" /> </template> <gl-dropdown-item v-if="expandBtnAvailable" @@ -362,6 +374,23 @@ export default { > {{ __('Alerts') }} </gl-dropdown-item> + + <template v-if="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> @@ -372,6 +401,7 @@ export default { :is="basicChartComponent" v-else-if="basicChartComponent" :graph-data="graphData" + :timezone="dashboardTimezone" v-bind="$attrs" v-on="$listeners" /> @@ -385,6 +415,7 @@ export default { :project-path="projectPath" :thresholds="getGraphAlertValues(graphData.metrics)" :group-id="groupId" + :timezone="dashboardTimezone" v-bind="$attrs" v-on="$listeners" @datazoom="onDatazoom" diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index 5a7981b6534..08fcfa3bc56 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -52,10 +52,17 @@ export default { </script> <template> - <div v-if="showPanels" ref="graph-group" class="card prometheus-panel"> + <div v-if="showPanels" ref="graph-group" class="card prometheus-panel" tabindex="0"> <div class="card-header d-flex align-items-center"> <h4 class="flex-grow-1">{{ name }}</h4> - <a role="button" class="js-graph-group-toggle" @click="collapse"> + <a + data-testid="group-toggle-button" + role="button" + class="js-graph-group-toggle gl-text-gray-900" + tabindex="0" + @click="collapse" + @keyup.enter="collapse" + > <icon :size="16" :aria-label="__('Toggle collapse')" :name="caretIcon" /> </a> </div> diff --git a/app/assets/javascripts/monitoring/components/links_section.vue b/app/assets/javascripts/monitoring/components/links_section.vue new file mode 100644 index 00000000000..98b07d17694 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/links_section.vue @@ -0,0 +1,32 @@ +<script> +import { mapGetters } from 'vuex'; +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + computed: { + ...mapGetters('monitoringDashboard', { links: 'linksWithMetadata' }), + }, +}; +</script> +<template> + <div + ref="linksSection" + class="gl-display-sm-flex gl-flex-sm-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-display-sm-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-700 gl-vertical-align-text-bottom gl-mr-2" />{{ + link.title + }} + </gl-link> + </div> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue index e054c9d8e26..3d1d111d5b3 100644 --- a/app/assets/javascripts/monitoring/components/variables_section.vue +++ b/app/assets/javascripts/monitoring/components/variables_section.vue @@ -2,7 +2,7 @@ import { mapState, mapActions } from 'vuex'; import CustomVariable from './variables/custom_variable.vue'; import TextVariable from './variables/text_variable.vue'; -import { setPromCustomVariablesFromUrl } from '../utils'; +import { setCustomVariablesFromUrl } from '../utils'; export default { components: { @@ -10,23 +10,21 @@ export default { TextVariable, }, computed: { - ...mapState('monitoringDashboard', ['promVariables']), + ...mapState('monitoringDashboard', ['variables']), }, methods: { - ...mapActions('monitoringDashboard', ['fetchDashboardData', 'updateVariableValues']), + ...mapActions('monitoringDashboard', ['updateVariablesAndFetchData']), refreshDashboard(variable, value) { - if (this.promVariables[variable].value !== value) { + if (this.variables[variable].value !== value) { const changedVariable = { key: variable, value }; // update the Vuex store - this.updateVariableValues(changedVariable); + this.updateVariablesAndFetchData(changedVariable); // 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 - setPromCustomVariablesFromUrl(this.promVariables); - // fetch data - this.fetchDashboardData(); + setCustomVariablesFromUrl(this.variables); } }, variableComponent(type) { @@ -41,7 +39,7 @@ export default { </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, key) in promVariables" :key="key" class="mb-1 pr-2 d-flex d-sm-block"> + <div v-for="(variable, key) in variables" :key="key" class="mb-1 pr-2 d-flex d-sm-block"> <component :is="variableComponent(variable.type)" class="mb-0 flex-grow-1" diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 0c2eafeed54..50330046c99 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -127,9 +127,25 @@ export const lineWidths = { default: 2, }; -export const dateFormats = { - timeOfDay: 'h:MM TT', - default: 'dd mmm yyyy, h:MMTT', +/** + * 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/master/src/utils/charts/constants.js + * + */ +export const legendLayoutTypes = { + inline: 'inline', + table: 'table', }; /** @@ -140,7 +156,6 @@ export const dateFormats = { * Currently used in `receiveMetricsDashboardSuccess` action. */ export const endpointKeys = [ - 'metricsEndpoint', 'deploymentsEndpoint', 'dashboardEndpoint', 'dashboardsEndpoint', diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js new file mode 100644 index 00000000000..a50d441a09e --- /dev/null +++ b/app/assets/javascripts/monitoring/format_date.js @@ -0,0 +1,39 @@ +import dateFormat from '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', + 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 new file mode 100644 index 00000000000..08543fa6eb3 --- /dev/null +++ b/app/assets/javascripts/monitoring/monitoring_app.js @@ -0,0 +1,59 @@ +import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterValues } from '~/lib/utils/url_utility'; +import { createStore } from './stores'; +import createRouter from './router'; + +Vue.use(GlToast); + +export default (props = {}) => { + const el = document.getElementById('prometheus-graphs'); + + if (el && el.dataset) { + const [currentDashboard] = getParameterValues('dashboard'); + + const { + deploymentsEndpoint, + dashboardEndpoint, + dashboardsEndpoint, + projectPath, + logsPath, + currentEnvironmentName, + dashboardTimezone, + metricsDashboardBasePath, + ...dataProps + } = el.dataset; + + const store = createStore({ + currentDashboard, + deploymentsEndpoint, + dashboardEndpoint, + dashboardsEndpoint, + dashboardTimezone, + projectPath, + logsPath, + currentEnvironmentName, + }); + + // HTML attributes are always strings, parse other types. + dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics); + dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable); + dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable); + + const router = createRouter(metricsDashboardBasePath); + + // eslint-disable-next-line no-new + new Vue({ + el, + store, + router, + data() { + return { + dashboardProps: { ...dataProps, ...props }, + }; + }, + template: `<router-view :dashboardProps="dashboardProps"/>`, + }); + } +}; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js deleted file mode 100644 index 2bbf9ef9d78..00000000000 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ /dev/null @@ -1,32 +0,0 @@ -import Vue from 'vue'; -import { GlToast } from '@gitlab/ui'; -import Dashboard from '~/monitoring/components/dashboard.vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { getParameterValues } from '~/lib/utils/url_utility'; -import store from './stores'; - -Vue.use(GlToast); - -export default (props = {}) => { - const el = document.getElementById('prometheus-graphs'); - - if (el && el.dataset) { - const [currentDashboard] = getParameterValues('dashboard'); - - // eslint-disable-next-line no-new - new Vue({ - el, - store, - render(createElement) { - return createElement(Dashboard, { - props: { - ...el.dataset, - currentDashboard, - hasMetrics: parseBoolean(el.dataset.hasMetrics), - ...props, - }, - }); - }, - }); - } -}; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js deleted file mode 100644 index afe5ee0938d..00000000000 --- a/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js +++ /dev/null @@ -1,13 +0,0 @@ -import { parseBoolean } from '~/lib/utils/common_utils'; -import initCeBundle from '~/monitoring/monitoring_bundle'; - -export default () => { - const el = document.getElementById('prometheus-graphs'); - - if (el && el.dataset) { - initCeBundle({ - customMetricsAvailable: parseBoolean(el.dataset.customMetricsAvailable), - prometheusAlertsAvailable: parseBoolean(el.dataset.prometheusAlertsAvailable), - }); - } -}; diff --git a/app/assets/javascripts/monitoring/pages/dashboard_page.vue b/app/assets/javascripts/monitoring/pages/dashboard_page.vue new file mode 100644 index 00000000000..519a20d7be3 --- /dev/null +++ b/app/assets/javascripts/monitoring/pages/dashboard_page.vue @@ -0,0 +1,18 @@ +<script> +import Dashboard from '../components/dashboard.vue'; + +export default { + components: { + Dashboard, + }, + props: { + dashboardProps: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <dashboard v-bind="{ ...dashboardProps }" /> +</template> diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js new file mode 100644 index 00000000000..acfcd03f928 --- /dev/null +++ b/app/assets/javascripts/monitoring/router/constants.js @@ -0,0 +1,3 @@ +export const BASE_DASHBOARD_PAGE = 'dashboard'; + +export default {}; diff --git a/app/assets/javascripts/monitoring/router/index.js b/app/assets/javascripts/monitoring/router/index.js new file mode 100644 index 00000000000..12692612bbc --- /dev/null +++ b/app/assets/javascripts/monitoring/router/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import routes from './routes'; + +Vue.use(VueRouter); + +export default function createRouter(base) { + const router = new VueRouter({ + base, + mode: 'history', + routes, + }); + + return router; +} diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js new file mode 100644 index 00000000000..1e0cc1715a7 --- /dev/null +++ b/app/assets/javascripts/monitoring/router/routes.js @@ -0,0 +1,18 @@ +import DashboardPage from '../pages/dashboard_page.vue'; + +import { BASE_DASHBOARD_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: BASE_DASHBOARD_PAGE, + path: '*', + component: DashboardPage, + }, +]; diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 9e3edfb495d..3a9cccec438 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -3,8 +3,6 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { parseTemplatingVariables } from './variable_mapping'; -import { mergeURLVariables } from '../utils'; import { gqClient, parseEnvironmentsResponse, @@ -161,7 +159,6 @@ export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response commit(types.SET_ALL_DASHBOARDS, all_dashboards); commit(types.RECEIVE_METRICS_DASHBOARD_SUCCESS, dashboard); - commit(types.SET_VARIABLES, mergeURLVariables(parseTemplatingVariables(dashboard.templating))); commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(metrics_data)); return dispatch('fetchDashboardData'); @@ -223,7 +220,7 @@ export const fetchPrometheusMetric = ( queryParams.step = metric.step; } - if (Object.keys(state.promVariables).length > 0) { + if (Object.keys(state.variables).length > 0) { queryParams = { ...queryParams, ...getters.getCustomVariablesParams, @@ -317,8 +314,7 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => { export const fetchAnnotations = ({ state, dispatch }) => { const { start } = convertToFixedRange(state.timeRange); - const dashboardPath = - state.currentDashboard === '' ? DEFAULT_DASHBOARD_PATH : state.currentDashboard; + const dashboardPath = state.currentDashboard || DEFAULT_DASHBOARD_PATH; return gqClient .mutate({ mutation: getAnnotations, @@ -373,7 +369,7 @@ export const toggleStarredValue = ({ commit, state, getters }) => { method, }) .then(() => { - commit(types.RECEIVE_DASHBOARD_STARRING_SUCCESS, newStarredValue); + commit(types.RECEIVE_DASHBOARD_STARRING_SUCCESS, { selectedDashboard, newStarredValue }); }) .catch(() => { commit(types.RECEIVE_DASHBOARD_STARRING_FAILURE); @@ -419,8 +415,10 @@ export const duplicateSystemDashboard = ({ state }, payload) => { // Variables manipulation -export const updateVariableValues = ({ commit }, updatedVariable) => { - commit(types.UPDATE_VARIABLE_VALUES, updatedVariable); +export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => { + commit(types.UPDATE_VARIABLES, updatedVariable); + + return dispatch('fetchDashboardData'); }; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index f309addee6b..b7681012472 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -1,5 +1,5 @@ import { NOT_IN_DB_PREFIX } from '../constants'; -import { addPrefixToCustomVariableParams } from './utils'; +import { addPrefixToCustomVariableParams, addDashboardMetaDataToLink } from './utils'; const metricsIdsInPanel = panel => panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); @@ -113,6 +113,22 @@ export const filteredEnvironments = state => ); /** + * 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 an variables object to an array along with stripping * the variable prefix. * @@ -133,8 +149,8 @@ export const filteredEnvironments = state => */ export const getCustomVariablesParams = state => - Object.keys(state.promVariables).reduce((acc, variable) => { - acc[addPrefixToCustomVariableParams(variable)] = state.promVariables[variable]?.value; + Object.keys(state.variables).reduce((acc, variable) => { + acc[addPrefixToCustomVariableParams(variable)] = state.variables[variable]?.value; return acc; }, {}); diff --git a/app/assets/javascripts/monitoring/stores/index.js b/app/assets/javascripts/monitoring/stores/index.js index f08a6402aa6..213a8508aa2 100644 --- a/app/assets/javascripts/monitoring/stores/index.js +++ b/app/assets/javascripts/monitoring/stores/index.js @@ -15,11 +15,15 @@ export const monitoringDashboard = { state, }; -export const createStore = () => +export const createStore = (initState = {}) => new Vuex.Store({ modules: { - monitoringDashboard, + monitoringDashboard: { + ...monitoringDashboard, + state: { + ...state(), + ...initState, + }, + }, }, }); - -export default createStore(); diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index d60334609fd..4593461776b 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -3,7 +3,7 @@ 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 SET_VARIABLES = 'SET_VARIABLES'; -export const UPDATE_VARIABLE_VALUES = 'UPDATE_VARIABLE_VALUES'; +export const UPDATE_VARIABLES = 'UPDATE_VARIABLES'; export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING'; export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index f41cf3fc477..2d63fdd6e34 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,7 +1,6 @@ import Vue from 'vue'; import { pick } from 'lodash'; import * as types from './mutation_types'; -import { selectedDashboard } from './getters'; import { mapToDashboardViewModel, normalizeQueryResult } from './utils'; import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; import { endpointKeys, initialStateKeys, metricStates } from '../constants'; @@ -61,8 +60,14 @@ export default { state.emptyState = 'loading'; state.showEmptyState = true; }, - [types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, dashboard) { - state.dashboard = mapToDashboardViewModel(dashboard); + [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 = 'noData'; @@ -76,15 +81,14 @@ export default { [types.REQUEST_DASHBOARD_STARRING](state) { state.isUpdatingStarredValue = true; }, - [types.RECEIVE_DASHBOARD_STARRING_SUCCESS](state, newStarredValue) { - const dashboard = selectedDashboard(state); - const index = state.allDashboards.findIndex(d => d === dashboard); + [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, { ...dashboard, starred: newStarredValue }); + Vue.set(state.allDashboards, index, { ...selectedDashboard, starred: newStarredValue }); }, [types.RECEIVE_DASHBOARD_STARRING_FAILURE](state) { state.isUpdatingStarredValue = false; @@ -189,11 +193,11 @@ export default { state.expandedPanel.panel = panel; }, [types.SET_VARIABLES](state, variables) { - state.promVariables = variables; + state.variables = variables; }, - [types.UPDATE_VARIABLE_VALUES](state, updatedVariable) { - Object.assign(state.promVariables[updatedVariable.key], { - ...state.promVariables[updatedVariable.key], + [types.UPDATE_VARIABLES](state, updatedVariable) { + Object.assign(state.variables[updatedVariable.key], { + ...state.variables[updatedVariable.key], value: updatedVariable.value, }); }, diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index 9ae1da93e5f..8000f27c0d5 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -1,10 +1,11 @@ import invalidUrl from '~/lib/utils/invalid_url'; +import { timezones } from '../format_date'; export default () => ({ // API endpoints - metricsEndpoint: null, deploymentsEndpoint: null, dashboardEndpoint: invalidUrl, + dashboardsEndpoint: invalidUrl, // Dashboard request parameters timeRange: null, @@ -34,14 +35,24 @@ export default () => ({ panel: null, }, allDashboards: [], - promVariables: {}, - + /** + * User-defined custom variables are passed + * via the dashboard yml file. + */ + variables: {}, + /** + * User-defined custom links are passed + * via the dashboard yml file. + */ + links: [], // Other project data + dashboardTimezone: timezones.LOCAL, annotations: [], deploymentData: [], environments: [], environmentsSearchTerm: '', environmentsLoading: false, + currentEnvironmentName: null, // GitLab paths to other pages projectPath: null, diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index b6817e7279a..058fab5f4fc 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -2,7 +2,11 @@ import { slugify } from '~/lib/utils/text_utility'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { NOT_IN_DB_PREFIX } from '../constants'; +import { parseTemplatingVariables } from './variable_mapping'; +import { NOT_IN_DB_PREFIX, linkTypes } from '../constants'; +import { DATETIME_RANGE_TYPES } from '~/lib/utils/constants'; +import { timeRangeToParams, getRangeType } from '~/lib/utils/datetime_range'; +import { isSafeURL, mergeUrlParams } from '~/lib/utils/url_utility'; export const gqClient = createGqClient( {}, @@ -138,6 +142,24 @@ const mapYAxisToViewModel = ({ }; /** + * 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 @@ -152,6 +174,7 @@ const mapPanelToViewModel = ({ y_label, y_axis = {}, metrics = [], + links = [], max_value, }) => { // Both `x_axis.name` and `x_label` are supported for now @@ -171,7 +194,8 @@ const mapPanelToViewModel = ({ yAxis, xAxis, maxValue: max_value, - metrics: mapToMetricsViewModel(metrics, yAxis.name), + links: links.map(mapLinksToViewModel), + metrics: mapToMetricsViewModel(metrics), }; }; @@ -190,6 +214,66 @@ const mapToPanelGroupViewModel = ({ group = '', panels = [] }, i) => { }; /** + * 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 @@ -197,13 +281,33 @@ const mapToPanelGroupViewModel = ({ group = '', panels = [] }, i) => { * @param {Array} dashboard.panel_groups - Panel groups array * @returns {Object} */ -export const mapToDashboardViewModel = ({ dashboard = '', panel_groups = [] }) => { +export const mapToDashboardViewModel = ({ + dashboard = '', + templating = {}, + links = [], + panel_groups = [], +}) => { return { dashboard, + variables: parseTemplatingVariables(templating), + links: links.map(mapLinksToViewModel), panelGroups: panel_groups.map(mapToPanelGroupViewModel), }; }; +/** + * Processes a single Range vector, part of the result + * of type `matrix` in the form: + * + * { + * "metric": { "<label_name>": "<label_value>", ... }, + * "values": [ [ <unix_time>, "<sample_value>" ], ... ] + * }, + * + * See https://prometheus.io/docs/prometheus/latest/querying/api/#range-vectors + * + * @param {*} timeSeries + */ export const normalizeQueryResult = timeSeries => { let normalizedResult = {}; diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js index bfb469da19e..66b9899f673 100644 --- a/app/assets/javascripts/monitoring/stores/variable_mapping.js +++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js @@ -47,7 +47,7 @@ const textAdvancedVariableParser = advTextVar => ({ */ const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({ default: defaultOpt, - text, + text: text || value, value, }); diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 1f028ffbcad..95d544bd6d4 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -151,7 +151,7 @@ export const removePrefixFromLabel = label => /** * Convert parsed template variables to an object - * with just keys and values. Prepare the promVariables + * 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. @@ -183,15 +183,15 @@ export const getPromCustomVariablesFromUrl = (search = window.location.search) = }; /** - * Update the URL with promVariables. This usually get triggered when + * 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} promVariables user defined variables + * @param {Object} variables user defined variables */ -export const setPromCustomVariablesFromUrl = promVariables => { +export const setCustomVariablesFromUrl = variables => { // prep the variables to append to URL - const parsedVariables = convertVariablesForURL(promVariables); + const parsedVariables = convertVariablesForURL(variables); // update the URL updateHistory({ url: mergeUrlParams(parsedVariables, window.location.href), @@ -262,7 +262,7 @@ export const expandedPanelPayloadFromUrl = (dashboard, search = window.location. * If no group/panel is set, the dashboard URL is returned. * * @param {?String} dashboard - Dashboard path, used as identifier for a dashboard - * @param {?Object} promVariables - Custom variables that came from the URL + * @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 @@ -270,14 +270,14 @@ export const expandedPanelPayloadFromUrl = (dashboard, search = window.location. */ export const panelToUrl = ( dashboard = null, - promVariables, + variables, group, panel, url = window.location.href, ) => { const params = { dashboard, - ...promVariables, + ...variables, }; if (group && panel) { diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index c1edf7be870..fb6ef0249bb 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import store from 'ee_else_ce/mr_notes/stores'; +import store from '~/mr_notes/stores'; import initNotesApp from './init_notes'; import initDiffsApp from '../diffs'; import discussionCounter from '../notes/components/discussion_counter.vue'; diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index 2580f8e86b1..fcde9bf7849 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; -import store from 'ee_else_ce/mr_notes/stores'; +import store from '~/mr_notes/stores'; import notesApp from '../notes/components/notes_app.vue'; import discussionKeyboardNavigator from '../notes/components/discussion_keyboard_navigator.vue'; import initWidget from '../vue_merge_request_widget'; diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js index 8fbd0291a7d..8492b8d0aff 100644 --- a/app/assets/javascripts/mr_notes/stores/index.js +++ b/app/assets/javascripts/mr_notes/stores/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments'; import notesModule from '~/notes/stores/modules'; import diffsModule from '~/diffs/store/modules'; import mrPageModule from './modules'; @@ -12,6 +13,7 @@ export const createStore = () => page: mrPageModule(), notes: notesModule(), diffs: diffsModule(), + batchComments: batchCommentsModule(), }, }); diff --git a/app/assets/javascripts/namespace_storage_limit_alert.js b/app/assets/javascripts/namespace_storage_limit_alert.js new file mode 100644 index 00000000000..34ad93c127d --- /dev/null +++ b/app/assets/javascripts/namespace_storage_limit_alert.js @@ -0,0 +1,20 @@ +import Cookies from 'js-cookie'; + +const handleOnDismiss = ({ currentTarget }) => { + const { + dataset: { id, level }, + } = currentTarget; + + Cookies.set(`hide_storage_limit_alert_${id}_${level}`, true, { expires: 365 }); + + const notification = document.querySelector('.js-namespace-storage-alert'); + notification.parentNode.removeChild(notification); +}; + +export default () => { + const alert = document.querySelector('.js-namespace-storage-alert-dismiss'); + + if (alert) { + alert.addEventListener('click', handleOnDismiss); + } +}; diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index a070cf8866a..16dcde46262 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -29,6 +29,7 @@ export default { name: 'CommentForm', components: { issueWarning, + epicWarning: () => import('ee_component/vue_shared/components/epic/epic_warning.vue'), noteSignedOutWidget, discussionLockedWidget, markdownField, @@ -60,6 +61,7 @@ export default { 'getCurrentUserLastNote', 'getUserData', 'getNoteableData', + 'getNoteableDataByProp', 'getNotesData', 'openState', 'getBlockedByIssues', @@ -135,6 +137,9 @@ export default { ? __('merge request') : __('issue'); }, + isIssueType() { + return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE; + }, trackingLabel() { return slugifyWithUnderscore(`${this.commentButtonTitle} button`); }, @@ -346,13 +351,13 @@ export default { <div class="error-alert"></div> <issue-warning - v-if="hasWarning(getNoteableData)" + v-if="hasWarning(getNoteableData) && isIssueType" :is-locked="isLocked(getNoteableData)" :is-confidential="isConfidential(getNoteableData)" :locked-issue-docs-path="lockedIssueDocsPath" :confidential-issue-docs-path="confidentialIssueDocsPath" /> - + <epic-warning :is-confidential="isConfidential(getNoteableData)" /> <markdown-field ref="markdownField" :is-submitting="isSubmitting" @@ -412,7 +417,7 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" </gl-alert> <div class="note-form-actions"> <div - class="float-left btn-group + class="btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" > <button diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index cd5cfc09ea0..8897b54fac7 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -116,6 +116,7 @@ export default { </div> <div v-else> <diff-viewer + :diff-file="discussion.diff_file" :diff-mode="diffMode" :diff-viewer-mode="diffViewerMode" :new-path="discussion.diff_file.new_path" diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue new file mode 100644 index 00000000000..5fba011a153 --- /dev/null +++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue @@ -0,0 +1,68 @@ +<script> +import { GlFormSelect, GlSprintf } from '@gitlab/ui'; +import { getSymbol, getLineClasses } from './multiline_comment_utils'; + +export default { + components: { GlFormSelect, GlSprintf }, + props: { + lineRange: { + type: Object, + required: false, + default: null, + }, + line: { + type: Object, + required: true, + }, + commentLineOptions: { + type: Array, + required: true, + }, + }, + data() { + return { + commentLineStart: { + lineCode: this.lineRange ? this.lineRange.start_line_code : this.line.line_code, + type: this.lineRange ? this.lineRange.start_line_type : this.line.type, + }, + }; + }, + methods: { + getSymbol({ type }) { + return getSymbol(type); + }, + getLineClasses(line) { + return getLineClasses(line); + }, + }, +}; +</script> + +<template> + <div> + <gl-sprintf + :message=" + s__('MergeRequestDiffs|Commenting on lines %{selectStart}start%{selectEnd} to %{end}') + " + > + <template #select> + <label for="comment-line-start" class="sr-only">{{ + s__('MergeRequestDiffs|Select comment starting line') + }}</label> + <gl-form-select + id="comment-line-start" + :value="commentLineStart" + :options="commentLineOptions" + size="sm" + class="gl-w-auto gl-vertical-align-baseline" + @change="$emit('input', $event)" + /> + </template> + <template #end> + <span :class="getLineClasses(line)"> + {{ getSymbol(line) + (line.new_line || line.old_line) }} + </span> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/multiline_comment_utils.js b/app/assets/javascripts/notes/components/multiline_comment_utils.js new file mode 100644 index 00000000000..dc9c55e9b30 --- /dev/null +++ b/app/assets/javascripts/notes/components/multiline_comment_utils.js @@ -0,0 +1,57 @@ +import { takeRightWhile } from 'lodash'; + +export function getSymbol(type) { + if (type === 'new') return '+'; + if (type === 'old') return '-'; + return ''; +} + +function getLineNumber(lineRange, key) { + if (!lineRange || !key) return ''; + const lineCode = lineRange[`${key}_line_code`] || ''; + const lineType = lineRange[`${key}_line_type`] || ''; + const lines = lineCode.split('_') || []; + const lineNumber = lineType === 'old' ? lines[1] : lines[2]; + return (lineNumber && getSymbol(lineType) + lineNumber) || ''; +} + +export function getStartLineNumber(lineRange) { + return getLineNumber(lineRange, 'start'); +} + +export function getEndLineNumber(lineRange) { + return getLineNumber(lineRange, 'end'); +} + +export function getLineClasses(line) { + const symbol = typeof line === 'string' ? line.charAt(0) : getSymbol(line?.type); + + if (symbol !== '+' && symbol !== '-') return ''; + + return [ + 'gl-px-1 gl-rounded-small gl-border-solid gl-border-1 gl-border-white', + { + 'gl-bg-green-100 gl-text-green-800': symbol === '+', + 'gl-bg-red-100 gl-text-red-800': symbol === '-', + }, + ]; +} + +export function commentLineOptions(diffLines, lineCode) { + const selectedIndex = diffLines.findIndex(line => line.line_code === lineCode); + const notMatchType = l => l.type !== 'match'; + + // We're limiting adding comments to only lines above the current line + // to make rendering simpler. Future interations will use a more + // intuitive dragging interface that will make this unnecessary + const upToSelected = diffLines.slice(0, selectedIndex + 1); + + // Only include the lines up to the first "Show unchanged lines" block + // i.e. not a "match" type + const lines = takeRightWhile(upToSelected, notMatchType); + + return lines.map(l => ({ + value: { lineCode: l.line_code, type: l.type }, + text: `${getSymbol(l.type)}${l.new_line || l.old_line}`, + })); +} diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index dc514f00801..f1af8be590a 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,9 +1,13 @@ <script> +import { __ } from '~/locale'; import { mapGetters } from 'vuex'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; -import resolvedStatusMixin from 'ee_else_ce/batch_comments/mixins/resolved_status'; +import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; import Icon from '~/vue_shared/components/icon.vue'; import ReplyButton from './note_actions/reply_button.vue'; +import eventHub from '~/sidebar/event_hub'; +import Api from '~/api'; +import flash from '~/flash'; export default { name: 'NoteActions', @@ -17,6 +21,10 @@ export default { }, mixins: [resolvedStatusMixin], props: { + author: { + type: Object, + required: true, + }, authorId: { type: Number, required: true, @@ -87,7 +95,7 @@ export default { }, }, computed: { - ...mapGetters(['getUserDataByProp']), + ...mapGetters(['getUserDataByProp', 'getNoteableData']), shouldShowActionsDropdown() { return this.currentUserId && (this.canEdit || this.canReportAsAbuse); }, @@ -100,6 +108,26 @@ export default { currentUserId() { return this.getUserDataByProp('id'); }, + isUserAssigned() { + return this.assignees && this.assignees.some(({ id }) => id === this.author.id); + }, + displayAssignUserText() { + return this.isUserAssigned + ? __('Unassign from commenting user') + : __('Assign to commenting user'); + }, + sidebarAction() { + return this.isUserAssigned ? 'sidebar.addAssignee' : 'sidebar.removeAssignee'; + }, + targetType() { + return this.getNoteableData.targetType; + }, + assignees() { + return this.getNoteableData.assignees || []; + }, + isIssue() { + return this.targetType === 'issue'; + }, }, methods: { onEdit() { @@ -116,6 +144,29 @@ export default { this.$root.$emit('bv::hide::tooltip'); }); }, + handleAssigneeUpdate(assignees) { + this.$emit('updateAssignees', assignees); + eventHub.$emit(this.sidebarAction, this.author); + eventHub.$emit('sidebar.saveAssignees'); + }, + assignUser() { + let { assignees } = this; + const { project_id, iid } = this.getNoteableData; + + if (this.isUserAssigned) { + assignees = assignees.filter(assignee => assignee.id !== this.author.id); + } else { + assignees.push({ id: this.author.id }); + } + + if (this.targetType === 'issue') { + Api.updateIssue(project_id, iid, { + assignee_ids: assignees.map(assignee => assignee.id), + }) + .then(() => this.handleAssigneeUpdate(assignees)) + .catch(() => flash(__('Something went wrong while updating assignees'))); + } + }, }, }; </script> @@ -215,6 +266,16 @@ export default { <span class="text-danger">{{ __('Delete comment') }}</span> </button> </li> + <li v-if="isIssue"> + <button + class="btn-default btn-transparent" + data-testid="assign-user" + type="button" + @click="assignUser" + > + {{ displayAssignUserText }} + </button> + </li> </ul> </div> </div> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 358f49deb35..42b78929f8a 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -1,8 +1,7 @@ <script> -import { mapActions } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; -import getDiscussion from 'ee_else_ce/notes/mixins/get_discussion'; import noteEditedText from './note_edited_text.vue'; import noteAwardsList from './note_awards_list.vue'; import noteAttachment from './note_attachment.vue'; @@ -18,7 +17,7 @@ export default { noteForm, Suggestions, }, - mixins: [autosave, getDiscussion], + mixins: [autosave], props: { note: { type: Object, @@ -45,6 +44,15 @@ export default { }, }, computed: { + ...mapGetters(['getDiscussion']), + discussion() { + if (!this.note.isDraft) return {}; + + return this.getDiscussion(this.note.discussion_id); + }, + ...mapState({ + batchSuggestionsInfo: state => state.notes.batchSuggestionsInfo, + }), noteBody() { return this.note.note; }, @@ -74,7 +82,12 @@ export default { } }, methods: { - ...mapActions(['submitSuggestion']), + ...mapActions([ + 'submitSuggestion', + 'submitSuggestionBatch', + 'addSuggestionInfoToBatch', + 'removeSuggestionInfoFromBatch', + ]), renderGFM() { $(this.$refs['note-body']).renderGFM(); }, @@ -91,6 +104,17 @@ export default { callback, ); }, + applySuggestionBatch({ flashContainer }) { + return this.submitSuggestionBatch({ flashContainer }); + }, + addSuggestionToBatch(suggestionId) { + const { discussion_id: discussionId, id: noteId } = this.note; + + this.addSuggestionInfoToBatch({ suggestionId, discussionId, noteId }); + }, + removeSuggestionFromBatch(suggestionId) { + this.removeSuggestionInfoFromBatch(suggestionId); + }, }, }; </script> @@ -100,10 +124,14 @@ export default { <suggestions v-if="hasSuggestion && !isEditing" :suggestions="note.suggestions" + :batch-suggestions-info="batchSuggestionsInfo" :note-html="note.note_html" :line-type="lineType" :help-page-path="helpPagePath" @apply="applySuggestion" + @applyBatch="applySuggestionBatch" + @addToBatch="addSuggestionToBatch" + @removeFromBatch="removeSuggestionFromBatch" /> <div v-else class="note-text md" v-html="note.note_html"></div> <note-form diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 21d0bffdf1c..795ee10ca0f 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,6 +1,5 @@ <script> -import { mapGetters, mapActions } from 'vuex'; -import noteFormMixin from 'ee_else_ce/notes/mixins/note_form'; +import { mapGetters, mapActions, mapState } from 'vuex'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; @@ -16,7 +15,7 @@ export default { issueWarning, markdownField, }, - mixins: [issuableStateMixin, resolvable, noteFormMixin], + mixins: [issuableStateMixin, resolvable], props: { noteBody: { type: String, @@ -82,6 +81,11 @@ export default { required: false, default: false, }, + isDraft: { + type: Boolean, + required: false, + default: false, + }, }, data() { let updatedNoteBody = this.noteBody; @@ -107,6 +111,16 @@ export default { 'getNotesDataByProp', 'getUserDataByProp', ]), + ...mapState({ + withBatchComments: state => state.batchComments?.withBatchComments, + }), + ...mapGetters('batchComments', ['hasDrafts']), + showBatchCommentsActions() { + return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit; + }, + showResolveDiscussionToggle() { + return (this.discussion?.id && this.discussion.resolvable) || this.isDraft; + }, noteHash() { if (this.noteId) { return `#note_${this.noteId}`; @@ -202,8 +216,6 @@ export default { methods: { ...mapActions(['toggleResolveNote']), shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState) { - // shouldBeResolved() checks the actual resolution state, - // considering batchComments (EEP), if applicable/enabled. const newResolvedStateAfterUpdate = this.shouldBeResolved && this.shouldBeResolved(shouldResolve); @@ -234,6 +246,50 @@ export default { updateDraft(autosaveKey, text); } }, + handleKeySubmit() { + if (this.showBatchCommentsActions) { + this.handleAddToReview(); + } else { + this.handleUpdate(); + } + }, + handleUpdate(shouldResolve) { + const beforeSubmitDiscussionState = this.discussionResolved; + this.isSubmitting = true; + + this.$emit( + 'handleFormUpdate', + this.updatedNoteBody, + this.$refs.editNoteForm, + () => { + this.isSubmitting = false; + + if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) { + this.resolveHandler(beforeSubmitDiscussionState); + } + }, + this.discussionResolved ? !this.isUnresolving : this.isResolving, + ); + }, + shouldBeResolved(resolveStatus) { + if (this.withBatchComments) { + return ( + (this.discussionResolved && !this.isUnresolving) || + (!this.discussionResolved && this.isResolving) + ); + } + + return resolveStatus; + }, + handleAddToReview() { + // check if draft should resolve thread + const shouldResolve = + (this.discussionResolved && !this.isUnresolving) || + (!this.discussionResolved && this.isResolving); + this.isSubmitting = true; + + this.$emit('handleFormUpdateAddToReview', this.updatedNoteBody, shouldResolve); + }, }, }; </script> @@ -293,6 +349,7 @@ export default { <input v-model="isUnresolving" type="checkbox" + class="js-unresolve-checkbox" data-qa-selector="unresolve_review_discussion_checkbox" /> {{ __('Unresolve thread') }} @@ -301,6 +358,7 @@ export default { <input v-model="isResolving" type="checkbox" + class="js-resolve-checkbox" data-qa-selector="resolve_review_discussion_checkbox" /> {{ __('Resolve thread') }} @@ -320,7 +378,7 @@ export default { <button :disabled="isDisabled" type="button" - class="btn qa-comment-now" + class="btn qa-comment-now js-comment-button" @click="handleUpdate()" > {{ __('Add comment now') }} diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 189ff88feb3..7fe50d36c0c 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,11 +1,12 @@ <script> import { mapActions, mapGetters } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; -import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; +import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import { s__, __ } from '~/locale'; import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import icon from '~/vue_shared/components/icon.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import DraftNote from '~/batch_comments/components/draft_note.vue'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import diffDiscussionHeader from './diff_discussion_header.vue'; @@ -26,7 +27,7 @@ export default { diffDiscussionHeader, noteSignedOutWidget, noteForm, - DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'), + DraftNote, TimelineEntryItem, DiscussionNotes, DiscussionActions, diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 37675e20b3d..0e4dd1b9c84 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -2,7 +2,8 @@ import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; import { escape } from 'lodash'; -import draftMixin from 'ee_else_ce/notes/mixins/draft'; +import { GlSprintf } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import { __, s__, sprintf } from '../../locale'; @@ -15,17 +16,26 @@ import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import httpStatusCodes from '~/lib/utils/http_status'; +import { + getStartLineNumber, + getEndLineNumber, + getLineClasses, + commentLineOptions, +} from './multiline_comment_utils'; +import MultilineCommentForm from './multiline_comment_form.vue'; export default { name: 'NoteableNote', components: { + GlSprintf, userAvatarLink, noteHeader, noteActions, NoteBody, TimelineEntryItem, + MultilineCommentForm, }, - mixins: [noteable, resolvable, draftMixin], + mixins: [noteable, resolvable, glFeatureFlagsMixin()], props: { note: { type: Object, @@ -51,6 +61,11 @@ export default { required: false, default: false, }, + diffLines: { + type: Object, + required: false, + default: null, + }, }, data() { return { @@ -58,9 +73,14 @@ export default { isDeleting: false, isRequesting: false, isResolving: false, + commentLineStart: { + line_code: this.line?.line_code, + type: this.line?.type, + }, }; }, computed: { + ...mapGetters('diffs', ['getDiffFileByHash']), ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']), author() { return this.note.author; @@ -105,6 +125,41 @@ export default { )}</a>`; return sprintf(s__('MergeRequests|commented on commit %{commitLink}'), { commitLink }, false); }, + isDraft() { + return this.note.isDraft; + }, + canResolve() { + return ( + this.note.current_user.can_resolve || + (this.note.isDraft && this.note.discussion_id !== null) + ); + }, + lineRange() { + return this.note.position?.line_range; + }, + startLineNumber() { + return getStartLineNumber(this.lineRange); + }, + endLineNumber() { + return getEndLineNumber(this.lineRange); + }, + showMultiLineComment() { + return ( + this.glFeatures.multilineComments && + this.startLineNumber && + this.endLineNumber && + (this.startLineNumber !== this.endLineNumber || this.isEditing) + ); + }, + commentLineOptions() { + if (this.diffLines) { + return commentLineOptions(this.diffLines, this.line.line_code); + } + + const diffFile = this.diffFile || this.getDiffFileByHash(this.targetNoteHash); + if (!diffFile) return null; + return commentLineOptions(diffFile.highlighted_diff_lines, this.line.line_code); + }, }, created() { @@ -129,6 +184,7 @@ export default { 'updateNote', 'toggleResolveNote', 'scrollToNoteIfNeeded', + 'updateAssignees', ]), editHandler() { this.isEditing = true; @@ -166,10 +222,20 @@ export default { this.$emit('updateSuccess'); }, formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) { + const position = { + ...this.note.position, + line_range: { + start_line_code: this.commentLineStart?.lineCode, + start_line_type: this.commentLineStart?.type, + end_line_code: this.line?.line_code, + end_line_type: this.line?.type, + }, + }; this.$emit('handleUpdateNote', { note: this.note, noteText, resolveDiscussion, + position, callback: () => this.updateSuccess(), }); @@ -231,6 +297,12 @@ export default { noteBody.note.note = noteText; } }, + getLineClasses(lineNumber) { + return getLineClasses(lineNumber); + }, + assigneesUpdate(assignees) { + this.updateAssignees(assignees); + }, }, }; </script> @@ -243,6 +315,26 @@ export default { :data-note-id="note.id" class="note note-wrapper qa-noteable-note-item" > + <div v-if="showMultiLineComment" data-testid="multiline-comment"> + <multiline-comment-form + v-if="isEditing && commentLineOptions && line" + v-model="commentLineStart" + :line="line" + :comment-line-options="commentLineOptions" + :line-range="note.position.line_range" + class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" + /> + <div v-else class="gl-mb-3 gl-text-gray-700"> + <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')"> + <template #startLine> + <span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span> + </template> + <template #endLine> + <span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span> + </template> + </gl-sprintf> + </div> + </div> <div v-once class="timeline-icon"> <user-avatar-link :link-href="author.path" @@ -267,6 +359,7 @@ export default { <span v-else-if="note.created_at" class="d-none d-sm-inline">·</span> </note-header> <note-actions + :author="author" :author-id="author.id" :note-id="note.id" :note-url="note.noteable_note_url" @@ -289,6 +382,7 @@ export default { @handleDelete="deleteHandler" @handleResolve="resolveHandler" @startReplying="$emit('startReplying')" + @updateAssignees="assigneesUpdate" /> </div> <div class="timeline-discussion-body"> diff --git a/app/assets/javascripts/notes/mixins/description_version_history.js b/app/assets/javascripts/notes/mixins/description_version_history.js index 66e6685cfd8..d1006e37a70 100644 --- a/app/assets/javascripts/notes/mixins/description_version_history.js +++ b/app/assets/javascripts/notes/mixins/description_version_history.js @@ -3,7 +3,7 @@ export default { computed: { canSeeDescriptionVersion() {}, - canDeleteDescriptionVersion() {}, + displayDeleteButton() {}, shouldShowDescriptionVersion() {}, descriptionVersionToggleIcon() {}, }, 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 188556e8921..5930b5f3321 100644 --- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js +++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js @@ -1,10 +1,100 @@ +import { mapActions, mapGetters, mapState } from 'vuex'; +import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils'; +import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import { clearDraft } from '~/lib/utils/autosave'; + export default { computed: { - draftForDiscussion: () => () => ({}), + ...mapState({ + noteableData: state => state.notes.noteableData, + notesData: state => state.notes.notesData, + withBatchComments: state => state.batchComments?.withBatchComments, + }), + ...mapGetters('diffs', ['getDiffFileByHash']), + ...mapGetters('batchComments', ['shouldRenderDraftRowInDiscussion', 'draftForDiscussion']), + ...mapState('diffs', ['commit']), }, methods: { - showDraft: () => false, - addReplyToReview: () => {}, - addToReview: () => {}, + ...mapActions('diffs', ['cancelCommentForm']), + ...mapActions('batchComments', ['addDraftToReview', 'saveDraft', 'insertDraftIntoDrafts']), + addReplyToReview(noteText, isResolving) { + const postData = getDraftReplyFormData({ + in_reply_to_discussion_id: this.discussion.reply_id, + target_type: this.getNoteableData.targetType, + notesData: this.notesData, + draft_note: { + note: noteText, + resolve_discussion: isResolving, + }, + }); + + if (this.discussion.for_commit) { + postData.note_project_id = this.discussion.project_id; + } + + this.isReplying = false; + + this.saveDraft(postData) + .then(() => { + this.handleClearForm(this.discussion.line_code); + }) + .catch(() => { + createFlash(s__('MergeRequests|An error occurred while saving the draft comment.')); + }); + }, + addToReview(note) { + const positionType = this.diffFileCommentForm + ? IMAGE_DIFF_POSITION_TYPE + : TEXT_DIFF_POSITION_TYPE; + const selectedDiffFile = this.getDiffFileByHash(this.diffFileHash); + const postData = getDraftFormData({ + note, + notesData: this.notesData, + noteableData: this.noteableData, + noteableType: this.noteableType, + noteTargetLine: this.noteTargetLine, + diffViewType: this.diffViewType, + diffFile: selectedDiffFile, + linePosition: this.position, + positionType, + ...this.diffFileCommentForm, + }); + + const diffFileHeadSha = this.commit && this?.diffFile?.diff_refs?.head_sha; + + postData.data.note.commit_id = diffFileHeadSha || null; + + return this.saveDraft(postData) + .then(() => { + if (positionType === IMAGE_DIFF_POSITION_TYPE) { + this.closeDiffFileCommentForm(this.diffFileHash); + } else { + this.handleClearForm(this.line.line_code); + } + }) + .catch(() => { + createFlash(s__('MergeRequests|An error occurred while saving the draft comment.')); + }); + }, + handleClearForm(lineCode) { + this.cancelCommentForm({ + lineCode, + fileHash: this.diffFileHash, + }); + this.$nextTick(() => { + if (this.autosaveKey) { + clearDraft(this.autosaveKey); + } else { + // TODO: remove the following after replacing the autosave mixin + // https://gitlab.com/gitlab-org/gitlab-foss/issues/60587 + this.resetAutoSave(); + } + }); + }, + showDraft(replyId) { + return this.withBatchComments && this.shouldRenderDraftRowInDiscussion(replyId); + }, }, }; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index c9026352d18..9281149d9d3 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,5 +1,5 @@ import { mapGetters, mapActions, mapState } from 'vuex'; -import { scrollToElement } from '~/lib/utils/common_utils'; +import { scrollToElementWithContext } from '~/lib/utils/common_utils'; import eventHub from '../event_hub'; /** @@ -10,7 +10,7 @@ function scrollTo(selector) { const el = document.querySelector(selector); if (el) { - scrollToElement(el); + scrollToElementWithContext(el); return true; } diff --git a/app/assets/javascripts/notes/mixins/draft.js b/app/assets/javascripts/notes/mixins/draft.js deleted file mode 100644 index 1370f3978df..00000000000 --- a/app/assets/javascripts/notes/mixins/draft.js +++ /dev/null @@ -1,8 +0,0 @@ -export default { - computed: { - isDraft: () => false, - canResolve() { - return this.note.current_user.can_resolve; - }, - }, -}; diff --git a/app/assets/javascripts/notes/mixins/get_discussion.js b/app/assets/javascripts/notes/mixins/get_discussion.js deleted file mode 100644 index b5d820fe083..00000000000 --- a/app/assets/javascripts/notes/mixins/get_discussion.js +++ /dev/null @@ -1,7 +0,0 @@ -export default { - computed: { - discussion() { - return {}; - }, - }, -}; diff --git a/app/assets/javascripts/notes/mixins/note_form.js b/app/assets/javascripts/notes/mixins/note_form.js deleted file mode 100644 index b74879f2256..00000000000 --- a/app/assets/javascripts/notes/mixins/note_form.js +++ /dev/null @@ -1,24 +0,0 @@ -export default { - data() { - return { - showBatchCommentsActions: false, - }; - }, - methods: { - handleKeySubmit() { - this.handleUpdate(); - }, - handleUpdate(shouldResolve) { - const beforeSubmitDiscussionState = this.discussionResolved; - this.isSubmitting = true; - - this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => { - this.isSubmitting = false; - - if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) { - this.resolveHandler(beforeSubmitDiscussionState); - } - }); - }, - }, -}; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 0999d0aa7ac..a5b006fc301 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -524,12 +524,55 @@ export const submitSuggestion = ( const defaultMessage = __( 'Something went wrong while applying the suggestion. Please try again.', ); - const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage; + + const errorMessage = err.response.data?.message; + + const flashMessage = errorMessage || defaultMessage; Flash(__(flashMessage), 'alert', flashContainer); }); }; +export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContainer }) => { + const suggestionIds = state.batchSuggestionsInfo.map(({ suggestionId }) => suggestionId); + + const applyAllSuggestions = () => + state.batchSuggestionsInfo.map(suggestionInfo => + commit(types.APPLY_SUGGESTION, suggestionInfo), + ); + + const resolveAllDiscussions = () => + state.batchSuggestionsInfo.map(suggestionInfo => { + const { discussionId } = suggestionInfo; + return dispatch('resolveDiscussion', { discussionId }).catch(() => {}); + }); + + commit(types.SET_APPLYING_BATCH_STATE, true); + + return Api.applySuggestionBatch(suggestionIds) + .then(() => Promise.all(applyAllSuggestions())) + .then(() => Promise.all(resolveAllDiscussions())) + .then(() => commit(types.CLEAR_SUGGESTION_BATCH)) + .catch(err => { + const defaultMessage = __( + 'Something went wrong while applying the batch of suggestions. Please try again.', + ); + + const errorMessage = err.response.data?.message; + + const flashMessage = errorMessage || defaultMessage; + + Flash(__(flashMessage), 'alert', flashContainer); + }) + .finally(() => commit(types.SET_APPLYING_BATCH_STATE, false)); +}; + +export const addSuggestionInfoToBatch = ({ commit }, { suggestionId, noteId, discussionId }) => + commit(types.ADD_SUGGESTION_TO_BATCH, { suggestionId, noteId, discussionId }); + +export const removeSuggestionInfoFromBatch = ({ commit }, suggestionId) => + commit(types.REMOVE_SUGGESTION_FROM_BATCH, suggestionId); + export const convertToDiscussion = ({ commit }, noteId) => commit(types.CONVERT_TO_DISCUSSION, noteId); @@ -587,6 +630,10 @@ export const softDeleteDescriptionVersion = ( .catch(error => { dispatch('receiveDeleteDescriptionVersionError', error); Flash(__('Something went wrong while deleting description changes. Please try again.')); + + // Throw an error here because a component like SystemNote - + // needs to know if the request failed to reset its internal state. + throw new Error(); }); }; @@ -600,5 +647,9 @@ export const receiveDeleteDescriptionVersionError = ({ commit }, error) => { commit(types.RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR, error); }; +export const updateAssignees = ({ commit }, assignees) => { + commit(types.UPDATE_ASSIGNEES, assignees); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 25f0f546103..329bf5e147e 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -11,6 +11,7 @@ export default () => ({ targetNoteHash: null, lastFetchedAt: null, currentDiscussionId: null, + batchSuggestionsInfo: [], // View layer isToggleStateButtonLoading: false, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 2f7b2788d8a..538774ee467 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -17,8 +17,13 @@ export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE'; export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; export const APPLY_SUGGESTION = 'APPLY_SUGGESTION'; +export const SET_APPLYING_BATCH_STATE = 'SET_APPLYING_BATCH_STATE'; +export const ADD_SUGGESTION_TO_BATCH = 'ADD_SUGGESTION_TO_BATCH'; +export const REMOVE_SUGGESTION_FROM_BATCH = 'REMOVE_SUGGESTION_FROM_BATCH'; +export const CLEAR_SUGGESTION_BATCH = 'CLEAR_SUGGESTION_BATCH'; export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION'; export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION'; +export const UPDATE_ASSIGNEES = 'UPDATE_ASSIGNEES'; // DISCUSSION export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index f06874991f0..2aeadcb2da1 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -225,6 +225,39 @@ export default { })); }, + [types.SET_APPLYING_BATCH_STATE](state, isApplyingBatch) { + state.batchSuggestionsInfo.forEach(suggestionInfo => { + const { discussionId, noteId, suggestionId } = suggestionInfo; + + const noteObj = utils.findNoteObjectById(state.discussions, discussionId); + const comment = utils.findNoteObjectById(noteObj.notes, noteId); + + comment.suggestions = comment.suggestions.map(suggestion => ({ + ...suggestion, + is_applying_batch: suggestion.id === suggestionId && isApplyingBatch, + })); + }); + }, + + [types.ADD_SUGGESTION_TO_BATCH](state, { noteId, discussionId, suggestionId }) { + state.batchSuggestionsInfo.push({ + suggestionId, + noteId, + discussionId, + }); + }, + + [types.REMOVE_SUGGESTION_FROM_BATCH](state, id) { + const index = state.batchSuggestionsInfo.findIndex(({ suggestionId }) => suggestionId === id); + if (index !== -1) { + state.batchSuggestionsInfo.splice(index, 1); + } + }, + + [types.CLEAR_SUGGESTION_BATCH](state) { + state.batchSuggestionsInfo.splice(0, state.batchSuggestionsInfo.length); + }, + [types.UPDATE_DISCUSSION](state, noteData) { const note = noteData; const selectedDiscussion = state.discussions.find(disc => disc.id === note.id); @@ -322,4 +355,7 @@ export default { [types.RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR](state) { state.isLoadingDescriptionVersion = false; }, + [types.UPDATE_ASSIGNEES](state, assignees) { + state.noteableData.assignees = assignees; + }, }; diff --git a/app/assets/javascripts/onboarding_issues/index.js b/app/assets/javascripts/onboarding_issues/index.js new file mode 100644 index 00000000000..5a6f952ffdf --- /dev/null +++ b/app/assets/javascripts/onboarding_issues/index.js @@ -0,0 +1,120 @@ +import $ from 'jquery'; +import { parseBoolean, getCookie, setCookie, removeCookie } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; + +const COOKIE_NAME = 'onboarding_issues_settings'; + +const POPOVER_LOCATIONS = { + GROUPS_SHOW: 'groups#show', + PROJECTS_SHOW: 'projects#show', + ISSUES_INDEX: 'issues#index', +}; + +const removeLearnGitLabCookie = () => { + removeCookie(COOKIE_NAME); +}; + +function disposePopover(event) { + event.preventDefault(); + this.popover('dispose'); + removeLearnGitLabCookie(); + Tracking.event('Growth::Conversion::Experiment::OnboardingIssues', 'dismiss_popover'); +} + +const showPopover = (el, path, footer, options) => { + // Cookie value looks like `{ 'groups#show': true, 'projects#show': true, 'issues#index': true }`. When it doesn't exist, don't show the popover. + const cookie = getCookie(COOKIE_NAME); + if (!cookie) return; + + // When the popover action has already been taken, don't show the popover. + const settings = JSON.parse(cookie); + if (!parseBoolean(settings[path])) return; + + const defaultOptions = { + boundary: 'window', + html: true, + placement: 'top', + template: `<div class="popover blue learn-gitlab d-none d-xl-block" role="tooltip"> + <div class="arrow"></div> + <div class="close cursor-pointer gl-font-base text-white gl-opacity-10 p-2">✕</div> + <div class="popover-body gl-font-base gl-line-height-20 pb-0 px-3"></div> + <div class="bold text-right text-white p-2">${footer}</div> + </div>`, + }; + + // When one of the popovers is dismissed, remove the cookie. + const closeButton = () => document.querySelector('.learn-gitlab.popover .close'); + + // We still have to use jQuery, since Bootstrap's Popover is based on jQuery. + const jQueryEl = $(el); + const clickCloseButton = disposePopover.bind(jQueryEl); + + jQueryEl + .popover({ ...defaultOptions, ...options }) + .on('inserted.bs.popover', () => closeButton().addEventListener('click', clickCloseButton)) + .on('hide.bs.dropdown', () => closeButton().removeEventListener('click', clickCloseButton)) + .popover('show'); + + // The previous popover actions have been taken, don't show those popovers anymore. + Object.keys(settings).forEach(pathSetting => { + if (path !== pathSetting) { + settings[pathSetting] = false; + } else { + setCookie(COOKIE_NAME, settings); + } + }); + + // The final popover action will be taken on click, we then no longer need the cookie. + if (path === POPOVER_LOCATIONS.ISSUES_INDEX) { + el.addEventListener('click', removeLearnGitLabCookie); + } +}; + +export const showLearnGitLabGroupItemPopover = id => { + const el = document.querySelector(`#group-${id} .group-text a`); + + if (!el) return; + + const options = { + content: __( + 'Here are all your projects in your group, including the one you just created. To start, let’s take a look at your personalized learning project which will help you learn about GitLab at your own pace.', + ), + }; + + showPopover(el, POPOVER_LOCATIONS.GROUPS_SHOW, '1 / 2', options); +}; + +export const showLearnGitLabProjectPopover = () => { + // Do not show a popover if we are not viewing the 'Learn GitLab' project. + if (!window.location.pathname.includes('learn-gitlab')) return; + + const el = document.querySelector('a.shortcuts-issues'); + + if (!el) return; + + const options = { + content: __( + 'Go to <strong>Issues</strong> > <strong>Boards</strong> to access your personalized learning issue board.', + ), + }; + + showPopover(el, POPOVER_LOCATIONS.PROJECTS_SHOW, '2 / 2', options); +}; + +export const showLearnGitLabIssuesPopover = () => { + // Do not show a popover if we are not viewing the 'Learn GitLab' project. + if (!window.location.pathname.includes('learn-gitlab')) return; + + const el = document.querySelector('a[data-qa-selector="issue_boards_link"]'); + + if (!el) return; + + const options = { + content: __( + 'Go to <strong>Issues</strong> > <strong>Boards</strong> to access your personalized learning issue board.', + ), + }; + + showPopover(el, POPOVER_LOCATIONS.ISSUES_INDEX, '2 / 2', options); +}; diff --git a/app/assets/javascripts/operation_settings/components/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/external_dashboard.vue deleted file mode 100644 index e9c7d7c5d56..00000000000 --- a/app/assets/javascripts/operation_settings/components/external_dashboard.vue +++ /dev/null @@ -1,72 +0,0 @@ -<script> -import { mapState, mapActions } from 'vuex'; -import { GlDeprecatedButton, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui'; - -export default { - components: { - GlDeprecatedButton, - GlFormGroup, - GlFormInput, - GlLink, - }, - computed: { - ...mapState([ - 'externalDashboardHelpPagePath', - 'externalDashboardUrl', - 'operationsSettingsEndpoint', - ]), - userDashboardUrl: { - get() { - return this.externalDashboardUrl; - }, - set(url) { - this.setExternalDashboardUrl(url); - }, - }, - }, - methods: { - ...mapActions(['setExternalDashboardUrl', 'updateExternalDashboardUrl']), - }, -}; -</script> - -<template> - <section class="settings no-animate"> - <div class="settings-header"> - <h3 class="js-section-header h4"> - {{ s__('ExternalMetrics|External Dashboard') }} - </h3> - <gl-deprecated-button class="js-settings-toggle">{{ __('Expand') }}</gl-deprecated-button> - <p class="js-section-sub-header"> - {{ - s__( - 'ExternalMetrics|Add a button to the metrics dashboard linking directly to your existing external dashboards.', - ) - }} - <gl-link :href="externalDashboardHelpPagePath">{{ __('Learn more') }}</gl-link> - </p> - </div> - <div class="settings-content"> - <form> - <gl-form-group - :label="s__('ExternalMetrics|Full dashboard URL')" - label-for="full-dashboard-url" - :description="s__('ExternalMetrics|Enter the URL of the dashboard you want to link to')" - > - <!-- placeholder with a url is a false positive --> - <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> - <gl-form-input - id="full-dashboard-url" - v-model="userDashboardUrl" - placeholder="https://my-org.gitlab.io/my-dashboards" - @keydown.enter.native.prevent="updateExternalDashboardUrl" - /> - <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> - </gl-form-group> - <gl-deprecated-button variant="success" @click="updateExternalDashboardUrl"> - {{ __('Save Changes') }} - </gl-deprecated-button> - </form> - </div> - </section> -</template> diff --git a/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue b/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue new file mode 100644 index 00000000000..42c9d876595 --- /dev/null +++ b/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue @@ -0,0 +1,60 @@ +<script> +import { s__ } from '~/locale'; +import { mapState, mapActions } from 'vuex'; +import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { timezones } from '~/monitoring/format_date'; + +export default { + components: { + GlFormGroup, + GlFormSelect, + }, + computed: { + ...mapState(['dashboardTimezone']), + dashboardTimezoneModel: { + get() { + return this.dashboardTimezone.selected; + }, + set(selected) { + this.setDashboardTimezone(selected); + }, + }, + options() { + return [ + { + value: timezones.LOCAL, + text: s__("MetricsSettings|User's local timezone"), + }, + { + value: timezones.UTC, + text: s__('MetricsSettings|UTC (Coordinated Universal Time)'), + }, + ]; + }, + }, + methods: { + ...mapActions(['setDashboardTimezone']), + }, +}; +</script> + +<template> + <gl-form-group + :label="s__('MetricsSettings|Dashboard timezone')" + label-for="dashboard-timezone-setting" + > + <template #description> + {{ + s__( + "MetricsSettings|Choose whether to display dashboard metrics in UTC or the user's local timezone.", + ) + }} + </template> + + <gl-form-select + id="dashboard-timezone-setting" + v-model="dashboardTimezoneModel" + :options="options" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue new file mode 100644 index 00000000000..812c5a3fe9a --- /dev/null +++ b/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue @@ -0,0 +1,48 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; + +export default { + components: { + GlFormGroup, + GlFormInput, + }, + computed: { + ...mapState(['externalDashboard']), + userDashboardUrl: { + get() { + return this.externalDashboard.url; + }, + set(url) { + this.setExternalDashboardUrl(url); + }, + }, + }, + methods: { + ...mapActions(['setExternalDashboardUrl']), + }, +}; +</script> + +<template> + <gl-form-group + :label="s__('MetricsSettings|External dashboard URL')" + label-for="external-dashboard-url" + > + <template #description> + {{ + s__( + 'MetricsSettings|Add a button to the metrics dashboard linking directly to your existing external dashboard.', + ) + }} + </template> + <!-- placeholder with a url is a false positive --> + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> + <gl-form-input + id="external-dashboard-url" + v-model="userDashboardUrl" + placeholder="https://my-org.gitlab.io/my-dashboards" + /> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue new file mode 100644 index 00000000000..77c356e5a7f --- /dev/null +++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue @@ -0,0 +1,53 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlDeprecatedButton, GlLink } from '@gitlab/ui'; +import ExternalDashboard from './form_group/external_dashboard.vue'; +import DashboardTimezone from './form_group/dashboard_timezone.vue'; + +export default { + components: { + GlDeprecatedButton, + GlLink, + ExternalDashboard, + DashboardTimezone, + }, + computed: { + ...mapState(['helpPage']), + userDashboardUrl: { + get() { + return this.externalDashboard.url; + }, + set(url) { + this.setExternalDashboardUrl(url); + }, + }, + }, + methods: { + ...mapActions(['saveChanges']), + }, +}; +</script> + +<template> + <section class="settings no-animate"> + <div class="settings-header"> + <h3 class="js-section-header h4"> + {{ s__('MetricsSettings|Metrics Dashboard') }} + </h3> + <gl-deprecated-button class="js-settings-toggle">{{ __('Expand') }}</gl-deprecated-button> + <p class="js-section-sub-header"> + {{ s__('MetricsSettings|Manage Metrics Dashboard settings.') }} + <gl-link :href="helpPage">{{ __('Learn more') }}</gl-link> + </p> + </div> + <div class="settings-content"> + <form> + <dashboard-timezone /> + <external-dashboard /> + <gl-deprecated-button variant="success" @click="saveChanges"> + {{ __('Save Changes') }} + </gl-deprecated-button> + </form> + </div> + </section> +</template> diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js index f075291ce98..426a060949e 100644 --- a/app/assets/javascripts/operation_settings/index.js +++ b/app/assets/javascripts/operation_settings/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import store from './store'; -import ExternalDashboardForm from './components/external_dashboard.vue'; +import MetricsSettingsForm from './components/metrics_settings.vue'; export default () => { const el = document.querySelector('.js-operation-settings'); @@ -9,7 +9,7 @@ export default () => { el, store: store(el.dataset), render(createElement) { - return createElement(ExternalDashboardForm); + return createElement(MetricsSettingsForm); }, }); }; diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js index ec05b0c76cf..122acb6bdcf 100644 --- a/app/assets/javascripts/operation_settings/store/actions.js +++ b/app/assets/javascripts/operation_settings/store/actions.js @@ -7,19 +7,23 @@ import * as mutationTypes from './mutation_types'; export const setExternalDashboardUrl = ({ commit }, url) => commit(mutationTypes.SET_EXTERNAL_DASHBOARD_URL, url); -export const updateExternalDashboardUrl = ({ state, dispatch }) => +export const setDashboardTimezone = ({ commit }, selected) => + commit(mutationTypes.SET_DASHBOARD_TIMEZONE, selected); + +export const saveChanges = ({ state, dispatch }) => axios .patch(state.operationsSettingsEndpoint, { project: { metrics_setting_attributes: { - external_dashboard_url: state.externalDashboardUrl, + dashboard_timezone: state.dashboardTimezone.selected, + external_dashboard_url: state.externalDashboard.url, }, }, }) - .then(() => dispatch('receiveExternalDashboardUpdateSuccess')) - .catch(error => dispatch('receiveExternalDashboardUpdateError', error)); + .then(() => dispatch('receiveSaveChangesSuccess')) + .catch(error => dispatch('receiveSaveChangesError', error)); -export const receiveExternalDashboardUpdateSuccess = () => { +export const receiveSaveChangesSuccess = () => { /** * The operations_controller currently handles successful requests * by creating a flash banner messsage to notify the user. @@ -27,8 +31,8 @@ export const receiveExternalDashboardUpdateSuccess = () => { refreshCurrentPage(); }; -export const receiveExternalDashboardUpdateError = (_, error) => { - const { response } = error; +export const receiveSaveChangesError = (_, error) => { + const { response = {} } = error; const message = response.data && response.data.message ? response.data.message : ''; createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); diff --git a/app/assets/javascripts/operation_settings/store/mutation_types.js b/app/assets/javascripts/operation_settings/store/mutation_types.js index 237d2b6122f..92543fd7f03 100644 --- a/app/assets/javascripts/operation_settings/store/mutation_types.js +++ b/app/assets/javascripts/operation_settings/store/mutation_types.js @@ -1,3 +1,2 @@ -/* eslint-disable import/prefer-default-export */ - export const SET_EXTERNAL_DASHBOARD_URL = 'SET_EXTERNAL_DASHBOARD_URL'; +export const SET_DASHBOARD_TIMEZONE = 'SET_DASHBOARD_TIMEZONE'; diff --git a/app/assets/javascripts/operation_settings/store/mutations.js b/app/assets/javascripts/operation_settings/store/mutations.js index 64bb33bb89f..f55717f6c98 100644 --- a/app/assets/javascripts/operation_settings/store/mutations.js +++ b/app/assets/javascripts/operation_settings/store/mutations.js @@ -2,6 +2,9 @@ import * as types from './mutation_types'; export default { [types.SET_EXTERNAL_DASHBOARD_URL](state, url) { - state.externalDashboardUrl = url; + state.externalDashboard.url = url; + }, + [types.SET_DASHBOARD_TIMEZONE](state, selected) { + state.dashboardTimezone.selected = selected; }, }; diff --git a/app/assets/javascripts/operation_settings/store/state.js b/app/assets/javascripts/operation_settings/store/state.js index 72167141c48..c0eca580848 100644 --- a/app/assets/javascripts/operation_settings/store/state.js +++ b/app/assets/javascripts/operation_settings/store/state.js @@ -1,5 +1,10 @@ export default (initialState = {}) => ({ - externalDashboardUrl: initialState.externalDashboardUrl || '', operationsSettingsEndpoint: initialState.operationsSettingsEndpoint, - externalDashboardHelpPagePath: initialState.externalDashboardHelpPagePath, + helpPage: initialState.helpPage, + externalDashboard: { + url: initialState.externalDashboardUrl, + }, + dashboardTimezone: { + selected: initialState.dashboardTimezoneSetting, + }, }); diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js index 46e80ba72e3..2aa37842707 100644 --- a/app/assets/javascripts/pager.js +++ b/app/assets/javascripts/pager.js @@ -1,7 +1,8 @@ import $ from 'jquery'; +import 'vendor/jquery.endless-scroll'; import { getParameterByName } from '~/lib/utils/common_utils'; -import axios from './lib/utils/axios_utils'; -import { removeParams } from './lib/utils/url_utility'; +import axios from '~/lib/utils/axios_utils'; +import { removeParams } from '~/lib/utils/url_utility'; const ENDLESS_SCROLL_BOTTOM_PX = 400; const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000; diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js index 493c216cc6e..143d15f92cd 100644 --- a/app/assets/javascripts/pages/admin/application_settings/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/index.js @@ -2,8 +2,12 @@ import initSettingsPanels from '~/settings_panels'; import projectSelect from '~/project_select'; import selfMonitor from '~/self_monitor'; import maintenanceModeSettings from '~/maintenance_mode_settings'; +import initVariableList from '~/ci_variable_list'; document.addEventListener('DOMContentLoaded', () => { + if (gon.features?.ciInstanceVariablesUi) { + initVariableList('js-instance-variables'); + } selfMonitor(); maintenanceModeSettings(); // Initialize expandable settings panels diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js index ad7276132b9..a4e5df559ff 100644 --- a/app/assets/javascripts/pages/admin/groups/edit/index.js +++ b/app/assets/javascripts/pages/admin/groups/edit/index.js @@ -1,3 +1,3 @@ -import initAvatarPicker from '~/avatar_picker'; +import initFilePickers from '~/file_pickers'; -document.addEventListener('DOMContentLoaded', initAvatarPicker); +document.addEventListener('DOMContentLoaded', initFilePickers); diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js index 6de740ee9ce..b94c999ed12 100644 --- a/app/assets/javascripts/pages/admin/groups/new/index.js +++ b/app/assets/javascripts/pages/admin/groups/new/index.js @@ -1,9 +1,10 @@ import BindInOut from '../../../../behaviors/bind_in_out'; import Group from '../../../../group'; -import initAvatarPicker from '~/avatar_picker'; +import initFilePickers from '~/file_pickers'; document.addEventListener('DOMContentLoaded', () => { BindInOut.initAll(); - new Group(); // eslint-disable-line no-new - initAvatarPicker(); + initFilePickers(); + + return new Group(); }); diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue index b22fbf6b833..8bb093da771 100644 --- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue +++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue @@ -92,7 +92,7 @@ export default { @submit="onSubmit" @cancel="onCancel" > - <template slot="body" slot-scope="props"> + <template #body="props"> <p v-html="props.text"></p> <p v-html="confirmationTextLabel"></p> <form ref="form" :action="deleteProjectUrl" method="post"> diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/clusters/index.js index 4d04c37caa7..4d04c37caa7 100644 --- a/app/assets/javascripts/pages/groups/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index.js diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index f32392c9e29..33e552cd1ba 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -1,4 +1,4 @@ -import initAvatarPicker from '~/avatar_picker'; +import initFilePickers from '~/file_pickers'; import TransferDropdown from '~/groups/transfer_dropdown'; import initConfirmDangerModal from '~/confirm_danger_modal'; import initSettingsPanels from '~/settings_panels'; @@ -10,8 +10,7 @@ import groupsSelect from '~/groups_select'; import projectSelect from '~/project_select'; document.addEventListener('DOMContentLoaded', () => { - initAvatarPicker(); - new TransferDropdown(); // eslint-disable-line no-new + initFilePickers(); initConfirmDangerModal(); initSettingsPanels(); dirtySubmitFactory( @@ -24,4 +23,6 @@ document.addEventListener('DOMContentLoaded', () => { groupsSelect(); projectSelect(); + + return new TransferDropdown(); }); diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 0710fefe70c..640e64b5d3e 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import BindInOut from '~/behaviors/bind_in_out'; import Group from '~/group'; -import initAvatarPicker from '~/avatar_picker'; import GroupPathValidator from './group_path_validator'; +import initFilePickers from '~/file_pickers'; document.addEventListener('DOMContentLoaded', () => { const parentId = $('#group_parent_id'); @@ -10,6 +10,7 @@ document.addEventListener('DOMContentLoaded', () => { new GroupPathValidator(); // eslint-disable-line no-new } BindInOut.initAll(); - new Group(); // eslint-disable-line no-new - initAvatarPicker(); + initFilePickers(); + + return new Group(); }); diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index 37b253d7c48..85daff3f60f 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -8,6 +8,7 @@ import NotificationsForm from '~/notifications_form'; import ProjectsList from '~/projects_list'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GroupTabs from './group_tabs'; +import initNamespaceStorageLimitAlert from '~/namespace_storage_limit_alert'; export default function initGroupDetails(actionName = 'show') { const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); @@ -27,4 +28,6 @@ export default function initGroupDetails(actionName = 'show') { if (newGroupChildWrapper) { new NewGroupChild(newGroupChildWrapper); } + + initNamespaceStorageLimitAlert(); } diff --git a/app/assets/javascripts/pages/ide/index.js b/app/assets/javascripts/pages/ide/index.js index d192df3561e..15933256e75 100644 --- a/app/assets/javascripts/pages/ide/index.js +++ b/app/assets/javascripts/pages/ide/index.js @@ -1,3 +1,4 @@ import { startIde } from '~/ide/index'; +import extendStore from '~/ide/stores/extend'; -startIde(); +startIde({ extendStore }); diff --git a/app/assets/javascripts/pages/import/bitbucket/status/index.js b/app/assets/javascripts/pages/import/bitbucket/status/index.js new file mode 100644 index 00000000000..52b5adb79d1 --- /dev/null +++ b/app/assets/javascripts/pages/import/bitbucket/status/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import { initStoreFromElement, initPropsFromElement } from '~/import_projects'; +import BitbucketStatusTable from '~/import_projects/components/bitbucket_status_table.vue'; + +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('import-projects-mount-element'); + if (!mountElement) return undefined; + + const store = initStoreFromElement(mountElement); + const props = initPropsFromElement(mountElement); + + return new Vue({ + el: mountElement, + store, + render(createElement) { + return createElement(BitbucketStatusTable, { props }); + }, + }); +}); diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue new file mode 100644 index 00000000000..e01c7b80e1a --- /dev/null +++ b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue @@ -0,0 +1,30 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import BitbucketStatusTable from '~/import_projects/components/bitbucket_status_table.vue'; + +export default { + components: { + BitbucketStatusTable, + GlButton, + }, + props: { + providerTitle: { + type: String, + required: true, + }, + reconfigurePath: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <bitbucket-status-table :provider-title="providerTitle"> + <template #actions> + <gl-button variant="info" class="gl-ml-3" data-method="post" :href="reconfigurePath">{{ + __('Reconfigure') + }}</gl-button> + </template> + </bitbucket-status-table> +</template> diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js new file mode 100644 index 00000000000..88455c9b7b9 --- /dev/null +++ b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import { initStoreFromElement, initPropsFromElement } from '~/import_projects'; +import BitbucketServerStatusTable from './components/bitbucket_server_status_table.vue'; + +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('import-projects-mount-element'); + if (!mountElement) return undefined; + + const store = initStoreFromElement(mountElement); + const props = initPropsFromElement(mountElement); + const { reconfigurePath } = mountElement.dataset; + + return new Vue({ + el: mountElement, + store, + render(createElement) { + return createElement(BitbucketServerStatusTable, { props: { ...props, reconfigurePath } }); + }, + }); +}); diff --git a/app/assets/javascripts/pages/import/fogbugz/status/index.js b/app/assets/javascripts/pages/import/fogbugz/status/index.js new file mode 100644 index 00000000000..dcd84f0faf9 --- /dev/null +++ b/app/assets/javascripts/pages/import/fogbugz/status/index.js @@ -0,0 +1,7 @@ +import mountImportProjectsTable from '~/import_projects'; + +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('import-projects-mount-element'); + + mountImportProjectsTable(mountElement); +}); diff --git a/app/assets/javascripts/pages/import/gitlab/status/index.js b/app/assets/javascripts/pages/import/gitlab/status/index.js new file mode 100644 index 00000000000..dcd84f0faf9 --- /dev/null +++ b/app/assets/javascripts/pages/import/gitlab/status/index.js @@ -0,0 +1,7 @@ +import mountImportProjectsTable from '~/import_projects'; + +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('import-projects-mount-element'); + + mountImportProjectsTable(mountElement); +}); diff --git a/app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js b/app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js index edd7e38471b..e93def5323f 100644 --- a/app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js +++ b/app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js @@ -1,3 +1,3 @@ -import initU2F from '../../../shared/sessions/u2f'; +import { mount2faAuthentication } from '~/authentication/mount_2fa'; -document.addEventListener('DOMContentLoaded', initU2F); +document.addEventListener('DOMContentLoaded', mount2faAuthentication); diff --git a/app/assets/javascripts/pages/omniauth_callbacks/index.js b/app/assets/javascripts/pages/omniauth_callbacks/index.js index c2c069d1ca8..e93def5323f 100644 --- a/app/assets/javascripts/pages/omniauth_callbacks/index.js +++ b/app/assets/javascripts/pages/omniauth_callbacks/index.js @@ -1,3 +1,3 @@ -import initU2F from '../../shared/sessions/u2f'; +import { mount2faAuthentication } from '~/authentication/mount_2fa'; -document.addEventListener('DOMContentLoaded', initU2F); +document.addEventListener('DOMContentLoaded', mount2faAuthentication); 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 95936c2d1db..1aeba6669ee 100644 --- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js +++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js @@ -1,6 +1,5 @@ -import $ from 'jquery'; -import U2FRegister from '~/u2f/register'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { mount2faRegistration } from '~/authentication/mount_2fa'; document.addEventListener('DOMContentLoaded', () => { const twoFactorNode = document.querySelector('.js-two-factor-auth'); @@ -12,6 +11,5 @@ document.addEventListener('DOMContentLoaded', () => { if (flashAlert) flashAlert.insertAdjacentHTML('beforeend', button); } - const u2fRegister = new U2FRegister($('#js-register-u2f'), gon.u2f); - u2fRegister.start(); + mount2faRegistration(); }); diff --git a/app/assets/javascripts/pages/projects/clusters/index.js b/app/assets/javascripts/pages/projects/clusters/index.js new file mode 100644 index 00000000000..4d04c37caa7 --- /dev/null +++ b/app/assets/javascripts/pages/projects/clusters/index.js @@ -0,0 +1,5 @@ +import initCreateCluster from '~/create_cluster/init_create_cluster'; + +document.addEventListener('DOMContentLoaded', () => { + initCreateCluster(document, gon); +}); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index c9dbe576c4b..9fb07917f9b 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -4,12 +4,12 @@ import setupTransferEdit from '~/transfer_edit'; import initConfirmDangerModal from '~/confirm_danger_modal'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; -import initAvatarPicker from '~/avatar_picker'; +import initFilePickers from '~/file_pickers'; import initProjectLoadingSpinner from '../shared/save_project_loader'; import initProjectPermissionsSettings from '../shared/permissions'; document.addEventListener('DOMContentLoaded', () => { - initAvatarPicker(); + initFilePickers(); initConfirmDangerModal(); initSettingsPanels(); mountBadgeSettings(PROJECT_BADGE); diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js index 31ec4e29ad2..d3028aec313 100644 --- a/app/assets/javascripts/pages/projects/environments/metrics/index.js +++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js @@ -1,3 +1,3 @@ -import monitoringBundle from '~/monitoring/monitoring_bundle_with_alerts'; +import monitoringApp from '~/monitoring/monitoring_app'; -document.addEventListener('DOMContentLoaded', monitoringBundle); +document.addEventListener('DOMContentLoaded', monitoringApp); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 803f4e37705..03504fba1ae 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -1,10 +1,12 @@ import Vue from 'vue'; import { __ } from '~/locale'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; +import CodeCoverage from '../components/code_coverage.vue'; import SeriesDataMixin from './series_data_mixin'; document.addEventListener('DOMContentLoaded', () => { const languagesContainer = document.getElementById('js-languages-chart'); + const codeCoverageContainer = document.getElementById('js-code-coverage-chart'); const monthContainer = document.getElementById('js-month-chart'); const weekdayContainer = document.getElementById('js-weekday-chart'); const hourContainer = document.getElementById('js-hour-chart'); @@ -59,6 +61,18 @@ document.addEventListener('DOMContentLoaded', () => { // eslint-disable-next-line no-new new Vue({ + el: codeCoverageContainer, + render(h) { + return h(CodeCoverage, { + props: { + graphEndpoint: codeCoverageContainer.dataset?.graphEndpoint, + }, + }); + }, + }); + + // eslint-disable-next-line no-new + new Vue({ el: monthContainer, components: { GlColumnChart, diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue new file mode 100644 index 00000000000..af8fb032c22 --- /dev/null +++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue @@ -0,0 +1,177 @@ +<script> +import { GlAlert, GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import dateFormat from 'dateformat'; +import axios from '~/lib/utils/axios_utils'; +import { get } from 'lodash'; + +import { __ } from '~/locale'; + +export default { + components: { + GlAlert, + GlAreaChart, + GlDropdown, + GlDropdownItem, + GlIcon, + GlSprintf, + }, + props: { + graphEndpoint: { + type: String, + required: true, + }, + }, + data() { + return { + dailyCoverageData: [], + hasFetchError: false, + isLoading: true, + selectedCoverageIndex: 0, + tooltipTitle: '', + coveragePercentage: '', + chartOptions: { + yAxis: { + name: __('Bi-weekly code coverage'), + type: 'value', + min: 0, + max: 100, + }, + xAxis: { + name: '', + type: 'category', + }, + }, + }; + }, + computed: { + hasData() { + return this.dailyCoverageData.length > 0; + }, + isReady() { + return !this.isLoading && !this.hasFetchError; + }, + canShowData() { + return this.isReady && this.hasData; + }, + noDataAvailable() { + return this.isReady && !this.hasData; + }, + selectedDailyCoverage() { + return this.hasData && this.dailyCoverageData[this.selectedCoverageIndex]; + }, + selectedDailyCoverageName() { + return this.selectedDailyCoverage?.group_name; + }, + formattedData() { + if (this.selectedDailyCoverage?.data) { + return this.selectedDailyCoverage.data.map(value => [ + dateFormat(value.date, 'mmm dd'), + value.coverage, + ]); + } + + // If the fetching failed, we return an empty array which + // allow the graph to render while empty + return []; + }, + chartData() { + return [ + { + // The default string 'data' will get shown in the legend if we fail to fetch the data + name: this.canShowData ? this.selectedDailyCoverageName : __('data'), + data: this.formattedData, + type: 'line', + smooth: true, + }, + ]; + }, + }, + created() { + axios + .get(this.graphEndpoint) + .then(({ data }) => { + this.dailyCoverageData = data; + }) + .catch(() => { + this.hasFetchError = true; + }) + .finally(() => { + this.isLoading = false; + }); + }, + methods: { + setSelectedCoverage(index) { + this.selectedCoverageIndex = index; + }, + formatTooltipText(params) { + this.tooltipTitle = params.value; + this.coveragePercentage = get(params, 'seriesData[0].data[1]', ''); + }, + }, + height: 200, +}; +</script> + +<template> + <div> + <div class="gl-mt-3 gl-mb-3"> + <gl-alert + v-if="hasFetchError" + variant="danger" + :title="s__('Code Coverage|Couldn\'t fetch the code coverage data')" + :dismissible="false" + /> + <gl-alert + v-if="noDataAvailable" + variant="info" + :title="s__('Code Coverage| Empty code coverage data')" + :dismissible="false" + > + <span> + {{ __('It seems that there is currently no available data for code coverage') }} + </span> + </gl-alert> + <gl-dropdown v-if="canShowData" :text="selectedDailyCoverageName"> + <gl-dropdown-item + v-for="({ group_name }, index) in dailyCoverageData" + :key="index" + :value="group_name" + @click="setSelectedCoverage(index)" + > + <div class="gl-display-flex"> + <gl-icon + v-if="index === selectedCoverageIndex" + name="mobile-issue-close" + class="gl-absolute" + /> + <span class="gl-display-flex align-items-center ml-4"> + {{ group_name }} + </span> + </div> + </gl-dropdown-item> + </gl-dropdown> + </div> + <gl-area-chart + v-if="!isLoading" + :height="$options.height" + :data="chartData" + :option="chartOptions" + :format-tooltip-text="formatTooltipText" + > + <template v-if="canShowData" #tooltipTitle> + {{ tooltipTitle }} + </template> + <template v-if="canShowData" #tooltipContent> + <gl-sprintf :message="__('Code Coverage: %{coveragePercentage}%{percentSymbol}')"> + <template #coveragePercentage> + {{ coveragePercentage }} + </template> + <template #percentSymbol> + % + </template> + </gl-sprintf> + </template> + </gl-area-chart> + </div> +</template> diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 190d0806c28..8e0af018b61 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,10 +1,7 @@ import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; -import initCreateCluster from '~/create_cluster/init_create_cluster'; document.addEventListener('DOMContentLoaded', () => { - initCreateCluster(document, gon); - new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index e8e0cda2139..a66b665d152 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -9,6 +9,7 @@ import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; import initIssuablesList from '~/issuables_list'; import initManualOrdering from '~/manual_ordering'; +import { showLearnGitLabIssuesPopover } from '~/onboarding_issues'; document.addEventListener('DOMContentLoaded', () => { IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); @@ -24,4 +25,5 @@ document.addEventListener('DOMContentLoaded', () => { initManualOrdering(); initIssuablesList(); + showLearnGitLabIssuesPopover(); }); diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue index 3b26047455d..08078fa6b62 100644 --- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue @@ -90,7 +90,7 @@ export default { footer-primary-button-variant="warning" @submit="onSubmit" > - <div slot="title" class="modal-title-with-label" v-html="title">{{ title }}</div> + <div slot="title" class="modal-title-with-label" v-html="title"></div> {{ text }} </gl-modal> 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 ddc648702f1..4708970efef 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,4 +1,5 @@ import initMrNotes from '~/mr_notes'; +import { initReviewBar } from '~/batch_comments'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; import initShow from '../init_merge_request_show'; @@ -8,4 +9,5 @@ document.addEventListener('DOMContentLoaded', () => { initSidebarBundle(); } initMrNotes(); + initReviewBar(); }); diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index 097403ba9e2..e17059dd55a 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -1,7 +1,46 @@ import initProjectVisibilitySelector from '../../../project_visibility'; import initProjectNew from '../../../projects/project_new'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import Tracking from '~/tracking'; document.addEventListener('DOMContentLoaded', () => { initProjectVisibilitySelector(); initProjectNew.bindEvents(); + + const { category, property } = gon.tracking_data ?? { category: 'projects:new' }; + const hasNewCreateProjectUi = 'newCreateProjectUi' in gon?.features; + + if (!hasNewCreateProjectUi) { + // Setting additional tracking for HAML template + + Array.from( + document.querySelectorAll('.project-edit-container [data-experiment-track-label]'), + ).forEach(node => + node.addEventListener('click', event => { + const { experimentTrackLabel: label } = event.currentTarget.dataset; + Tracking.event(category, 'click_tab', { property, label }); + }), + ); + } else { + import( + /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation' + ) + .then(m => { + const el = document.querySelector('.js-experiment-new-project-creation'); + + if (!el) { + return; + } + + const config = { + hasErrors: 'hasErrors' in el.dataset, + isCiCdAvailable: 'isCiCdAvailable' in el.dataset, + }; + m.default(el, config); + }) + .catch(() => { + createFlash(__('An error occurred while loading project creation UI')); + }); + } }); diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js index bbad3238ec4..2c37d7da4a7 100644 --- a/app/assets/javascripts/pages/projects/pipelines/index/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js @@ -51,6 +51,7 @@ document.addEventListener( ciLintPath: this.dataset.ciLintPath, resetCachePath: this.dataset.resetCachePath, projectId: this.dataset.projectId, + params: JSON.parse(this.dataset.params), }, }); }, diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index ab32fe18972..7181332a1d6 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -46,7 +46,11 @@ export default { allowedVisibilityOptions: { type: Array, required: false, - default: () => [0, 10, 20], + default: () => [ + visibilityOptions.PRIVATE, + visibilityOptions.INTERNAL, + visibilityOptions.PUBLIC, + ], }, lfsAvailable: { type: Boolean, @@ -118,16 +122,14 @@ export default { const defaults = { visibilityOptions, visibilityLevel: visibilityOptions.PUBLIC, - // TODO: Change all of these to use the visibilityOptions constants - // https://gitlab.com/gitlab-org/gitlab/-/issues/214667 - issuesAccessLevel: 20, - repositoryAccessLevel: 20, - forkingAccessLevel: 20, - mergeRequestsAccessLevel: 20, - buildsAccessLevel: 20, - wikiAccessLevel: 20, - snippetsAccessLevel: 20, - pagesAccessLevel: 20, + issuesAccessLevel: featureAccessLevel.EVERYONE, + repositoryAccessLevel: featureAccessLevel.EVERYONE, + forkingAccessLevel: featureAccessLevel.EVERYONE, + mergeRequestsAccessLevel: featureAccessLevel.EVERYONE, + buildsAccessLevel: featureAccessLevel.EVERYONE, + wikiAccessLevel: featureAccessLevel.EVERYONE, + snippetsAccessLevel: featureAccessLevel.EVERYONE, + pagesAccessLevel: featureAccessLevel.EVERYONE, metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS, containerRegistryEnabled: true, lfsEnabled: true, @@ -180,7 +182,7 @@ export default { }, repositoryEnabled() { - return this.repositoryAccessLevel > 0; + return this.repositoryAccessLevel > featureAccessLevel.NOT_ENABLED; }, visibilityLevelDescription() { @@ -206,40 +208,70 @@ export default { visibilityLevel(value, oldValue) { if (value === visibilityOptions.PRIVATE) { // when private, features are restricted to "only team members" - this.issuesAccessLevel = Math.min(10, this.issuesAccessLevel); - this.repositoryAccessLevel = Math.min(10, this.repositoryAccessLevel); - this.mergeRequestsAccessLevel = Math.min(10, this.mergeRequestsAccessLevel); - this.buildsAccessLevel = Math.min(10, this.buildsAccessLevel); - this.wikiAccessLevel = Math.min(10, this.wikiAccessLevel); - this.snippetsAccessLevel = Math.min(10, this.snippetsAccessLevel); - this.metricsDashboardAccessLevel = Math.min(10, this.metricsDashboardAccessLevel); - if (this.pagesAccessLevel === 20) { + this.issuesAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.issuesAccessLevel, + ); + this.repositoryAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.repositoryAccessLevel, + ); + this.mergeRequestsAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.mergeRequestsAccessLevel, + ); + this.buildsAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.buildsAccessLevel, + ); + this.wikiAccessLevel = Math.min(featureAccessLevel.PROJECT_MEMBERS, this.wikiAccessLevel); + this.snippetsAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.snippetsAccessLevel, + ); + this.metricsDashboardAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.metricsDashboardAccessLevel, + ); + if (this.pagesAccessLevel === featureAccessLevel.EVERYONE) { // When from Internal->Private narrow access for only members - this.pagesAccessLevel = 10; + this.pagesAccessLevel = featureAccessLevel.PROJECT_MEMBERS; } this.highlightChanges(); } else if (oldValue === visibilityOptions.PRIVATE) { // if changing away from private, make enabled features more permissive - if (this.issuesAccessLevel > 0) this.issuesAccessLevel = 20; - if (this.repositoryAccessLevel > 0) this.repositoryAccessLevel = 20; - if (this.mergeRequestsAccessLevel > 0) this.mergeRequestsAccessLevel = 20; - if (this.buildsAccessLevel > 0) this.buildsAccessLevel = 20; - if (this.wikiAccessLevel > 0) this.wikiAccessLevel = 20; - if (this.snippetsAccessLevel > 0) this.snippetsAccessLevel = 20; - if (this.pagesAccessLevel === 10) this.pagesAccessLevel = 20; - if (this.metricsDashboardAccessLevel === 10) this.metricsDashboardAccessLevel = 20; + if (this.issuesAccessLevel > featureAccessLevel.NOT_ENABLED) + this.issuesAccessLevel = featureAccessLevel.EVERYONE; + if (this.repositoryAccessLevel > featureAccessLevel.NOT_ENABLED) + this.repositoryAccessLevel = featureAccessLevel.EVERYONE; + if (this.mergeRequestsAccessLevel > featureAccessLevel.NOT_ENABLED) + this.mergeRequestsAccessLevel = featureAccessLevel.EVERYONE; + if (this.buildsAccessLevel > featureAccessLevel.NOT_ENABLED) + this.buildsAccessLevel = featureAccessLevel.EVERYONE; + if (this.wikiAccessLevel > featureAccessLevel.NOT_ENABLED) + this.wikiAccessLevel = featureAccessLevel.EVERYONE; + if (this.snippetsAccessLevel > featureAccessLevel.NOT_ENABLED) + this.snippetsAccessLevel = featureAccessLevel.EVERYONE; + if (this.pagesAccessLevel === featureAccessLevel.PROJECT_MEMBERS) + this.pagesAccessLevel = featureAccessLevel.EVERYONE; + if (this.metricsDashboardAccessLevel === featureAccessLevel.PROJECT_MEMBERS) + this.metricsDashboardAccessLevel = featureAccessLevel.EVERYONE; this.highlightChanges(); } }, issuesAccessLevel(value, oldValue) { - if (value === 0) toggleHiddenClassBySelector('.issues-feature', true); - else if (oldValue === 0) toggleHiddenClassBySelector('.issues-feature', false); + if (value === featureAccessLevel.NOT_ENABLED) + toggleHiddenClassBySelector('.issues-feature', true); + else if (oldValue === featureAccessLevel.NOT_ENABLED) + toggleHiddenClassBySelector('.issues-feature', false); }, mergeRequestsAccessLevel(value, oldValue) { - if (value === 0) toggleHiddenClassBySelector('.merge-requests-feature', true); - else if (oldValue === 0) toggleHiddenClassBySelector('.merge-requests-feature', false); + if (value === featureAccessLevel.NOT_ENABLED) + toggleHiddenClassBySelector('.merge-requests-feature', true); + else if (oldValue === featureAccessLevel.NOT_ENABLED) + toggleHiddenClassBySelector('.merge-requests-feature', false); }, }, diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 6ae10c98058..3c44053e2b2 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,4 +1,6 @@ import $ from 'jquery'; +import 'jquery.waitforimages'; + import initBlob from '~/blob_edit/blob_bundle'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import NotificationsForm from '~/notifications_form'; @@ -12,9 +14,12 @@ import initReadMore from '~/read_more'; import leaveByUrl from '~/namespaces/leave_by_url'; import Star from '../../../star'; import notificationsDropdown from '../../../notifications_dropdown'; +import initNamespaceStorageLimitAlert from '~/namespace_storage_limit_alert'; +import { showLearnGitLabProjectPopover } from '~/onboarding_issues'; document.addEventListener('DOMContentLoaded', () => { initReadMore(); + initNamespaceStorageLimitAlert(); new Star(); // eslint-disable-line no-new notificationsDropdown(); new ShortcutsNavigation(); // eslint-disable-line no-new @@ -55,4 +60,6 @@ document.addEventListener('DOMContentLoaded', () => { throw e; }); } + + showLearnGitLabProjectPopover(); }); diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index 16d71379e31..0d1d32317fe 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -1,4 +1,6 @@ import $ from 'jquery'; +import 'jquery.waitforimages'; + import Vue from 'vue'; import initBlob from '~/blob_edit/blob_bundle'; import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js index f5fd84d69ac..9c75531ca40 100644 --- a/app/assets/javascripts/pages/projects/wikis/index.js +++ b/app/assets/javascripts/pages/projects/wikis/index.js @@ -1,41 +1,3 @@ -import $ from 'jquery'; -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import csrf from '~/lib/utils/csrf'; -import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki'; -import Wikis from './wikis'; -import ZenMode from '../../../zen_mode'; -import GLForm from '../../../gl_form'; -import deleteWikiModal from './components/delete_wiki_modal.vue'; +import initWikis from '~/pages/shared/wikis'; -document.addEventListener('DOMContentLoaded', () => { - new Wikis(); // eslint-disable-line no-new - new ShortcutsWiki(); // eslint-disable-line no-new - new ZenMode(); // eslint-disable-line no-new - new GLForm($('.wiki-form')); // eslint-disable-line no-new - - const deleteWikiModalWrapperEl = document.getElementById('delete-wiki-modal-wrapper'); - - if (deleteWikiModalWrapperEl) { - Vue.use(Translate); - - const { deleteWikiUrl, pageTitle } = deleteWikiModalWrapperEl.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el: deleteWikiModalWrapperEl, - data: { - deleteWikiUrl: '', - }, - render(createElement) { - return createElement(deleteWikiModal, { - props: { - pageTitle, - deleteWikiUrl, - csrfToken: csrf.token, - }, - }); - }, - }); - } -}); +document.addEventListener('DOMContentLoaded', initWikis); diff --git a/app/assets/javascripts/pages/sessions/index.js b/app/assets/javascripts/pages/sessions/index.js index c2c069d1ca8..e93def5323f 100644 --- a/app/assets/javascripts/pages/sessions/index.js +++ b/app/assets/javascripts/pages/sessions/index.js @@ -1,3 +1,3 @@ -import initU2F from '../../shared/sessions/u2f'; +import { mount2faAuthentication } from '~/authentication/mount_2fa'; -document.addEventListener('DOMContentLoaded', initU2F); +document.addEventListener('DOMContentLoaded', mount2faAuthentication); diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js index 191221a48cd..8d2d5d41f6a 100644 --- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js +++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js @@ -5,13 +5,12 @@ import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; * OAuth-based login buttons have a separate "remember me" checkbox. * * Toggling this checkbox adds/removes a `remember_me` parameter to the - * login buttons' href, which is passed on to the omniauth callback. + * login buttons' parent form action, which is passed on to the omniauth callback. */ export default class OAuthRememberMe { constructor(opts = {}) { this.container = opts.container || ''; - this.loginLinkSelector = '.oauth-login'; } bindEvents() { @@ -22,12 +21,13 @@ export default class OAuthRememberMe { const rememberMe = $(event.target).is(':checked'); $('.oauth-login', this.container).each((i, element) => { - const href = $(element).attr('href'); + const $form = $(element).parent('form'); + const href = $form.attr('action'); if (rememberMe) { - $(element).attr('href', mergeUrlParams({ remember_me: 1 }, href)); + $form.attr('action', mergeUrlParams({ remember_me: 1 }, href)); } else { - $(element).attr('href', removeParams(['remember_me'], href)); + $form.attr('action', removeParams(['remember_me'], href)); } }); } diff --git a/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js index e617fecaa0f..1d47a9aed47 100644 --- a/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js +++ b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js @@ -12,7 +12,7 @@ export default function preserveUrlFragment(fragment = '') { // Append the fragment to all sign-in/sign-up form actions so it is preserved when the user is // eventually redirected back to the originally requested URL. - const forms = document.querySelectorAll('#signin-container form'); + const forms = document.querySelectorAll('#signin-container .tab-content form'); Array.prototype.forEach.call(forms, form => { const actionWithFragment = setUrlFragment(form.getAttribute('action'), `#${normalFragment}`); form.setAttribute('action', actionWithFragment); @@ -20,13 +20,13 @@ export default function preserveUrlFragment(fragment = '') { // Append a redirect_fragment query param to all oauth provider links. The redirect_fragment // query param will be available in the omniauth callback upon successful authentication - const anchors = document.querySelectorAll('#signin-container a.oauth-login'); - Array.prototype.forEach.call(anchors, anchor => { + const oauthForms = document.querySelectorAll('#signin-container .omniauth-container form'); + Array.prototype.forEach.call(oauthForms, oauthForm => { const newHref = mergeUrlParams( { redirect_fragment: normalFragment }, - anchor.getAttribute('href'), + oauthForm.getAttribute('action'), ); - anchor.setAttribute('href', newHref); + oauthForm.setAttribute('action', newHref); }); } } diff --git a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue index 580cca49b5e..580cca49b5e 100644 --- a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/index.js new file mode 100644 index 00000000000..5e23b9bab4e --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/index.js @@ -0,0 +1,41 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import csrf from '~/lib/utils/csrf'; +import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki'; +import Wikis from './wikis'; +import ZenMode from '../../../zen_mode'; +import GLForm from '../../../gl_form'; +import deleteWikiModal from './components/delete_wiki_modal.vue'; + +export default () => { + new Wikis(); // eslint-disable-line no-new + new ShortcutsWiki(); // eslint-disable-line no-new + new ZenMode(); // eslint-disable-line no-new + new GLForm($('.wiki-form')); // eslint-disable-line no-new + + const deleteWikiModalWrapperEl = document.getElementById('delete-wiki-modal-wrapper'); + + if (deleteWikiModalWrapperEl) { + Vue.use(Translate); + + const { deleteWikiUrl, pageTitle } = deleteWikiModalWrapperEl.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: deleteWikiModalWrapperEl, + data: { + deleteWikiUrl: '', + }, + render(createElement) { + return createElement(deleteWikiModal, { + props: { + pageTitle, + deleteWikiUrl, + csrfToken: csrf.token, + }, + }); + }, + }); + } +}; diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js index ed67219383b..ed67219383b 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/shared/wikis/wikis.js diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index e1a0e2df0e0..ef24dbfb6ce 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -39,6 +39,11 @@ export default { metricDetails() { return this.currentRequest.details[this.metric]; }, + metricDetailsLabel() { + return this.metricDetails.duration + ? `${this.metricDetails.duration} / ${this.metricDetails.calls}` + : this.metricDetails.calls; + }, detailsList() { return this.metricDetails.details; }, @@ -68,7 +73,7 @@ export default { type="button" data-toggle="modal" > - {{ metricDetails.duration }} / {{ metricDetails.calls }} + {{ metricDetailsLabel }} </button> <gl-modal :id="`modal-peek-${metric}-details`" @@ -80,7 +85,9 @@ export default { <template v-if="detailsList.length"> <tr v-for="(item, index) in detailsList" :key="index"> <td> - <span>{{ sprintf(__('%{duration}ms'), { duration: item.duration }) }}</span> + <span v-if="item.duration">{{ + sprintf(__('%{duration}ms'), { duration: item.duration }) + }}</span> </td> <td> <div class="js-toggle-container"> diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 1df5562e1b6..cccb5e1be06 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -38,6 +38,11 @@ export default { keys: ['sql'], }, { + metric: 'bullet', + header: s__('PerformanceBar|Bullet notifications'), + keys: ['notification'], + }, + { metric: 'gitaly', header: s__('PerformanceBar|Gitaly calls'), keys: ['feature', 'request'], @@ -50,7 +55,12 @@ export default { { metric: 'redis', header: s__('PerformanceBar|Redis calls'), - keys: ['cmd'], + keys: ['cmd', 'instance'], + }, + { + metric: 'es', + header: s__('PerformanceBar|Elasticsearch calls'), + keys: ['request', 'body'], }, { metric: 'total', diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js new file mode 100644 index 00000000000..6e292299778 --- /dev/null +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -0,0 +1,15 @@ +import PersistentUserCallout from './persistent_user_callout'; + +const PERSISTENT_USER_CALLOUTS = [ + '.js-recovery-settings-callout', + '.js-users-over-license-callout', + '.js-admin-licensed-user-count-threshold', +]; + +const initCallouts = () => { + PERSISTENT_USER_CALLOUTS.forEach(calloutContainer => + PersistentUserCallout.factory(document.querySelector(calloutContainer)), + ); +}; + +export default initCallouts; diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js new file mode 100644 index 00000000000..51b1fb4f4cc --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/constants.js @@ -0,0 +1,10 @@ +/* Error constants */ +export const PARSE_FAILURE = 'parse_failure'; +export const LOAD_FAILURE = 'load_failure'; +export const UNSUPPORTED_DATA = 'unsupported_data'; +export const DEFAULT = 'default'; + +/* Interaction handles */ +export const IS_HIGHLIGHTED = 'dag-highlighted'; +export const LINK_SELECTOR = 'dag-link'; +export const NODE_SELECTOR = 'dag-node'; diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue new file mode 100644 index 00000000000..6e0d23ef87f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -0,0 +1,136 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import DagGraph from './dag_graph.vue'; +import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from './constants'; +import { parseData } from './parsing_utils'; + +export default { + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Dag', + components: { + DagGraph, + GlAlert, + GlLink, + GlSprintf, + }, + props: { + graphUrl: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + showFailureAlert: false, + showBetaInfo: true, + failureType: null, + graphData: null, + }; + }, + errorTexts: { + [LOAD_FAILURE]: __('We are currently unable to fetch data for this graph.'), + [PARSE_FAILURE]: __('There was an error parsing the data for this graph.'), + [UNSUPPORTED_DATA]: __('A DAG must have two dependent jobs to be visualized on this tab.'), + [DEFAULT]: __('An unknown error occurred while loading this graph.'), + }, + computed: { + betaMessage() { + return __( + 'This feature is currently in beta. We invite you to %{linkStart}give feedback%{linkEnd}.', + ); + }, + failure() { + switch (this.failureType) { + case LOAD_FAILURE: + return { + text: this.$options.errorTexts[LOAD_FAILURE], + variant: 'danger', + }; + case PARSE_FAILURE: + return { + text: this.$options.errorTexts[PARSE_FAILURE], + variant: 'danger', + }; + case UNSUPPORTED_DATA: + return { + text: this.$options.errorTexts[UNSUPPORTED_DATA], + variant: 'info', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT], + vatiant: 'danger', + }; + } + }, + shouldDisplayGraph() { + return Boolean(!this.showFailureAlert && this.graphData); + }, + }, + mounted() { + const { processGraphData, reportFailure } = this; + + if (!this.graphUrl) { + reportFailure(); + return; + } + + axios + .get(this.graphUrl) + .then(response => { + processGraphData(response.data); + }) + .catch(() => reportFailure(LOAD_FAILURE)); + }, + methods: { + processGraphData(data) { + let parsed; + + try { + parsed = parseData(data.stages); + } catch { + this.reportFailure(PARSE_FAILURE); + return; + } + + if (parsed.links.length < 2) { + this.reportFailure(UNSUPPORTED_DATA); + return; + } + + this.graphData = parsed; + }, + hideAlert() { + this.showFailureAlert = false; + }, + hideBetaInfo() { + this.showBetaInfo = false; + }, + reportFailure(type) { + this.showFailureAlert = true; + this.failureType = type; + }, + }, +}; +</script> +<template> + <div> + <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert"> + {{ failure.text }} + </gl-alert> + + <gl-alert v-if="showBetaInfo" @dismiss="hideBetaInfo"> + <gl-sprintf :message="betaMessage"> + <template #link="{ content }"> + <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/220368" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> + <dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue new file mode 100644 index 00000000000..063ec091e4d --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue @@ -0,0 +1,299 @@ +<script> +import * as d3 from 'd3'; +import { uniqueId } from 'lodash'; +import { LINK_SELECTOR, NODE_SELECTOR, PARSE_FAILURE } from './constants'; +import { + highlightLinks, + restoreLinks, + toggleLinkHighlight, + togglePathHighlights, +} from './interactions'; +import { getMaxNodes, removeOrphanNodes } from './parsing_utils'; +import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils'; + +export default { + viewOptions: { + baseHeight: 300, + baseWidth: 1000, + minNodeHeight: 60, + nodeWidth: 16, + nodePadding: 25, + paddingForLabels: 100, + labelMargin: 8, + + baseOpacity: 0.8, + containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join( + ' ', + ), + }, + gitLabColorRotation: [ + '#e17223', + '#83ab4a', + '#5772ff', + '#b24800', + '#25d2d2', + '#006887', + '#487900', + '#d84280', + '#3547de', + '#6f3500', + '#006887', + '#275600', + '#b31756', + ], + props: { + graphData: { + type: Object, + required: true, + }, + }, + data() { + return { + color: () => {}, + width: 0, + height: 0, + }; + }, + mounted() { + let countedAndTransformed; + + try { + countedAndTransformed = this.transformData(this.graphData); + } catch { + this.$emit('onFailure', PARSE_FAILURE); + return; + } + + this.drawGraph(countedAndTransformed); + }, + methods: { + addSvg() { + return d3 + .select('.dag-graph-container') + .append('svg') + .attr('viewBox', [0, 0, this.width, this.height]) + .attr('width', this.width) + .attr('height', this.height); + }, + + appendLinks(link) { + return ( + link + .append('path') + .attr('d', (d, i) => createLinkPath(d, i, this.$options.viewOptions.nodeWidth)) + .attr('stroke', ({ gradId }) => `url(#${gradId})`) + .style('stroke-linejoin', 'round') + // minus two to account for the rounded nodes + .attr('stroke-width', ({ width }) => Math.max(1, width - 2)) + .attr('clip-path', ({ clipId }) => `url(#${clipId})`) + ); + }, + + appendLinkInteractions(link) { + return link + .on('mouseover', highlightLinks) + .on('mouseout', restoreLinks.bind(null, this.$options.viewOptions.baseOpacity)) + .on('click', toggleLinkHighlight.bind(null, this.$options.viewOptions.baseOpacity)); + }, + + appendNodeInteractions(node) { + return node.on( + 'click', + togglePathHighlights.bind(null, this.$options.viewOptions.baseOpacity), + ); + }, + + appendLabelAsForeignObject(d, i, n) { + const currentNode = n[i]; + const { height, wrapperWidth, width, x, y, textAlign } = labelPosition(d, { + ...this.$options.viewOptions, + width: this.width, + }); + + const labelClasses = [ + 'gl-display-flex', + 'gl-pointer-events-none', + 'gl-flex-direction-column', + 'gl-justify-content-center', + 'gl-overflow-wrap-break', + ].join(' '); + + return ( + d3 + .select(currentNode) + .attr('requiredFeatures', 'http://www.w3.org/TR/SVG11/feature#Extensibility') + .attr('height', height) + /* + items with a 'max-content' width will have a wrapperWidth for the foreignObject + */ + .attr('width', wrapperWidth || width) + .attr('x', x) + .attr('y', y) + .classed('gl-overflow-visible', true) + .append('xhtml:div') + .classed(labelClasses, true) + .style('height', height) + .style('width', width) + .style('text-align', textAlign) + .text(({ name }) => name) + ); + }, + + createAndAssignId(datum, field, modifier = '') { + const id = uniqueId(modifier); + /* eslint-disable-next-line no-param-reassign */ + datum[field] = id; + return id; + }, + + createClip(link) { + return link + .append('clipPath') + .attr('id', d => { + return this.createAndAssignId(d, 'clipId', 'dag-clip'); + }) + .append('path') + .attr('d', calculateClip); + }, + + createGradient(link) { + const gradient = link + .append('linearGradient') + .attr('id', d => { + return this.createAndAssignId(d, 'gradId', 'dag-grad'); + }) + .attr('gradientUnits', 'userSpaceOnUse') + .attr('x1', ({ source }) => source.x1) + .attr('x2', ({ target }) => target.x0); + + gradient + .append('stop') + .attr('offset', '0%') + .attr('stop-color', ({ source }) => this.color(source)); + + gradient + .append('stop') + .attr('offset', '100%') + .attr('stop-color', ({ target }) => this.color(target)); + }, + + createLinks(svg, linksData) { + const links = this.generateLinks(svg, linksData); + this.createGradient(links); + this.createClip(links); + this.appendLinks(links); + this.appendLinkInteractions(links); + }, + + createNodes(svg, nodeData) { + const nodes = this.generateNodes(svg, nodeData); + this.labelNodes(svg, nodeData); + this.appendNodeInteractions(nodes); + }, + + drawGraph({ maxNodesPerLayer, linksAndNodes }) { + const { + baseWidth, + baseHeight, + minNodeHeight, + nodeWidth, + nodePadding, + paddingForLabels, + } = this.$options.viewOptions; + + this.width = baseWidth; + this.height = baseHeight + maxNodesPerLayer * minNodeHeight; + this.color = this.initColors(); + + const { links, nodes } = createSankey({ + width: this.width, + height: this.height, + nodeWidth, + nodePadding, + paddingForLabels, + })(linksAndNodes); + + const svg = this.addSvg(); + this.createLinks(svg, links); + this.createNodes(svg, nodes); + }, + + generateLinks(svg, linksData) { + return svg + .append('g') + .attr('fill', 'none') + .attr('stroke-opacity', this.$options.viewOptions.baseOpacity) + .selectAll(`.${LINK_SELECTOR}`) + .data(linksData) + .enter() + .append('g') + .attr('id', d => { + return this.createAndAssignId(d, 'uid', LINK_SELECTOR); + }) + .classed(`${LINK_SELECTOR} gl-cursor-pointer`, true); + }, + + generateNodes(svg, nodeData) { + const { nodeWidth } = this.$options.viewOptions; + + return svg + .append('g') + .selectAll(`.${NODE_SELECTOR}`) + .data(nodeData) + .enter() + .append('line') + .classed(`${NODE_SELECTOR} gl-cursor-pointer`, true) + .attr('id', d => { + return this.createAndAssignId(d, 'uid', NODE_SELECTOR); + }) + .attr('stroke', d => { + const color = this.color(d); + /* eslint-disable-next-line no-param-reassign */ + d.color = color; + return color; + }) + .attr('stroke-width', nodeWidth) + .attr('stroke-linecap', 'round') + .attr('x1', d => Math.floor((d.x1 + d.x0) / 2)) + .attr('x2', d => Math.floor((d.x1 + d.x0) / 2)) + .attr('y1', d => d.y0 + 4) + .attr('y2', d => d.y1 - 4); + }, + + labelNodes(svg, nodeData) { + return svg + .append('g') + .classed('gl-font-sm', true) + .selectAll('text') + .data(nodeData) + .enter() + .append('foreignObject') + .each(this.appendLabelAsForeignObject); + }, + + initColors() { + const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation); + return ({ name }) => colorFn(name); + }, + + transformData(parsed) { + const baseLayout = createSankey()(parsed); + const cleanedNodes = removeOrphanNodes(baseLayout.nodes); + const maxNodesPerLayer = getMaxNodes(cleanedNodes); + + return { + maxNodesPerLayer, + linksAndNodes: { + links: parsed.links, + nodes: cleanedNodes, + }, + }; + }, + }, +}; +</script> +<template> + <div :class="$options.viewOptions.containerClasses" data-testid="dag-graph-container"> + <!-- graph goes here --> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/dag/drawing_utils.js b/app/assets/javascripts/pipelines/components/dag/drawing_utils.js new file mode 100644 index 00000000000..d56addc473f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/drawing_utils.js @@ -0,0 +1,134 @@ +import * as d3 from 'd3'; +import { sankey, sankeyLeft } from 'd3-sankey'; + +export const calculateClip = ({ y0, y1, source, target, width }) => { + /* + Because large link values can overrun their box, we create a clip path + to trim off the excess in charts that have few nodes per column and are + therefore tall. + + The box is created by + M: moving to outside midpoint of the source node + V: drawing a vertical line to maximum of the bottom link edge or + the lowest edge of the node (can be d.y0 or d.y1 depending on the link's path) + H: drawing a horizontal line to the outside edge of the destination node + V: drawing a vertical line back up to the minimum of the top link edge or + the highest edge of the node (can be d.y0 or d.y1 depending on the link's path) + H: drawing a horizontal line back to the outside edge of the source node + Z: closing the path, back to the start point + */ + + const bottomLinkEdge = Math.max(y1, y0) + width / 2; + const topLinkEdge = Math.min(y0, y1) - width / 2; + + /* eslint-disable @gitlab/require-i18n-strings */ + return ` + M${source.x0}, ${y1} + V${Math.max(bottomLinkEdge, y0, y1)} + H${target.x1} + V${Math.min(topLinkEdge, y0, y1)} + H${source.x0} + Z + `; + /* eslint-enable @gitlab/require-i18n-strings */ +}; + +export const createLinkPath = ({ y0, y1, source, target, width }, idx, nodeWidth) => { + /* + Creates a series of staggered midpoints for the link paths, so they + don't run along one channel and can be distinguished. + + First, get a point staggered by index and link width, modulated by the link box + to find a point roughly between the nodes. + + Then offset it by nodeWidth, so it doesn't run under any nodes at the left. + + Determine where it would overlap at the right. + + Finally, select the leftmost of these options: + - offset from the source node based on index + fudge; + - a fuzzy offset from the right node, using Math.random adds a little blur + - a hard offset from the end node, if random pushes it over + + Then draw a line from the start node to the bottom-most point of the midline + up to the topmost point in that line and then to the middle of the end node + */ + + const xValRaw = source.x1 + (((idx + 1) * width) % (target.x1 - source.x0)); + const xValMin = xValRaw + nodeWidth; + const overlapPoint = source.x1 + (target.x0 - source.x1); + const xValMax = overlapPoint - nodeWidth * 1.4; + + const midPointX = Math.min(xValMin, target.x0 - nodeWidth * 4 * Math.random(), xValMax); + + return d3.line()([ + [(source.x0 + source.x1) / 2, y0], + [midPointX, y0], + [midPointX, y1], + [(target.x0 + target.x1) / 2, y1], + ]); +}; + +/* + createSankey calls the d3 layout to generate the relationships and positioning + values for the nodes and links in the graph. + */ + +export const createSankey = ({ + width = 10, + height = 10, + nodeWidth = 10, + nodePadding = 10, + paddingForLabels = 1, +} = {}) => { + const sankeyGenerator = sankey() + .nodeId(({ name }) => name) + .nodeAlign(sankeyLeft) + .nodeWidth(nodeWidth) + .nodePadding(nodePadding) + .extent([ + [paddingForLabels, paddingForLabels], + [width - paddingForLabels, height - paddingForLabels], + ]); + return ({ nodes, links }) => + sankeyGenerator({ + nodes: nodes.map(d => ({ ...d })), + links: links.map(d => ({ ...d })), + }); +}; + +export const labelPosition = ({ x0, x1, y0, y1 }, viewOptions) => { + const { paddingForLabels, labelMargin, nodePadding, width } = viewOptions; + + const firstCol = x0 <= paddingForLabels; + const lastCol = x1 >= width - paddingForLabels; + + if (firstCol) { + return { + x: 0 + labelMargin, + y: y0, + height: `${y1 - y0}px`, + width: paddingForLabels - 2 * labelMargin, + textAlign: 'right', + }; + } + + if (lastCol) { + return { + x: width - paddingForLabels + labelMargin, + y: y0, + height: `${y1 - y0}px`, + width: paddingForLabels - 2 * labelMargin, + textAlign: 'left', + }; + } + + return { + x: (x1 + x0) / 2, + y: y0 - nodePadding, + height: `${nodePadding}px`, + width: 'max-content', + wrapperWidth: paddingForLabels - 2 * labelMargin, + textAlign: x0 < width / 2 ? 'left' : 'right', + }; +}; diff --git a/app/assets/javascripts/pipelines/components/dag/interactions.js b/app/assets/javascripts/pipelines/components/dag/interactions.js new file mode 100644 index 00000000000..c9008730c90 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/interactions.js @@ -0,0 +1,134 @@ +import * as d3 from 'd3'; +import { LINK_SELECTOR, NODE_SELECTOR, IS_HIGHLIGHTED } from './constants'; + +export const highlightIn = 1; +export const highlightOut = 0.2; + +const getCurrent = (idx, collection) => d3.select(collection[idx]); +const currentIsLive = (idx, collection) => getCurrent(idx, collection).classed(IS_HIGHLIGHTED); +const getOtherLinks = () => d3.selectAll(`.${LINK_SELECTOR}:not(.${IS_HIGHLIGHTED})`); +const getNodesNotLive = () => d3.selectAll(`.${NODE_SELECTOR}:not(.${IS_HIGHLIGHTED})`); + +const backgroundLinks = selection => selection.style('stroke-opacity', highlightOut); +const backgroundNodes = selection => selection.attr('stroke', '#f2f2f2'); +const foregroundLinks = selection => selection.style('stroke-opacity', highlightIn); +const foregroundNodes = selection => selection.attr('stroke', d => d.color); +const renewLinks = (selection, baseOpacity) => selection.style('stroke-opacity', baseOpacity); +const renewNodes = selection => selection.attr('stroke', d => d.color); + +const getAllLinkAncestors = node => { + if (node.targetLinks) { + return node.targetLinks.flatMap(n => { + return [n.uid, ...getAllLinkAncestors(n.source)]; + }); + } + + return []; +}; + +const getAllNodeAncestors = node => { + let allNodes = []; + + if (node.targetLinks) { + allNodes = node.targetLinks.flatMap(n => { + return getAllNodeAncestors(n.source); + }); + } + + return [...allNodes, node.uid]; +}; + +export const highlightLinks = (d, idx, collection) => { + const currentLink = getCurrent(idx, collection); + const currentSourceNode = d3.select(`#${d.source.uid}`); + const currentTargetNode = d3.select(`#${d.target.uid}`); + + /* Higlight selected link, de-emphasize others */ + backgroundLinks(getOtherLinks()); + foregroundLinks(currentLink); + + /* Do the same to related nodes */ + backgroundNodes(getNodesNotLive()); + foregroundNodes(currentSourceNode); + foregroundNodes(currentTargetNode); +}; + +const highlightPath = (parentLinks, parentNodes) => { + /* de-emphasize everything else */ + backgroundLinks(getOtherLinks()); + backgroundNodes(getNodesNotLive()); + + /* highlight correct links */ + parentLinks.forEach(id => { + foregroundLinks(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true); + }); + + /* highlight correct nodes */ + parentNodes.forEach(id => { + foregroundNodes(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true); + }); +}; + +const restorePath = (parentLinks, parentNodes, baseOpacity) => { + parentLinks.forEach(id => { + renewLinks(d3.select(`#${id}`), baseOpacity).classed(IS_HIGHLIGHTED, false); + }); + + parentNodes.forEach(id => { + d3.select(`#${id}`).classed(IS_HIGHLIGHTED, false); + }); + + if (d3.selectAll(`.${IS_HIGHLIGHTED}`).empty()) { + renewLinks(getOtherLinks(), baseOpacity); + renewNodes(getNodesNotLive()); + return; + } + + backgroundLinks(getOtherLinks()); + backgroundNodes(getNodesNotLive()); +}; + +export const restoreLinks = (baseOpacity, d, idx, collection) => { + /* in this case, it has just been clicked */ + if (currentIsLive(idx, collection)) { + return; + } + + /* + if there exist live links, reset to highlight out / pale + otherwise, reset to base + */ + + if (d3.selectAll(`.${IS_HIGHLIGHTED}`).empty()) { + renewLinks(d3.selectAll(`.${LINK_SELECTOR}`), baseOpacity); + renewNodes(d3.selectAll(`.${NODE_SELECTOR}`)); + return; + } + + backgroundLinks(getOtherLinks()); + backgroundNodes(getNodesNotLive()); +}; + +export const toggleLinkHighlight = (baseOpacity, d, idx, collection) => { + if (currentIsLive(idx, collection)) { + restorePath([d.uid], [d.source.uid, d.target.uid], baseOpacity); + return; + } + + highlightPath([d.uid], [d.source.uid, d.target.uid]); +}; + +export const togglePathHighlights = (baseOpacity, d, idx, collection) => { + const parentLinks = getAllLinkAncestors(d); + const parentNodes = getAllNodeAncestors(d); + const currentNode = getCurrent(idx, collection); + + /* if this node is already live, make it unlive and reset its path */ + if (currentIsLive(idx, collection)) { + currentNode.classed(IS_HIGHLIGHTED, false); + restorePath(parentLinks, parentNodes, baseOpacity); + return; + } + + highlightPath(parentLinks, parentNodes); +}; diff --git a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js new file mode 100644 index 00000000000..3234f80ee91 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js @@ -0,0 +1,164 @@ +import { uniqWith, isEqual } from 'lodash'; + +/* + The following functions are the main engine in transforming the data as + received from the endpoint into the format the d3 graph expects. + + Input is of the form: + [stages] + stages: {name, groups} + groups: [{ name, size, jobs }] + name is a group name; in the case that the group has one job, it is + also the job name + size is the number of parallel jobs + jobs: [{ name, needs}] + job name is either the same as the group name or group x/y + + Output is of the form: + { nodes: [node], links: [link] } + node: { name, category }, + unused info passed through + link: { source, target, value }, with source & target being node names + and value being a constant + + We create nodes, create links, and then dedupe the links, so that in the case where + job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link + from job 1 to job 2 then another from job 2 to job 4. + + CREATE NODES + stage.name -> node.category + stage.group.name -> node.name (this is the group name if there are parallel jobs) + stage.group.jobs -> node.jobs + stage.group.size -> node.size + + CREATE LINKS + stages.groups.name -> target + stages.groups.needs.each -> source (source is the name of the group, not the parallel job) + 10 -> value (constant) + */ + +export const createNodes = data => { + return data.flatMap(({ groups, name }) => { + return groups.map(group => { + return { ...group, category: name }; + }); + }); +}; + +export const createNodeDict = nodes => { + return nodes.reduce((acc, node) => { + const newNode = { + ...node, + needs: node.jobs.map(job => job.needs || []).flat(), + }; + + if (node.size > 1) { + node.jobs.forEach(job => { + acc[job.name] = newNode; + }); + } + + acc[node.name] = newNode; + return acc; + }, {}); +}; + +export const createNodesStructure = data => { + const nodes = createNodes(data); + const nodeDict = createNodeDict(nodes); + + return { nodes, nodeDict }; +}; + +export const makeLinksFromNodes = (nodes, nodeDict) => { + const constantLinkValue = 10; // all links are the same weight + return nodes + .map(group => { + return group.jobs.map(job => { + if (!job.needs) { + return []; + } + + return job.needs.map(needed => { + return { + source: nodeDict[needed]?.name, + target: group.name, + value: constantLinkValue, + }; + }); + }); + }) + .flat(2); +}; + +export const getAllAncestors = (nodes, nodeDict) => { + const needs = nodes + .map(node => { + return nodeDict[node].needs || ''; + }) + .flat() + .filter(Boolean); + + if (needs.length) { + return [...needs, ...getAllAncestors(needs, nodeDict)]; + } + + return []; +}; + +export const filterByAncestors = (links, nodeDict) => + links.filter(({ target, source }) => { + /* + + for every link, check out it's target + for every target, get the target node's needs + then drop the current link source from that list + + call a function to get all ancestors, recursively + is the current link's source in the list of all parents? + then we drop this link + + */ + const targetNode = target; + const targetNodeNeeds = nodeDict[targetNode].needs; + const targetNodeNeedsMinusSource = targetNodeNeeds.filter(need => need !== source); + + const allAncestors = getAllAncestors(targetNodeNeedsMinusSource, nodeDict); + return !allAncestors.includes(source); + }); + +export const parseData = data => { + const { nodes, nodeDict } = createNodesStructure(data); + const allLinks = makeLinksFromNodes(nodes, nodeDict); + const filteredLinks = filterByAncestors(allLinks, nodeDict); + const links = uniqWith(filteredLinks, isEqual); + + return { nodes, links }; +}; + +/* + The number of nodes in the most populous generation drives the height of the graph. +*/ + +export const getMaxNodes = nodes => { + const counts = nodes.reduce((acc, { layer }) => { + if (!acc[layer]) { + acc[layer] = 0; + } + + acc[layer] += 1; + + return acc; + }, []); + + return Math.max(...counts); +}; + +/* + Because we cannot know if a node is part of a relationship until after we + generate the links with createSankey, this function is used after the first call + to find nodes that have no relations. +*/ + +export const removeOrphanNodes = sankeyfiedNodes => { + return sankeyfiedNodes.filter(node => node.sourceLinks.length || node.targetLinks.length); +}; diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index fc93635bdb5..dbf29b0c29c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -10,7 +10,8 @@ import NavigationControls from './nav_controls.vue'; import { getParameterByName } from '../../lib/utils/common_utils'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; -import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING } from '../constants'; +import { validateParams } from '../utils'; +import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { @@ -86,6 +87,10 @@ export default { type: String, required: true, }, + params: { + type: Object, + required: true, + }, }, data() { return { @@ -220,10 +225,13 @@ export default { canFilterPipelines() { return this.glFeatures.filterPipelinesSearch; }, + validatedParams() { + return validateParams(this.params); + }, }, created() { this.service = new PipelinesService(this.endpoint); - this.requestData = { page: this.page, scope: this.scope }; + this.requestData = { page: this.page, scope: this.scope, ...this.validatedParams }; }, methods: { successCallback(resp) { @@ -258,10 +266,18 @@ export default { filters.forEach(filter => { // do not add Any for username query param, so we // can fetch all trigger authors - if (filter.type && filter.value.data !== ANY_TRIGGER_AUTHOR) { + if ( + filter.type && + filter.value.data !== ANY_TRIGGER_AUTHOR && + filter.type !== FILTER_TAG_IDENTIFIER + ) { this.requestData[filter.type] = filter.value.data; } + if (filter.type === FILTER_TAG_IDENTIFIER) { + this.requestData.ref = filter.value.data; + } + if (!filter.type) { createFlash(RAW_TEXT_WARNING, 'warning'); } @@ -304,8 +320,8 @@ export default { <pipelines-filtered-search v-if="canFilterPipelines" - :pipelines="state.pipelines" :project-id="projectId" + :params="validatedParams" @filterPipelines="filterPipelines" /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue index 2212428ced5..59c066b2683 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue @@ -35,7 +35,7 @@ export default { <ul class="dropdown-menu dropdown-menu-right"> <li v-for="(artifact, i) in artifacts" :key="i"> <gl-link :href="artifact.path" rel="nofollow" download - >Download {{ artifact.name }} artifacts</gl-link + >Download {{ artifact.name }} artifact</gl-link > </li> </ul> diff --git a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue index 8f9c3eb70a2..0505a8668d1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue @@ -3,74 +3,93 @@ import { GlFilteredSearch } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue'; import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue'; -import Api from '~/api'; -import createFlash from '~/flash'; -import { FETCH_AUTHOR_ERROR_MESSAGE, FETCH_BRANCH_ERROR_MESSAGE } from '../constants'; +import PipelineStatusToken from './tokens/pipeline_status_token.vue'; +import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue'; +import { map } from 'lodash'; export default { + userType: 'username', + branchType: 'ref', + tagType: 'tag', + statusType: 'status', + defaultTokensLength: 1, components: { GlFilteredSearch, }, props: { - pipelines: { - type: Array, - required: true, - }, projectId: { type: String, required: true, }, + params: { + type: Object, + required: true, + }, }, data() { return { - projectUsers: null, - projectBranches: null, + internalValue: [], }; }, computed: { + selectedTypes() { + return this.value.map(i => i.type); + }, tokens() { return [ { - type: 'username', + type: this.$options.userType, icon: 'user', title: s__('Pipeline|Trigger author'), unique: true, token: PipelineTriggerAuthorToken, operators: [{ value: '=', description: __('is'), default: 'true' }], - triggerAuthors: this.projectUsers, projectId: this.projectId, }, { - type: 'ref', + type: this.$options.branchType, icon: 'branch', title: s__('Pipeline|Branch name'), unique: true, token: PipelineBranchNameToken, operators: [{ value: '=', description: __('is'), default: 'true' }], - branches: this.projectBranches, projectId: this.projectId, + disabled: this.selectedTypes.includes(this.$options.tagType), + }, + { + type: this.$options.tagType, + icon: 'tag', + title: s__('Pipeline|Tag name'), + unique: true, + token: PipelineTagNameToken, + operators: [{ value: '=', description: __('is'), default: 'true' }], + projectId: this.projectId, + disabled: this.selectedTypes.includes(this.$options.branchType), + }, + { + type: this.$options.statusType, + icon: 'status', + title: s__('Pipeline|Status'), + unique: true, + token: PipelineStatusToken, + operators: [{ value: '=', description: __('is'), default: 'true' }], }, ]; }, - }, - created() { - Api.projectUsers(this.projectId) - .then(users => { - this.projectUsers = users; - }) - .catch(err => { - createFlash(FETCH_AUTHOR_ERROR_MESSAGE); - throw err; - }); - - Api.branches(this.projectId) - .then(({ data }) => { - this.projectBranches = data.map(branch => branch.name); - }) - .catch(err => { - createFlash(FETCH_BRANCH_ERROR_MESSAGE); - throw err; - }); + parsedParams() { + return map(this.params, (val, key) => ({ + type: key, + value: { data: val, operator: '=' }, + })); + }, + value: { + get() { + return this.internalValue.length > 0 ? this.internalValue : this.parsedParams; + }, + set(value) { + this.internalValue = value; + }, + }, }, methods: { onSubmit(filters) { @@ -83,6 +102,7 @@ export default { <template> <div class="row-content-block"> <gl-filtered-search + v-model="value" :placeholder="__('Filter pipelines')" :available-tokens="tokens" @submit="onSubmit" diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue index 80a1c83f171..67646c537bd 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -68,7 +68,7 @@ export default { <template> <div> <div class="row"> - <div class="col-12 d-flex prepend-top-8 align-items-center"> + <div class="col-12 d-flex gl-mt-3 align-items-center"> <gl-deprecated-button v-if="showBack" size="sm" diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue index a7a3f986255..da14bb2d308 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue +++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue @@ -23,15 +23,18 @@ export default { }, data() { return { - branches: this.config.branches, + branches: null, loading: true, }; }, + created() { + this.fetchBranches(); + }, methods: { - fetchBranchBySearchTerm(searchTerm) { - Api.branches(this.config.projectId, searchTerm) - .then(res => { - this.branches = res.data.map(branch => branch.name); + fetchBranches(searchterm) { + Api.branches(this.config.projectId, searchterm) + .then(({ data }) => { + this.branches = data.map(branch => branch.name); this.loading = false; }) .catch(err => { @@ -41,7 +44,7 @@ export default { }); }, searchBranches: debounce(function debounceSearch({ data }) { - this.fetchBranchBySearchTerm(data); + this.fetchBranches(data); }, FILTER_PIPELINES_SEARCH_DELAY), }, }; diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue new file mode 100644 index 00000000000..dc43d94f4fd --- /dev/null +++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue @@ -0,0 +1,104 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + statuses() { + return [ + { + class: 'ci-status-icon-canceled', + icon: 'status_canceled', + text: s__('Pipeline|Canceled'), + value: 'canceled', + }, + { + class: 'ci-status-icon-created', + icon: 'status_created', + text: s__('Pipeline|Created'), + value: 'created', + }, + { + class: 'ci-status-icon-failed', + icon: 'status_failed', + text: s__('Pipeline|Failed'), + value: 'failed', + }, + { + class: 'ci-status-icon-manual', + icon: 'status_manual', + text: s__('Pipeline|Manual'), + value: 'manual', + }, + { + class: 'ci-status-icon-success', + icon: 'status_success', + text: s__('Pipeline|Passed'), + value: 'success', + }, + { + class: 'ci-status-icon-pending', + icon: 'status_pending', + text: s__('Pipeline|Pending'), + value: 'pending', + }, + { + class: 'ci-status-icon-running', + icon: 'status_running', + text: s__('Pipeline|Running'), + value: 'running', + }, + { + class: 'ci-status-icon-skipped', + icon: 'status_skipped', + text: s__('Pipeline|Skipped'), + value: 'skipped', + }, + ]; + }, + findActiveStatus() { + return this.statuses.find(status => status.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <div :class="findActiveStatus.class"> + <gl-icon :name="findActiveStatus.icon" class="gl-mr-2 gl-display-block" /> + </div> + <span>{{ findActiveStatus.text }}</span> + </div> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="(status, index) in statuses" + :key="index" + :value="status.value" + > + <div class="gl-display-flex" :class="status.class"> + <gl-icon :name="status.icon" class="gl-mr-3" /> + <span>{{ status.text }}</span> + </div> + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue new file mode 100644 index 00000000000..7b209c5fa12 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue @@ -0,0 +1,64 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import Api from '~/api'; +import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants'; +import createFlash from '~/flash'; +import { debounce } from 'lodash'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + tags: null, + loading: true, + }; + }, + created() { + this.fetchTags(); + }, + methods: { + fetchTags(searchTerm) { + Api.tags(this.config.projectId, searchTerm) + .then(({ data }) => { + this.tags = data.map(tag => tag.name); + this.loading = false; + }) + .catch(err => { + createFlash(FETCH_TAG_ERROR_MESSAGE); + this.loading = false; + throw err; + }); + }, + searchTags: debounce(function debounceSearch({ data }) { + this.fetchTags(data); + }, FILTER_PIPELINES_SEARCH_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" @input="searchTags"> + <template #suggestions> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion v-for="(tag, index) in tags" :key="index" :value="tag"> + {{ tag }} + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue index 83e3558e1a1..4062a3b11bb 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue +++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue @@ -36,7 +36,7 @@ export default { }, data() { return { - users: this.config.triggerAuthors, + users: [], loading: true, }; }, @@ -50,11 +50,14 @@ export default { }); }, }, + created() { + this.fetchProjectUsers(); + }, methods: { - fetchAuthorBySearchTerm(searchTerm) { + fetchProjectUsers(searchTerm) { Api.projectUsers(this.config.projectId, searchTerm) - .then(res => { - this.users = res; + .then(users => { + this.users = users; this.loading = false; }) .catch(err => { @@ -64,7 +67,7 @@ export default { }); }, searchAuthors: debounce(function debounceSearch({ data }) { - this.fetchAuthorBySearchTerm(data); + this.fetchProjectUsers(data); }, FILTER_PIPELINES_SEARCH_DELAY), }, }; diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index d694430830b..c709f329728 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -5,6 +5,8 @@ export const PIPELINES_TABLE = 'PIPELINES_TABLE'; export const LAYOUT_CHANGE_DELAY = 300; export const FILTER_PIPELINES_SEARCH_DELAY = 200; export const ANY_TRIGGER_AUTHOR = 'Any'; +export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status']; +export const FILTER_TAG_IDENTIFIER = 'tag'; export const TestStatus = { FAILED: 'failed', @@ -14,6 +16,7 @@ export const TestStatus = { export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.'); export const FETCH_BRANCH_ERROR_MESSAGE = __('There was a problem fetching project branches.'); +export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project tags.'); export const RAW_TEXT_WARNING = s__( 'Pipeline|Raw text search is not currently supported. Please use the available search tokens.', ); diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 01295874e56..90109598542 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate'; import { __ } from '~/locale'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import pipelineGraph from './components/graph/graph_component.vue'; +import Dag from './components/dag/dag.vue'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; import pipelineHeader from './components/header_component.vue'; @@ -144,6 +145,29 @@ const createTestDetails = detailsEndpoint => { .catch(() => {}); }; +const createDagApp = () => { + if (!window.gon?.features?.dagPipelineTab) { + return; + } + + const el = document.querySelector('#js-pipeline-dag-vue'); + const graphUrl = el?.dataset?.pipelineDataPath; + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + Dag, + }, + render(createElement) { + return createElement('dag', { + props: { + graphUrl, + }, + }); + }, + }); +}; + export default () => { const { dataset } = document.querySelector('.js-pipeline-details-vue'); const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); @@ -153,4 +177,5 @@ export default () => { createPipelineHeaderApp(mediator); createPipelinesTabs(dataset); createTestDetails(dataset.testReportsCountEndpoint); + createDagApp(); }; diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index ae94d7a7ca0..0b06bcf243a 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -1,5 +1,6 @@ import axios from '../../lib/utils/axios_utils'; import Api from '~/api'; +import { validateParams } from '../utils'; export default class PipelinesService { /** @@ -19,18 +20,10 @@ export default class PipelinesService { } getPipelines(data = {}) { - const { scope, page, username, ref } = data; + const { scope, page } = data; const { CancelToken } = axios; - const queryParams = { scope, page }; - - if (username) { - queryParams.username = username; - } - - if (ref) { - queryParams.ref = ref; - } + const queryParams = { scope, page, ...validateParams(data) }; this.cancelationSource = CancelToken.source(); diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js new file mode 100644 index 00000000000..9dbc8073d3a --- /dev/null +++ b/app/assets/javascripts/pipelines/utils.js @@ -0,0 +1,8 @@ +import { pickBy } from 'lodash'; +import { SUPPORTED_FILTER_PARAMETERS } from './constants'; + +export const validateParams = params => { + return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val); +}; + +export default () => {}; diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue index eb514b5c070..a8589b50899 100644 --- a/app/assets/javascripts/projects/commits/components/author_select.vue +++ b/app/assets/javascripts/projects/commits/components/author_select.vue @@ -110,8 +110,8 @@ export default { <gl-new-dropdown :text="dropdownText" :disabled="hasSearchParam" - toggle-class="gl-py-3" - class="gl-dropdown w-100 mt-2 mt-sm-0" + toggle-class="gl-py-3 gl-border-0" + class="w-100 mt-2 mt-sm-0" > <gl-new-dropdown-header> {{ __('Search by author') }} diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index a3a53c2f975..0a52a92ae9d 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -3,6 +3,7 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { __ } from '~/locale'; +import { joinPaths } from '~/lib/utils/url_utility'; export default { setInitialData({ commit }, data) { @@ -16,10 +17,8 @@ export default { }, fetchAuthors({ dispatch, state }, author = null) { const { projectId } = state; - const path = '/autocomplete/users.json'; - return axios - .get(path, { + .get(joinPaths(gon.relative_url_root || '', '/autocomplete/users.json'), { params: { project_id: projectId, active: true, diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue new file mode 100644 index 00000000000..e553599357c --- /dev/null +++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue @@ -0,0 +1,160 @@ +<script> +import WelcomePage from './welcome.vue'; +import LegacyContainer from './legacy_container.vue'; +import { GlBreadcrumb, GlIcon } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +import blankProjectIllustration from '../illustrations/blank-project.svg'; +import createFromTemplateIllustration from '../illustrations/create-from-template.svg'; +import importProjectIllustration from '../illustrations/import-project.svg'; +import ciCdProjectIllustration from '../illustrations/ci-cd-project.svg'; + +const BLANK_PANEL = 'blank_project'; +const CI_CD_PANEL = 'cicd_for_external_repo'; +const PANELS = [ + { + name: BLANK_PANEL, + selector: '#blank-project-pane', + title: s__('ProjectsNew|Create blank project'), + description: s__( + 'ProjectsNew|Create a blank project to house your files, plan your work, and collaborate on code, among other things.', + ), + illustration: blankProjectIllustration, + }, + { + name: 'create_from_template', + selector: '#create-from-template-pane', + title: s__('ProjectsNew|Create from template'), + description: s__( + 'Create a project pre-populated with the necessary files to get you started quickly.', + ), + illustration: createFromTemplateIllustration, + }, + { + name: 'import_project', + selector: '#import-project-pane', + title: s__('ProjectsNew|Import project'), + description: s__( + 'Migrate your data from an external source like GitHub, Bitbucket, or another instance of GitLab.', + ), + illustration: importProjectIllustration, + }, + { + name: CI_CD_PANEL, + 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, + }, +]; + +export default { + components: { + GlBreadcrumb, + GlIcon, + WelcomePage, + LegacyContainer, + }, + + props: { + hasErrors: { + type: Boolean, + required: false, + default: false, + }, + isCiCdAvailable: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + activeTab: null, + }; + }, + + computed: { + availablePanels() { + if (this.isCiCdAvailable) { + return PANELS; + } + + return PANELS.filter(p => p.name !== CI_CD_PANEL); + }, + + activePanel() { + return PANELS.find(p => p.name === this.activeTab); + }, + + breadcrumbs() { + if (!this.activeTab || !this.activePanel) { + return null; + } + + return [ + { text: __('New project'), href: '#' }, + { text: this.activePanel.title, href: `#${this.activeTab}` }, + ]; + }, + }, + + created() { + this.handleLocationHashChange(); + + if (this.hasErrors) { + this.activeTab = BLANK_PANEL; + } + + window.addEventListener('hashchange', () => { + this.handleLocationHashChange(); + this.resetProjectErrors(); + }); + this.$root.$on('clicked::link', e => { + window.location = e.target.href; + }); + }, + + methods: { + resetProjectErrors() { + const errorsContainer = document.querySelector('.project-edit-errors'); + if (errorsContainer) { + errorsContainer.innerHTML = ''; + } + }, + + handleLocationHashChange() { + this.activeTab = window.location.hash.substring(1) || null; + }, + }, + + PANELS, +}; +</script> + +<template> + <welcome-page v-if="activeTab === null" :panels="availablePanels" /> + <div v-else class="row"> + <div class="col-lg-3"> + <div class="text-center" v-html="activePanel.illustration"></div> + <h4>{{ activePanel.title }}</h4> + <p>{{ activePanel.description }}</p> + </div> + <div class="col-lg-9"> + <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs"> + <template #separator> + <gl-icon name="chevron-right" :size="8" /> + </template> + </gl-breadcrumb> + <template v-for="panel in $options.PANELS"> + <legacy-container + v-if="activeTab === panel.name" + :key="panel.name" + class="gl-mt-3" + :selector="panel.selector" + /> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue new file mode 100644 index 00000000000..d2fc2c66924 --- /dev/null +++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue @@ -0,0 +1,31 @@ +<script> +export default { + inheritAttrs: false, + props: { + selector: { + type: String, + required: true, + }, + }, + mounted() { + const legacyEntry = document.querySelector(this.selector); + if (legacyEntry.tagName === 'TEMPLATE') { + this.$el.innerHTML = legacyEntry.innerHTML; + } else { + this.source = legacyEntry.parentNode; + this.$el.appendChild(legacyEntry); + legacyEntry.classList.add('active'); + } + }, + + beforeDestroy() { + if (this.source) { + this.$el.firstChild.classList.remove('active'); + this.source.appendChild(this.$el.firstChild); + } + }, +}; +</script> +<template> + <div></div> +</template> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue new file mode 100644 index 00000000000..ea22818da0e --- /dev/null +++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue @@ -0,0 +1,70 @@ +<script> +import Tracking from '~/tracking'; +import { GlPopover } from '@gitlab/ui'; +import LegacyContainer from './legacy_container.vue'; + +const trackingMixin = Tracking.mixin(gon.tracking_data); + +export default { + components: { + GlPopover, + LegacyContainer, + }, + mixins: [trackingMixin], + props: { + panels: { + type: Array, + required: true, + }, + }, +}; +</script> +<template> + <div class="container"> + <div class="blank-state-welcome"> + <h2 class="blank-state-welcome-title gl-mt-5! gl-mb-3!"> + {{ s__('ProjectsNew|Create new project') }} + </h2> + <p div class="blank-state-text"> </p> + </div> + <div class="row blank-state-row"> + <a + v-for="panel in panels" + :key="panel.name" + :href="`#${panel.name}`" + :data-qa-selector="`${panel.name}_link`" + class="blank-state blank-state-link experiment-new-project-page-blank-state" + @click="track('click_tab', { label: panel.name })" + > + <div class="blank-state-icon" v-html="panel.illustration"></div> + <div class="blank-state-body gl-pl-4!"> + <h3 class="blank-state-title experiment-new-project-page-blank-state-title"> + {{ panel.title }} + </h3> + <p class="blank-state-text"> + {{ panel.description }} + </p> + </div> + </a> + </div> + <div class="blank-state-welcome"> + <p> + {{ __('You can also create a project from the command line.') }} + <a + id="cli-tip" + href="#" + click.prevent + class="push-new-project-tip" + data-title="Push to create a project" + rel="noopener noreferrer" + > + {{ __('Show command') }} + </a> + + <gl-popover target="cli-tip" triggers="click blur" placement="top"> + <legacy-container selector=".push-new-project-tip-template" /> + </gl-popover> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg new file mode 100644 index 00000000000..0d8021658d1 --- /dev/null +++ b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="215px" height="115px" viewBox="0 0 215 115" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: Sketch 61.2 (89653) - https://sketch.com --> + <title>create-new-project-md</title> + <desc>Created with Sketch.</desc> + <g id="create-new-project-md" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="Group-3" transform="translate(71.000000, 18.000000)" fill-rule="nonzero"> + <g id="New-Blank1"> + <path d="M6.11141667,3.90697674 L62.6947849,3.90697674 C65.9485064,3.90697674 68.5891473,6.56494969 68.5891473,9.8400273 L68.5891473,78.0669494 C68.5891473,81.342027 65.9485064,84 62.6947849,84 L6.11141667,84 C2.85769514,84 0.217054264,81.342027 0.217054264,78.0669494 L0.217054264,9.8400273 C0.217054264,6.56494969 2.85769514,3.90697674 6.11141667,3.90697674 Z" id="Path" fill="#F9F9F9"></path> + <path d="M8.89436241,1 L65.4777306,1 C68.7314521,1 71.372093,3.65598929 71.372093,6.9286227 L71.372093,74.5132378 C71.372093,77.7858712 68.7314521,80.4418605 65.4777306,80.4418605 L8.89436241,80.4418605 C5.64064088,80.4418605 3,77.7858712 3,74.5132378 L3,6.9286227 C3.00209243,3.65598929 5.64064088,1 8.89436241,1 Z" id="Path" fill="#FFFFFF"></path> + <path d="M9.2677971,2.35980136 C6.65357171,2.35980136 4.53489427,4.47043114 4.53489427,7.07940407 L4.53489427,74.3201325 C4.53489427,76.9270116 6.65147193,79.0397352 9.2677971,79.0397352 L66.0500324,79.0397352 C68.6642577,79.0397352 70.7829352,76.9291055 70.7829352,74.3201325 L70.7829352,7.07731019 C70.7829352,4.47043114 68.6663575,2.35770748 66.0500324,2.35770748 L9.2677971,2.35980136 L9.2677971,2.35980136 Z M9.2677971,0 L66.0500324,0 C69.9724203,0 73.1472868,3.16803856 73.1472868,7.07731019 L73.1472868,74.3180386 C73.1472868,78.2294042 69.9703205,81.3953488 66.0500324,81.3953488 L9.2677971,81.3953488 C5.34540913,81.3953488 2.17054264,78.2273103 2.17054264,74.3180386 L2.17054264,7.07731019 C2.17054264,3.17222631 5.34750891,0 9.2677971,0 Z" id="Shape" fill="#EEEEEE"></path> + <path d="M21.6234891,28.6511628 L28.9501543,28.6511628 C29.6221266,28.6511628 30.1705426,29.2387129 30.1705426,29.9534884 C30.1705426,30.6682639 29.6199589,31.255814 28.9501543,31.255814 L21.6234891,31.255814 C20.9515168,31.255814 20.4031008,30.6682639 20.4031008,29.9534884 C20.4031008,29.2387129 20.9515168,28.6511628 21.6234891,28.6511628 Z" id="Path" fill="#E1DBF1"></path> + <path d="M33.9142229,35.8139535 L36.1943042,35.8139535 C36.8214783,35.8139535 37.3333333,36.4015036 37.3333333,37.1162791 C37.3333333,37.8333678 36.8194552,38.4186047 36.1943042,38.4186047 L33.9142229,38.4186047 C33.2870488,38.4186047 32.7751938,37.8310546 32.7751938,37.1162791 C32.7751938,36.4015036 33.2890719,35.8139535 33.9142229,35.8139535 Z" id="Path" fill="#FC6D26"></path> + <path d="M24.200844,42.9767442 L28.9774506,42.9767442 C29.6343929,42.9767442 30.1705426,43.5642943 30.1705426,44.2790698 C30.1705426,44.9961585 29.6322737,45.5813953 28.9774506,45.5813953 L24.200844,45.5813953 C23.5439017,45.5813953 23.0077519,44.9938453 23.0077519,44.2790698 C23.0077519,43.5642943 23.5439017,42.9767442 24.200844,42.9767442 Z" id="Path" fill="#E1DBF1"></path> + <path d="M41.0770181,35.8139535 L43.3570964,35.8139535 C43.9842697,35.8139535 44.496124,36.4015036 44.496124,37.1162791 C44.496124,37.8333678 43.9822466,38.4186047 43.3570964,38.4186047 L41.0770181,38.4186047 C40.4498448,38.4186047 39.9379845,37.8310546 39.9379845,37.1162791 C39.9359673,36.4015036 40.4498448,35.8139535 41.0770181,35.8139535 Z" id="Path" fill="#FC6D26"></path> + <path d="M33.9372473,28.6511628 L47.89221,28.6511628 C48.5320619,28.6511628 49.0542636,29.2387129 49.0542636,29.9534884 C49.0542636,30.6682639 48.5299978,31.255814 47.89221,31.255814 L33.9372473,31.255814 C33.2973955,31.255814 32.7751938,30.6682639 32.7751938,29.9534884 C32.7751938,29.2387129 33.2994595,28.6511628 33.9372473,28.6511628 Z" id="Path" fill="#C3B8E3"></path> + <path d="M33.9142229,42.9767442 L36.1943042,42.9767442 C36.8214783,42.9767442 37.3333333,43.5642943 37.3333333,44.2790698 C37.3333333,44.9961585 36.8194552,45.5813953 36.1943042,45.5813953 L33.9142229,45.5813953 C33.2870488,45.5813953 32.7751938,44.9938453 32.7751938,44.2790698 C32.7751938,43.5642943 33.2890719,42.9767442 33.9142229,42.9767442 Z" id="Path" fill="#6B4FBB"></path> + <g id="Group" transform="translate(16.000000, 19.000000)"> + <circle id="Oval" fill="#FFFFFF" cx="20.8396947" cy="20.8396947" r="20.7533889"></circle> + <path d="M20.8396947,41.5930835 C9.3778626,41.5930835 0.0863058062,32.3015267 0.0863058062,20.8396947 C0.0863058062,9.3778626 9.3778626,0.0863058062 20.8396947,0.0863058062 C32.3015267,0.0863058062 41.5930835,9.3778626 41.5930835,20.8396947 C41.5930835,32.3015267 32.3015267,41.5930835 20.8396947,41.5930835 Z M20.8396947,39.2207263 C30.9922045,39.2207263 39.2207263,30.9900995 39.2207263,20.8396947 C39.2207263,10.6892898 30.9900995,2.45866297 20.8396947,2.45866297 C10.6892898,2.45866297 2.45866297,10.6892898 2.45866297,20.8396947 C2.45866297,30.9900995 10.6871848,39.2207263 20.8396947,39.2207263 Z" id="Shape" fill="#EEEEEE"></path> + <path d="M13.7647236,19.060953 L27.9967615,19.060953 C28.6493176,19.060953 29.1818876,19.595628 29.1818876,20.2460791 C29.1818876,20.8986352 28.6472126,21.4312052 27.9967615,21.4312052 L13.7647236,21.4312052 C13.1121675,21.4312052 12.5795975,20.8965302 12.5795975,20.2460791 C12.5795975,19.593523 13.1142725,19.060953 13.7647236,19.060953 Z" id="Path" fill="#6B4FBB"></path> + <path d="M22.0669211,13.1311127 L22.0669211,27.3631506 C22.0669211,28.0157067 21.5322461,28.5482767 20.881795,28.5482767 C20.231344,28.5482767 19.696669,28.0136017 19.696669,27.3631506 L19.696669,13.1311127 C19.696669,12.4785566 20.231344,11.9459866 20.881795,11.9459866 C21.5322461,11.9459866 22.0669211,12.4785566 22.0669211,13.1311127 Z" id="Path" fill="#6B4FBB"></path> + </g> + </g> + </g> + </g> +</svg>
\ No newline at end of file diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg new file mode 100644 index 00000000000..c85e1a245b8 --- /dev/null +++ b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg @@ -0,0 +1,73 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="215px" height="115px" viewBox="0 0 215 115" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: Sketch 61.2 (89653) - https://sketch.com --> + <title>run-CICD-pipelines-md</title> + <desc>Created with Sketch.</desc> + <g id="run-CICD-pipelines-md" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="CICD-Repoj1" transform="translate(22.000000, 16.000000)"> + <g id="Group" transform="translate(100.000000, 0.000000)"> + <path d="M5.88230137,4.144 L61.8884384,4.144 C65.1195616,4.144 67.7155068,6.804 67.7155068,10.052 L67.7155068,78.064 C67.7155068,81.34 65.0919452,83.972 61.8884384,83.972 L5.88230137,83.972 C2.65117808,83.972 0.0552328767,81.312 0.0552328767,78.064 L0.0552328767,10.052 C0.0552328767,6.804 2.67879452,4.144 5.88230137,4.144 Z" id="Path" fill="#F9F9F9" fill-rule="nonzero"></path> + <path d="M8.80964384,1.176 L64.8157808,1.176 C68.0469041,1.176 70.6428493,3.836 70.6428493,7.084 L70.6428493,74.508 C70.6428493,77.784 68.0192877,80.416 64.8157808,80.416 L8.80964384,80.416 C5.57852055,80.416 2.98257534,77.756 2.98257534,74.508 L2.98257534,7.112 C2.98257534,3.836 5.57852055,1.176 8.80964384,1.176 Z" id="Path" fill="#FFFFFF" fill-rule="nonzero"></path> + <path d="M8.80964384,2.38 C6.24131507,2.38 4.14246575,4.508 4.14246575,7.112 L4.14246575,74.536 C4.14246575,77.14 6.24131507,79.268 8.80964384,79.268 L64.8157808,79.268 C67.3841096,79.268 69.4829589,77.14 69.4829589,74.536 L69.4829589,7.112 C69.4829589,4.508 67.3841096,2.38 64.8157808,2.38 L8.80964384,2.38 L8.80964384,2.38 Z M8.80964384,0 L64.8157808,0 C68.6820822,0 71.8027397,3.164 71.8027397,7.084 L71.8027397,74.508 C71.8027397,78.428 68.6820822,81.592 64.8157808,81.592 L8.80964384,81.592 C4.94334247,81.592 1.82250462,78.4 1.82250462,74.508 L1.82250462,7.112 C1.79506849,3.192 4.94334247,0 8.80964384,0 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path> + <path d="M14.6367123,14.784 L21.6236712,14.784 C22.2588493,14.784 22.7835616,15.316 22.7835616,15.96 C22.7835616,16.604 22.2588493,17.136 21.6236712,17.136 L14.6367123,17.136 C14.0015342,17.136 13.4768219,16.604 13.4768219,15.96 C13.4768219,15.316 14.0015342,14.784 14.6367123,14.784 Z M33.3054247,21.896 L40.2923836,21.896 C40.9275616,21.896 41.452274,22.428 41.452274,23.072 C41.452274,23.716 40.9275616,24.248 40.2923836,24.248 L33.3054247,24.248 C32.6702466,24.248 32.1455342,23.716 32.1455342,23.072 C32.1455342,22.428 32.6702466,21.896 33.3054247,21.896 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M40.32,14.784 L47.3069589,14.784 C47.942137,14.784 48.4668493,15.316 48.4668493,15.96 C48.4668493,16.604 47.942137,17.136 47.3069589,17.136 L40.32,17.136 C39.6848219,17.136 39.1601096,16.604 39.1601096,15.96 C39.1324932,15.316 39.6572055,14.784 40.32,14.784 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path> + <path d="M21.6512877,28.98 L28.6382466,28.98 C29.2734247,28.98 29.798137,29.512 29.798137,30.156 C29.798137,30.8 29.2734247,31.332 28.6382466,31.332 L21.6512877,31.332 C21.0161096,31.332 20.4913973,30.8 20.4913973,30.156 C20.4637808,29.512 20.9884932,28.98 21.6512877,28.98 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M26.2908493,14.784 L28.6382466,14.784 C29.2734247,14.784 29.798137,15.316 29.798137,15.96 C29.798137,16.604 29.2734247,17.136 28.6382466,17.136 L26.2908493,17.136 C25.6556712,17.136 25.1309589,16.604 25.1309589,15.96 C25.1309589,15.316 25.6556712,14.784 26.2908493,14.784 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path> + <path d="M33.3054247,36.092 L35.6528219,36.092 C36.288,36.092 36.8127123,36.624 36.8127123,37.268 C36.8127123,37.912 36.288,38.444 35.6528219,38.444 L33.3054247,38.444 C32.6702466,38.444 32.1455342,37.912 32.1455342,37.268 C32.1455342,36.624 32.6702466,36.092 33.3054247,36.092 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M44.9595616,21.896 L47.3069589,21.896 C47.942137,21.896 48.4668493,22.428 48.4668493,23.072 C48.4668493,23.716 47.942137,24.248 47.3069589,24.248 L44.9595616,24.248 C44.3243836,24.248 43.7996712,23.716 43.7996712,23.072 C43.7996712,22.428 44.3243836,21.896 44.9595616,21.896 Z M51.974137,14.784 L54.3215342,14.784 C54.9567123,14.784 55.4814247,15.316 55.4814247,15.96 C55.4814247,16.604 54.9567123,17.136 54.3215342,17.136 L51.974137,17.136 C51.3389589,17.136 50.8142466,16.604 50.8142466,15.96 C50.8142466,15.316 51.3389589,14.784 51.974137,14.784 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M23.9710685,43.176 L28.6382466,43.176 C29.2734247,43.176 29.798137,43.708 29.798137,44.352 C29.798137,44.996 29.2734247,45.528 28.6382466,45.528 L23.9710685,45.528 C23.3358904,45.528 22.8111781,44.996 22.8111781,44.352 C22.8111781,43.708 23.3358904,43.176 23.9710685,43.176 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M40.32,36.092 L42.6673973,36.092 C43.3025753,36.092 43.8272877,36.624 43.8272877,37.268 C43.8272877,37.912 43.3025753,38.444 42.6673973,38.444 L40.32,38.444 C39.6848219,38.444 39.1601096,37.912 39.1601096,37.268 C39.1324932,36.624 39.6572055,36.092 40.32,36.092 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M52.2503014,33.712 C53.0511781,33.712 53.7139726,34.384 53.7139726,35.196 C53.7139726,36.008 53.0511781,36.68 52.2503014,36.68 C51.4494247,36.68 50.7866301,36.008 50.7866301,35.196 C50.8142466,34.384 51.4494247,33.712 52.2503014,33.712 Z M58.1049863,50.876 C58.905863,50.876 59.5686575,51.548 59.5686575,52.36 C59.5686575,53.172 58.905863,53.844 58.1049863,53.844 C57.3041096,53.844 56.6413151,53.172 56.6413151,52.36 C56.6413151,51.548 57.3041096,50.876 58.1049863,50.876 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M63.3521096,50.876 C64.1529863,50.876 64.8157808,51.548 64.8157808,52.36 C64.8157808,53.172 64.1529863,53.844 63.3521096,53.844 C62.5512329,53.844 61.8884384,53.172 61.8884384,52.36 C61.8884384,51.548 62.5512329,50.876 63.3521096,50.876 Z M33.3054247,14.784 L35.6528219,14.784 C36.288,14.784 36.8127123,15.316 36.8127123,15.96 C36.8127123,16.604 36.288,17.136 35.6528219,17.136 L33.3054247,17.136 C32.6702466,17.136 32.1455342,16.604 32.1455342,15.96 C32.1455342,15.316 32.6702466,14.784 33.3054247,14.784 Z" id="Shape" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M14.6367123,36.092 L28.6382466,36.092 C29.2734247,36.092 29.798137,36.624 29.798137,37.268 C29.798137,37.912 29.2734247,38.444 28.6382466,38.444 L14.6367123,38.444 C14.0015342,38.444 13.4768219,37.912 13.4768219,37.268 C13.4768219,36.624 14.0015342,36.092 14.6367123,36.092 Z M44.0482192,42 L61.0599452,42 C61.8332055,42 62.4683836,42.672 62.4683836,43.484 C62.4683836,44.296 61.8332055,44.968 61.0599452,44.968 L44.0758356,44.968 C43.3025753,44.968 42.6673973,44.296 42.6673973,43.484 C42.6673973,42.672 43.2749589,42 44.0482192,42 L44.0482192,42 L44.0482192,42 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path> + <path d="M35.3214247,50.876 L52.3055342,50.876 C53.0787945,50.876 53.7139726,51.548 53.7139726,52.36 C53.7139726,53.172 53.0787945,53.844 52.3055342,53.844 L35.3214247,53.844 C34.5481644,53.844 33.9129863,53.172 33.9129863,52.36 C33.8853699,51.548 34.5205479,50.876 35.3214247,50.876 L35.3214247,50.876 L35.3214247,50.876 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path> + <path d="M14.6367123,21.896 L28.6382466,21.896 C29.2734247,21.896 29.798137,22.428 29.798137,23.072 C29.798137,23.716 29.2734247,24.248 28.6382466,24.248 L14.6367123,24.248 C14.0015342,24.248 13.4768219,23.716 13.4768219,23.072 C13.4768219,22.428 14.0015342,21.896 14.6367123,21.896 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path> + <path d="M33.3054247,28.98 L47.3069589,28.98 C47.942137,28.98 48.4668493,29.512 48.4668493,30.156 C48.4668493,30.8 47.942137,31.332 47.3069589,31.332 L33.3054247,31.332 C32.6702466,31.332 32.1455342,30.8 32.1455342,30.156 C32.1455342,29.512 32.6702466,28.98 33.3054247,28.98 Z" id="Path" fill="#C3B8E3" fill-rule="nonzero"></path> + <path d="M14.6367123,28.98 L16.9841096,28.98 C17.6192877,28.98 18.144,29.512 18.144,30.156 C18.144,30.8 17.6192877,31.332 16.9841096,31.332 L14.6367123,31.332 C14.0015342,31.332 13.4768219,30.8 13.4768219,30.156 C13.4768219,29.512 14.0015342,28.98 14.6367123,28.98 Z" id="Path" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M33.3054247,43.176 L35.6528219,43.176 C36.288,43.176 36.8127123,43.708 36.8127123,44.352 C36.8127123,44.996 36.288,45.528 35.6528219,45.528 L33.3054247,45.528 C32.6702466,45.528 32.1455342,44.996 32.1455342,44.352 C32.1455342,43.708 32.6702466,43.176 33.3054247,43.176 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path> + <path d="M14.6367123,43.176 L19.3038904,43.176 C19.9390685,43.176 20.4637808,43.708 20.4637808,44.352 C20.4637808,44.996 19.9390685,45.528 19.3038904,45.528 L14.6367123,45.528 C14.0015342,45.528 13.4768219,44.996 13.4768219,44.352 C13.4768219,43.708 14.0015342,43.176 14.6367123,43.176 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M14.6367123,50.288 L19.3038904,50.288 C19.9390685,50.288 20.4637808,50.82 20.4637808,51.464 C20.4637808,52.108 19.9390685,52.64 19.3038904,52.64 L14.6367123,52.64 C14.0015342,52.64 13.4768219,52.108 13.4768219,51.464 C13.4768219,50.82 14.0015342,50.288 14.6367123,50.288 Z M23.9710685,50.288 L28.6382466,50.288 C29.2734247,50.288 29.798137,50.82 29.798137,51.464 C29.798137,52.108 29.2734247,52.64 28.6382466,52.64 L23.9710685,52.64 C23.3358904,52.64 22.8111781,52.108 22.8111781,51.464 C22.8111781,50.82 23.3358904,50.288 23.9710685,50.288 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M14.6367123,57.372 L21.6236712,57.372 C22.2588493,57.372 22.7835616,57.904 22.7835616,58.548 C22.7835616,59.192 22.2588493,59.724 21.6236712,59.724 L14.6367123,59.724 C14.0015342,59.724 13.4768219,59.192 13.4768219,58.548 C13.4768219,57.904 14.0015342,57.372 14.6367123,57.372 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path> + <path d="M25.3518904,64.484 L33.6644384,64.484 C34.4376986,64.484 35.0452603,65.016 35.0452603,65.66 C35.0452603,66.304 34.4376986,66.836 33.6644384,66.836 L25.3518904,66.836 C24.5786301,66.836 23.9710685,66.304 23.9710685,65.66 C23.9710685,65.016 24.5786301,64.484 25.3518904,64.484 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M44.0206027,59.136 L52.3331507,59.136 C53.106411,59.136 53.7139726,59.808 53.7139726,60.62 C53.7139726,61.432 53.106411,62.104 52.3331507,62.104 L44.0206027,62.104 C43.2473425,62.104 42.6397808,61.432 42.6397808,60.62 C42.6397808,59.808 43.2473425,59.136 44.0206027,59.136 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path> + <path d="M26.2908493,57.372 L28.6382466,57.372 C29.2734247,57.372 29.798137,57.904 29.798137,58.548 C29.798137,59.192 29.2734247,59.724 28.6382466,59.724 L26.2908493,59.724 C25.6556712,59.724 25.1309589,59.192 25.1309589,58.548 C25.1309589,57.904 25.6556712,57.372 26.2908493,57.372 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path> + <path d="M36.8127123,64.484 L39.1601096,64.484 C39.7952877,64.484 40.32,65.016 40.32,65.66 C40.32,66.304 39.7952877,66.836 39.1601096,66.836 L36.8127123,66.836 C36.1775342,66.836 35.6528219,66.304 35.6528219,65.66 C35.6528219,65.016 36.1775342,64.484 36.8127123,64.484 Z M58.1049863,59.136 L61.0323288,59.136 C61.8332055,59.136 62.496,59.808 62.496,60.62 C62.496,61.432 61.8332055,62.104 61.0323288,62.104 L58.1049863,62.104 C57.3041096,62.104 56.6413151,61.432 56.6413151,60.62 C56.6413151,59.808 57.3041096,59.136 58.1049863,59.136 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M35.3490411,59.136 L38.2763836,59.136 C39.0772603,59.136 39.7400548,59.808 39.7400548,60.62 C39.7400548,61.432 39.0772603,62.104 38.2763836,62.104 L35.3490411,62.104 C34.5481644,62.104 33.8853699,61.432 33.8853699,60.62 C33.8853699,59.808 34.5481644,59.136 35.3490411,59.136 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M14.6367123,64.484 L20.4637808,64.484 C21.0989589,64.484 21.6236712,65.016 21.6236712,65.66 C21.6236712,66.304 21.0989589,66.836 20.4637808,66.836 L14.6367123,66.836 C14.0015342,66.836 13.4768219,66.304 13.4768219,65.66 C13.4768219,65.016 14.0015342,64.484 14.6367123,64.484 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path> + <ellipse id="Oval" fill="#FFFFFF" fill-rule="nonzero" cx="36.7574795" cy="38.612" rx="20.4085479" ry="20.692"></ellipse> + <path d="M36.7574795,59.304 C25.4899726,59.304 16.3489315,50.036 16.3489315,38.612 C16.3489315,27.188 25.4899726,17.92 36.7574795,17.92 C48.0249863,17.92 57.1660274,27.188 57.1660274,38.612 C57.1660274,50.036 48.0526027,59.304 36.7574795,59.304 Z M36.7574795,56.952 C46.7546301,56.952 54.8462466,48.748 54.8462466,38.612 C54.8462466,28.476 46.7546301,20.272 36.7574795,20.272 C26.7603288,20.272 18.6687123,28.476 18.6687123,38.612 C18.6687123,48.748 26.7879452,56.952 36.7574795,56.952 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path> + <g transform="translate(26.787945, 29.400000)" id="Path"> + <path d="M19.7457534,10.528 L18.6410959,7.112 L16.4593973,0.336 C16.3489315,7.97972799e-15 15.8794521,7.97972799e-15 15.7413699,0.336 L13.5872877,7.112 L6.37939726,7.112 L4.19769863,0.336 C4.08723288,7.97972799e-15 3.61775342,7.97972799e-15 3.47967123,0.336 L1.2979726,7.112 L0.193315068,10.528 C0.0828493151,10.836 0.193315068,11.172 0.469479452,11.368 L9.94191781,18.34 L19.4143562,11.368 C19.718137,11.172 19.8286027,10.836 19.7457534,10.528" fill="#FC6D26"></path> + <polygon fill="#E24329" points="9.96953425 18.34 13.5596712 7.112 6.35178082 7.112"></polygon> + <polygon fill="#FC6D26" points="9.96953425 18.34 6.37939726 7.112 1.32558904 7.112"></polygon> + <path d="M1.32558904,7.112 L0.220931507,10.528 C0.110465753,10.836 0.220931507,11.172 0.49709589,11.368 L9.96953425,18.34 L1.32558904,7.112 Z" fill="#FCA326"></path> + <path d="M1.32558904,7.112 L6.37939726,7.112 L4.19769863,0.336 C4.08723288,7.9658502e-15 3.61775342,7.9658502e-15 3.47967123,0.336 L1.32558904,7.112 Z" fill="#E24329"></path> + <polygon fill="#FC6D26" points="9.96953425 18.34 13.5596712 7.112 18.6134795 7.112"></polygon> + <path d="M18.6410959,7.112 L19.7457534,10.528 C19.8562192,10.836 19.7457534,11.172 19.469589,11.368 L9.99715068,18.34 L18.6410959,7.112 Z" fill="#FCA326"></path> + <path d="M18.6410959,7.112 L13.5872877,7.112 L15.7689863,0.336 C15.8794521,7.9658502e-15 16.3489315,7.9658502e-15 16.4870137,0.336 L18.6410959,7.112 Z" fill="#E24329"></path> + </g> + </g> + <path d="M76,41.475 C76,40.660967 76.8066938,40 77.8150611,40 L81.4537653,40 C82.4578417,40 83.2688264,40.6540094 83.2688264,41.475 C83.2688264,42.289033 82.4621326,42.95 81.4537653,42.95 L77.8150611,42.95 C76.8152757,42.95 76,42.2959906 76,41.4819575 C76,41.4784788 76,41.4784788 76,41.475 Z M88.7311736,41.475 C88.7311736,40.660967 89.5378674,40 90.5462347,40 L94.1849389,40 C95.1890152,40 96,40.6540094 96,41.475 C96,42.289033 95.1933062,42.95 94.1849389,42.95 L90.5462347,42.95 C89.5464493,42.95 88.7311736,42.2959906 88.7311736,41.4819575 C88.7311736,41.4784788 88.7311736,41.4784788 88.7311736,41.475 Z" id="Shape" fill="#E5E5E5" fill-rule="nonzero"></path> + <g id="Group"> + <g transform="translate(3.686038, 58.800000)"> + <path d="M6.33328302,0.207529412 L17.1233208,0.207529412 C20.541283,0.207529412 23.2890566,3.04376471 23.2890566,6.57176471 L23.2890566,17.7091765 C23.2890566,21.2025882 20.541283,24.0734118 17.1233208,24.0734118 L6.33328302,24.0734118 C2.94883019,24.0734118 0.16754717,21.2371765 0.16754717,17.7091765 L0.16754717,6.57176471 C0.16754717,3.04376471 2.91532075,0.207529412 6.33328302,0.207529412 Z" id="Path" fill="#FFFFFF"></path> + <path d="M6.33328302,3.38964706 C4.62430189,3.38964706 3.25041509,4.80776471 3.25041509,6.57176471 L3.25041509,17.7091765 C3.25041509,19.4731765 4.62430189,20.8912941 6.33328302,20.8912941 L17.1233208,20.8912941 C18.8323019,20.8912941 20.2061887,19.4731765 20.2061887,17.7091765 L20.2061887,6.57176471 C20.2061887,4.80776471 18.8323019,3.38964706 17.1233208,3.38964706 L6.33328302,3.38964706 Z M6.33328302,0.207529412 L17.1233208,0.207529412 C20.541283,0.207529412 23.2890566,3.04376471 23.2890566,6.57176471 L23.2890566,17.7091765 C23.2890566,21.2025882 20.541283,24.0734118 17.1233208,24.0734118 L6.33328302,24.0734118 C2.94883019,24.0734118 0.16754717,21.2371765 0.16754717,17.7091765 L0.16754717,6.57176471 C0.16754717,3.04376471 2.91532075,0.207529412 6.33328302,0.207529412 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M11.7283019,8.12823529 L11.7283019,8.12823529 C13.8393962,8.12823529 15.5818868,9.89223529 15.5818868,12.1058824 L15.5818868,12.1058824 C15.5818868,14.3195294 13.8729057,16.0835294 11.7283019,16.0835294 L11.7283019,16.0835294 C9.61720755,16.0835294 7.87471698,14.3195294 7.87471698,12.1058824 L7.87471698,12.1058824 C7.87471698,9.92682353 9.58369811,8.12823529 11.7283019,8.12823529 Z" id="Path" fill="#6B4FBB"></path> + </g> + <path d="M4.62430189,3.35505882 C3.78656604,3.35505882 3.08286792,4.08141176 3.08286792,4.94611765 L3.08286792,14.4924706 C3.08286792,15.3571765 3.78656604,16.0835294 4.62430189,16.0835294 L13.8729057,16.0835294 C14.7106415,16.0835294 15.4143396,15.3571765 15.4143396,14.4924706 L15.4143396,4.94611765 C15.4143396,4.08141176 14.7106415,3.35505882 13.8729057,3.35505882 L4.62430189,3.35505882 Z" id="Path" fill="#FFFFFF"></path> + <path d="M4.62430189,3.35505882 C3.78656604,3.35505882 3.08286792,4.08141176 3.08286792,4.94611765 L3.08286792,14.4924706 C3.08286792,15.3571765 3.78656604,16.0835294 4.62430189,16.0835294 L13.8729057,16.0835294 C14.7106415,16.0835294 15.4143396,15.3571765 15.4143396,14.4924706 L15.4143396,4.94611765 C15.4143396,4.08141176 14.7106415,3.35505882 13.8729057,3.35505882 L4.62430189,3.35505882 Z M4.62430189,0.172941176 L13.8729057,0.172941176 C16.4196226,0.172941176 18.4972075,2.31741176 18.4972075,4.94611765 L18.4972075,14.4924706 C18.4972075,17.1211765 16.4196226,19.2656471 13.8729057,19.2656471 L4.62430189,19.2656471 C2.07758491,19.2656471 -1.19049424e-15,17.1211765 -1.19049424e-15,14.4924706 L-1.19049424e-15,4.94611765 C-1.19049424e-15,2.31741176 2.07758491,0.172941176 4.62430189,0.172941176 Z" id="Shape" fill="#FDC4A8" fill-rule="nonzero"></path> + <path d="M9.24860377,6.53717647 L9.24860377,6.53717647 C10.9575849,6.53717647 12.3314717,7.95529412 12.3314717,9.71929412 L12.3314717,9.71929412 C12.3314717,11.4832941 10.9575849,12.9014118 9.24860377,12.9014118 L9.24860377,12.9014118 C7.53962264,12.9014118 6.16573585,11.4832941 6.16573585,9.71929412 L6.16573585,9.71929412 C6.16573585,7.95529412 7.53962264,6.53717647 9.24860377,6.53717647 Z" id="Path" fill="#FC6D26"></path> + <g transform="translate(35.184906, 23.174118)"> + <path d="M7.94173585,1.62564706 L27.9803774,1.62564706 C32.2360755,1.62564706 35.6875472,5.18823529 35.6875472,9.58094118 L35.6875472,30.2647059 C35.6875472,34.6574118 32.2360755,38.22 27.9803774,38.22 L7.94173585,38.22 C3.68603774,38.22 0.234566038,34.6574118 0.234566038,30.2647059 L0.234566038,9.58094118 C0.234566038,5.18823529 3.68603774,1.62564706 7.94173585,1.62564706 Z" id="Path" fill="#E1DBF1"></path> + <path d="M7.94173585,0.0345882353 L27.9803774,0.0345882353 C32.2360755,0.0345882353 35.6875472,3.59717647 35.6875472,7.98988235 L35.6875472,28.6736471 C35.6875472,33.0663529 32.2360755,36.6289412 27.9803774,36.6289412 L7.94173585,36.6289412 C3.68603774,36.6289412 0.234566038,33.0663529 0.234566038,28.6736471 L0.234566038,7.98988235 C0.234566038,3.59717647 3.68603774,0.0345882353 7.94173585,0.0345882353 Z" id="Path" fill="#E1DBF1"></path> + <path d="M7.94173585,3.21670588 C5.39501887,3.21670588 3.31743396,5.36117647 3.31743396,7.98988235 L3.31743396,28.6736471 C3.31743396,31.3023529 5.39501887,33.4468235 7.94173585,33.4468235 L27.9803774,33.4468235 C30.5270943,33.4468235 32.6046792,31.3023529 32.6046792,28.6736471 L32.6046792,7.98988235 C32.6046792,5.36117647 30.5270943,3.21670588 27.9803774,3.21670588 C27.9468679,3.21670588 7.94173585,3.21670588 7.94173585,3.21670588 Z M7.94173585,0.0345882353 L27.9803774,0.0345882353 C32.2360755,0.0345882353 35.6875472,3.59717647 35.6875472,7.98988235 L35.6875472,28.6736471 C35.6875472,33.0663529 32.2360755,36.6289412 27.9803774,36.6289412 L7.94173585,36.6289412 C3.68603774,36.6289412 0.234566038,33.0663529 0.234566038,28.6736471 L0.234566038,7.98988235 C0.234566038,3.59717647 3.68603774,0.0345882353 7.94173585,0.0345882353 Z" id="Shape" fill="#C3B8E3" fill-rule="nonzero"></path> + <path d="M14.1074717,12.7630588 L21.8146415,12.7630588 C22.6523774,12.7630588 23.3560755,13.4894118 23.3560755,14.3541176 L23.3560755,22.3094118 C23.3560755,23.1741176 22.6523774,23.9004706 21.8146415,23.9004706 L14.1074717,23.9004706 C13.2697358,23.9004706 12.5660377,23.1741176 12.5660377,22.3094118 L12.5660377,14.3541176 C12.5660377,13.4894118 13.2362264,12.7630588 14.1074717,12.7630588 Z" id="Path" fill="#6B4FBB"></path> + </g> + <path d="M32.6716981,71.4592941 C32.0685283,72.0818824 31.0967547,72.0818824 30.4935849,71.4592941 C29.8904151,70.8367059 29.8904151,69.8336471 30.4935849,69.2110588 L32.1355472,67.5162353 C32.738717,66.8936471 33.7104906,66.8936471 34.3136604,67.5162353 C34.9168302,68.1388235 34.9168302,69.1418824 34.3136604,69.7644706 L32.6716981,71.4592941 Z" id="Path" fill="#C3B8E3" fill-rule="nonzero"></path> + <path d="M37.5640755,66.4094118 C36.9609057,67.032 35.9891321,67.032 35.3859623,66.4094118 C34.7827925,65.7868235 34.7827925,64.7837647 35.3859623,64.1611765 L37.0279245,62.4663529 C37.6310943,61.8437647 38.6028679,61.8437647 39.2060377,62.4663529 C39.8092075,63.0889412 39.8092075,64.092 39.2060377,64.7145882 L37.5640755,66.4094118 Z" id="Path" fill="#E1DBF1"></path> + <path d="M21.3455094,21.2717647 C20.7088302,20.7183529 20.6083019,19.7152941 21.1444528,19.0235294 C21.6806038,18.3663529 22.6523774,18.2625882 23.322566,18.816 L25.098566,20.3378824 C25.7352453,20.8912941 25.8357736,21.8943529 25.2996226,22.5861176 C24.7634717,23.2432941 23.7916981,23.3470588 23.1215094,22.7936471 L21.3455094,21.2717647 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M26.64,25.872 C26.0033208,25.3185882 25.9027925,24.3155294 26.4389434,23.6237647 C26.9750943,22.9665882 27.9468679,22.8628235 28.6170566,23.4162353 L30.3930566,24.9381176 C31.0297358,25.4915294 31.1302642,26.4945882 30.5941132,27.1863529 C30.0579623,27.8781176 29.0861887,27.9472941 28.416,27.3938824 L26.64,25.872 Z" id="Path" fill="#C3B8E3"></path> + </g> + </g> + </g> +</svg>
\ No newline at end of file diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg new file mode 100644 index 00000000000..e90c354fe65 --- /dev/null +++ b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg @@ -0,0 +1,54 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="215px" height="115px" viewBox="0 0 215 115" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: Sketch 61.2 (89653) - https://sketch.com --> + <title>create-project-from-template-md</title> + <desc>Created with Sketch.</desc> + <g id="create-project-from-template-md" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="New-Template1" transform="translate(71.000000, 15.000000)"> + <g id="Group"> + <path d="M5.88230137,4.144 L61.8884384,4.144 C65.1195616,4.144 67.7155068,6.804 67.7155068,10.052 L67.7155068,78.064 C67.7155068,81.34 65.0919452,83.972 61.8884384,83.972 L5.88230137,83.972 C2.65117808,83.972 0.0552328767,81.312 0.0552328767,78.064 L0.0552328767,10.052 C0.0552328767,6.804 2.67879452,4.144 5.88230137,4.144 Z" id="Path" fill="#F9F9F9" fill-rule="nonzero"></path> + <path d="M8.82706849,1 L64.8332055,1 C68.0643288,1 70.660274,3.66 70.660274,6.908 L70.660274,74.332 C70.660274,77.608 68.0367123,80.24 64.8332055,80.24 L8.82706849,80.24 C5.59594521,80.24 3,77.58 3,74.332 L3,6.936 C3,3.66 5.59594521,1 8.82706849,1 Z" id="Path" fill="#FFFFFF" fill-rule="nonzero"></path> + <path d="M8.80964384,2.38 C6.24131507,2.38 4.14246575,4.508 4.14246575,7.112 L4.14246575,74.536 C4.14246575,77.14 6.24131507,79.268 8.80964384,79.268 L64.8157808,79.268 C67.3841096,79.268 69.4829589,77.14 69.4829589,74.536 L69.4829589,7.112 C69.4829589,4.508 67.3841096,2.38 64.8157808,2.38 L8.80964384,2.38 L8.80964384,2.38 Z M8.80964384,0 L64.8157808,0 C68.6820822,0 71.8027397,3.164 71.8027397,7.084 L71.8027397,74.508 C71.8027397,78.428 68.6820822,81.592 64.8157808,81.592 L8.80964384,81.592 C4.94334247,81.592 1.82250462,78.4 1.82250462,74.508 L1.82250462,7.112 C1.79506849,3.192 4.94334247,0 8.80964384,0 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path> + <path d="M14.6367123,14.784 L21.6236712,14.784 C22.2588493,14.784 22.7835616,15.316 22.7835616,15.96 C22.7835616,16.604 22.2588493,17.136 21.6236712,17.136 L14.6367123,17.136 C14.0015342,17.136 13.4768219,16.604 13.4768219,15.96 C13.4768219,15.316 14.0015342,14.784 14.6367123,14.784 Z M33.3054247,21.896 L40.2923836,21.896 C40.9275616,21.896 41.452274,22.428 41.452274,23.072 C41.452274,23.716 40.9275616,24.248 40.2923836,24.248 L33.3054247,24.248 C32.6702466,24.248 32.1455342,23.716 32.1455342,23.072 C32.1455342,22.428 32.6702466,21.896 33.3054247,21.896 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M40.32,14.784 L47.3069589,14.784 C47.942137,14.784 48.4668493,15.316 48.4668493,15.96 C48.4668493,16.604 47.942137,17.136 47.3069589,17.136 L40.32,17.136 C39.6848219,17.136 39.1601096,16.604 39.1601096,15.96 C39.1324932,15.316 39.6572055,14.784 40.32,14.784 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path> + <path d="M21.6512877,28.98 L28.6382466,28.98 C29.2734247,28.98 29.798137,29.512 29.798137,30.156 C29.798137,30.8 29.2734247,31.332 28.6382466,31.332 L21.6512877,31.332 C21.0161096,31.332 20.4913973,30.8 20.4913973,30.156 C20.4637808,29.512 20.9884932,28.98 21.6512877,28.98 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M26.2908493,14.784 L28.6382466,14.784 C29.2734247,14.784 29.798137,15.316 29.798137,15.96 C29.798137,16.604 29.2734247,17.136 28.6382466,17.136 L26.2908493,17.136 C25.6556712,17.136 25.1309589,16.604 25.1309589,15.96 C25.1309589,15.316 25.6556712,14.784 26.2908493,14.784 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path> + <path d="M33.3054247,36.092 L35.6528219,36.092 C36.288,36.092 36.8127123,36.624 36.8127123,37.268 C36.8127123,37.912 36.288,38.444 35.6528219,38.444 L33.3054247,38.444 C32.6702466,38.444 32.1455342,37.912 32.1455342,37.268 C32.1455342,36.624 32.6702466,36.092 33.3054247,36.092 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M44.9595616,21.896 L47.3069589,21.896 C47.942137,21.896 48.4668493,22.428 48.4668493,23.072 C48.4668493,23.716 47.942137,24.248 47.3069589,24.248 L44.9595616,24.248 C44.3243836,24.248 43.7996712,23.716 43.7996712,23.072 C43.7996712,22.428 44.3243836,21.896 44.9595616,21.896 Z M51.974137,14.784 L54.3215342,14.784 C54.9567123,14.784 55.4814247,15.316 55.4814247,15.96 C55.4814247,16.604 54.9567123,17.136 54.3215342,17.136 L51.974137,17.136 C51.3389589,17.136 50.8142466,16.604 50.8142466,15.96 C50.8142466,15.316 51.3389589,14.784 51.974137,14.784 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M23.9710685,43.176 L28.6382466,43.176 C29.2734247,43.176 29.798137,43.708 29.798137,44.352 C29.798137,44.996 29.2734247,45.528 28.6382466,45.528 L23.9710685,45.528 C23.3358904,45.528 22.8111781,44.996 22.8111781,44.352 C22.8111781,43.708 23.3358904,43.176 23.9710685,43.176 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M40.32,36.092 L42.6673973,36.092 C43.3025753,36.092 43.8272877,36.624 43.8272877,37.268 C43.8272877,37.912 43.3025753,38.444 42.6673973,38.444 L40.32,38.444 C39.6848219,38.444 39.1601096,37.912 39.1601096,37.268 C39.1324932,36.624 39.6572055,36.092 40.32,36.092 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M52.2503014,33.712 C53.0511781,33.712 53.7139726,34.384 53.7139726,35.196 C53.7139726,36.008 53.0511781,36.68 52.2503014,36.68 C51.4494247,36.68 50.7866301,36.008 50.7866301,35.196 C50.8142466,34.384 51.4494247,33.712 52.2503014,33.712 Z M58.1049863,50.876 C58.905863,50.876 59.5686575,51.548 59.5686575,52.36 C59.5686575,53.172 58.905863,53.844 58.1049863,53.844 C57.3041096,53.844 56.6413151,53.172 56.6413151,52.36 C56.6413151,51.548 57.3041096,50.876 58.1049863,50.876 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M63.3521096,50.876 C64.1529863,50.876 64.8157808,51.548 64.8157808,52.36 C64.8157808,53.172 64.1529863,53.844 63.3521096,53.844 C62.5512329,53.844 61.8884384,53.172 61.8884384,52.36 C61.8884384,51.548 62.5512329,50.876 63.3521096,50.876 Z M33.3054247,14.784 L35.6528219,14.784 C36.288,14.784 36.8127123,15.316 36.8127123,15.96 C36.8127123,16.604 36.288,17.136 35.6528219,17.136 L33.3054247,17.136 C32.6702466,17.136 32.1455342,16.604 32.1455342,15.96 C32.1455342,15.316 32.6702466,14.784 33.3054247,14.784 Z" id="Shape" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M14.6367123,36.092 L28.6382466,36.092 C29.2734247,36.092 29.798137,36.624 29.798137,37.268 C29.798137,37.912 29.2734247,38.444 28.6382466,38.444 L14.6367123,38.444 C14.0015342,38.444 13.4768219,37.912 13.4768219,37.268 C13.4768219,36.624 14.0015342,36.092 14.6367123,36.092 Z M44.0482192,42 L61.0599452,42 C61.8332055,42 62.4683836,42.672 62.4683836,43.484 C62.4683836,44.296 61.8332055,44.968 61.0599452,44.968 L44.0758356,44.968 C43.3025753,44.968 42.6673973,44.296 42.6673973,43.484 C42.6673973,42.672 43.2749589,42 44.0482192,42 L44.0482192,42 L44.0482192,42 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path> + <path d="M35.3214247,50.876 L52.3055342,50.876 C53.0787945,50.876 53.7139726,51.548 53.7139726,52.36 C53.7139726,53.172 53.0787945,53.844 52.3055342,53.844 L35.3214247,53.844 C34.5481644,53.844 33.9129863,53.172 33.9129863,52.36 C33.8853699,51.548 34.5205479,50.876 35.3214247,50.876 L35.3214247,50.876 L35.3214247,50.876 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path> + <path d="M14.6367123,21.896 L28.6382466,21.896 C29.2734247,21.896 29.798137,22.428 29.798137,23.072 C29.798137,23.716 29.2734247,24.248 28.6382466,24.248 L14.6367123,24.248 C14.0015342,24.248 13.4768219,23.716 13.4768219,23.072 C13.4768219,22.428 14.0015342,21.896 14.6367123,21.896 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path> + <path d="M33.3054247,28.98 L47.3069589,28.98 C47.942137,28.98 48.4668493,29.512 48.4668493,30.156 C48.4668493,30.8 47.942137,31.332 47.3069589,31.332 L33.3054247,31.332 C32.6702466,31.332 32.1455342,30.8 32.1455342,30.156 C32.1455342,29.512 32.6702466,28.98 33.3054247,28.98 Z" id="Path" fill="#C3B8E3" fill-rule="nonzero"></path> + <path d="M14.6367123,28.98 L16.9841096,28.98 C17.6192877,28.98 18.144,29.512 18.144,30.156 C18.144,30.8 17.6192877,31.332 16.9841096,31.332 L14.6367123,31.332 C14.0015342,31.332 13.4768219,30.8 13.4768219,30.156 C13.4768219,29.512 14.0015342,28.98 14.6367123,28.98 Z" id="Path" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M33.3054247,43.176 L35.6528219,43.176 C36.288,43.176 36.8127123,43.708 36.8127123,44.352 C36.8127123,44.996 36.288,45.528 35.6528219,45.528 L33.3054247,45.528 C32.6702466,45.528 32.1455342,44.996 32.1455342,44.352 C32.1455342,43.708 32.6702466,43.176 33.3054247,43.176 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path> + <path d="M14.6367123,43.176 L19.3038904,43.176 C19.9390685,43.176 20.4637808,43.708 20.4637808,44.352 C20.4637808,44.996 19.9390685,45.528 19.3038904,45.528 L14.6367123,45.528 C14.0015342,45.528 13.4768219,44.996 13.4768219,44.352 C13.4768219,43.708 14.0015342,43.176 14.6367123,43.176 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M14.6367123,50.288 L19.3038904,50.288 C19.9390685,50.288 20.4637808,50.82 20.4637808,51.464 C20.4637808,52.108 19.9390685,52.64 19.3038904,52.64 L14.6367123,52.64 C14.0015342,52.64 13.4768219,52.108 13.4768219,51.464 C13.4768219,50.82 14.0015342,50.288 14.6367123,50.288 Z M23.9710685,50.288 L28.6382466,50.288 C29.2734247,50.288 29.798137,50.82 29.798137,51.464 C29.798137,52.108 29.2734247,52.64 28.6382466,52.64 L23.9710685,52.64 C23.3358904,52.64 22.8111781,52.108 22.8111781,51.464 C22.8111781,50.82 23.3358904,50.288 23.9710685,50.288 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M14.6367123,57.372 L21.6236712,57.372 C22.2588493,57.372 22.7835616,57.904 22.7835616,58.548 C22.7835616,59.192 22.2588493,59.724 21.6236712,59.724 L14.6367123,59.724 C14.0015342,59.724 13.4768219,59.192 13.4768219,58.548 C13.4768219,57.904 14.0015342,57.372 14.6367123,57.372 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path> + <path d="M25.3518904,64.484 L33.6644384,64.484 C34.4376986,64.484 35.0452603,65.016 35.0452603,65.66 C35.0452603,66.304 34.4376986,66.836 33.6644384,66.836 L25.3518904,66.836 C24.5786301,66.836 23.9710685,66.304 23.9710685,65.66 C23.9710685,65.016 24.5786301,64.484 25.3518904,64.484 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M44.0206027,59.136 L52.3331507,59.136 C53.106411,59.136 53.7139726,59.808 53.7139726,60.62 C53.7139726,61.432 53.106411,62.104 52.3331507,62.104 L44.0206027,62.104 C43.2473425,62.104 42.6397808,61.432 42.6397808,60.62 C42.6397808,59.808 43.2473425,59.136 44.0206027,59.136 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path> + <path d="M26.2908493,57.372 L28.6382466,57.372 C29.2734247,57.372 29.798137,57.904 29.798137,58.548 C29.798137,59.192 29.2734247,59.724 28.6382466,59.724 L26.2908493,59.724 C25.6556712,59.724 25.1309589,59.192 25.1309589,58.548 C25.1309589,57.904 25.6556712,57.372 26.2908493,57.372 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path> + <path d="M36.8127123,64.484 L39.1601096,64.484 C39.7952877,64.484 40.32,65.016 40.32,65.66 C40.32,66.304 39.7952877,66.836 39.1601096,66.836 L36.8127123,66.836 C36.1775342,66.836 35.6528219,66.304 35.6528219,65.66 C35.6528219,65.016 36.1775342,64.484 36.8127123,64.484 Z M58.1049863,59.136 L61.0323288,59.136 C61.8332055,59.136 62.496,59.808 62.496,60.62 C62.496,61.432 61.8332055,62.104 61.0323288,62.104 L58.1049863,62.104 C57.3041096,62.104 56.6413151,61.432 56.6413151,60.62 C56.6413151,59.808 57.3041096,59.136 58.1049863,59.136 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M35.3490411,59.136 L38.2763836,59.136 C39.0772603,59.136 39.7400548,59.808 39.7400548,60.62 C39.7400548,61.432 39.0772603,62.104 38.2763836,62.104 L35.3490411,62.104 C34.5481644,62.104 33.8853699,61.432 33.8853699,60.62 C33.8853699,59.808 34.5481644,59.136 35.3490411,59.136 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M14.6367123,64.484 L20.4637808,64.484 C21.0989589,64.484 21.6236712,65.016 21.6236712,65.66 C21.6236712,66.304 21.0989589,66.836 20.4637808,66.836 L14.6367123,66.836 C14.0015342,66.836 13.4768219,66.304 13.4768219,65.66 C13.4768219,65.016 14.0015342,64.484 14.6367123,64.484 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path> + <g id="Group-2" transform="translate(16.000000, 20.000000)"> + <ellipse id="Oval" fill="#FFFFFF" fill-rule="nonzero" cx="20.4085479" cy="20.692" rx="20.4085479" ry="20.692"></ellipse> + <path d="M20.4085479,41.384 C9.1410411,41.384 8.17124146e-14,32.116 8.17124146e-14,20.692 C8.17124146e-14,9.268 9.1410411,1.0658141e-14 20.4085479,1.0658141e-14 C31.6760548,1.0658141e-14 40.8170959,9.268 40.8170959,20.692 C40.8170959,32.116 31.7036712,41.384 20.4085479,41.384 Z M20.4085479,39.032 C30.4056986,39.032 38.4973151,30.828 38.4973151,20.692 C38.4973151,10.556 30.4056986,2.352 20.4085479,2.352 C10.4113973,2.352 2.31978082,10.556 2.31978082,20.692 C2.31978082,30.828 10.4390137,39.032 20.4085479,39.032 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path> + <g id="Group" transform="translate(10.439014, 11.480000)"> + <path d="M19.7457534,10.528 L18.6410959,7.112 L16.4593973,0.336 C16.3489315,7.97972799e-15 15.8794521,7.97972799e-15 15.7413699,0.336 L13.5872877,7.112 L6.37939726,7.112 L4.19769863,0.336 C4.08723288,7.97972799e-15 3.61775342,7.97972799e-15 3.47967123,0.336 L1.2979726,7.112 L0.193315068,10.528 C0.0828493151,10.836 0.193315068,11.172 0.469479452,11.368 L9.94191781,18.34 L19.4143562,11.368 C19.718137,11.172 19.8286027,10.836 19.7457534,10.528" id="Path" fill="#FC6D26"></path> + <polygon id="Path" fill="#E24329" points="9.96953425 18.34 13.5596712 7.112 6.35178082 7.112"></polygon> + <polygon id="Path" fill="#FC6D26" points="9.96953425 18.34 6.37939726 7.112 1.32558904 7.112"></polygon> + <path d="M1.32558904,7.112 L0.220931507,10.528 C0.110465753,10.836 0.220931507,11.172 0.49709589,11.368 L9.96953425,18.34 L1.32558904,7.112 Z" id="Path" fill="#FCA326"></path> + <path d="M1.32558904,7.112 L6.37939726,7.112 L4.19769863,0.336 C4.08723288,7.9658502e-15 3.61775342,7.9658502e-15 3.47967123,0.336 L1.32558904,7.112 Z" id="Path" fill="#E24329"></path> + <polygon id="Path" fill="#FC6D26" points="9.96953425 18.34 13.5596712 7.112 18.6134795 7.112"></polygon> + <path d="M18.6410959,7.112 L19.7457534,10.528 C19.8562192,10.836 19.7457534,11.172 19.469589,11.368 L9.99715068,18.34 L18.6410959,7.112 Z" id="Path" fill="#FCA326"></path> + <path d="M18.6410959,7.112 L13.5872877,7.112 L15.7689863,0.336 C15.8794521,7.9658502e-15 16.3489315,7.9658502e-15 16.4870137,0.336 L18.6410959,7.112 Z" id="Path" fill="#E24329"></path> + </g> + </g> + </g> + </g> + </g> +</svg>
\ No newline at end of file diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg new file mode 100644 index 00000000000..c507fb8d73d --- /dev/null +++ b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg @@ -0,0 +1,95 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="215px" height="115px" viewBox="0 0 215 115" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: Sketch 61.2 (89653) - https://sketch.com --> + <title>import-project-md</title> + <desc>Created with Sketch.</desc> + <g id="import-project-md" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="Group-4" transform="translate(14.000000, 15.000000)"> + <g id="Group-7" transform="translate(0.000000, 0.007864)" fill-rule="nonzero"> + <path d="M5.84551724,4.12490066 L61.9641379,4.12490066 C65.1917241,4.12490066 67.8096552,6.76450331 67.8096552,10.0188079 L67.8096552,77.8001325 C67.8096552,81.0544371 65.1917241,83.6940397 61.9641379,83.6940397 L5.84551724,83.6940397 C2.61793103,83.6940397 2.84217094e-14,81.0544371 2.84217094e-14,77.8001325 L2.84217094e-14,10.0188079 C2.84217094e-14,6.76450331 2.61793103,4.12490066 5.84551724,4.12490066 Z" id="Path" fill="#F9F9F9"></path> + <path d="M8.76965517,1.17933775 L64.8882759,1.17933775 C68.1158621,1.17933775 70.7337931,3.8189404 70.7337931,7.07324503 L70.7337931,74.2649007 C70.7337931,77.5192053 68.1158621,80.1588079 64.8882759,80.1588079 L8.76965517,80.1588079 C5.54206897,80.1588079 2.92413793,77.5192053 2.92413793,74.2649007 L2.92413793,7.07324503 C2.92413793,3.81615894 5.53931034,1.17933775 8.76965517,1.17933775 Z" id="Path" fill="#FFFFFF"></path> + <path d="M64.8882759,2.20268248e-13 C68.7613793,2.20268248e-13 71.9034483,3.16807947 71.9034483,7.07324503 L71.9024828,19.847 L69.5634828,19.847 L69.5641379,7.07324503 C69.5641379,4.46980132 67.4703448,2.3586755 64.8882759,2.3586755 L8.76965517,2.3586755 L8.76965517,2.35589404 C6.18758621,2.35589404 4.0937931,4.46701987 4.0937931,7.07046358 L4.0937931,74.2621192 C4.0937931,76.8655629 6.18758621,78.9766887 8.76965517,78.9766887 L64.8882759,78.9766887 C67.4703448,78.9766887 69.5641379,76.8655629 69.5641379,74.2621192 L69.5624828,54.847 L71.9014828,54.847 L71.9034483,74.2649007 C71.9034483,78.1700662 68.7613793,81.3381457 64.8882759,81.3381457 L8.76965517,81.3381457 C4.89655172,81.3381457 1.75448276,78.1700662 1.75448276,74.2649007 L1.75448276,7.07324503 C1.75448276,3.16529801 4.8937931,2.20268248e-13 8.76965517,2.20268248e-13 L64.8882759,2.20268248e-13 Z M71.9014828,44.847 L71.9004828,48.847 L69.5614828,48.847 L69.5624828,44.847 L71.9014828,44.847 Z M71.9024828,26.847 L71.9014828,31.847 L69.5624828,31.847 L69.5634828,26.847 L71.9024828,26.847 Z" id="Combined-Shape" fill="#EEEEEE"></path> + <path d="M14.6151724,14.7333775 L21.6275862,14.7333775 C22.2731034,14.7333775 22.7972414,15.2618543 22.7972414,15.9127152 C22.7972414,16.5635762 22.2731034,17.092053 21.6275862,17.092053 L14.6151724,17.092053 C13.9696552,17.092053 13.4455172,16.5635762 13.4455172,15.9127152 C13.4455172,15.2618543 13.9696552,14.7333775 14.6151724,14.7333775 Z M33.3213793,21.8066225 L40.3365517,21.8066225 C40.982069,21.8066225 41.5062069,22.3350993 41.5062069,22.9859603 C41.5062069,23.6368212 40.982069,24.165298 40.3365517,24.165298 L33.3213793,24.165298 C32.6758621,24.165298 32.1517241,23.6368212 32.1517241,22.9859603 C32.1517241,22.3350993 32.6731034,21.8066225 33.3213793,21.8066225 Z" id="Shape" fill="#E1DBF1"></path> + <path d="M40.3337931,14.7333775 L47.3489655,14.7333775 C47.9944828,14.7333775 48.5186207,15.2618543 48.5186207,15.9127152 C48.5186207,16.5635762 47.9944828,17.092053 47.3489655,17.092053 L40.3337931,17.092053 C39.6882759,17.092053 39.1641379,16.5635762 39.1641379,15.9127152 C39.1668966,15.2618543 39.6882759,14.7333775 40.3337931,14.7333775 Z" id="Path" fill="#EEEEEE"></path> + <path d="M21.6275862,28.8798675 L28.6427586,28.8798675 C29.2882759,28.8798675 29.8124138,29.4083444 29.8124138,30.0592053 C29.8124138,30.7100662 29.2882759,31.238543 28.6427586,31.238543 L21.6275862,31.238543 C20.982069,31.238543 20.457931,30.7100662 20.457931,30.0592053 C20.4606897,29.4083444 20.982069,28.8798675 21.6275862,28.8798675 Z" id="Path" fill="#E1DBF1"></path> + <path d="M26.3062069,14.7333775 L28.6455172,14.7333775 C29.2910345,14.7333775 29.8151724,15.2618543 29.8151724,15.9127152 C29.8151724,16.5635762 29.2910345,17.092053 28.6455172,17.092053 L26.3062069,17.092053 C25.6606897,17.092053 25.1365517,16.5635762 25.1365517,15.9127152 C25.1365517,15.2618543 25.6606897,14.7333775 26.3062069,14.7333775 Z" id="Path" fill="#FEE1D3"></path> + <path d="M33.3213793,35.9531126 L35.6606897,35.9531126 C36.3062069,35.9531126 36.8303448,36.4815894 36.8303448,37.1324503 C36.8303448,37.7833113 36.3062069,38.3117881 35.6606897,38.3117881 L33.3213793,38.3117881 C32.6758621,38.3117881 32.1517241,37.7833113 32.1517241,37.1324503 C32.1517241,36.4815894 32.6731034,35.9531126 33.3213793,35.9531126 Z" id="Path" fill="#FC6D26"></path> + <path d="M52.0248276,14.7333775 L54.3641379,14.7333775 C55.0096552,14.7333775 55.5337931,15.2618543 55.5337931,15.9127152 C55.5337931,16.5635762 55.0096552,17.092053 54.3641379,17.092053 L52.0248276,17.092053 C51.3793103,17.092053 50.8551724,16.5635762 50.8551724,15.9127152 C50.857931,15.2618543 51.3793103,14.7333775 52.0248276,14.7333775 Z" id="Shape" fill="#FEF0E8"></path> + <path d="M23.9668966,43.0263576 L28.6427586,43.0263576 C29.2882759,43.0263576 29.8124138,43.5548344 29.8124138,44.2056954 C29.8124138,44.8565563 29.2882759,45.3850331 28.6427586,45.3850331 L23.9668966,45.3850331 C23.3213793,45.3850331 22.7972414,44.8565563 22.7972414,44.2056954 C22.7972414,43.5548344 23.3213793,43.0263576 23.9668966,43.0263576 Z" id="Path" fill="#E1DBF1"></path> + <path d="M40.3337931,35.9531126 L42.6731034,35.9531126 C43.3186207,35.9531126 43.8427586,36.4815894 43.8427586,37.1324503 C43.8427586,37.7833113 43.3186207,38.3117881 42.6731034,38.3117881 L40.3337931,38.3117881 C39.6882759,38.3117881 39.1641379,37.7833113 39.1641379,37.1324503 C39.1641379,36.4815894 39.6882759,35.9531126 40.3337931,35.9531126 Z" id="Path" fill="#FC6D26"></path> + <path d="M52.3172414,33.5944371 C53.1255172,33.5944371 53.7793103,34.2536424 53.7793103,35.0686093 C53.7793103,35.8835762 53.1255172,36.5427815 52.3172414,36.5427815 C51.5089655,36.5427815 50.8551724,35.8835762 50.8551724,35.0686093 C50.8551724,34.2536424 51.5117241,33.5944371 52.3172414,33.5944371 Z M58.1627586,50.6892715 C58.9710345,50.6892715 59.6248276,51.3484768 59.6248276,52.1634437 C59.6248276,52.9784106 58.9710345,53.6376159 58.1627586,53.6376159 C57.3544828,53.6376159 56.7006897,52.9784106 56.7006897,52.1634437 C56.7006897,51.3484768 57.3572414,50.6892715 58.1627586,50.6892715 Z" id="Shape" fill="#E1DBF1"></path> + <path d="M63.4262069,50.6892715 C64.2344828,50.6892715 64.8882759,51.3484768 64.8882759,52.1634437 C64.8882759,52.9784106 64.2344828,53.6376159 63.4262069,53.6376159 C62.617931,53.6376159 61.9641379,52.9784106 61.9641379,52.1634437 C61.9641379,51.3484768 62.617931,50.6892715 63.4262069,50.6892715 Z M33.3213793,14.7333775 L35.6606897,14.7333775 C36.3062069,14.7333775 36.8303448,15.2618543 36.8303448,15.9127152 C36.8303448,16.5635762 36.3062069,17.092053 35.6606897,17.092053 L33.3213793,17.092053 C32.6758621,17.092053 32.1517241,16.5635762 32.1517241,15.9127152 C32.1517241,15.2618543 32.6731034,14.7333775 33.3213793,14.7333775 Z" id="Shape" fill="#FC6D26"></path> + <path d="M59.1696552,33.8470199 L66.182069,33.8470199 C66.8275862,33.8470199 67.3517241,34.3754967 67.3517241,35.0263576 C67.3517241,35.6772185 66.8275862,36.2056954 66.182069,36.2056954 L59.1696552,36.2056954 C58.5241379,36.2056954 58,35.6772185 58,35.0263576 C58,34.3754967 58.5241379,33.8470199 59.1696552,33.8470199 Z" id="Shape" fill="#E1DBF1"></path> + <path d="M70.8606897,33.8470199 L73.2,33.8470199 C73.8455172,33.8470199 74.3696552,34.3754967 74.3696552,35.0263576 C74.3696552,35.6772185 73.8455172,36.2056954 73.2,36.2056954 L70.8606897,36.2056954 C70.2151724,36.2056954 69.6910345,35.6772185 69.6910345,35.0263576 C69.6910345,34.3754967 70.2151724,33.8470199 70.8606897,33.8470199 Z" id="Path" fill="#FEE1D3"></path> + <path d="M77.8758621,33.8470199 L80.2151724,33.8470199 C80.8606897,33.8470199 81.3848276,34.3754967 81.3848276,35.0263576 C81.3848276,35.6772185 80.8606897,36.2056954 80.2151724,36.2056954 L77.8758621,36.2056954 C77.2303448,36.2056954 76.7062069,35.6772185 76.7062069,35.0263576 C76.7062069,34.3754967 77.2275862,33.8470199 77.8758621,33.8470199 Z" id="Shape" fill="#FC6D26"></path> + <path d="M14.6151724,35.9531126 L28.6455172,35.9531126 C29.2910345,35.9531126 29.8151724,36.4815894 29.8151724,37.1324503 C29.8151724,37.7833113 29.2910345,38.3117881 28.6455172,38.3117881 L14.6151724,38.3117881 C13.9696552,38.3117881 13.4455172,37.7833113 13.4455172,37.1324503 C13.4455172,36.4815894 13.9696552,35.9531126 14.6151724,35.9531126 Z M44.0937931,41.8470199 L61.1282759,41.8470199 C61.9117241,41.8470199 62.5489655,42.5062252 62.5489655,43.3211921 C62.5489655,44.1361589 61.9144828,44.7953642 61.1282759,44.7953642 L44.0937931,44.7953642 C43.3103448,44.7953642 42.6731034,44.1361589 42.6731034,43.3211921 C42.6731034,42.5062252 43.3103448,41.8470199 44.0937931,41.8470199 L44.0937931,41.8470199 L44.0937931,41.8470199 Z" id="Shape" fill="#EEEEEE"></path> + <path d="M35.3241379,50.6892715 L52.3586207,50.6892715 C53.142069,50.6892715 53.7793103,51.3484768 53.7793103,52.1634437 C53.7793103,52.9784106 53.1448276,53.6376159 52.3586207,53.6376159 L35.3241379,53.6376159 C34.5406897,53.6376159 33.9034483,52.9784106 33.9034483,52.1634437 C33.9034483,51.3484768 34.5406897,50.6892715 35.3241379,50.6892715 L35.3241379,50.6892715 L35.3241379,50.6892715 Z" id="Path" fill="#EFEDF8"></path> + <path d="M14.6151724,21.8066225 L28.6455172,21.8066225 C29.2910345,21.8066225 29.8151724,22.3350993 29.8151724,22.9859603 C29.8151724,23.6368212 29.2910345,24.165298 28.6455172,24.165298 L14.6151724,24.165298 C13.9696552,24.165298 13.4455172,23.6368212 13.4455172,22.9859603 C13.4455172,22.3350993 13.9696552,21.8066225 14.6151724,21.8066225 Z" id="Path" fill="#6B4FBB"></path> + <path d="M33.3213793,28.8798675 L47.3517241,28.8798675 C47.9972414,28.8798675 48.5213793,29.4083444 48.5213793,30.0592053 C48.5213793,30.7100662 47.9972414,31.238543 47.3517241,31.238543 L33.3213793,31.238543 C32.6758621,31.238543 32.1517241,30.7100662 32.1517241,30.0592053 C32.1517241,29.4083444 32.6731034,28.8798675 33.3213793,28.8798675 Z" id="Path" fill="#C3B8E3"></path> + <path d="M14.6151724,28.8798675 L16.9544828,28.8798675 C17.6,28.8798675 18.1241379,29.4083444 18.1241379,30.0592053 C18.1241379,30.7100662 17.6,31.238543 16.9544828,31.238543 L14.6151724,31.238543 C13.9696552,31.238543 13.4455172,30.7100662 13.4455172,30.0592053 C13.4455172,29.4083444 13.9696552,28.8798675 14.6151724,28.8798675 Z" id="Path" fill="#FEF0E8"></path> + <path d="M75.182069,50.8470199 L82.1972414,50.8470199 C82.8427586,50.8470199 83.3668966,51.3754967 83.3668966,52.0263576 C83.3668966,52.6772185 82.8427586,53.2056954 82.1972414,53.2056954 L75.182069,53.2056954 C74.5365517,53.2056954 74.0124138,52.6772185 74.0124138,52.0263576 C74.0151724,51.3754967 74.5365517,50.8470199 75.182069,50.8470199 Z" id="Path" fill="#E1DBF1"></path> + <path d="M86.8758621,50.8470199 L100.906207,50.8470199 C101.551724,50.8470199 102.075862,51.5079868 102.075862,52.3220199 C102.075862,53.1360529 101.551724,53.7970199 100.906207,53.7970199 L86.8758621,53.7970199 C86.2303448,53.7970199 85.7062069,53.1360529 85.7062069,52.3220199 C85.7062069,51.5079868 86.2275862,50.8470199 86.8758621,50.8470199 Z" id="Path" fill="#C3B8E3"></path> + <path d="M68.1696552,50.8470199 L70.5089655,50.8470199 C71.1544828,50.8470199 71.6786207,51.3754967 71.6786207,52.0263576 C71.6786207,52.6772185 71.1544828,53.2056954 70.5089655,53.2056954 L68.1696552,53.2056954 C67.5241379,53.2056954 67,52.6772185 67,52.0263576 C67,51.3754967 67.5241379,50.8470199 68.1696552,50.8470199 Z" id="Path" fill="#FEF0E8"></path> + <path d="M33.3213793,43.0263576 L35.6606897,43.0263576 C36.3062069,43.0263576 36.8303448,43.5548344 36.8303448,44.2056954 C36.8303448,44.8565563 36.3062069,45.3850331 35.6606897,45.3850331 L33.3213793,45.3850331 C32.6758621,45.3850331 32.1517241,44.8565563 32.1517241,44.2056954 C32.1517241,43.5548344 32.6731034,43.0263576 33.3213793,43.0263576 Z" id="Path" fill="#6B4FBB"></path> + <path d="M14.6151724,43.0263576 L19.2910345,43.0263576 C19.9365517,43.0263576 20.4606897,43.5548344 20.4606897,44.2056954 C20.4606897,44.8565563 19.9365517,45.3850331 19.2910345,45.3850331 L14.6151724,45.3850331 C13.9696552,45.3850331 13.4455172,44.8565563 13.4455172,44.2056954 C13.4455172,43.5548344 13.9696552,43.0263576 14.6151724,43.0263576 Z" id="Path" fill="#FC6D26"></path> + <path d="M14.6151724,50.0996026 L19.2910345,50.0996026 C19.9365517,50.0996026 20.4606897,50.6280795 20.4606897,51.2789404 C20.4606897,51.9298013 19.9365517,52.4582781 19.2910345,52.4582781 L14.6151724,52.4582781 C13.9696552,52.4582781 13.4455172,51.9298013 13.4455172,51.2789404 C13.4455172,50.625298 13.9696552,50.0996026 14.6151724,50.0996026 Z M23.9668966,50.0996026 L28.6427586,50.0996026 C29.2882759,50.0996026 29.8124138,50.6280795 29.8124138,51.2789404 C29.8124138,51.9298013 29.2882759,52.4582781 28.6427586,52.4582781 L23.9668966,52.4582781 C23.3213793,52.4582781 22.7972414,51.9298013 22.7972414,51.2789404 C22.7972414,50.625298 23.3213793,50.0996026 23.9668966,50.0996026 Z" id="Shape" fill="#FEF0E8"></path> + <path d="M88.7172414,21.8029139 C89.5255172,21.8029139 90.1793103,22.4621192 90.1793103,23.2770861 C90.1793103,24.092053 89.5255172,24.7512583 88.7172414,24.7512583 C87.9089655,24.7512583 87.2551724,24.092053 87.2551724,23.2770861 C87.2551724,22.4621192 87.9117241,21.8029139 88.7172414,21.8029139 Z" id="Shape" fill="#FEE1D3"></path> + <path d="M93.9806897,21.8029139 C94.7889655,21.8029139 95.4427586,22.4621192 95.4427586,23.2770861 C95.4427586,24.092053 94.7889655,24.7512583 93.9806897,24.7512583 C93.1724138,24.7512583 92.5186207,24.092053 92.5186207,23.2770861 C92.5186207,22.4621192 93.1724138,21.8029139 93.9806897,21.8029139 Z" id="Shape" fill="#6B4FBB"></path> + <path d="M65.8786207,21.8029139 L82.9131034,21.8029139 C83.6965517,21.8029139 84.3337931,22.4624894 84.3337931,23.2779139 C84.3337931,24.0933384 83.6993103,24.7529139 82.9131034,24.7529139 L65.8786207,24.7529139 C65.0951724,24.7529139 64.457931,24.0933384 64.457931,23.2779139 C64.457931,22.4624894 65.0951724,21.8029139 65.8786207,21.8029139 L65.8786207,21.8029139 L65.8786207,21.8029139 Z" id="Path" fill="#FC6D26"></path> + <path d="M54.5213793,21.213245 L59.1972414,21.213245 C59.8427586,21.213245 60.3668966,21.7417219 60.3668966,22.3925828 C60.3668966,23.0434437 59.8427586,23.5719205 59.1972414,23.5719205 L54.5213793,23.5719205 C53.8758621,23.5719205 53.3517241,23.0434437 53.3517241,22.3925828 C53.3517241,21.7389404 53.8758621,21.213245 54.5213793,21.213245 Z" id="Shape" fill="#FEF0E8"></path> + <path d="M45.1696552,21.213245 L49.8455172,21.213245 C50.4910345,21.213245 51.0151724,21.7417219 51.0151724,22.3925828 C51.0151724,23.0434437 50.4910345,23.5719205 49.8455172,23.5719205 L45.1696552,23.5719205 C44.5241379,23.5719205 44,23.0434437 44,22.3925828 C44,21.7389404 44.5241379,21.213245 45.1696552,21.213245 Z" id="Path" fill="#EEEEEE"></path> + <path d="M14.6151724,57.1728477 L21.6275862,57.1728477 C22.2731034,57.1728477 22.7972414,57.7013245 22.7972414,58.3521854 C22.7972414,59.0030464 22.2731034,59.5315232 21.6275862,59.5315232 L14.6151724,59.5315232 C13.9696552,59.5315232 13.4455172,59.0030464 13.4455172,58.3521854 C13.4455172,57.698543 13.9696552,57.1728477 14.6151724,57.1728477 Z" id="Path" fill="#EFEDF8"></path> + <path d="M25.3544828,64.2433113 L33.6855172,64.2433113 C34.4524138,64.2433113 35.0731034,64.7717881 35.0731034,65.422649 C35.0731034,66.0735099 34.4524138,66.6019868 33.6855172,66.6019868 L25.3544828,66.6019868 C24.5875862,66.6019868 23.9668966,66.0735099 23.9668966,65.422649 C23.9668966,64.7717881 24.5875862,64.2433113 25.3544828,64.2433113 Z" id="Path" fill="#FC6D26"></path> + <path d="M44.0606897,58.9390728 L52.3917241,58.9390728 C53.1586207,58.9390728 53.7793103,59.5982781 53.7793103,60.413245 C53.7793103,61.2254305 53.1586207,61.8874172 52.3917241,61.8874172 L44.0606897,61.8874172 C43.2937931,61.8874172 42.6731034,61.2282119 42.6731034,60.413245 C42.6731034,59.6010596 43.2937931,58.9390728 44.0606897,58.9390728 Z" id="Path" fill="#6B4FBB"></path> + <path d="M26.3062069,57.1728477 L28.6455172,57.1728477 C29.2910345,57.1728477 29.8151724,57.7013245 29.8151724,58.3521854 C29.8151724,59.0030464 29.2910345,59.5315232 28.6455172,59.5315232 L26.3062069,59.5315232 C25.6606897,59.5315232 25.1365517,59.0030464 25.1365517,58.3521854 C25.1365517,57.698543 25.6606897,57.1728477 26.3062069,57.1728477 Z" id="Path" fill="#FEE1D3"></path> + <path d="M36.8275862,64.2433113 L39.1668966,64.2433113 C39.8124138,64.2433113 40.3365517,64.7717881 40.3365517,65.422649 C40.3365517,66.0735099 39.8124138,66.6019868 39.1668966,66.6019868 L36.8275862,66.6019868 C36.182069,66.6019868 35.657931,66.0735099 35.657931,65.422649 C35.657931,64.7717881 36.182069,64.2433113 36.8275862,64.2433113 Z M58.1627586,58.9390728 L61.0868966,58.9390728 C61.8951724,58.9390728 62.5489655,59.5982781 62.5489655,60.413245 C62.5489655,61.2282119 61.8951724,61.8874172 61.0868966,61.8874172 L58.1627586,61.8874172 C57.3544828,61.8874172 56.7006897,61.2282119 56.7006897,60.413245 C56.7034483,59.5982781 57.3572414,58.9390728 58.1627586,58.9390728 Z" id="Shape" fill="#FEF0E8"></path> + <path d="M35.3655172,58.9390728 L38.2896552,58.9390728 C39.097931,58.9390728 39.7517241,59.5982781 39.7517241,60.413245 C39.7517241,61.2282119 39.097931,61.8874172 38.2896552,61.8874172 L35.3655172,61.8874172 C34.5572414,61.8874172 33.9034483,61.2282119 33.9034483,60.413245 C33.9034483,59.5982781 34.56,58.9390728 35.3655172,58.9390728 Z" id="Path" fill="#FC6D26"></path> + <path d="M66.1696552,40.8470199 L73.182069,40.8470199 C73.8275862,40.8470199 74.3517241,41.3754967 74.3517241,42.0263576 C74.3517241,42.6772185 73.8275862,43.2056954 73.182069,43.2056954 L66.1696552,43.2056954 C65.5241379,43.2056954 65,42.6772185 65,42.0263576 C65,41.3727152 65.5241379,40.8470199 66.1696552,40.8470199 Z" id="Path" fill="#EFEDF8"></path> + <path d="M95.6151724,42.613245 L103.946207,42.613245 C104.713103,42.613245 105.333793,43.1409054 105.333793,43.793245 C105.333793,44.4433582 104.713103,44.973245 103.946207,44.973245 L95.6151724,44.973245 C94.8482759,44.973245 94.2275862,44.4455847 94.2275862,43.793245 C94.2275862,43.1431318 94.8482759,42.613245 95.6151724,42.613245 Z" id="Path" fill="#6B4FBB"></path> + <path d="M77.8606897,40.8470199 L80.2,40.8470199 C80.8455172,40.8470199 81.3696552,41.3754967 81.3696552,42.0263576 C81.3696552,42.6772185 80.8455172,43.2056954 80.2,43.2056954 L77.8606897,43.2056954 C77.2151724,43.2056954 76.6910345,42.6772185 76.6910345,42.0263576 C76.6910345,41.3727152 77.2151724,40.8470199 77.8606897,40.8470199 Z" id="Path" fill="#FEE1D3"></path> + <path d="M86.92,42.613245 L89.8441379,42.613245 C90.6524138,42.613245 91.3062069,43.1409054 91.3062069,43.793245 C91.3062069,44.4455847 90.6524138,44.973245 89.8441379,44.973245 L86.92,44.973245 C86.1117241,44.973245 85.457931,44.4455847 85.457931,43.793245 C85.457931,43.1409054 86.1144828,42.613245 86.92,42.613245 Z" id="Path" fill="#FC6D26"></path> + <path d="M14.6151724,64.2433113 L20.4606897,64.2433113 C21.1062069,64.2433113 21.6303448,64.7717881 21.6303448,65.422649 C21.6303448,66.0735099 21.1062069,66.6019868 20.4606897,66.6019868 L14.6151724,66.6019868 C13.9696552,66.6019868 13.4455172,66.0735099 13.4455172,65.422649 C13.4455172,64.7717881 13.9696552,64.2433113 14.6151724,64.2433113 Z" id="Path" fill="#EEEEEE"></path> + </g> + <g id="Group-12" transform="translate(112.058152, -0.000000)"> + <path d="M5.84861758,4.12465116 L62.0003099,4.12465116 C65.229233,4.12465116 67.8489253,6.76465116 67.8489253,10.0186047 L67.8489253,77.8046512 C67.8489253,81.0586047 65.229233,83.6986047 62.0003099,83.6986047 L5.84861758,83.6986047 C2.6196945,83.6986047 1.42108547e-14,81.0586047 1.42108547e-14,77.8046512 L1.42108547e-14,10.0213953 C-0.00276703963,6.76744186 2.6196945,4.12465116 5.84861758,4.12465116 Z" id="Path" fill="#F9F9F9" fill-rule="nonzero"></path> + <path d="M8.77292527,1.17767442 L64.9246176,1.17767442 C68.1535407,1.17767442 70.773233,3.81767442 70.773233,7.07162791 L70.773233,74.2688372 C70.773233,77.5227907 68.1535407,80.1627907 64.9246176,80.1627907 L8.77292527,80.1627907 C5.54400219,80.1627907 2.92430988,77.5227907 2.92430988,74.2688372 L2.92430988,7.07162791 C2.92430988,3.81767442 5.54400219,1.17767442 8.77292527,1.17767442 Z" id="Path" fill="#FFFFFF" fill-rule="nonzero"></path> + <path d="M8.77292527,2.35813953 C6.18646373,2.35813953 4.09292527,4.46790698 4.09292527,7.0744186 L4.09292527,74.2716279 C4.09292527,76.8781395 6.18646373,78.987907 8.77292527,78.987907 L64.9246176,78.987907 C67.5110791,78.987907 69.6046176,76.8781395 69.6046176,74.2716279 L69.6046176,7.07162791 C69.6046176,4.46511628 67.5110791,2.35534884 64.9246176,2.35534884 L8.77292527,2.35813953 L8.77292527,2.35813953 Z M8.77292527,-4.19220214e-13 L64.9246176,-4.19220214e-13 C68.8043099,-4.19220214e-13 71.9418483,3.16744186 71.9418483,7.07162791 L71.9418483,74.2688372 C71.9418483,78.1786047 68.7987714,81.3404651 64.9246176,81.3404651 L8.77292527,81.3404651 C4.89323296,81.3404651 1.75569267,78.1730233 1.75569267,74.2688372 L1.75569267,7.07162791 C1.75292527,3.17023256 4.89600219,-4.19220214e-13 8.77292527,-4.19220214e-13 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path> + <path d="M14.6215407,14.7348837 L21.6387714,14.7348837 C22.281233,14.7348837 22.8073868,15.2651163 22.8073868,15.9125581 C22.8073868,16.56 22.281233,17.0902326 21.6387714,17.0902326 L14.6215407,17.0902326 C13.9790791,17.0902326 13.4529253,16.56 13.4529253,15.9125581 C13.4529253,15.2651163 13.9763099,14.7348837 14.6215407,14.7348837 Z M33.3387714,21.8093023 L40.3560022,21.8093023 C40.9984637,21.8093023 41.5246176,22.3395349 41.5246176,22.9869767 C41.5246176,23.6344186 40.9984637,24.1646512 40.3560022,24.1646512 L33.3387714,24.1646512 C32.6963099,24.1646512 32.170156,23.6344186 32.170156,22.9869767 C32.170156,22.3395349 32.6963099,21.8093023 33.3387714,21.8093023 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M40.3587714,14.7348837 L47.3760022,14.7348837 C48.0184637,14.7348837 48.5446176,15.2651163 48.5446176,15.9125581 C48.5446176,16.56 48.0184637,17.0902326 47.3760022,17.0902326 L40.3587714,17.0902326 C39.7163099,17.0902326 39.1901452,16.56 39.1901452,15.9125581 C39.1873868,15.267907 39.7163099,14.7348837 40.3587714,14.7348837 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path> + <path d="M21.6415407,28.8837209 L28.6587714,28.8837209 C29.301233,28.8837209 29.8273868,29.4139535 29.8273868,30.0613953 C29.8273868,30.7088372 29.301233,31.2390698 28.6587714,31.2390698 L21.6415407,31.2390698 C20.9990791,31.2390698 20.4729253,30.7088372 20.4729253,30.0613953 C20.4729253,29.4139535 20.9990791,28.8837209 21.6415407,28.8837209 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M26.3187714,14.7348837 L28.6587714,14.7348837 C29.301233,14.7348837 29.8273868,15.2651163 29.8273868,15.9125581 C29.8273868,16.56 29.301233,17.0902326 28.6587714,17.0902326 L26.3187714,17.0902326 C25.6763099,17.0902326 25.150156,16.56 25.150156,15.9125581 C25.150156,15.2651163 25.6763099,14.7348837 26.3187714,14.7348837 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path> + <path d="M33.3387714,35.9553488 L35.6787714,35.9553488 C36.321233,35.9553488 36.8473868,36.4855814 36.8473868,37.1330233 C36.8473868,37.7804651 36.321233,38.3106977 35.6787714,38.3106977 L33.3387714,38.3106977 C32.6963099,38.3106977 32.170156,37.7804651 32.170156,37.1330233 C32.170156,36.4855814 32.6963099,35.9553488 33.3387714,35.9553488 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M45.0360022,21.8093023 L47.3760022,21.8093023 C48.0184637,21.8093023 48.5446176,22.3395349 48.5446176,22.9869767 C48.5446176,23.6344186 48.0184637,24.1646512 47.3760022,24.1646512 L45.0360022,24.1646512 C44.3935407,24.1646512 43.8673868,23.6344186 43.8673868,22.9869767 C43.8673868,22.3395349 44.3935407,21.8093023 45.0360022,21.8093023 Z M52.0560022,14.7348837 L54.3960022,14.7348837 C55.0384637,14.7348837 55.5646176,15.2651163 55.5646176,15.9125581 C55.5646176,16.56 55.0384637,17.0902326 54.3960022,17.0902326 L52.0560022,17.0902326 C51.4135407,17.0902326 50.8873868,16.56 50.8873868,15.9125581 C50.8873868,15.2651163 51.4135407,14.7348837 52.0560022,14.7348837 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M23.9787714,43.0297674 L28.6587714,43.0297674 C29.301233,43.0297674 29.8273868,43.56 29.8273868,44.2074419 C29.8273868,44.8548837 29.301233,45.3851163 28.6587714,45.3851163 L23.9787714,45.3851163 C23.3363099,45.3851163 22.810156,44.8548837 22.810156,44.2074419 C22.810156,43.56 23.3363099,43.0297674 23.9787714,43.0297674 Z" id="Path" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M40.3587714,35.9553488 L42.6987714,35.9553488 C43.341233,35.9553488 43.8673868,36.4855814 43.8673868,37.1330233 C43.8673868,37.7804651 43.341233,38.3106977 42.6987714,38.3106977 L40.3587714,38.3106977 C39.7163099,38.3106977 39.1901452,37.7804651 39.1901452,37.1330233 C39.1873868,36.4883721 39.7163099,35.9553488 40.3587714,35.9553488 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M52.3495407,33.5972093 C53.158156,33.5972093 53.8116945,34.255814 53.8116945,35.0706977 C53.8116945,35.8855814 53.158156,36.544186 52.3495407,36.544186 C51.5409253,36.544186 50.8873868,35.8855814 50.8873868,35.0706977 C50.8873868,34.2586047 51.5436945,33.5972093 52.3495407,33.5972093 Z M58.198156,50.6930233 C59.0067714,50.6930233 59.6603099,51.3516279 59.6603099,52.1665116 C59.6603099,52.9813953 59.0067714,53.64 58.198156,53.64 C57.3895407,53.64 56.7360022,52.9813953 56.7360022,52.1665116 C56.7360022,51.3516279 57.3895407,50.6930233 58.198156,50.6930233 Z" id="Shape" fill="#E1DBF1" fill-rule="nonzero"></path> + <path d="M63.4624637,50.6930233 C64.2710791,50.6930233 64.9246176,51.3516279 64.9246176,52.1665116 C64.9246176,52.9813953 64.2710791,53.64 63.4624637,53.64 C62.6538483,53.64 62.0003099,52.9813953 62.0003099,52.1665116 C62.0003099,51.3516279 62.6566176,50.6930233 63.4624637,50.6930233 Z M33.3387714,14.7348837 L35.6787714,14.7348837 C36.321233,14.7348837 36.8473868,15.2651163 36.8473868,15.9125581 C36.8473868,16.56 36.321233,17.0902326 35.6787714,17.0902326 L33.3387714,17.0902326 C32.6963099,17.0902326 32.170156,16.56 32.170156,15.9125581 C32.170156,15.2651163 32.6963099,14.7348837 33.3387714,14.7348837 Z" id="Shape" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M14.6215407,35.9553488 L28.6615407,35.9553488 C29.3040022,35.9553488 29.830156,36.4855814 29.830156,37.1330233 C29.830156,37.7804651 29.3040022,38.3106977 28.6615407,38.3106977 L14.6215407,38.3106977 C13.9790791,38.3106977 13.4529253,37.7804651 13.4529253,37.1330233 C13.4529253,36.4855814 13.9763099,35.9553488 14.6215407,35.9553488 Z M44.1193868,41.8493023 L61.1640022,41.8493023 C61.9476945,41.8493023 62.5873868,42.507907 62.5873868,43.3227907 C62.5873868,44.1376744 61.9504637,44.7962791 61.1640022,44.7962791 L44.1193868,44.7962791 C43.3356945,44.7962791 42.6960022,44.1376744 42.6960022,43.3227907 C42.6960022,42.507907 43.3356945,41.8493023 44.1193868,41.8493023 L44.1193868,41.8493023 L44.1193868,41.8493023 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path> + <path d="M35.3464637,50.6930233 L52.3910791,50.6930233 C53.1747714,50.6930233 53.8144637,51.3516279 53.8144637,52.1665116 C53.8144637,52.9813953 53.1775407,53.64 52.3910791,53.64 L35.3464637,53.64 C34.5627714,53.64 33.9230791,52.9813953 33.9230791,52.1665116 C33.9230791,51.3516279 34.5600022,50.6930233 35.3464637,50.6930233 L35.3464637,50.6930233 L35.3464637,50.6930233 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path> + <path d="M14.6215407,21.8093023 L28.6615407,21.8093023 C29.3040022,21.8093023 29.830156,22.3395349 29.830156,22.9869767 C29.830156,23.6344186 29.3040022,24.1646512 28.6615407,24.1646512 L14.6215407,24.1646512 C13.9790791,24.1646512 13.4529253,23.6344186 13.4529253,22.9869767 C13.4529253,22.3395349 13.9763099,21.8093023 14.6215407,21.8093023 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path> + <path d="M33.3387714,28.8837209 L47.3787714,28.8837209 C48.021233,28.8837209 48.5473868,29.4139535 48.5473868,30.0613953 C48.5473868,30.7088372 48.021233,31.2390698 47.3787714,31.2390698 L33.3387714,31.2390698 C32.6963099,31.2390698 32.170156,30.7088372 32.170156,30.0613953 C32.170156,29.4139535 32.6963099,28.8837209 33.3387714,28.8837209 Z" id="Path" fill="#C3B8E3" fill-rule="nonzero"></path> + <path d="M14.6215407,28.8837209 L16.9615407,28.8837209 C17.6040022,28.8837209 18.130156,29.4139535 18.130156,30.0613953 C18.130156,30.7088372 17.6040022,31.2390698 16.9615407,31.2390698 L14.6215407,31.2390698 C13.9790791,31.2390698 13.4529253,30.7088372 13.4529253,30.0613953 C13.4529253,29.4139535 13.9763099,28.8837209 14.6215407,28.8837209 Z" id="Path" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M33.3387714,43.0297674 L35.6787714,43.0297674 C36.321233,43.0297674 36.8473868,43.56 36.8473868,44.2074419 C36.8473868,44.8548837 36.321233,45.3851163 35.6787714,45.3851163 L33.3387714,45.3851163 C32.6963099,45.3851163 32.170156,44.8548837 32.170156,44.2074419 C32.170156,43.56 32.6963099,43.0297674 33.3387714,43.0297674 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path> + <path d="M14.6215407,43.0297674 L19.3015407,43.0297674 C19.9440022,43.0297674 20.470156,43.56 20.470156,44.2074419 C20.470156,44.8548837 19.9440022,45.3851163 19.3015407,45.3851163 L14.6215407,45.3851163 C13.9790791,45.3851163 13.4529253,44.8548837 13.4529253,44.2074419 C13.4529253,43.56 13.9763099,43.0297674 14.6215407,43.0297674 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M14.6215407,50.104186 L19.3015407,50.104186 C19.9440022,50.104186 20.470156,50.6344186 20.470156,51.2818605 C20.470156,51.9293023 19.9440022,52.4595349 19.3015407,52.4595349 L14.6215407,52.4595349 C13.9790791,52.4595349 13.4529253,51.9293023 13.4529253,51.2818605 C13.4529253,50.6344186 13.9763099,50.104186 14.6215407,50.104186 Z M23.9787714,50.104186 L28.6587714,50.104186 C29.301233,50.104186 29.8273868,50.6344186 29.8273868,51.2818605 C29.8273868,51.9293023 29.301233,52.4595349 28.6587714,52.4595349 L23.9787714,52.4595349 C23.3363099,52.4595349 22.810156,51.9293023 22.810156,51.2818605 C22.810156,50.6344186 23.3363099,50.104186 23.9787714,50.104186 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M14.6215407,57.175814 L21.6387714,57.175814 C22.281233,57.175814 22.8073868,57.7060465 22.8073868,58.3534884 C22.8073868,59.0009302 22.281233,59.5311628 21.6387714,59.5311628 L14.6215407,59.5311628 C13.9790791,59.5311628 13.4529253,59.0009302 13.4529253,58.3534884 C13.4529253,57.7060465 13.9763099,57.175814 14.6215407,57.175814 Z" id="Path" fill="#EFEDF8" fill-rule="nonzero"></path> + <path d="M25.366156,64.2502326 L33.7015407,64.2502326 C34.4686176,64.2502326 35.0916945,64.7748837 35.0916945,65.427907 C35.0916945,66.0753488 34.4713868,66.6055814 33.7015407,66.6055814 L25.366156,66.6055814 C24.5990791,66.6055814 23.9760022,66.0753488 23.9760022,65.427907 C23.9787714,64.7804651 24.6018483,64.2502326 25.366156,64.2502326 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M44.0833868,58.9451163 L52.4187714,58.9451163 C53.1858483,58.9451163 53.8089253,59.6037209 53.8089253,60.4186047 C53.8089253,61.2334884 53.1886176,61.892093 52.4187714,61.892093 L44.0833868,61.892093 C43.3163099,61.892093 42.693233,61.2334884 42.693233,60.4186047 C42.6960022,59.6065116 43.3190791,58.9451163 44.0833868,58.9451163 Z" id="Path" fill="#6B4FBB" fill-rule="nonzero"></path> + <path d="M26.3187714,57.175814 L28.6587714,57.175814 C29.301233,57.175814 29.8273868,57.7060465 29.8273868,58.3534884 C29.8273868,59.0009302 29.301233,59.5311628 28.6587714,59.5311628 L26.3187714,59.5311628 C25.6763099,59.5311628 25.150156,59.0009302 25.150156,58.3534884 C25.150156,57.7060465 25.6763099,57.175814 26.3187714,57.175814 Z" id="Path" fill="#FEE1D3" fill-rule="nonzero"></path> + <path d="M36.850156,64.2502326 L39.190156,64.2502326 C39.8326176,64.2502326 40.3587714,64.7804651 40.3587714,65.427907 C40.3587714,66.0753488 39.8326176,66.6055814 39.190156,66.6055814 L36.850156,66.6055814 C36.2076945,66.6055814 35.6815407,66.0753488 35.6815407,65.427907 C35.6815407,64.7804651 36.2049253,64.2502326 36.850156,64.2502326 Z M58.198156,58.9451163 L61.1224637,58.9451163 C61.9310791,58.9451163 62.5846176,59.6037209 62.5846176,60.4186047 C62.5846176,61.2334884 61.9310791,61.892093 61.1224637,61.892093 L58.198156,61.892093 C57.3895407,61.892093 56.7360022,61.2334884 56.7360022,60.4186047 C56.7360022,59.6065116 57.3895407,58.9451163 58.198156,58.9451163 Z" id="Shape" fill="#FEF0E8" fill-rule="nonzero"></path> + <path d="M35.385233,58.9451163 L38.3095407,58.9451163 C39.118156,58.9451163 39.7716945,59.6037209 39.7716945,60.4186047 C39.7716945,61.2334884 39.118156,61.892093 38.3095407,61.892093 L35.385233,61.892093 C34.5766176,61.892093 33.9230791,61.2334884 33.9230791,60.4186047 C33.9230791,59.6065116 34.5793868,58.9451163 35.385233,58.9451163 Z" id="Path" fill="#FC6D26" fill-rule="nonzero"></path> + <path d="M14.6215407,64.2502326 L20.470156,64.2502326 C21.1126176,64.2502326 21.6387714,64.7804651 21.6387714,65.427907 C21.6387714,66.0753488 21.1126176,66.6055814 20.470156,66.6055814 L14.6215407,66.6055814 C13.9790791,66.6055814 13.4529253,66.0753488 13.4529253,65.427907 C13.4529253,64.7804651 13.9763099,64.2502326 14.6215407,64.2502326 Z" id="Path" fill="#EEEEEE" fill-rule="nonzero"></path> + <ellipse id="Oval" fill="#FFFFFF" fill-rule="nonzero" cx="36.814156" cy="38.4725581" rx="20.4729231" ry="20.6316279"></ellipse> + <path d="M36.814156,59.104186 C25.5073868,59.104186 16.341233,49.8669767 16.341233,38.4725581 C16.341233,27.0781395 25.5073868,17.8409302 36.814156,17.8409302 C48.1209253,17.8409302 57.2870791,27.0781395 57.2870791,38.4725581 C57.2870791,49.8669767 48.1209253,59.104186 36.814156,59.104186 Z M36.814156,56.7460465 C46.8276945,56.7460465 54.9470791,48.5637209 54.9470791,38.4725581 C54.9470791,28.3813953 46.8276945,20.1990698 36.814156,20.1990698 C26.8006176,20.1990698 18.681233,28.3813953 18.681233,38.4725581 C18.681233,48.5637209 26.8006176,56.7460465 36.814156,56.7460465 Z" id="Shape" fill="#EEEEEE" fill-rule="nonzero"></path> + <path d="M46.5895407,39.7813953 L45.490156,36.3739535 L43.3135407,29.6260465 C43.2027714,29.28 42.718156,29.28 42.5990791,29.6260465 L40.4280022,36.3739535 L33.2030791,36.3739535 L31.0264637,29.6260465 C30.9156945,29.28 30.4310791,29.28 30.3120022,29.6260465 L28.1353868,36.3739535 L27.0360022,39.7813953 C26.9363099,40.0939535 27.0470791,40.4288372 27.3046176,40.624186 L36.8086176,47.5786047 L46.3126176,40.624186 C46.581233,40.4288372 46.689233,40.0883721 46.5895407,39.7813953" id="Path" fill="#FC6D26"></path> + <polygon id="Path" fill="#E24329" points="36.814156 47.5813953 40.4280022 36.3767442 33.2030791 36.3767442"></polygon> + <polygon id="Path" fill="#FC6D26" points="36.814156 47.5813953 33.2003099 36.3767442 28.1353868 36.3767442"></polygon> + <path d="M28.138156,36.3739535 L27.0387714,39.7813953 C26.9390791,40.0939535 27.0498483,40.4288372 27.3073868,40.624186 L36.8113868,47.5786047 L28.138156,36.3739535 Z" id="Path" fill="#FCA326"></path> + <path d="M28.138156,36.3739535 L33.2030791,36.3739535 L31.0264637,29.6260465 C30.9156945,29.28 30.4310791,29.28 30.3120022,29.6260465 L28.138156,36.3739535 Z" id="Path" fill="#E24329"></path> + <polygon id="Path" fill="#FC6D26" points="36.814156 47.5813953 40.4280022 36.3767442 45.4929253 36.3767442"></polygon> + <path d="M45.4929253,36.3739535 L46.5923099,39.7813953 C46.6920022,40.0939535 46.581233,40.4288372 46.3236945,40.624186 L36.8196945,47.5786047 L45.4929253,36.3739535 Z" id="Path" fill="#FCA326"></path> + <path d="M45.4929253,36.3739535 L40.4280022,36.3739535 L42.6046176,29.6260465 C42.7153868,29.28 43.2000022,29.28 43.3190791,29.6260465 L45.4929253,36.3739535 Z" id="Path" fill="#E24329"></path> + </g> + </g> + </g> +</svg>
\ No newline at end of file diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/index.js b/app/assets/javascripts/projects/experiment_new_project_creation/index.js new file mode 100644 index 00000000000..3715c52b6b9 --- /dev/null +++ b/app/assets/javascripts/projects/experiment_new_project_creation/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import NewProjectCreationApp from './components/app.vue'; + +export default function(el, props) { + return new Vue({ + el, + components: { + NewProjectCreationApp, + }, + render(h) { + return h(NewProjectCreationApp, { props }); + }, + }); +} diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue new file mode 100644 index 00000000000..8bdf043a106 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue @@ -0,0 +1,70 @@ +<script> +import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui'; + +import { ALERT_MESSAGES, ADMIN_GARBAGE_COLLECTION_TIP } from '../../constants/index'; + +export default { + components: { + GlSprintf, + GlAlert, + GlLink, + }, + model: { + prop: 'deleteAlertType', + event: 'change', + }, + props: { + deleteAlertType: { + type: String, + default: null, + required: false, + validator(value) { + return !value || ALERT_MESSAGES[value] !== undefined; + }, + }, + garbageCollectionHelpPagePath: { type: String, required: false, default: '' }, + isAdmin: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + deleteAlertConfig() { + const config = { + title: '', + message: '', + type: 'success', + }; + if (this.deleteAlertType) { + [config.type] = this.deleteAlertType.split('_'); + + config.message = ALERT_MESSAGES[this.deleteAlertType]; + + if (this.isAdmin && config.type === 'success') { + config.title = config.message; + config.message = ADMIN_GARBAGE_COLLECTION_TIP; + } + } + return config; + }, + }, +}; +</script> + +<template> + <gl-alert + v-if="deleteAlertType" + :variant="deleteAlertConfig.type" + :title="deleteAlertConfig.title" + @dismiss="$emit('change', null)" + > + <gl-sprintf :message="deleteAlertConfig.message"> + <template #docLink="{content}"> + <gl-link :href="garbageCollectionHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue new file mode 100644 index 00000000000..96f221bf71d --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue @@ -0,0 +1,67 @@ +<script> +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import { REMOVE_TAG_CONFIRMATION_TEXT, REMOVE_TAGS_CONFIRMATION_TEXT } from '../../constants/index'; + +export default { + components: { + GlModal, + GlSprintf, + }, + props: { + itemsToBeDeleted: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + modalAction() { + return n__( + 'ContainerRegistry|Remove tag', + 'ContainerRegistry|Remove tags', + this.itemsToBeDeleted.length, + ); + }, + modalDescription() { + if (this.itemsToBeDeleted.length > 1) { + return { + message: REMOVE_TAGS_CONFIRMATION_TEXT, + item: this.itemsToBeDeleted.length, + }; + } + const [first] = this.itemsToBeDeleted; + + return { + message: REMOVE_TAG_CONFIRMATION_TEXT, + item: first?.path, + }; + }, + }, + methods: { + show() { + this.$refs.deleteModal.show(); + }, + }, +}; +</script> + +<template> + <gl-modal + ref="deleteModal" + modal-id="delete-tag-modal" + ok-variant="danger" + @ok="$emit('confirmDelete')" + @cancel="$emit('cancelDelete')" + > + <template #modal-title>{{ modalAction }}</template> + <template #modal-ok>{{ modalAction }}</template> + <p v-if="modalDescription" data-testid="description"> + <gl-sprintf :message="modalDescription.message"> + <template #item + ><b>{{ modalDescription.item }}</b></template + > + </gl-sprintf> + </p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue new file mode 100644 index 00000000000..c254dd05aa4 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue @@ -0,0 +1,30 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { DETAILS_PAGE_TITLE } from '../../constants/index'; + +export default { + components: { GlSprintf }, + props: { + imageName: { + type: String, + required: false, + default: '', + }, + }, + i18n: { + DETAILS_PAGE_TITLE, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-my-2 gl-align-items-center"> + <h4> + <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> + <template #imageName> + {{ imageName }} + </template> + </gl-sprintf> + </h4> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue b/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue new file mode 100644 index 00000000000..0c684d124d5 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue @@ -0,0 +1,33 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { + EMPTY_IMAGE_REPOSITORY_TITLE, + EMPTY_IMAGE_REPOSITORY_MESSAGE, +} from '../../constants/index'; + +export default { + components: { + GlEmptyState, + }, + props: { + noContainersImage: { + type: String, + required: false, + default: '', + }, + }, + i18n: { + EMPTY_IMAGE_REPOSITORY_TITLE, + EMPTY_IMAGE_REPOSITORY_MESSAGE, + }, +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE" + :svg-path="noContainersImage" + :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE" + class="gl-mx-auto gl-my-0" + /> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue new file mode 100644 index 00000000000..b7afa5fba33 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue @@ -0,0 +1,34 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, + loader: { + repeat: 10, + width: 1000, + height: 40, + }, +}; +</script> + +<template> + <div> + <gl-skeleton-loader + v-for="index in $options.loader.repeat" + :key="index" + :width="$options.loader.width" + :height="$options.loader.height" + preserve-aspect-ratio="xMinYMax meet" + > + <rect width="15" x="0" y="12.5" height="15" rx="4" /> + <rect width="250" x="25" y="10" height="20" rx="4" /> + <circle cx="290" cy="20" r="10" /> + <rect width="100" x="315" y="10" height="20" rx="4" /> + <rect width="100" x="500" y="10" height="20" rx="4" /> + <rect width="100" x="630" y="10" height="20" rx="4" /> + <rect x="960" y="0" width="40" height="40" rx="4" /> + </gl-skeleton-loader> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue new file mode 100644 index 00000000000..81be778e1e5 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue @@ -0,0 +1,210 @@ +<script> +import { GlTable, GlFormCheckbox, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { + LIST_KEY_TAG, + LIST_KEY_IMAGE_ID, + LIST_KEY_SIZE, + LIST_KEY_LAST_UPDATED, + LIST_KEY_ACTIONS, + LIST_KEY_CHECKBOX, + LIST_LABEL_TAG, + LIST_LABEL_IMAGE_ID, + LIST_LABEL_SIZE, + LIST_LABEL_LAST_UPDATED, + REMOVE_TAGS_BUTTON_TITLE, + REMOVE_TAG_BUTTON_TITLE, +} from '../../constants/index'; + +export default { + components: { + GlTable, + GlFormCheckbox, + GlButton, + ClipboardButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + tags: { + type: Array, + required: false, + default: () => [], + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + isDesktop: { + type: Boolean, + required: false, + default: false, + }, + }, + i18n: { + REMOVE_TAGS_BUTTON_TITLE, + REMOVE_TAG_BUTTON_TITLE, + }, + data() { + return { + selectedItems: [], + }; + }, + computed: { + fields() { + const tagClass = this.isDesktop ? 'w-25' : ''; + const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end'; + return [ + { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' }, + { + key: LIST_KEY_TAG, + label: LIST_LABEL_TAG, + class: `${tagClass} js-tag-column`, + innerClass: tagInnerClass, + }, + { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID }, + { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE }, + { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED }, + { key: LIST_KEY_ACTIONS, label: '' }, + ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop); + }, + tagsNames() { + return this.tags.map(t => t.name); + }, + selectAllChecked() { + return this.selectedItems.length === this.tags.length && this.tags.length > 0; + }, + }, + watch: { + tagsNames: { + immediate: false, + handler(tagsNames) { + this.selectedItems = this.selectedItems.filter(t => tagsNames.includes(t)); + }, + }, + }, + methods: { + formatSize(size) { + return numberToHumanSize(size); + }, + layers(layers) { + return layers ? n__('%d layer', '%d layers', layers) : ''; + }, + onSelectAllChange() { + if (this.selectAllChecked) { + this.selectedItems = []; + } else { + this.selectedItems = this.tags.map(x => x.name); + } + }, + updateSelectedItems(name) { + const delIndex = this.selectedItems.findIndex(x => x === name); + + if (delIndex > -1) { + this.selectedItems.splice(delIndex, 1); + } else { + this.selectedItems.push(name); + } + }, + }, +}; +</script> + +<template> + <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty :busy="isLoading"> + <template v-if="isDesktop" #head(checkbox)> + <gl-form-checkbox + data-testid="mainCheckbox" + :checked="selectAllChecked" + @change="onSelectAllChange" + /> + </template> + <template #head(actions)> + <span class="gl-display-flex gl-justify-content-end"> + <gl-button + v-gl-tooltip + data-testid="bulkDeleteButton" + :disabled="!selectedItems || selectedItems.length === 0" + icon="remove" + variant="danger" + :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" + :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" + @click="$emit('delete', selectedItems)" + /> + </span> + </template> + + <template #cell(checkbox)="{item}"> + <gl-form-checkbox + data-testid="rowCheckbox" + :checked="selectedItems.includes(item.name)" + @change="updateSelectedItems(item.name)" + /> + </template> + <template #cell(name)="{item, field}"> + <div data-testid="rowName" :class="[field.innerClass, 'gl-display-flex']"> + <span + v-gl-tooltip + data-testid="rowNameText" + :title="item.name" + class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" + > + {{ item.name }} + </span> + <clipboard-button + v-if="item.location" + data-testid="rowClipboardButton" + :title="item.location" + :text="item.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + </div> + </template> + <template #cell(short_revision)="{value}"> + <span data-testid="rowShortRevision"> + {{ value }} + </span> + </template> + <template #cell(total_size)="{item}"> + <span data-testid="rowSize"> + {{ formatSize(item.total_size) }} + <template v-if="item.total_size && item.layers"> + · + </template> + {{ layers(item.layers) }} + </span> + </template> + <template #cell(created_at)="{value}"> + <span v-gl-tooltip data-testid="rowTime" :title="tooltipTitle(value)"> + {{ timeFormatted(value) }} + </span> + </template> + <template #cell(actions)="{item}"> + <span class="gl-display-flex gl-justify-content-end"> + <gl-button + data-testid="singleDeleteButton" + :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" + :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE" + :disabled="!item.destroy_path" + variant="danger" + icon="remove" + category="secondary" + @click="$emit('delete', [item.name])" + /> + </span> + </template> + + <template #empty> + <slot name="empty"></slot> + </template> + <template #table-busy> + <slot name="loader"></slot> + </template> + </gl-table> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/image_list.vue b/app/assets/javascripts/registry/explorer/components/image_list.vue deleted file mode 100644 index bc209b12738..00000000000 --- a/app/assets/javascripts/registry/explorer/components/image_list.vue +++ /dev/null @@ -1,124 +0,0 @@ -<script> -import { GlPagination, GlTooltipDirective, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; - -import { - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, - LIST_DELETE_BUTTON_DISABLED, - REMOVE_REPOSITORY_LABEL, - ROW_SCHEDULED_FOR_DELETION, -} from '../constants'; - -export default { - name: 'ImageList', - components: { - GlPagination, - ClipboardButton, - GlDeprecatedButton, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - images: { - type: Array, - required: true, - }, - pagination: { - type: Object, - required: true, - }, - }, - i18n: { - LIST_DELETE_BUTTON_DISABLED, - REMOVE_REPOSITORY_LABEL, - ROW_SCHEDULED_FOR_DELETION, - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, - }, - computed: { - currentPage: { - get() { - return this.pagination.page; - }, - set(page) { - this.$emit('pageChange', page); - }, - }, - }, - methods: { - encodeListItem(item) { - const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id }); - return window.btoa(params); - }, - }, -}; -</script> - -<template> - <div class="gl-display-flex gl-flex-direction-column"> - <div - v-for="(listItem, index) in images" - :key="index" - v-gl-tooltip="{ - placement: 'left', - disabled: !listItem.deleting, - title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, - }" - data-testid="rowItem" - > - <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 border-bottom" - :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }" - > - <div class="gl-display-flex gl-align-items-center"> - <router-link - data-testid="detailsLink" - :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" - > - {{ listItem.path }} - </router-link> - <clipboard-button - v-if="listItem.location" - :disabled="listItem.deleting" - :text="listItem.location" - :title="listItem.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - <gl-icon - v-if="listItem.failedDelete" - v-gl-tooltip - :title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE" - name="warning" - class="text-warning align-middle" - /> - </div> - <div - v-gl-tooltip="{ disabled: listItem.destroy_path }" - class="d-none d-sm-block" - :title="$options.i18n.LIST_DELETE_BUTTON_DISABLED" - > - <gl-deprecated-button - v-gl-tooltip - data-testid="deleteImageButton" - :disabled="!listItem.destroy_path || listItem.deleting" - :title="$options.i18n.REMOVE_REPOSITORY_LABEL" - :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL" - class="btn-inverted" - variant="danger" - @click="$emit('delete', listItem)" - > - <gl-icon name="remove" /> - </gl-deprecated-button> - </div> - </div> - </div> - <gl-pagination - v-model="currentPage" - :per-page="pagination.perPage" - :total-items="pagination.total" - align="center" - class="w-100 gl-mt-2" - /> - </div> -</template> diff --git a/app/assets/javascripts/registry/explorer/components/quickstart_dropdown.vue b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue index 96455496239..8b06797c0ae 100644 --- a/app/assets/javascripts/registry/explorer/components/quickstart_dropdown.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue @@ -11,7 +11,7 @@ import { COPY_BUILD_TITLE, PUSH_COMMAND_LABEL, COPY_PUSH_TITLE, -} from '../constants'; +} from '../../constants/index'; export default { components: { diff --git a/app/assets/javascripts/registry/explorer/components/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue index a29a9bd23c3..a29a9bd23c3 100644 --- a/app/assets/javascripts/registry/explorer/components/group_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue new file mode 100644 index 00000000000..9d48769cbad --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue @@ -0,0 +1,52 @@ +<script> +import { GlPagination } from '@gitlab/ui'; +import ImageListRow from './image_list_row.vue'; + +export default { + name: 'ImageList', + components: { + GlPagination, + ImageListRow, + }, + props: { + images: { + type: Array, + required: true, + }, + pagination: { + type: Object, + required: true, + }, + }, + computed: { + currentPage: { + get() { + return this.pagination.page; + }, + set(page) { + this.$emit('pageChange', page); + }, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column"> + <image-list-row + v-for="(listItem, index) in images" + :key="index" + :item="listItem" + :show-top-border="index === 0" + @delete="$emit('delete', $event)" + /> + + <gl-pagination + v-model="currentPage" + :per-page="pagination.perPage" + :total-items="pagination.total" + align="center" + class="w-100 gl-mt-3" + /> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue new file mode 100644 index 00000000000..cd878c38081 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue @@ -0,0 +1,136 @@ +<script> +import { GlTooltipDirective, GlButton, GlIcon, GlSprintf } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +import { + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + ROW_SCHEDULED_FOR_DELETION, +} from '../../constants/index'; + +export default { + name: 'ImageListrow', + components: { + ClipboardButton, + GlButton, + GlSprintf, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + item: { + type: Object, + required: true, + }, + showTopBorder: { + type: Boolean, + default: false, + required: false, + }, + }, + i18n: { + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + ROW_SCHEDULED_FOR_DELETION, + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + }, + computed: { + encodedItem() { + const params = JSON.stringify({ + name: this.item.path, + tags_path: this.item.tags_path, + id: this.item.id, + }); + return window.btoa(params); + }, + disabledDelete() { + return !this.item.destroy_path || this.item.deleting; + }, + tagsCountText() { + return n__( + 'ContainerRegistry|%{count} Tag', + 'ContainerRegistry|%{count} Tags', + this.item.tags_count, + ); + }, + }, +}; +</script> + +<template> + <div + v-gl-tooltip="{ + placement: 'left', + disabled: !item.deleting, + title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, + }" + > + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-py-4 " + :class="{ + 'gl-border-t-solid gl-border-t-1': showTopBorder, + 'disabled-content': item.deleting, + }" + > + <div class="gl-display-flex gl-flex-direction-column"> + <div class="gl-display-flex gl-align-items-center"> + <router-link + class="gl-text-black-normal gl-font-weight-bold" + data-testid="detailsLink" + :to="{ name: 'details', params: { id: encodedItem } }" + > + {{ item.path }} + </router-link> + <clipboard-button + v-if="item.location" + :disabled="item.deleting" + :text="item.location" + :title="item.location" + css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500" + /> + <gl-icon + v-if="item.failedDelete" + v-gl-tooltip + :title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE" + name="warning" + class="text-warning" + /> + </div> + <div class="gl-font-sm gl-text-gray-500"> + <span class="gl-display-flex gl-align-items-center" data-testid="tagsCount"> + <gl-icon name="tag" class="gl-mr-2" /> + <gl-sprintf :message="tagsCountText"> + <template #count> + {{ item.tags_count }} + </template> + </gl-sprintf> + </span> + </div> + </div> + <div + v-gl-tooltip="{ + disabled: item.destroy_path, + title: $options.i18n.LIST_DELETE_BUTTON_DISABLED, + }" + class="d-none d-sm-block" + data-testid="deleteButtonWrapper" + > + <gl-button + v-gl-tooltip + data-testid="deleteImageButton" + :disabled="disabledDelete" + :title="$options.i18n.REMOVE_REPOSITORY_LABEL" + :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL" + category="secondary" + variant="danger" + icon="remove" + @click="$emit('delete', item)" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue index 0ce38c4a9ec..c27d53f4351 100644 --- a/app/assets/javascripts/registry/explorer/components/project_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue @@ -3,7 +3,12 @@ import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { COPY_LOGIN_TITLE, COPY_BUILD_TITLE, COPY_PUSH_TITLE, QUICK_START } from '../constants'; +import { + COPY_LOGIN_TITLE, + COPY_BUILD_TITLE, + COPY_PUSH_TITLE, + QUICK_START, +} from '../../constants/index'; export default { name: 'ProjectEmptyState', diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue new file mode 100644 index 00000000000..d4ff84447bb --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue @@ -0,0 +1,138 @@ +<script> +import { GlSprintf, GlLink, GlIcon } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; + +import { + CONTAINER_REGISTRY_TITLE, + LIST_INTRO_TEXT, + EXPIRATION_POLICY_WILL_RUN_IN, + EXPIRATION_POLICY_DISABLED_TEXT, + EXPIRATION_POLICY_DISABLED_MESSAGE, +} from '../../constants/index'; + +export default { + components: { + GlIcon, + GlSprintf, + GlLink, + }, + props: { + expirationPolicy: { + type: Object, + default: () => ({}), + required: false, + }, + imagesCount: { + type: Number, + default: 0, + required: false, + }, + helpPagePath: { + type: String, + default: '', + required: false, + }, + expirationPolicyHelpPagePath: { + type: String, + default: '', + required: false, + }, + hideExpirationPolicyData: { + type: Boolean, + required: false, + default: false, + }, + }, + loader: { + repeat: 10, + width: 1000, + height: 40, + }, + i18n: { + CONTAINER_REGISTRY_TITLE, + LIST_INTRO_TEXT, + EXPIRATION_POLICY_DISABLED_MESSAGE, + }, + computed: { + imagesCountText() { + return n__( + 'ContainerRegistry|%{count} Image repository', + 'ContainerRegistry|%{count} Image repositories', + this.imagesCount, + ); + }, + timeTillRun() { + const difference = calculateRemainingMilliseconds(this.expirationPolicy?.next_run_at); + return approximateDuration(difference / 1000); + }, + expirationPolicyEnabled() { + return this.expirationPolicy?.enabled; + }, + expirationPolicyText() { + return this.expirationPolicyEnabled + ? EXPIRATION_POLICY_WILL_RUN_IN + : EXPIRATION_POLICY_DISABLED_TEXT; + }, + showExpirationPolicyTip() { + return ( + !this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData + ); + }, + }, +}; +</script> + +<template> + <div> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center" + data-testid="header" + > + <h4 data-testid="title">{{ $options.i18n.CONTAINER_REGISTRY_TITLE }}</h4> + <div class="gl-display-none d-sm-block" data-testid="commands-slot"> + <slot name="commands"></slot> + </div> + </div> + <div + v-if="imagesCount" + class="gl-display-flex gl-align-items-center gl-mt-1 gl-mb-3 gl-text-gray-700" + data-testid="subheader" + > + <span class="gl-mr-3" data-testid="images-count"> + <gl-icon class="gl-mr-1" name="container-image" /> + <gl-sprintf :message="imagesCountText"> + <template #count> + {{ imagesCount }} + </template> + </gl-sprintf> + </span> + <span v-if="!hideExpirationPolicyData" data-testid="expiration-policy"> + <gl-icon class="gl-mr-1" name="expire" /> + <gl-sprintf :message="expirationPolicyText"> + <template #time> + {{ timeTillRun }} + </template> + </gl-sprintf> + </span> + </div> + <div data-testid="info-area"> + <p> + <span data-testid="default-intro"> + <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT"> + <template #docLink="{content}"> + <gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + <span v-if="showExpirationPolicyTip" data-testid="expiration-disabled-message"> + <gl-sprintf :message="$options.i18n.EXPIRATION_POLICY_DISABLED_MESSAGE"> + <template #docLink="{content}"> + <gl-link :href="expirationPolicyHelpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue deleted file mode 100644 index 88a0710574f..00000000000 --- a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue +++ /dev/null @@ -1,68 +0,0 @@ -<script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { mapState } from 'vuex'; -import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; -import { - EXPIRATION_POLICY_ALERT_TITLE, - EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON, - EXPIRATION_POLICY_ALERT_FULL_MESSAGE, - EXPIRATION_POLICY_ALERT_SHORT_MESSAGE, -} from '../constants'; - -export default { - components: { - GlAlert, - GlSprintf, - GlLink, - }, - - computed: { - ...mapState(['config', 'images', 'isLoading']), - isEmpty() { - return !this.images || this.images.length === 0; - }, - showAlert() { - return this.config.expirationPolicy?.enabled; - }, - timeTillRun() { - const difference = calculateRemainingMilliseconds(this.config.expirationPolicy?.next_run_at); - return approximateDuration(difference / 1000); - }, - alertConfiguration() { - if (this.isEmpty || this.isLoading) { - return { - title: null, - primaryButton: null, - message: EXPIRATION_POLICY_ALERT_SHORT_MESSAGE, - }; - } - return { - title: EXPIRATION_POLICY_ALERT_TITLE, - primaryButton: EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON, - message: EXPIRATION_POLICY_ALERT_FULL_MESSAGE, - }; - }, - }, -}; -</script> - -<template> - <gl-alert - v-if="showAlert" - :dismissible="false" - :primary-button-text="alertConfiguration.primaryButton" - :primary-button-link="config.settingsPath" - :title="alertConfiguration.title" - > - <gl-sprintf :message="alertConfiguration.message"> - <template #days> - <strong>{{ timeTillRun }}</strong> - </template> - <template #link="{content}"> - <gl-link :href="config.expirationPolicyHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </gl-alert> -</template> diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js deleted file mode 100644 index 7cbe657bfc0..00000000000 --- a/app/assets/javascripts/registry/explorer/constants.js +++ /dev/null @@ -1,130 +0,0 @@ -import { s__ } from '~/locale'; - -// List page - -export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry'); -export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error'); -export const CONNECTION_ERROR_MESSAGE = s__( - `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, -); -export const LIST_INTRO_TEXT = s__( - `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, -); - -export const LIST_DELETE_BUTTON_DISABLED = s__( - 'ContainerRegistry|Missing or insufficient permission, delete button disabled', -); -export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository'); -export const REMOVE_REPOSITORY_MODAL_TEXT = s__( - 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', -); -export const ROW_SCHEDULED_FOR_DELETION = s__( - `ContainerRegistry|This image repository is scheduled for deletion`, -); -export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the repository list.', -); -export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the tags list.', -); -export const DELETE_IMAGE_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.', -); -export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__( - `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`, -); -export const DELETE_IMAGE_SUCCESS_MESSAGE = s__( - 'ContainerRegistry|%{title} was successfully scheduled for deletion', -); - -export const IMAGE_REPOSITORY_LIST_LABEL = s__('ContainerRegistry|Image Repositories'); - -export const SEARCH_PLACEHOLDER_TEXT = s__('ContainerRegistry|Filter by name'); - -export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.'); -export const EMPTY_RESULT_MESSAGE = s__( - 'ContainerRegistry|To widen your search, change or remove the filters above.', -); - -// Image details page - -export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); - -export const DELETE_TAG_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while marking the tag for deletion.', -); -export const DELETE_TAG_SUCCESS_MESSAGE = s__( - 'ContainerRegistry|Tag successfully marked for deletion.', -); -export const DELETE_TAGS_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while marking the tags for deletion.', -); -export const DELETE_TAGS_SUCCESS_MESSAGE = s__( - 'ContainerRegistry|Tags successfully marked for deletion.', -); - -export const DEFAULT_PAGE = 1; -export const DEFAULT_PAGE_SIZE = 10; - -export const GROUP_PAGE_TYPE = 'groups'; - -export const LIST_KEY_TAG = 'name'; -export const LIST_KEY_IMAGE_ID = 'short_revision'; -export const LIST_KEY_SIZE = 'total_size'; -export const LIST_KEY_LAST_UPDATED = 'created_at'; -export const LIST_KEY_ACTIONS = 'actions'; -export const LIST_KEY_CHECKBOX = 'checkbox'; - -export const LIST_LABEL_TAG = s__('ContainerRegistry|Tag'); -export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID'); -export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size'); -export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated'); - -export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); -export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags'); - -export const REMOVE_TAG_CONFIRMATION_TEXT = s__( - `ContainerRegistry|You are about to remove %{item}. Are you sure?`, -); -export const REMOVE_TAGS_CONFIRMATION_TEXT = s__( - `ContainerRegistry|You are about to remove %{item} tags. Are you sure?`, -); - -export const EMPTY_IMAGE_REPOSITORY_TITLE = s__('ContainerRegistry|This image has no active tags'); -export const EMPTY_IMAGE_REPOSITORY_MESSAGE = s__( - `ContainerRegistry|The last tag related to this image was recently removed. -This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. -If you have any questions, contact your administrator.`, -); - -export const ADMIN_GARBAGE_COLLECTION_TIP = s__( - 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.', -); - -// Expiration policies - -export const EXPIRATION_POLICY_ALERT_TITLE = s__( - 'ContainerRegistry|Retention policy has been Enabled', -); -export const EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON = s__('ContainerRegistry|Edit Settings'); -export const EXPIRATION_POLICY_ALERT_FULL_MESSAGE = s__( - 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled and will run in %{days}. For more information visit the %{linkStart}documentation%{linkEnd}', -); -export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__( - 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}', -); - -// Quick Start - -export const QUICK_START = s__('ContainerRegistry|Quick Start'); -export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login'); -export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command'); -export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image'); -export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command'); -export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image'); -export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command'); - -// Image state - -export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled'; -export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed'; diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js new file mode 100644 index 00000000000..a1fa995c17f --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -0,0 +1,60 @@ +import { s__ } from '~/locale'; + +// Translations strings +export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); +export const DELETE_TAG_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while marking the tag for deletion.', +); +export const DELETE_TAG_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|Tag successfully marked for deletion.', +); +export const DELETE_TAGS_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while marking the tags for deletion.', +); +export const DELETE_TAGS_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|Tags successfully marked for deletion.', +); +export const LIST_LABEL_TAG = s__('ContainerRegistry|Tag'); +export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID'); +export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size'); +export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated'); +export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); +export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags'); +export const REMOVE_TAG_CONFIRMATION_TEXT = s__( + `ContainerRegistry|You are about to remove %{item}. Are you sure?`, +); +export const REMOVE_TAGS_CONFIRMATION_TEXT = s__( + `ContainerRegistry|You are about to remove %{item} tags. Are you sure?`, +); +export const EMPTY_IMAGE_REPOSITORY_TITLE = s__('ContainerRegistry|This image has no active tags'); +export const EMPTY_IMAGE_REPOSITORY_MESSAGE = s__( + `ContainerRegistry|The last tag related to this image was recently removed. +This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. +If you have any questions, contact your administrator.`, +); +export const ADMIN_GARBAGE_COLLECTION_TIP = s__( + 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.', +); + +// Parameters + +export const DEFAULT_PAGE = 1; +export const DEFAULT_PAGE_SIZE = 10; +export const GROUP_PAGE_TYPE = 'groups'; +export const LIST_KEY_TAG = 'name'; +export const LIST_KEY_IMAGE_ID = 'short_revision'; +export const LIST_KEY_SIZE = 'total_size'; +export const LIST_KEY_LAST_UPDATED = 'created_at'; +export const LIST_KEY_ACTIONS = 'actions'; +export const LIST_KEY_CHECKBOX = 'checkbox'; +export const ALERT_SUCCESS_TAG = 'success_tag'; +export const ALERT_DANGER_TAG = 'danger_tag'; +export const ALERT_SUCCESS_TAGS = 'success_tags'; +export const ALERT_DANGER_TAGS = 'danger_tags'; + +export const ALERT_MESSAGES = { + [ALERT_SUCCESS_TAG]: DELETE_TAG_SUCCESS_MESSAGE, + [ALERT_DANGER_TAG]: DELETE_TAG_ERROR_MESSAGE, + [ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE, + [ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE, +}; diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js new file mode 100644 index 00000000000..8af25ca6ecc --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js @@ -0,0 +1,11 @@ +import { s__ } from '~/locale'; + +export const EXPIRATION_POLICY_WILL_RUN_IN = s__( + 'ContainerRegistry|Expiration policy will run in %{time}', +); +export const EXPIRATION_POLICY_DISABLED_TEXT = s__( + 'ContainerRegistry|Expiration policy is disabled', +); +export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__( + 'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}', +); diff --git a/app/assets/javascripts/registry/explorer/constants/index.js b/app/assets/javascripts/registry/explorer/constants/index.js new file mode 100644 index 00000000000..10816e12ead --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/index.js @@ -0,0 +1,4 @@ +export * from './expiration_policies'; +export * from './quick_start'; +export * from './list'; +export * from './details'; diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/registry/explorer/constants/list.js new file mode 100644 index 00000000000..39f63d2a153 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/list.js @@ -0,0 +1,48 @@ +import { s__ } from '~/locale'; + +// Translations strings + +export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry'); +export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error'); +export const CONNECTION_ERROR_MESSAGE = s__( + `ContainerRegistry|We are having trouble connecting to the Registry, which could be due to an issue with your project name or path. %{docLinkStart}More information%{docLinkEnd}`, +); +export const LIST_INTRO_TEXT = s__( + `ContainerRegistry|With the GitLab Container Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`, +); +export const LIST_DELETE_BUTTON_DISABLED = s__( + 'ContainerRegistry|Missing or insufficient permission, delete button disabled', +); +export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository'); +export const REMOVE_REPOSITORY_MODAL_TEXT = s__( + 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', +); +export const ROW_SCHEDULED_FOR_DELETION = s__( + `ContainerRegistry|This image repository is scheduled for deletion`, +); +export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while fetching the repository list.', +); +export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while fetching the tags list.', +); +export const DELETE_IMAGE_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.', +); +export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__( + `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`, +); +export const DELETE_IMAGE_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|%{title} was successfully scheduled for deletion', +); +export const IMAGE_REPOSITORY_LIST_LABEL = s__('ContainerRegistry|Image Repositories'); +export const SEARCH_PLACEHOLDER_TEXT = s__('ContainerRegistry|Filter by name'); +export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.'); +export const EMPTY_RESULT_MESSAGE = s__( + 'ContainerRegistry|To widen your search, change or remove the filters above.', +); + +// Parameters + +export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled'; +export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed'; diff --git a/app/assets/javascripts/registry/explorer/constants/quick_start.js b/app/assets/javascripts/registry/explorer/constants/quick_start.js new file mode 100644 index 00000000000..6a39c07eba2 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/quick_start.js @@ -0,0 +1,9 @@ +import { s__ } from '~/locale'; + +export const QUICK_START = s__('ContainerRegistry|CLI Commands'); +export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login'); +export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command'); +export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image'); +export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command'); +export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image'); +export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command'); diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index cc2dc531dc8..598e643ce1a 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -1,139 +1,56 @@ <script> -import { mapState, mapActions, mapGetters } from 'vuex'; -import { - GlTable, - GlFormCheckbox, - GlDeprecatedButton, - GlIcon, - GlTooltipDirective, - GlPagination, - GlModal, - GlSprintf, - GlAlert, - GlLink, - GlEmptyState, - GlResizeObserverDirective, - GlSkeletonLoader, -} from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import { n__ } from '~/locale'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; import Tracking from '~/tracking'; +import DeleteAlert from '../components/details_page/delete_alert.vue'; +import DeleteModal from '../components/details_page/delete_modal.vue'; +import DetailsHeader from '../components/details_page/details_header.vue'; +import TagsTable from '../components/details_page/tags_table.vue'; +import TagsLoader from '../components/details_page/tags_loader.vue'; +import EmptyTagsState from '../components/details_page/empty_tags_state.vue'; + import { decodeAndParse } from '../utils'; import { - LIST_KEY_TAG, - LIST_KEY_IMAGE_ID, - LIST_KEY_SIZE, - LIST_KEY_LAST_UPDATED, - LIST_KEY_ACTIONS, - LIST_KEY_CHECKBOX, - LIST_LABEL_TAG, - LIST_LABEL_IMAGE_ID, - LIST_LABEL_SIZE, - LIST_LABEL_LAST_UPDATED, - DELETE_TAG_SUCCESS_MESSAGE, - DELETE_TAG_ERROR_MESSAGE, - DELETE_TAGS_SUCCESS_MESSAGE, - DELETE_TAGS_ERROR_MESSAGE, - REMOVE_TAG_CONFIRMATION_TEXT, - REMOVE_TAGS_CONFIRMATION_TEXT, - DETAILS_PAGE_TITLE, - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, - EMPTY_IMAGE_REPOSITORY_TITLE, - EMPTY_IMAGE_REPOSITORY_MESSAGE, - ADMIN_GARBAGE_COLLECTION_TIP, -} from '../constants'; + ALERT_SUCCESS_TAG, + ALERT_DANGER_TAG, + ALERT_SUCCESS_TAGS, + ALERT_DANGER_TAGS, +} from '../constants/index'; export default { components: { - GlTable, - GlFormCheckbox, - GlDeprecatedButton, - GlIcon, - ClipboardButton, + DeleteAlert, + DetailsHeader, GlPagination, - GlModal, - GlSkeletonLoader, - GlSprintf, - GlEmptyState, - GlAlert, - GlLink, + DeleteModal, + TagsTable, + TagsLoader, + EmptyTagsState, }, directives: { - GlTooltip: GlTooltipDirective, GlResizeObserver: GlResizeObserverDirective, }, - mixins: [timeagoMixin, Tracking.mixin()], - loader: { - repeat: 10, - width: 1000, - height: 40, - }, - i18n: { - DETAILS_PAGE_TITLE, - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, - EMPTY_IMAGE_REPOSITORY_TITLE, - EMPTY_IMAGE_REPOSITORY_MESSAGE, - }, - alertMessages: { - success_tag: DELETE_TAG_SUCCESS_MESSAGE, - danger_tag: DELETE_TAG_ERROR_MESSAGE, - success_tags: DELETE_TAGS_SUCCESS_MESSAGE, - danger_tags: DELETE_TAGS_ERROR_MESSAGE, - }, + mixins: [Tracking.mixin()], data() { return { - selectedItems: [], itemsToBeDeleted: [], - selectAllChecked: false, - modalDescription: null, isDesktop: true, - deleteAlertType: false, + deleteAlertType: null, }; }, computed: { - ...mapGetters(['tags']), - ...mapState(['tagsPagination', 'isLoading', 'config']), + ...mapState(['tagsPagination', 'isLoading', 'config', 'tags']), imageName() { const { name } = decodeAndParse(this.$route.params.id); return name; }, - fields() { - const tagClass = this.isDesktop ? 'w-25' : ''; - const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end'; - return [ - { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' }, - { - key: LIST_KEY_TAG, - label: LIST_LABEL_TAG, - class: `${tagClass} js-tag-column`, - innerClass: tagInnerClass, - }, - { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID }, - { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE }, - { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED }, - { key: LIST_KEY_ACTIONS, label: '' }, - ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop); - }, - isMultiDelete() { - return this.itemsToBeDeleted.length > 1; - }, tracking() { return { - label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + label: + this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete', }; }, - modalAction() { - return n__( - 'ContainerRegistry|Remove tag', - 'ContainerRegistry|Remove tags', - this.isMultiDelete ? this.itemsToBeDeleted.length : 1, - ); - }, currentPage: { get() { return this.tagsPagination.page; @@ -142,132 +59,51 @@ export default { this.requestTagsList({ pagination: { page }, params: this.$route.params.id }); }, }, - deleteAlertConfig() { - const config = { - title: '', - message: '', - type: 'success', - }; - if (this.deleteAlertType) { - [config.type] = this.deleteAlertType.split('_'); - - const defaultMessage = this.$options.alertMessages[this.deleteAlertType]; - - if (this.config.isAdmin && config.type === 'success') { - config.title = defaultMessage; - config.message = ADMIN_GARBAGE_COLLECTION_TIP; - } else { - config.message = defaultMessage; - } - } - return config; - }, }, mounted() { this.requestTagsList({ params: this.$route.params.id }); }, methods: { ...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']), - setModalDescription(itemIndex = -1) { - if (itemIndex === -1) { - this.modalDescription = { - message: REMOVE_TAGS_CONFIRMATION_TEXT, - item: this.itemsToBeDeleted.length, - }; - } else { - const { path } = this.tags[itemIndex]; - - this.modalDescription = { - message: REMOVE_TAG_CONFIRMATION_TEXT, - item: path, - }; - } - }, - formatSize(size) { - return numberToHumanSize(size); - }, - layers(layers) { - return layers ? n__('%d layer', '%d layers', layers) : ''; - }, - onSelectAllChange() { - if (this.selectAllChecked) { - this.deselectAll(); - } else { - this.selectAll(); - } - }, - selectAll() { - this.selectedItems = this.tags.map((x, index) => index); - this.selectAllChecked = true; - }, - deselectAll() { - this.selectedItems = []; - this.selectAllChecked = false; - }, - updateSelectedItems(index) { - const delIndex = this.selectedItems.findIndex(x => x === index); - - if (delIndex > -1) { - this.selectedItems.splice(delIndex, 1); - this.selectAllChecked = false; - } else { - this.selectedItems.push(index); - - if (this.selectedItems.length === this.tags.length) { - this.selectAllChecked = true; - } - } - }, - deleteSingleItem(index) { - this.setModalDescription(index); - this.itemsToBeDeleted = [index]; + deleteTags(toBeDeletedList) { + this.itemsToBeDeleted = toBeDeletedList.map(name => ({ + ...this.tags.find(t => t.name === name), + })); this.track('click_button'); this.$refs.deleteModal.show(); }, - deleteMultipleItems() { - this.itemsToBeDeleted = [...this.selectedItems]; - if (this.selectedItems.length === 1) { - this.setModalDescription(this.itemsToBeDeleted[0]); - } else if (this.selectedItems.length > 1) { - this.setModalDescription(); - } - this.track('click_button'); - this.$refs.deleteModal.show(); - }, - handleSingleDelete(index) { - const itemToDelete = this.tags[index]; + handleSingleDelete() { + const [itemToDelete] = this.itemsToBeDeleted; this.itemsToBeDeleted = []; - this.selectedItems = this.selectedItems.filter(i => i !== index); return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id }) .then(() => { - this.deleteAlertType = 'success_tag'; + this.deleteAlertType = ALERT_SUCCESS_TAG; }) .catch(() => { - this.deleteAlertType = 'danger_tag'; + this.deleteAlertType = ALERT_DANGER_TAG; }); }, handleMultipleDelete() { const { itemsToBeDeleted } = this; this.itemsToBeDeleted = []; - this.selectedItems = []; return this.requestDeleteTags({ - ids: itemsToBeDeleted.map(x => this.tags[x].name), + ids: itemsToBeDeleted.map(x => x.name), params: this.$route.params.id, }) .then(() => { - this.deleteAlertType = 'success_tags'; + this.deleteAlertType = ALERT_SUCCESS_TAGS; }) .catch(() => { - this.deleteAlertType = 'danger_tags'; + this.deleteAlertType = ALERT_DANGER_TAGS; }); }, onDeletionConfirmed() { this.track('confirm_delete'); - if (this.isMultiDelete) { + if (this.itemsToBeDeleted.length > 1) { this.handleMultipleDelete(); } else { - this.handleSingleDelete(this.itemsToBeDeleted[0]); + this.handleSingleDelete(); } }, handleResize() { @@ -279,141 +115,23 @@ export default { <template> <div v-gl-resize-observer="handleResize" class="my-3 w-100 slide-enter-to-element"> - <gl-alert - v-if="deleteAlertType" - :variant="deleteAlertConfig.type" - :title="deleteAlertConfig.title" + <delete-alert + v-model="deleteAlertType" + :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" + :is-admin="config.isAdmin" class="my-2" - @dismiss="deleteAlertType = null" - > - <gl-sprintf :message="deleteAlertConfig.message"> - <template #docLink="{content}"> - <gl-link :href="config.garbageCollectionHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </gl-alert> - <div class="d-flex my-3 align-items-center"> - <h4> - <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> - <template #imageName> - {{ imageName }} - </template> - </gl-sprintf> - </h4> - </div> - - <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty> - <template v-if="isDesktop" #head(checkbox)> - <gl-form-checkbox - ref="mainCheckbox" - :checked="selectAllChecked" - @change="onSelectAllChange" - /> - </template> - <template #head(actions)> - <gl-deprecated-button - ref="bulkDeleteButton" - v-gl-tooltip - :disabled="!selectedItems || selectedItems.length === 0" - class="float-right" - variant="danger" - :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - @click="deleteMultipleItems()" - > - <gl-icon name="remove" /> - </gl-deprecated-button> - </template> + /> - <template #cell(checkbox)="{index}"> - <gl-form-checkbox - ref="rowCheckbox" - class="js-row-checkbox" - :checked="selectedItems.includes(index)" - @change="updateSelectedItems(index)" - /> - </template> - <template #cell(name)="{item, field}"> - <div ref="rowName" :class="[field.innerClass, 'gl-display-flex']"> - <span - v-gl-tooltip - data-testid="rowNameText" - :title="item.name" - class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" - > - {{ item.name }} - </span> - <clipboard-button - v-if="item.location" - ref="rowClipboardButton" - :title="item.location" - :text="item.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - </div> - </template> - <template #cell(short_revision)="{value}"> - <span ref="rowShortRevision"> - {{ value }} - </span> - </template> - <template #cell(total_size)="{item}"> - <span ref="rowSize"> - {{ formatSize(item.total_size) }} - <template v-if="item.total_size && item.layers"> - · - </template> - {{ layers(item.layers) }} - </span> - </template> - <template #cell(created_at)="{value}"> - <span ref="rowTime" v-gl-tooltip :title="tooltipTitle(value)"> - {{ timeFormatted(value) }} - </span> - </template> - <template #cell(actions)="{index, item}"> - <gl-deprecated-button - ref="singleDeleteButton" - :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :disabled="!item.destroy_path" - variant="danger" - class="js-delete-registry float-right btn-inverted btn-border-color btn-icon" - @click="deleteSingleItem(index)" - > - <gl-icon name="remove" /> - </gl-deprecated-button> - </template> + <details-header :image-name="imageName" /> + <tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags"> <template #empty> - <template v-if="isLoading"> - <gl-skeleton-loader - v-for="index in $options.loader.repeat" - :key="index" - :width="$options.loader.width" - :height="$options.loader.height" - preserve-aspect-ratio="xMinYMax meet" - > - <rect width="15" x="0" y="12.5" height="15" rx="4" /> - <rect width="250" x="25" y="10" height="20" rx="4" /> - <circle cx="290" cy="20" r="10" /> - <rect width="100" x="315" y="10" height="20" rx="4" /> - <rect width="100" x="500" y="10" height="20" rx="4" /> - <rect width="100" x="630" y="10" height="20" rx="4" /> - <rect x="960" y="0" width="40" height="40" rx="4" /> - </gl-skeleton-loader> - </template> - <gl-empty-state - v-else - :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE" - :svg-path="config.noContainersImage" - :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE" - class="mx-auto my-0" - /> + <empty-tags-state :no-containers-image="config.noContainersImage" /> </template> - </gl-table> + <template #loader> + <tags-loader v-once /> + </template> + </tags-table> <gl-pagination v-if="!isLoading" @@ -425,22 +143,11 @@ export default { class="w-100" /> - <gl-modal + <delete-modal ref="deleteModal" - modal-id="delete-tag-modal" - ok-variant="danger" - @ok="onDeletionConfirmed" + :items-to-be-deleted="itemsToBeDeleted" + @confirmDelete="onDeletionConfirmed" @cancel="track('cancel_delete')" - > - <template #modal-title>{{ modalAction }}</template> - <template #modal-ok>{{ modalAction }}</template> - <p v-if="modalDescription"> - <gl-sprintf :message="modalDescription.message"> - <template #item> - <b>{{ modalDescription.item }}</b> - </template> - </gl-sprintf> - </p> - </gl-modal> + /> </div> </template> diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 4efa6f08d84..e8a26dc58f2 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -12,26 +12,24 @@ import { } from '@gitlab/ui'; import Tracking from '~/tracking'; -import ProjectEmptyState from '../components/project_empty_state.vue'; -import GroupEmptyState from '../components/group_empty_state.vue'; -import ProjectPolicyAlert from '../components/project_policy_alert.vue'; -import QuickstartDropdown from '../components/quickstart_dropdown.vue'; -import ImageList from '../components/image_list.vue'; +import ProjectEmptyState from '../components/list_page/project_empty_state.vue'; +import GroupEmptyState from '../components/list_page/group_empty_state.vue'; +import RegistryHeader from '../components/list_page/registry_header.vue'; +import ImageList from '../components/list_page/image_list.vue'; +import CliCommands from '../components/list_page/cli_commands.vue'; import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, - CONTAINER_REGISTRY_TITLE, CONNECTION_ERROR_TITLE, CONNECTION_ERROR_MESSAGE, - LIST_INTRO_TEXT, REMOVE_REPOSITORY_MODAL_TEXT, REMOVE_REPOSITORY_LABEL, SEARCH_PLACEHOLDER_TEXT, IMAGE_REPOSITORY_LIST_LABEL, EMPTY_RESULT_TITLE, EMPTY_RESULT_MESSAGE, -} from '../constants'; +} from '../constants/index'; export default { name: 'RegistryListApp', @@ -39,8 +37,6 @@ export default { GlEmptyState, ProjectEmptyState, GroupEmptyState, - ProjectPolicyAlert, - QuickstartDropdown, ImageList, GlModal, GlSprintf, @@ -48,6 +44,8 @@ export default { GlAlert, GlSkeletonLoader, GlSearchBoxByClick, + RegistryHeader, + CliCommands, }, directives: { GlTooltip: GlTooltipDirective, @@ -59,10 +57,8 @@ export default { height: 40, }, i18n: { - CONTAINER_REGISTRY_TITLE, CONNECTION_ERROR_TITLE, CONNECTION_ERROR_MESSAGE, - LIST_INTRO_TEXT, REMOVE_REPOSITORY_MODAL_TEXT, REMOVE_REPOSITORY_LABEL, SEARCH_PLACEHOLDER_TEXT, @@ -85,7 +81,7 @@ export default { label: 'registry_repository_delete', }; }, - showQuickStartDropdown() { + showCommands() { return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); }, showDeleteAlert() { @@ -149,8 +145,6 @@ export default { </gl-sprintf> </gl-alert> - <project-policy-alert v-if="!config.isGroupPage" class="mt-2" /> - <gl-empty-state v-if="config.characterError" :title="$options.i18n.CONNECTION_ERROR_TITLE" @@ -170,21 +164,17 @@ export default { </gl-empty-state> <template v-else> - <div> - <div class="d-flex justify-content-between align-items-center"> - <h4>{{ $options.i18n.CONTAINER_REGISTRY_TITLE }}</h4> - <quickstart-dropdown v-if="showQuickStartDropdown" class="d-none d-sm-block" /> - </div> - <p> - <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT"> - <template #docLink="{content}"> - <gl-link :href="config.helpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </div> + <registry-header + :images-count="pagination.total" + :expiration-policy="config.expirationPolicy" + :help-page-path="config.helpPagePath" + :expiration-policy-help-page-path="config.expirationPolicyHelpPagePath" + :hide-expiration-policy-data="config.isGroupPage" + > + <template #commands> + <cli-commands v-if="showCommands" /> + </template> + </registry-header> <div v-if="isLoading" class="mt-2"> <gl-skeleton-loader @@ -201,7 +191,7 @@ export default { </div> <template v-else> <template v-if="!isEmpty"> - <div class="gl-display-flex gl-p-1" data-testid="listHeader"> + <div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader"> <div class="gl-flex-fill-1"> <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5> </div> diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js index 478eaca1a68..f570987023b 100644 --- a/app/assets/javascripts/registry/explorer/router.js +++ b/app/assets/javascripts/registry/explorer/router.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; -import { s__ } from '~/locale'; import List from './pages/list.vue'; import Details from './pages/details.vue'; import { decodeAndParse } from './utils'; +import { CONTAINER_REGISTRY_TITLE } from './constants/index'; Vue.use(VueRouter); @@ -17,7 +17,7 @@ export default function createRouter(base) { path: '/', component: List, meta: { - nameGenerator: () => s__('ContainerRegistry|Container Registry'), + nameGenerator: () => CONTAINER_REGISTRY_TITLE, root: true, }, }, diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js index 7f80bc21d6e..3d73ffbd23f 100644 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -6,7 +6,7 @@ import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, FETCH_TAGS_LIST_ERROR_MESSAGE, -} from '../constants'; +} from '../constants/index'; import { decodeAndParse } from '../utils'; export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); diff --git a/app/assets/javascripts/registry/explorer/stores/getters.js b/app/assets/javascripts/registry/explorer/stores/getters.js index a371d0e6356..7b5d1bd6da3 100644 --- a/app/assets/javascripts/registry/explorer/stores/getters.js +++ b/app/assets/javascripts/registry/explorer/stores/getters.js @@ -1,9 +1,3 @@ -export const tags = state => { - // to show the loader inside the table we need to pass an empty array to gl-table whenever the table is loading - // this is to take in account isLoading = true and state.tags =[1,2,3] during pagination and delete - return state.isLoading ? [] : state.tags; -}; - export const dockerBuildCommand = state => { /* eslint-disable @gitlab/require-i18n-strings */ return `docker build -t ${state.config.repositoryUrl} .`; diff --git a/app/assets/javascripts/registry/explorer/stores/index.js b/app/assets/javascripts/registry/explorer/stores/index.js index 153032e37d3..54a8e0e1c1c 100644 --- a/app/assets/javascripts/registry/explorer/stores/index.js +++ b/app/assets/javascripts/registry/explorer/stores/index.js @@ -7,6 +7,7 @@ import state from './state'; Vue.use(Vuex); +// eslint-disable-next-line import/prefer-default-export export const createStore = () => new Vuex.Store({ state, @@ -14,6 +15,3 @@ export const createStore = () => actions, mutations, }); - -// Deprecated and to be removed -export default createStore(); diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js index b25a0221dc1..706f6489287 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutations.js +++ b/app/assets/javascripts/registry/explorer/stores/mutations.js @@ -1,14 +1,14 @@ import * as types from './mutation_types'; -import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; -import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants'; +import { parseIntPagination, normalizeHeaders, parseBoolean } from '~/lib/utils/common_utils'; +import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants/index'; export default { [types.SET_INITIAL_STATE](state, config) { state.config = { ...config, expirationPolicy: config.expirationPolicy ? JSON.parse(config.expirationPolicy) : undefined, - isGroupPage: config.isGroupPage !== undefined, - isAdmin: config.isAdmin !== undefined, + isGroupPage: parseBoolean(config.isGroupPage), + isAdmin: parseBoolean(config.isAdmin), }; }, diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index 0698ca5e31f..d0d1485d8e7 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -8,12 +8,25 @@ import { GlIcon, GlTooltipDirective, GlFormInput, + GlFormSelect, } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { DEFAULT_ASSET_LINK_TYPE, ASSET_LINK_TYPE } from '../constants'; +import { s__ } from '~/locale'; export default { name: 'AssetLinksForm', - components: { GlSprintf, GlLink, GlFormGroup, GlButton, GlIcon, GlFormInput }, + components: { + GlSprintf, + GlLink, + GlFormGroup, + GlButton, + GlIcon, + GlFormInput, + GlFormSelect, + }, directives: { GlTooltip: GlTooltipDirective }, + mixins: [glFeatureFlagsMixin()], computed: { ...mapState('detail', ['release', 'releaseAssetsDocsPath']), ...mapGetters('detail', ['validationErrors']), @@ -26,6 +39,7 @@ export default { 'addEmptyAssetLink', 'updateAssetLinkUrl', 'updateAssetLinkName', + 'updateAssetLinkType', 'removeAssetLink', ]), onAddAnotherClicked() { @@ -35,12 +49,6 @@ export default { this.removeAssetLink(linkId); this.ensureAtLeastOneLink(); }, - onUrlInput(linkIdToUpdate, newUrl) { - this.updateAssetLinkUrl({ linkIdToUpdate, newUrl }); - }, - onLinkTitleInput(linkIdToUpdate, newName) { - this.updateAssetLinkName({ linkIdToUpdate, newName }); - }, hasDuplicateUrl(link) { return Boolean(this.getLinkErrors(link).isDuplicate); }, @@ -73,6 +81,13 @@ export default { } }, }, + typeOptions: [ + { value: ASSET_LINK_TYPE.IMAGE, text: s__('ReleaseAssetLinkType|Image') }, + { value: ASSET_LINK_TYPE.PACKAGE, text: s__('ReleaseAssetLinkType|Package') }, + { value: ASSET_LINK_TYPE.RUNBOOK, text: s__('ReleaseAssetLinkType|Runbook') }, + { value: ASSET_LINK_TYPE.OTHER, text: s__('ReleaseAssetLinkType|Other') }, + ], + defaultTypeOptionValue: DEFAULT_ASSET_LINK_TYPE, }; </script> @@ -109,10 +124,10 @@ export default { <div v-for="(link, index) in release.assets.links" :key="link.id" - class="row flex-column flex-sm-row align-items-stretch align-items-sm-start" + class="row flex-column flex-sm-row align-items-stretch align-items-sm-start no-gutters" > <gl-form-group - class="url-field form-group col" + class="url-field form-group col pr-sm-2" :label="__('URL')" :label-for="`asset-url-${index}`" > @@ -123,7 +138,7 @@ export default { type="text" class="form-control" :state="isUrlValid(link)" - @change="onUrlInput(link.id, $event)" + @change="updateAssetLinkUrl({ linkIdToUpdate: link.id, newUrl: $event })" /> <template #invalid-feedback> <span v-if="hasEmptyUrl(link)" class="invalid-feedback d-inline"> @@ -149,7 +164,7 @@ export default { </gl-form-group> <gl-form-group - class="link-title-field col" + class="link-title-field col px-sm-2" :label="__('Link title')" :label-for="`asset-link-name-${index}`" > @@ -160,7 +175,7 @@ export default { type="text" class="form-control" :state="isNameValid(link)" - @change="onLinkTitleInput(link.id, $event)" + @change="updateAssetLinkName({ linkIdToUpdate: link.id, newName: $event })" /> <template #invalid-feedback> <span v-if="hasEmptyName(link)" class="invalid-feedback d-inline"> @@ -169,16 +184,34 @@ export default { </template> </gl-form-group> - <div class="mb-5 mb-sm-3 mt-sm-4 col col-sm-auto"> + <gl-form-group + v-if="glFeatures.releaseAssetLinkType" + class="link-type-field col-auto px-sm-2" + :label="__('Type')" + :label-for="`asset-type-${index}`" + > + <gl-form-select + :id="`asset-type-${index}`" + ref="typeSelect" + :value="link.linkType || $options.defaultTypeOptionValue" + class="form-control pr-4" + :options="$options.typeOptions" + @change="updateAssetLinkType({ linkIdToUpdate: link.id, newType: $event })" + /> + </gl-form-group> + + <div class="mb-5 mb-sm-3 mt-sm-4 col col-sm-auto pl-sm-2"> <gl-button v-gl-tooltip - class="remove-button w-100" + class="remove-button w-100 form-control" :aria-label="__('Remove asset link')" :title="__('Remove asset link')" @click="onRemoveClicked(link.id)" > - <gl-icon class="mr-1 mr-sm-0 mb-1" :size="16" name="remove" /> - <span class="d-inline d-sm-none">{{ __('Remove asset link') }}</span> + <div class="d-flex"> + <gl-icon class="mr-1 mr-sm-0" :size="16" name="remove" /> + <span class="d-inline d-sm-none">{{ __('Remove asset link') }}</span> + </div> </gl-button> </div> </div> diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue index acae6fda533..2cc15777343 100644 --- a/app/assets/javascripts/releases/components/evidence_block.vue +++ b/app/assets/javascripts/releases/components/evidence_block.vue @@ -71,7 +71,7 @@ export default { :download="evidenceTitle(index)" :href="evidenceUrl(index)" > - <gl-icon name="review-list" class="align-middle append-right-8" /> + <gl-icon name="review-list" class="align-middle gl-mr-3" /> <span>{{ evidenceTitle(index) }}</span> </gl-link> @@ -96,7 +96,7 @@ export default { <gl-icon v-gl-tooltip name="clock" - class="align-middle append-right-8" + class="align-middle gl-mr-3" :title="collectedAt(index)" /> <span>{{ timeSummary(index) }}</span> diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index 58045b57d80..adb0e69b786 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -109,7 +109,7 @@ export default { <evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" /> <div ref="gfm-content" class="card-text prepend-top-default"> - <div v-html="release.descriptionHtml"></div> + <div class="md" v-html="release.descriptionHtml"></div> </div> </div> diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index f4b92416e47..e07646e9a9f 100644 --- a/app/assets/javascripts/releases/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -1,65 +1,190 @@ <script> -import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ASSET_LINK_TYPE } from '../constants'; +import { __, s__, sprintf } from '~/locale'; +import { difference } from 'lodash'; export default { name: 'ReleaseBlockAssets', components: { GlLink, + GlButton, + GlCollapse, + GlIcon, Icon, + GlBadge, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { assets: { type: Object, required: true, }, }, + data() { + return { + isAssetsExpanded: true, + }; + }, computed: { hasAssets() { return Boolean(this.assets.count); }, + imageLinks() { + return this.linksForType(ASSET_LINK_TYPE.IMAGE); + }, + packageLinks() { + return this.linksForType(ASSET_LINK_TYPE.PACKAGE); + }, + runbookLinks() { + return this.linksForType(ASSET_LINK_TYPE.RUNBOOK); + }, + otherLinks() { + return difference(this.assets.links, [ + ...this.imageLinks, + ...this.packageLinks, + ...this.runbookLinks, + ]); + }, + sections() { + return [ + { + links: this.assets.sources.map(s => ({ + url: s.url, + name: sprintf(__('Source code (%{fileExtension})'), { fileExtension: s.format }), + })), + iconName: 'doc-code', + }, + { + title: s__('ReleaseAssetLinkType|Images'), + links: this.imageLinks, + iconName: 'container-image', + }, + { + title: s__('ReleaseAssetLinkType|Packages'), + links: this.packageLinks, + iconName: 'package', + }, + { + title: s__('ReleaseAssetLinkType|Runbooks'), + links: this.runbookLinks, + iconName: 'book', + }, + { + title: s__('ReleaseAssetLinkType|Other'), + links: this.otherLinks, + iconName: 'link', + }, + ].filter(section => section.links.length > 0); + }, + }, + methods: { + toggleAssetsExpansion() { + this.isAssetsExpanded = !this.isAssetsExpanded; + }, + linksForType(type) { + return this.assets.links.filter(l => l.linkType === type); + }, }, + externalLinkTooltipText: __('This link points to external content'), }; </script> <template> <div class="card-text prepend-top-default"> - <b> - {{ __('Assets') }} - <span class="js-assets-count badge badge-pill">{{ assets.count }}</span> - </b> - - <ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list"> - <li v-for="link in assets.links" :key="link.name" class="append-bottom-8"> - <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.directAssetUrl"> - <icon name="package" class="align-middle append-right-4 align-text-bottom" /> - {{ link.name }} - <span v-if="link.external">{{ __('(external source)') }}</span> - </gl-link> - </li> - </ul> - - <div v-if="hasAssets" class="dropdown"> - <button - type="button" - class="btn btn-link" - data-toggle="dropdown" - aria-haspopup="true" - aria-expanded="false" + <template v-if="glFeatures.releaseAssetLinkType"> + <gl-button + data-testid="accordion-button" + variant="link" + class="gl-font-weight-bold" + @click="toggleAssetsExpansion" > - <icon name="doc-code" class="align-top append-right-4" /> - {{ __('Source code') }} - <icon name="chevron-down" /> - </button> + <gl-icon + name="chevron-right" + class="gl-transition-medium" + :class="{ 'gl-rotate-90': isAssetsExpanded }" + /> + {{ __('Assets') }} + <gl-badge size="sm" variant="neutral" class="gl-display-inline-block">{{ + assets.count + }}</gl-badge> + </gl-button> + <gl-collapse v-model="isAssetsExpanded"> + <div class="gl-pl-6 gl-pt-3 js-assets-list"> + <template v-for="(section, index) in sections"> + <h5 v-if="section.title" :key="`section-header-${index}`" class="gl-mb-2"> + {{ section.title }} + </h5> + <ul :key="`section-body-${index}`" class="list-unstyled gl-m-0"> + <li v-for="link in section.links" :key="link.url"> + <gl-link + :href="link.directAssetUrl || link.url" + class="gl-display-flex gl-align-items-center gl-line-height-24" + > + <gl-icon + :name="section.iconName" + class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0" + /> + {{ link.name }} + <gl-icon + v-if="link.external" + v-gl-tooltip + name="external-link" + :aria-label="$options.externalLinkTooltipText" + :title="$options.externalLinkTooltipText" + data-testid="external-link-indicator" + class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-600" + /> + </gl-link> + </li> + </ul> + </template> + </div> + </gl-collapse> + </template> - <div class="js-sources-dropdown dropdown-menu"> - <li v-for="asset in assets.sources" :key="asset.url"> - <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link> + <template v-else> + <b> + {{ __('Assets') }} + <span class="js-assets-count badge badge-pill">{{ assets.count }}</span> + </b> + + <ul v-if="assets.links.length" class="pl-0 mb-0 gl-mt-3 list-unstyled js-assets-list"> + <li v-for="link in assets.links" :key="link.name" class="gl-mb-3"> + <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.directAssetUrl"> + <icon name="package" class="align-middle append-right-4 align-text-bottom" /> + {{ link.name }} + <span v-if="link.external" data-testid="external-link-indicator">{{ + __('(external source)') + }}</span> + </gl-link> </li> + </ul> + + <div v-if="hasAssets" class="dropdown"> + <button + type="button" + class="btn btn-link" + data-toggle="dropdown" + aria-haspopup="true" + aria-expanded="false" + > + <icon name="doc-code" class="align-top append-right-4" /> + {{ __('Source code') }} + <icon name="chevron-down" /> + </button> + + <div class="js-sources-dropdown dropdown-menu"> + <li v-for="asset in assets.sources" :key="asset.url"> + <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link> + </li> + </div> </div> - </div> + </template> </div> </template> diff --git a/app/assets/javascripts/releases/components/release_block_author.vue b/app/assets/javascripts/releases/components/release_block_author.vue index 0432d45b2dc..94f2b1795f0 100644 --- a/app/assets/javascripts/releases/components/release_block_author.vue +++ b/app/assets/javascripts/releases/components/release_block_author.vue @@ -30,7 +30,7 @@ export default { <gl-sprintf :message="__('by %{user}')"> <template #user> <user-avatar-link - class="prepend-left-4" + class="gl-ml-2" :link-href="author.webUrl" :img-src="author.avatarUrl" :img-alt="userImageAltDescription" diff --git a/app/assets/javascripts/releases/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue index 40133941011..a3377ce044a 100644 --- a/app/assets/javascripts/releases/components/release_block_metadata.vue +++ b/app/assets/javascripts/releases/components/release_block_metadata.vue @@ -57,7 +57,7 @@ export default { <template> <div class="card-subtitle d-flex flex-wrap text-secondary"> - <div class="append-right-8"> + <div class="gl-mr-3"> <icon name="commit" class="align-middle" /> <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl"> {{ commit.shortId }} @@ -65,7 +65,7 @@ export default { <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.shortId }}</span> </div> - <div class="append-right-8"> + <div class="gl-mr-3"> <icon name="tag" class="align-middle" /> <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl"> {{ release.tagName }} diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue index d9fbd2884b7..4f75e15a149 100644 --- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue +++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue @@ -144,7 +144,7 @@ export default { <div class="d-flex flex-column align-items-start flex-shrink-0 mr-4 mb-3 js-issues-container"> <span class="mb-1"> {{ __('Issues') }} - <gl-badge pill variant="light" class="font-weight-bold">{{ totalIssuesCount }}</gl-badge> + <gl-badge variant="muted" size="sm">{{ totalIssuesCount }}</gl-badge> </span> <div class="d-flex"> <gl-link v-if="openIssuesPath" ref="openIssuesLink" :href="openIssuesPath"> diff --git a/app/assets/javascripts/releases/constants.js b/app/assets/javascripts/releases/constants.js index 1db93323a87..361cee70747 100644 --- a/app/assets/javascripts/releases/constants.js +++ b/app/assets/javascripts/releases/constants.js @@ -1,3 +1,12 @@ export const MAX_MILESTONES_TO_DISPLAY = 5; export const BACK_URL_PARAM = 'back_url'; + +export const ASSET_LINK_TYPE = Object.freeze({ + OTHER: 'other', + IMAGE: 'image', + PACKAGE: 'package', + RUNBOOK: 'runbook', +}); + +export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER; diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js index 3bc427dfa16..2026eeba880 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/actions.js +++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js @@ -3,7 +3,10 @@ import api from '~/api'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { + convertObjectPropsToCamelCase, + convertObjectPropsToSnakeCase, +} from '~/lib/utils/common_utils'; export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE); export const receiveReleaseSuccess = ({ commit }, data) => @@ -54,13 +57,18 @@ export const updateRelease = ({ dispatch, state, getters }) => { const { release } = state; const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : []; + const updatedRelease = convertObjectPropsToSnakeCase( + { + name: release.name, + description: release.description, + milestones, + }, + { deep: true }, + ); + return ( api - .updateRelease(state.projectId, state.tagName, { - name: release.name, - description: release.description, - milestones, - }) + .updateRelease(state.projectId, state.tagName, updatedRelease) /** * Currently, we delete all existing links and then @@ -91,7 +99,11 @@ export const updateRelease = ({ dispatch, state, getters }) => { // Create a new link for each link in the form return Promise.all( getters.releaseLinksToCreate.map(l => - api.createReleaseLink(state.projectId, release.tagName, l), + api.createReleaseLink( + state.projectId, + release.tagName, + convertObjectPropsToSnakeCase(l, { deep: true }), + ), ), ); }) @@ -118,6 +130,10 @@ export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName }); }; +export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => { + commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType }); +}; + export const removeAssetLink = ({ commit }, linkIdToRemove) => { commit(types.REMOVE_ASSET_LINK, linkIdToRemove); }; diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js index 1d6356990ce..7b694120126 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js @@ -13,4 +13,5 @@ export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR'; export const ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK'; export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL'; export const UPDATE_ASSET_LINK_NAME = 'UPDATE_ASSET_LINK_NAME'; +export const UPDATE_ASSET_LINK_TYPE = 'UPDATE_ASSET_LINK_TYPE'; export const REMOVE_ASSET_LINK = 'REMOVE_ASSET_LINK'; diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js index 5c29b402cba..ca544151323 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import { uniqueId, cloneDeep } from 'lodash'; +import { DEFAULT_ASSET_LINK_TYPE } from '../../../constants'; const findReleaseLink = (release, id) => { return release.assets.links.find(l => l.id === id); @@ -49,6 +50,7 @@ export default { id: uniqueId('new-link-'), url: '', name: '', + linkType: DEFAULT_ASSET_LINK_TYPE, }); }, @@ -62,6 +64,11 @@ export default { linkToUpdate.name = newName; }, + [types.UPDATE_ASSET_LINK_TYPE](state, { linkIdToUpdate, newType }) { + const linkToUpdate = findReleaseLink(state.release, linkIdToUpdate); + linkToUpdate.linkType = newType; + }, + [types.REMOVE_ASSET_LINK](state, linkIdToRemove) { state.release.assets.links = state.release.assets.links.filter(l => l.id !== linkIdToRemove); }, diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index 0f7a0e60dc0..a670cad5f9f 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -98,25 +98,27 @@ export default { :has-issues="reports.length > 0" class="mr-widget-section grouped-security-reports mr-report" > - <div slot="body" class="mr-widget-grouped-section report-block"> - <template v-for="(report, i) in reports"> - <summary-row - :key="`summary-row-${i}`" - :summary="reportText(report)" - :status-icon="getReportIcon(report)" - /> - <issues-list - v-if="shouldRenderIssuesList(report)" - :key="`issues-list-${i}`" - :unresolved-issues="unresolvedIssues(report)" - :new-issues="newIssues(report)" - :resolved-issues="resolvedIssues(report)" - :component="$options.componentNames.TestIssueBody" - class="report-block-group-list" - /> - </template> + <template #body> + <div class="mr-widget-grouped-section report-block"> + <template v-for="(report, i) in reports"> + <summary-row + :key="`summary-row-${i}`" + :summary="reportText(report)" + :status-icon="getReportIcon(report)" + /> + <issues-list + v-if="shouldRenderIssuesList(report)" + :key="`issues-list-${i}`" + :unresolved-issues="unresolvedIssues(report)" + :new-issues="newIssues(report)" + :resolved-issues="resolvedIssues(report)" + :component="$options.componentNames.TestIssueBody" + class="report-block-group-list" + /> + </template> - <modal :title="modalTitle" :modal-data="modalData" /> - </div> + <modal :title="modalTitle" :modal-data="modalData" /> + </div> + </template> </report-section> </template> diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue index 9cbe2a690a0..b9fc902cd3a 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/reports/components/summary_row.vue @@ -54,7 +54,10 @@ export default { <ci-icon v-else :status="iconStatus" :size="24" /> </div> <div class="report-block-list-issue-description"> - <div class="report-block-list-issue-description-text"> + <div + class="report-block-list-issue-description-text" + data-testid="test-summary-row-description" + > {{ summary }}<span v-if="popoverOptions" class="text-nowrap" > <popover v-if="popoverOptions" :options="popoverOptions" class="align-top" /> diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue index 7700f49bf7d..c41238070b1 100644 --- a/app/assets/javascripts/reports/components/test_issue_body.vue +++ b/app/assets/javascripts/reports/components/test_issue_body.vue @@ -26,7 +26,7 @@ export default { </script> <template> <div class="report-block-list-issue-description prepend-top-5 append-bottom-5"> - <div class="report-block-list-issue-description-text"> + <div class="report-block-list-issue-description-text" data-testid="test-issue-body-description"> <button type="button" class="btn-link btn-blank text-left break-link vulnerability-name-button" diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 010fc9a5d1a..c5c99d56e2a 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -149,7 +149,7 @@ export default { <pre v-if="commit.description" :class="{ 'd-block': showDescription }" - class="commit-row-description append-bottom-8" + class="commit-row-description gl-mb-3" >{{ commit.description }}</pre > </div> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 34424121390..d5363016335 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -147,8 +147,11 @@ export default { class="mr-1 position-relative text-secondary" /><span class="position-relative">{{ fullPath }}</span> </component> - <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> - <gl-badge v-if="lfsOid" variant="default" class="label-lfs ml-1">LFS</gl-badge> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <gl-badge v-if="lfsOid" variant="muted" size="sm" class="ml-1" data-qa-selector="label-lfs" + >LFS</gl-badge + > + <!-- eslint-enable @gitlab/vue-require-i18n-strings --> <template v-if="isSubmodule"> @ <gl-link :href="submoduleTreeUrl" class="commit-sha">{{ shortSha }}</gl-link> </template> diff --git a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql index 3c3d14881da..eb21c1e73d8 100644 --- a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql +++ b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql @@ -1,3 +1,3 @@ -query getProjectShortPath { +query getVueFileListLfsBadge { vueFileListLfsBadge @client } diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue index bbafdd7f8f1..aac7c46a295 100644 --- a/app/assets/javascripts/serverless/components/function_row.vue +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -71,7 +71,7 @@ export default { </p> <b>{{ name }}</b> <div v-for="line in description.split('\n')" :key="line">{{ line }}</div> - <url :uri="targetUrl" class="prepend-top-8 no-expand" /> + <url :uri="targetUrl" class="gl-mt-3 no-expand" /> </div> </li> </template> diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 2757d64bd7d..fd1f9eae152 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -82,7 +82,8 @@ export default { }) .catch(() => createFlash(__('Failed to load emoji list.'))); }, - showEmojiMenu() { + showEmojiMenu(e) { + e.stopPropagation(); this.isEmojiMenuVisible = true; this.emojiMenu.showEmojiMenu($(this.$refs.toggleEmojiMenuButton)); }, diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue index 51cd5810ac0..67abde0c22a 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -1,17 +1,12 @@ <script> import { sprintf, s__ } from '../../../locale'; +import { joinPaths } from '~/lib/utils/url_utility'; export default { name: 'TimeTrackingHelpState', - props: { - rootPath: { - type: String, - required: true, - }, - }, computed: { href() { - return `${this.rootPath}help/workflow/time_tracking.md`; + return joinPaths(gon.relative_url_root || '', '/help/user/project/time_tracking.md'); }, estimateText() { return sprintf( diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 1e8a31fff81..5cf574e1387 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -42,10 +42,6 @@ export default { default: false, required: false, }, - rootPath: { - type: String, - required: true, - }, }, data() { return { @@ -137,7 +133,7 @@ export default { :limit-to-hours="limitToHours" /> <transition name="help-state-toggle"> - <time-tracking-help-state v-if="showHelpState" :root-path="rootPath" /> + <time-tracking-help-state v-if="showHelpState" /> </transition> </div> </div> diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index d934463382f..0f5f8f2b53b 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -24,7 +24,6 @@ export default class SidebarMilestone { humanTimeEstimate, humanTimeSpent, limitToHours: parseBoolean(limitToHours), - rootPath: '/', }, }), }); diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index e8d6c005435..a6651515e47 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -11,7 +11,11 @@ import FormFooterActions from '~/vue_shared/components/form/form_footer_actions. import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql'; import { getSnippetMixin } from '../mixins/snippets'; -import { SNIPPET_VISIBILITY_PRIVATE } from '../constants'; +import { + SNIPPET_VISIBILITY_PRIVATE, + SNIPPET_CREATE_MUTATION_ERROR, + SNIPPET_UPDATE_MUTATION_ERROR, +} from '../constants'; import SnippetBlobEdit from './snippet_blob_edit.vue'; import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; import SnippetDescriptionEdit from './snippet_description_edit.vue'; @@ -98,7 +102,11 @@ export default { this.fileName = newName; }, flashAPIFailure(err) { - Flash(sprintf(__("Can't update snippet: %{err}"), { err })); + const defaultErrorMsg = this.newSnippet + ? SNIPPET_CREATE_MUTATION_ERROR + : SNIPPET_UPDATE_MUTATION_ERROR; + Flash(sprintf(defaultErrorMsg, { err })); + this.isUpdating = false; }, onNewSnippetFetched() { this.newSnippet = true; @@ -129,29 +137,45 @@ export default { this.onExistingSnippetFetched(); } }, + getAttachedFiles() { + const fileInputs = Array.from(this.$el.querySelectorAll('[name="files[]"]')); + return fileInputs.map(node => node.value); + }, + createMutation() { + return { + mutation: CreateSnippetMutation, + variables: { + input: { + ...this.apiData, + uploadedFiles: this.getAttachedFiles(), + projectPath: this.projectPath, + }, + }, + }; + }, + updateMutation() { + return { + mutation: UpdateSnippetMutation, + variables: { + input: this.apiData, + }, + }; + }, handleFormSubmit() { this.isUpdating = true; this.$apollo - .mutate({ - mutation: this.newSnippet ? CreateSnippetMutation : UpdateSnippetMutation, - variables: { - input: { - ...this.apiData, - projectPath: this.newSnippet ? this.projectPath : undefined, - }, - }, - }) + .mutate(this.newSnippet ? this.createMutation() : this.updateMutation()) .then(({ data }) => { const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet; const errors = baseObj?.errors; if (errors.length) { this.flashAPIFailure(errors[0]); + } else { + redirectTo(baseObj.snippet.webUrl); } - redirectTo(baseObj.snippet.webUrl); }) .catch(e => { - this.isUpdating = false; this.flashAPIFailure(e); }); }, @@ -168,6 +192,8 @@ export default { <form class="snippet-form js-requires-input js-quick-submit common-note-form" :data-snippet-type="isProjectSnippet ? 'project' : 'personal'" + data-testid="snippet-edit-form" + @submit.prevent="handleFormSubmit" > <gl-loading-icon v-if="isLoading" @@ -179,7 +205,7 @@ export default { <title-field :id="titleFieldId" v-model="snippet.title" - data-qa-selector="snippet_title" + data-qa-selector="snippet_title_field" required :autofocus="true" /> @@ -203,17 +229,17 @@ export default { <form-footer-actions> <template #prepend> <gl-button - type="submit" category="primary" + type="submit" variant="success" :disabled="updatePrevented" data-qa-selector="submit_button" - @click="handleFormSubmit" + data-testid="snippet-submit-btn" >{{ saveButtonLabel }}</gl-button > </template> <template #append> - <gl-button data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{ + <gl-button type="cancel" data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{ __('Cancel') }}</gl-button> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index dd03902417d..62c29b0c7cd 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -35,7 +35,7 @@ export default { <div class="file-holder snippet"> <blob-header-edit :value="fileName" - data-qa-selector="snippet_file_name" + data-qa-selector="file_name_field" @input="$emit('name-change', $event)" /> <gl-loading-icon diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index 6b218b21e56..7472aff3318 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -74,6 +74,9 @@ export default { canBeCloned() { return this.snippet.sshUrlToRepo || this.snippet.httpUrlToRepo; }, + hasRenderError() { + return Boolean(this.viewer.renderError); + }, }, methods: { switchViewer(newViewer) { @@ -92,7 +95,12 @@ export default { <div> <blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" /> <article class="file-holder snippet-file-content"> - <blob-header :blob="blob" :active-viewer-type="viewer.type" @viewer-changed="switchViewer"> + <blob-header + :blob="blob" + :active-viewer-type="viewer.type" + :has-render-error="hasRenderError" + @viewer-changed="switchViewer" + > <template #actions> <clone-dropdown-button v-if="canBeCloned" diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue index 0fe539a5de7..737845d09b8 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue @@ -30,7 +30,7 @@ export default { </script> <template> <div class="form-group js-description-input"> - <label>{{ s__('Snippets|Description (optional)') }}</label> + <label for="snippet-description">{{ s__('Snippets|Description (optional)') }}</label> <div class="js-collapsible-input"> <div class="js-collapsed" :class="{ 'd-none': value }"> <gl-form-input @@ -46,22 +46,26 @@ export default { <markdown-field class="js-expanded" :class="{ 'd-none': !value }" + :add-spacing-classes="false" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" > - <textarea - slot="textarea" - class="note-textarea js-gfm-input js-autosize markdown-area" - dir="auto" - data-qa-selector="snippet_description_field" - data-supports-quick-actions="false" - :value="value" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" - v-bind="$attrs" - @input="$emit('input', $event.target.value)" - > - </textarea> + <template #textarea> + <textarea + id="snippet-description" + ref="textarea" + :value="value" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-qa-selector="snippet_description_field" + data-supports-quick-actions="false" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files here…')" + v-bind="$attrs" + @input="$emit('input', $event.target.value)" + > + </textarea> + </template> </markdown-field> </div> </div> diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue index 72afcc30be6..a5107f09fc7 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue @@ -15,7 +15,7 @@ export default { }; </script> <template> - <markdown-field-view class="snippet-description" data-qa-selector="snippet_description_field"> + <markdown-field-view class="snippet-description" data-qa-selector="snippet_description_content"> <div class="md js-snippet-description" v-html="description"></div> </markdown-field-view> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index c0967e9093c..2a06296cb15 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -163,7 +163,8 @@ export default { <div class="detail-page-header"> <div class="detail-page-header-body"> <div - class="snippet-box qa-snippet-box has-tooltip d-flex align-items-center append-right-5 mb-1" + class="snippet-box has-tooltip d-flex align-items-center append-right-5 mb-1" + data-qa-selector="snippet_container" :title="snippetVisibilityLevelDescription" data-container="body" > diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue index 5267c3748ca..2cf7a1e267b 100644 --- a/app/assets/javascripts/snippets/components/snippet_title.vue +++ b/app/assets/javascripts/snippets/components/snippet_title.vue @@ -20,7 +20,7 @@ export default { </script> <template> <div class="snippet-header limited-header-width"> - <h2 class="snippet-title prepend-top-0 mb-3" data-qa-selector="snippet_title"> + <h2 class="snippet-title gl-mt-0 mb-3" data-qa-selector="snippet_title_content"> {{ snippet.title }} </h2> diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js index 7fd5e5b8ee4..b3abc73557c 100644 --- a/app/assets/javascripts/snippets/constants.js +++ b/app/assets/javascripts/snippets/constants.js @@ -22,3 +22,6 @@ export const SNIPPET_VISIBILITY = { description: __('The snippet can be accessed without any authentication.'), }, }; + +export const SNIPPET_CREATE_MUTATION_ERROR = __("Can't create snippet: %{err}"); +export const SNIPPET_UPDATE_MUTATION_ERROR = __("Can't update snippet: %{err}"); diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index dff21d919a9..e9efef40632 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -2,12 +2,16 @@ import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; import PublishToolbar from './publish_toolbar.vue'; import EditHeader from './edit_header.vue'; +import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue'; +import parseSourceFile from '~/static_site_editor/services/parse_source_file'; +import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; export default { components: { RichContentEditor, PublishToolbar, EditHeader, + UnsavedChangesConfirmDialog, }, props: { title: { @@ -30,26 +34,57 @@ export default { }, data() { return { - editableContent: this.content, saveable: false, + parsedSource: parseSourceFile(this.content), + editorMode: EDITOR_TYPES.wysiwyg, }; }, computed: { + editableContent() { + return this.parsedSource.editable; + }, + editableKey() { + return this.isWysiwygMode ? 'body' : 'raw'; + }, + isWysiwygMode() { + return this.editorMode === EDITOR_TYPES.wysiwyg; + }, modified() { - return this.content !== this.editableContent; + return this.isWysiwygMode + ? this.parsedSource.isModifiedBody() + : this.parsedSource.isModifiedRaw(); }, }, methods: { + syncSource() { + if (this.isWysiwygMode) { + this.parsedSource.syncBody(); + return; + } + + this.parsedSource.syncRaw(); + }, + onModeChange(mode) { + this.editorMode = mode; + this.syncSource(); + }, onSubmit() { - this.$emit('submit', { content: this.editableContent }); + this.syncSource(); + this.$emit('submit', { content: this.editableContent.raw }); }, }, }; </script> <template> - <div class="d-flex flex-grow-1 flex-column"> + <div class="d-flex flex-grow-1 flex-column h-100"> <edit-header class="py-2" :title="title" /> - <rich-content-editor v-model="editableContent" class="mb-9" /> + <rich-content-editor + v-model="editableContent[editableKey]" + :initial-edit-type="editorMode" + class="mb-9 h-100" + @modeChange="onModeChange" + /> + <unsaved-changes-confirm-dialog :modified="modified" /> <publish-toolbar class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full" :return-url="returnUrl" diff --git a/app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue b/app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue new file mode 100644 index 00000000000..255f029bd27 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue @@ -0,0 +1,27 @@ +<script> +export default { + props: { + modified: { + type: Boolean, + required: false, + default: false, + }, + }, + created() { + window.addEventListener('beforeunload', this.requestConfirmation); + }, + destroyed() { + window.removeEventListener('beforeunload', this.requestConfirmation); + }, + methods: { + requestConfirmation(e) { + if (this.modified) { + e.preventDefault(); + // eslint-disable-next-line no-param-reassign + e.returnValue = ''; + } + }, + }, + render: () => null, +}; +</script> diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index 4794cf5eead..947347922f2 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -17,3 +17,5 @@ export const LOAD_CONTENT_ERROR = __( export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor'); export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit'; +export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request'; +export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor'; diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index f65b648acd6..a1314c8a478 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -7,7 +7,8 @@ import appDataQuery from '../graphql/queries/app_data.query.graphql'; import sourceContentQuery from '../graphql/queries/source_content.query.graphql'; import submitContentChangesMutation from '../graphql/mutations/submit_content_changes.mutation.graphql'; import createFlash from '~/flash'; -import { LOAD_CONTENT_ERROR } from '../constants'; +import Tracking from '~/tracking'; +import { LOAD_CONTENT_ERROR, TRACKING_ACTION_INITIALIZE_EDITOR } from '../constants'; import { SUCCESS_ROUTE } from '../router/constants'; export default { @@ -59,6 +60,9 @@ export default { return Boolean(this.sourceContent); }, }, + mounted() { + Tracking.event(document.body.dataset.page, TRACKING_ACTION_INITIALIZE_EDITOR); + }, methods: { onDismissError() { this.submitChangesError = null; diff --git a/app/assets/javascripts/static_site_editor/services/parse_source_file.js b/app/assets/javascripts/static_site_editor/services/parse_source_file.js new file mode 100644 index 00000000000..f32c693411f --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/parse_source_file.js @@ -0,0 +1,55 @@ +const parseSourceFile = raw => { + const frontMatterRegex = /(^---$[\s\S]*?^---$)/m; + const preGroupedRegex = /([\s\S]*?)(^---$[\s\S]*?^---$)(\s*)([\s\S]*)/m; // preFrontMatter, frontMatter, spacing, and content + let initial; + let editable; + + const hasFrontMatter = source => frontMatterRegex.test(source); + + const buildPayload = (source, header, spacing, body) => { + return { raw: source, header, spacing, body }; + }; + + const parse = source => { + if (hasFrontMatter(source)) { + const match = source.match(preGroupedRegex); + const [, preFrontMatter, frontMatter, spacing, content] = match; + const header = preFrontMatter + frontMatter; + + return buildPayload(source, header, spacing, content); + } + + return buildPayload(source, '', '', source); + }; + + const computedRaw = () => `${editable.header}${editable.spacing}${editable.body}`; + + const syncBody = () => { + /* + We re-parse as markdown editing could have added non-body changes (preFrontMatter, frontMatter, or spacing). + Re-parsing additionally gets us the desired body that was extracted from the mutated editable.raw + Additionally we intentionally mutate the existing editable's key values as opposed to reassigning the object itself so consumers of the potentially reactive property stay in sync. + */ + Object.assign(editable, parse(editable.raw)); + }; + + const syncRaw = () => { + editable.raw = computedRaw(); + }; + + const isModifiedRaw = () => initial.raw !== editable.raw; + const isModifiedBody = () => initial.raw !== computedRaw(); + + initial = parse(raw); + editable = parse(raw); + + return { + editable, + isModifiedRaw, + isModifiedBody, + syncRaw, + syncBody, + }; +}; + +export default parseSourceFile; diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js index 49135d2141b..fce7c1f918f 100644 --- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js @@ -10,6 +10,7 @@ import { SUBMIT_CHANGES_COMMIT_ERROR, SUBMIT_CHANGES_MERGE_REQUEST_ERROR, TRACKING_ACTION_CREATE_COMMIT, + TRACKING_ACTION_CREATE_MERGE_REQUEST, } from '../constants'; const createBranch = (projectId, branch) => @@ -41,8 +42,15 @@ const commitContent = (projectId, message, branch, sourcePath, content) => { }); }; -const createMergeRequest = (projectId, title, sourceBranch, targetBranch = DEFAULT_TARGET_BRANCH) => - Api.createProjectMergeRequest( +const createMergeRequest = ( + projectId, + title, + sourceBranch, + targetBranch = DEFAULT_TARGET_BRANCH, +) => { + Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_MERGE_REQUEST); + + return Api.createProjectMergeRequest( projectId, convertObjectPropsToSnakeCase({ title, @@ -52,6 +60,7 @@ const createMergeRequest = (projectId, title, sourceBranch, targetBranch = DEFAU ).catch(() => { throw new Error(SUBMIT_CHANGES_MERGE_REQUEST_ERROR); }); +}; const submitContentChanges = ({ username, projectId, sourcePath, content }) => { const branch = generateBranchName(username); diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js index 96dfff77859..df00f38dd70 100644 --- a/app/assets/javascripts/user_callout.js +++ b/app/assets/javascripts/user_callout.js @@ -21,16 +21,17 @@ export default class UserCallout { dismissCallout(e) { const $currentTarget = $(e.currentTarget); + const cookieOptions = {}; + if (!$currentTarget.hasClass('js-close-session')) { + cookieOptions.expires = 365; + } if (this.options.setCalloutPerProject) { - Cookies.set(this.cookieName, 'true', { - expires: 365, - path: this.userCalloutBody.data('projectPath'), - }); - } else { - Cookies.set(this.cookieName, 'true', { expires: 365 }); + cookieOptions.path = this.userCalloutBody.data('projectPath'); } + Cookies.set(this.cookieName, 'true', cookieOptions); + if ($currentTarget.hasClass('close') || $currentTarget.hasClass('js-close')) { this.userCalloutBody.remove(); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue index 996e54a1183..7d74d5531b4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue @@ -64,7 +64,7 @@ export default { :title="buttonTitle" :loading="isLoading" :disabled="isActionInProgress" - :class="`btn btn-default btn-sm inline prepend-left-4 ${containerClasses}`" + :class="`btn btn-default btn-sm inline gl-ml-2 ${containerClasses}`" @click="$emit('click')" > <span class="d-inline-flex align-items-baseline"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue index 5dabd9fe5fe..bce25ca20ec 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -75,11 +75,11 @@ export default { rel="noopener noreferrer nofollow" class="js-deploy-url-menu-item menu-item" > - <strong class="str-truncated-100 append-bottom-0 d-block"> + <strong class="str-truncated-100 gl-mb-0 d-block"> {{ slotProps.result.path }} </strong> - <p class="text-secondary str-truncated-100 append-bottom-0 d-block"> + <p class="text-secondary str-truncated-100 gl-mb-0 d-block"> {{ slotProps.result.external_url }} </p> </gl-link> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 2433ba879aa..0464c4b9c15 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -117,7 +117,7 @@ export default { :href="webIdePath" :title="ideButtonTitle" :class="{ disabled: !mr.canPushToSourceBranch }" - class="btn btn-default js-web-ide d-none d-md-inline-block append-right-8" + class="btn btn-default js-web-ide d-none d-md-inline-block gl-mr-3" data-placement="bottom" tabindex="0" role="button" @@ -129,7 +129,7 @@ export default { :disabled="mr.sourceBranchRemoved" data-target="#modal_merge_info" data-toggle="modal" - class="btn btn-default js-check-out-branch append-right-8" + class="btn btn-default js-check-out-branch gl-mr-3" type="button" > {{ s__('mrWidget|Check out branch') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue index 8313b8afb1b..2ef5e81b36b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue @@ -1,16 +1,15 @@ <script> import { __ } from '~/locale'; -import { GlIcon, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import CiIcon from '../../vue_shared/components/ci_icon.vue'; import flash from '~/flash'; import Poll from '~/lib/utils/poll'; export default { name: 'MRWidgetTerraformPlan', components: { - CiIcon, GlIcon, + GlLink, GlLoadingIcon, GlSprintf, }, @@ -36,17 +35,12 @@ export default { deleteNum() { return Number(this.plan.delete); }, - iconStatusObj() { - return { - group: 'warning', - icon: 'status_warning', - }; - }, logUrl() { return this.plan.job_path; }, plan() { - return this.plans['tfplan.json'] || {}; + const firstPlanKey = Object.keys(this.plans)[0]; + return this.plans[firstPlanKey] ?? {}; }, validPlanValues() { return this.addNum + this.changeNum + this.deleteNum >= 0; @@ -90,7 +84,7 @@ export default { <section class="mr-widget-section"> <div class="mr-widget-body media d-flex flex-row"> <span class="append-right-default align-self-start align-self-lg-center"> - <ci-icon :status="iconStatusObj" :size="24" /> + <gl-icon name="status_warning" :size="24" /> </span> <div class="d-flex flex-fill flex-column flex-md-row"> @@ -125,7 +119,7 @@ export default { </div> <div class="terraform-mr-plan-actions"> - <a + <gl-link v-if="logUrl" :href="logUrl" target="_blank" @@ -137,7 +131,7 @@ export default { > {{ __('View full log') }} <gl-icon name="external-link" /> - </a> + </gl-link> </div> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue index 6aad2a26a53..a0e76b151f7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue @@ -14,7 +14,7 @@ export default { </script> <template> - <p v-once class="mr-info-list mr-links append-bottom-0"> + <p v-once class="mr-info-list mr-links gl-mb-0"> <span class="status-text" v-html="removesBranchText"> </span> <i v-tooltip :title="tooltipTitle" :aria-label="tooltipTitle" class="fa fa-question-circle"> </i> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index b1fb377e47a..c3cc30a1a6f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -127,7 +127,7 @@ export default { </button> <span v-if="!rebasingError" class="bold">{{ __( - 'Fast-forward merge is not possible. Rebase the source branch onto the target branch or merge target branch into source branch to allow this merge request to be merged.', + 'Fast-forward merge is not possible. Rebase the source branch onto the target branch.', ) }}</span> <span v-else class="bold danger">{{ rebasingError }}</span> diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 60e41a16854..7431b7e9ed4 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -1,7 +1,7 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; -import { getCommitIconMap } from '~/ide/utils'; +import getCommitIconMap from '~/ide/commit_icon'; import { __ } from '~/locale'; export default { diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue index e80cb06edfb..47231c4ad39 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -52,7 +52,7 @@ export default { :download="fileName" target="_blank" > - <icon :size="16" name="download" class="float-left append-right-8" /> + <icon :size="16" name="download" class="float-left gl-mr-3" /> {{ __('Download') }} </gl-link> </div> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index 07748482204..ddbb474bab6 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -1,20 +1,17 @@ <script> -import { GlDeprecatedButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui'; +import { GlIcon, GlDeprecatedButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range'; -import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import DateTimePickerInput from './date_time_picker_input.vue'; import { defaultTimeRanges, defaultTimeRange, - isValidDate, - stringToISODate, - ISODateToString, - truncateZerosInDateTime, - isDateTimePickerInputValid, + isValidInputString, + inputStringToIsoDate, + isoDateToInputString, } from './date_time_picker_lib'; const events = { @@ -24,13 +21,13 @@ const events = { export default { components: { - Icon, - TooltipOnTruncate, - DateTimePickerInput, - GlFormGroup, + GlIcon, GlDeprecatedButton, GlDropdown, GlDropdownItem, + GlFormGroup, + TooltipOnTruncate, + DateTimePickerInput, }, props: { value: { @@ -48,20 +45,41 @@ export default { required: false, default: true, }, + utc: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { timeRange: this.value, - startDate: '', - endDate: '', + + /** + * Valid start iso date string, null if not valid value + */ + startDate: null, + /** + * Invalid start date string as input by the user + */ + startFallbackVal: '', + + /** + * Valid end iso date string, null if not valid value + */ + endDate: null, + /** + * Invalid end date string as input by the user + */ + endFallbackVal: '', }; }, computed: { startInputValid() { - return isValidDate(this.startDate); + return isValidInputString(this.startDate); }, endInputValid() { - return isValidDate(this.endDate); + return isValidInputString(this.endDate); }, isValid() { return this.startInputValid && this.endInputValid; @@ -69,21 +87,31 @@ export default { startInput: { get() { - return this.startInputValid ? this.formatDate(this.startDate) : this.startDate; + return this.dateToInput(this.startDate) || this.startFallbackVal; }, set(val) { - // Attempt to set a formatted date if possible - this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; + try { + this.startDate = this.inputToDate(val); + this.startFallbackVal = null; + } catch (e) { + this.startDate = null; + this.startFallbackVal = val; + } this.timeRange = null; }, }, endInput: { get() { - return this.endInputValid ? this.formatDate(this.endDate) : this.endDate; + return this.dateToInput(this.endDate) || this.endFallbackVal; }, set(val) { - // Attempt to set a formatted date if possible - this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; + try { + this.endDate = this.inputToDate(val); + this.endFallbackVal = null; + } catch (e) { + this.endDate = null; + this.endFallbackVal = val; + } this.timeRange = null; }, }, @@ -96,10 +124,10 @@ export default { } const { start, end } = convertToFixedRange(this.value); - if (isValidDate(start) && isValidDate(end)) { + if (isValidInputString(start) && isValidInputString(end)) { return sprintf(__('%{start} to %{end}'), { - start: this.formatDate(start), - end: this.formatDate(end), + start: this.stripZerosInDateTime(this.dateToInput(start)), + end: this.stripZerosInDateTime(this.dateToInput(end)), }); } } catch { @@ -107,6 +135,13 @@ export default { } return ''; }, + + customLabel() { + if (this.utc) { + return __('Custom range (UTC)'); + } + return __('Custom range'); + }, }, watch: { value(newValue) { @@ -132,8 +167,17 @@ export default { } }, methods: { - formatDate(date) { - return truncateZerosInDateTime(ISODateToString(date)); + dateToInput(date) { + if (date === null) { + return null; + } + return isoDateToInputString(date, this.utc); + }, + inputToDate(value) { + return inputStringToIsoDate(value, this.utc); + }, + stripZerosInDateTime(str = '') { + return str.replace(' 00:00:00', ''); }, closeDropdown() { this.$refs.dropdown.hide(); @@ -169,10 +213,16 @@ export default { menu-class="date-time-picker-menu" toggle-class="date-time-picker-toggle text-truncate" > + <template #button-content> + <span class="gl-flex-grow-1 text-truncate">{{ timeWindowText }}</span> + <span v-if="utc" class="text-muted gl-font-weight-bold gl-font-sm">{{ __('UTC') }}</span> + <gl-icon class="gl-dropdown-caret" name="chevron-down" aria-hidden="true" /> + </template> + <div class="d-flex justify-content-between gl-p-2-deprecated-no-really-do-not-use-me"> <gl-form-group v-if="customEnabled" - :label="__('Custom range')" + :label="customLabel" label-for="custom-from-time" label-class="gl-pb-1-deprecated-no-really-do-not-use-me" class="custom-time-range-form-group col-md-7 gl-pl-1-deprecated-no-really-do-not-use-me gl-pr-0 m-0" @@ -214,7 +264,7 @@ export default { active-class="active" @click="setQuickRange(option)" > - <icon + <gl-icon name="mobile-issue-close" class="align-bottom" :class="{ invisible: !isOptionActive(option) }" diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue index f19f8bd46b3..32a24844d71 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue @@ -6,9 +6,9 @@ import { dateFormats } from './date_time_picker_lib'; const inputGroupText = { invalidFeedback: sprintf(__('Format: %{dateFormat}'), { - dateFormat: dateFormats.stringDate, + dateFormat: dateFormats.inputFormat, }), - placeholder: dateFormats.stringDate, + placeholder: dateFormats.inputFormat, }; export default { diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js index 673d981cf07..40708453d79 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js @@ -2,12 +2,6 @@ import dateformat from 'dateformat'; import { __ } from '~/locale'; /** - * Valid strings for this regex are - * 2019-10-01 and 2019-10-01 01:02:03 - */ -const dateTimePickerRegex = /^(\d{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])(?: (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]))?$/; - -/** * Default time ranges for the date picker. * @see app/assets/javascripts/lib/utils/datetime_range.js */ @@ -34,23 +28,33 @@ export const defaultTimeRanges = [ export const defaultTimeRange = defaultTimeRanges.find(tr => tr.default); export const dateFormats = { - ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'", - stringDate: 'yyyy-mm-dd HH:MM:ss', + /** + * Format used by users to input dates + * + * Note: Should be a format that can be parsed by Date.parse. + */ + inputFormat: 'yyyy-mm-dd HH:MM:ss', + /** + * Format used to strip timezone from inputs + */ + stripTimezoneFormat: "yyyy-mm-dd'T'HH:MM:ss'Z'", }; /** - * The URL params start and end need to be validated - * before passing them down to other components. + * Returns true if the date can be parsed succesfully after + * being typed by a user. * - * @param {string} dateString - * @returns true if the string is a valid date, false otherwise + * It allows some ambiguity so validation is not strict. + * + * @param {string} value - Value as typed by the user + * @returns true if the value can be parsed as a valid date, false otherwise */ -export const isValidDate = dateString => { +export const isValidInputString = value => { try { // dateformat throws error that can be caught. // This is better than using `new Date()` - if (dateString && dateString.trim()) { - dateformat(dateString, 'isoDateTime'); + if (value && value.trim()) { + dateformat(value, 'isoDateTime'); return true; } return false; @@ -60,25 +64,30 @@ export const isValidDate = dateString => { }; /** - * Convert the input in Time picker component to ISO date. + * Convert the input in time picker component to an ISO date. * - * @param {string} val - * @returns {string} + * @param {string} value + * @param {Boolean} utc - If true, it forces the date to by + * formatted using UTC format, ignoring the local time. + * @returns {Date} */ -export const stringToISODate = val => - dateformat(new Date(val.replace(/-/g, '/')), dateFormats.ISODate, true); +export const inputStringToIsoDate = (value, utc = false) => { + let date = new Date(value); + if (utc) { + // Forces date to be interpreted as UTC by stripping the timezone + // by formatting to a string with 'Z' and skipping timezone + date = dateformat(date, dateFormats.stripTimezoneFormat); + } + return dateformat(date, 'isoUtcDateTime'); +}; /** - * Convert the ISO date received from the URL to string - * for the Time picker component. + * Converts a iso date string to a formatted string for the Time picker component. * - * @param {Date} date + * @param {String} ISO Formatted date * @returns {string} */ -export const ISODateToString = date => dateformat(date, dateFormats.stringDate); - -export const truncateZerosInDateTime = datetime => datetime.replace(' 00:00:00', ''); - -export const isDateTimePickerInputValid = val => dateTimePickerRegex.test(val); +export const isoDateToInputString = (date, utc = false) => + dateformat(date, dateFormats.inputFormat, utc); export default {}; diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index bf3c3666300..a2fe19f9672 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -7,6 +7,10 @@ import ModeChanged from './viewers/mode_changed.vue'; export default { props: { + diffFile: { + type: Object, + required: true, + }, diffMode: { type: String, required: true, @@ -92,6 +96,7 @@ export default { <div v-if="viewer" class="diff-file preview-container"> <component :is="viewer" + :diff-file="diffFile" :diff-mode="diffMode" :new-path="fullNewPath" :old-path="fullOldPath" diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue index 5c1ea59b471..eba6dd4d14c 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue @@ -1,3 +1,108 @@ +<script> +import { mapActions } from 'vuex'; +import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; + +import { __ } from '~/locale'; +import { + TRANSITION_LOAD_START, + TRANSITION_LOAD_ERROR, + TRANSITION_LOAD_SUCCEED, + TRANSITION_ACKNOWLEDGE_ERROR, + STATE_IDLING, + STATE_LOADING, + STATE_ERRORED, + RENAMED_DIFF_TRANSITIONS, +} from '~/diffs/constants'; +import { truncateSha } from '~/lib/utils/text_utility'; + +export default { + STATE_LOADING, + STATE_ERRORED, + TRANSITIONS: RENAMED_DIFF_TRANSITIONS, + uiText: { + showLink: __('Show file contents'), + commitLink: __('View file @ %{commitSha}'), + description: __('File renamed with no changes.'), + loadError: __('Unable to load file contents. Try again later.'), + }, + components: { + GlAlert, + GlLink, + GlLoadingIcon, + GlSprintf, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + }, + data: () => ({ + state: STATE_IDLING, + }), + computed: { + shortSha() { + return truncateSha(this.diffFile.content_sha); + }, + canLoadFullDiff() { + return this.diffFile.alternate_viewer.name === 'text'; + }, + }, + methods: { + ...mapActions('diffs', ['switchToFullDiffFromRenamedFile']), + transition(transitionEvent) { + const key = `${this.state}:${transitionEvent}`; + + if (this.$options.TRANSITIONS[key]) { + this.state = this.$options.TRANSITIONS[key]; + } + }, + is(state) { + return this.state === state; + }, + switchToFull() { + this.transition(TRANSITION_LOAD_START); + + this.switchToFullDiffFromRenamedFile({ diffFile: this.diffFile }) + .then(() => { + this.transition(TRANSITION_LOAD_SUCCEED); + }) + .catch(() => { + this.transition(TRANSITION_LOAD_ERROR); + }); + }, + clickLink(event) { + if (this.canLoadFullDiff) { + event.preventDefault(); + + this.switchToFull(); + } + }, + dismissError() { + this.transition(TRANSITION_ACKNOWLEDGE_ERROR); + }, + }, +}; +</script> + <template> - <div class="nothing-here-block">{{ __('File moved') }}</div> + <div class="nothing-here-block"> + <gl-loading-icon v-if="is($options.STATE_LOADING)" /> + <template v-else> + <gl-alert + v-show="is($options.STATE_ERRORED)" + class="gl-mb-5 gl-text-left" + variant="danger" + @dismiss="dismissError" + >{{ $options.uiText.loadError }}</gl-alert + > + <span test-id="plaintext">{{ $options.uiText.description }}</span> + <gl-link :href="diffFile.view_path" @click="clickLink"> + <span v-if="canLoadFullDiff">{{ $options.uiText.showLink }}</span> + <gl-sprintf v-else :message="$options.uiText.commitLink"> + <template #commitSha>{{ shortSha }}</template> + </gl-sprintf> + </gl-link> + </template> + </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index b57455adaad..9f6f3d2d63a 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -261,7 +261,7 @@ export default { </li> </template> <li v-else class="dropdown-menu-empty-item"> - <div class="append-right-default prepend-left-default prepend-top-8 append-bottom-8"> + <div class="append-right-default prepend-left-default gl-mt-3 gl-mb-3"> <template v-if="loading"> {{ __('Loading...') }} </template> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue index 018e3a84c39..590501a975a 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue @@ -75,12 +75,8 @@ export default { @mouseover="mouseOverRow" @mousemove="mouseMove" > - <file-icon - :file-name="file.name" - :size="16" - css-classes="diff-file-changed-icon append-right-8" - /> - <span class="diff-changed-file-content append-right-8"> + <file-icon :file-name="file.name" :size="16" css-classes="diff-file-changed-icon gl-mr-3" /> + <span class="diff-changed-file-content gl-mr-3"> <strong class="diff-changed-file-name"> <span v-for="(char, charIndex) in file.name.split('')" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js new file mode 100644 index 00000000000..6665a5754b3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -0,0 +1,8 @@ +export const ANY_AUTHOR = 'Any'; + +export const DEBOUNCE_DELAY = 200; + +export const SortDirection = { + descending: 'descending', + ascending: 'ascending', +}; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue new file mode 100644 index 00000000000..a858ffdbed5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -0,0 +1,253 @@ +<script> +import { + GlFilteredSearch, + GlButtonGroup, + GlButton, + GlNewDropdown as GlDropdown, + GlNewDropdownItem as GlDropdownItem, + GlTooltipDirective, +} from '@gitlab/ui'; + +import { __ } from '~/locale'; +import createFlash from '~/flash'; + +import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; + +import { SortDirection } from './constants'; + +export default { + components: { + GlFilteredSearch, + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + namespace: { + type: String, + required: true, + }, + recentSearchesStorageKey: { + type: String, + required: false, + default: '', + }, + tokens: { + type: Array, + required: true, + }, + sortOptions: { + type: Array, + required: true, + }, + initialFilterValue: { + type: Array, + required: false, + default: () => [], + }, + initialSortBy: { + type: String, + required: false, + default: '', + validator: value => value === '' || /(_desc)|(_asc)/g.test(value), + }, + searchInputPlaceholder: { + type: String, + required: true, + }, + }, + data() { + let selectedSortOption = this.sortOptions[0].sortDirection.descending; + let selectedSortDirection = SortDirection.descending; + + // Extract correct sortBy value based on initialSortBy + if (this.initialSortBy) { + selectedSortOption = this.sortOptions + .filter( + sortBy => + sortBy.sortDirection.ascending === this.initialSortBy || + sortBy.sortDirection.descending === this.initialSortBy, + ) + .pop(); + selectedSortDirection = this.initialSortBy.endsWith('_desc') + ? SortDirection.descending + : SortDirection.ascending; + } + + return { + initialRender: true, + recentSearchesPromise: null, + filterValue: this.initialFilterValue, + selectedSortOption, + selectedSortDirection, + }; + }, + computed: { + tokenSymbols() { + return this.tokens.reduce( + (tokenSymbols, token) => ({ + ...tokenSymbols, + [token.type]: token.symbol, + }), + {}, + ); + }, + sortDirectionIcon() { + return this.selectedSortDirection === SortDirection.ascending + ? 'sort-lowest' + : 'sort-highest'; + }, + sortDirectionTooltip() { + return this.selectedSortDirection === SortDirection.ascending + ? __('Sort direction: Ascending') + : __('Sort direction: Descending'); + }, + }, + watch: { + /** + * GlFilteredSearch currently doesn't emit any event when + * search field is cleared, but we still want our parent + * component to know that filters were cleared and do + * necessary data refetch, so this watcher is basically + * a dirty hack/workaround to identify if filter input + * was cleared. :( + */ + filterValue(value) { + const [firstVal] = value; + if ( + !this.initialRender && + value.length === 1 && + firstVal.type === 'filtered-search-term' && + !firstVal.value.data + ) { + this.$emit('onFilter', []); + } + + // Set initial render flag to false + // as we don't want to emit event + // on initial load when value is empty already. + this.initialRender = false; + }, + }, + created() { + if (this.recentSearchesStorageKey) this.setupRecentSearch(); + }, + methods: { + /** + * Initialize service and store instances for + * getting Recent Search functional. + */ + setupRecentSearch() { + this.recentSearchesService = new RecentSearchesService( + `${this.namespace}-${RecentSearchesStorageKeys[this.recentSearchesStorageKey]}`, + ); + + this.recentSearchesStore = new RecentSearchesStore({ + isLocalStorageAvailable: RecentSearchesService.isAvailable(), + allowedKeys: this.tokens.map(token => token.type), + }); + + this.recentSearchesPromise = this.recentSearchesService + .fetch() + .catch(error => { + if (error.name === 'RecentSearchesServiceError') return undefined; + + createFlash(__('An error occurred while parsing recent searches')); + + // Gracefully fail to empty array + return []; + }) + .then(searches => { + if (!searches) return; + + // Put any searches that may have come in before + // we fetched the saved searches ahead of the already saved ones + const resultantSearches = this.recentSearchesStore.setRecentSearches( + this.recentSearchesStore.state.recentSearches.concat(searches), + ); + this.recentSearchesService.save(resultantSearches); + }); + }, + getRecentSearches() { + return this.recentSearchesStore?.state.recentSearches; + }, + handleSortOptionClick(sortBy) { + this.selectedSortOption = sortBy; + this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]); + }, + handleSortDirectionClick() { + this.selectedSortDirection = + this.selectedSortDirection === SortDirection.ascending + ? SortDirection.descending + : SortDirection.ascending; + this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]); + }, + handleFilterSubmit(filters) { + if (this.recentSearchesStorageKey) { + this.recentSearchesPromise + .then(() => { + if (filters.length) { + const searchTokens = filters.map(filter => { + // check filter was plain text search + if (typeof filter === 'string') { + return filter; + } + // filter was a token. + return `${filter.type}:${filter.value.operator}${this.tokenSymbols[filter.type]}${ + filter.value.data + }`; + }); + + const resultantSearches = this.recentSearchesStore.addRecentSearch( + searchTokens.join(' '), + ); + this.recentSearchesService.save(resultantSearches); + } + }) + .catch(() => { + // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 + }); + } + this.$emit('onFilter', filters); + }, + }, +}; +</script> + +<template> + <div class="vue-filtered-search-bar-container d-md-flex"> + <gl-filtered-search + v-model="filterValue" + :placeholder="searchInputPlaceholder" + :available-tokens="tokens" + :history-items="getRecentSearches()" + class="flex-grow-1" + @submit="handleFilterSubmit" + /> + <gl-button-group class="sort-dropdown-container d-flex"> + <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100"> + <gl-dropdown-item + v-for="sortBy in sortOptions" + :key="sortBy.id" + :is-check-item="true" + :is-checked="sortBy.id === selectedSortOption.id" + @click="handleSortOptionClick(sortBy)" + >{{ sortBy.title }}</gl-dropdown-item + > + </gl-dropdown> + <gl-button + v-gl-tooltip + :title="sortDirectionTooltip" + :icon="sortDirectionIcon" + class="flex-shrink-1" + @click="handleSortDirectionClick" + /> + </gl-button-group> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue new file mode 100644 index 00000000000..412bfa5aa7f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -0,0 +1,114 @@ +<script> +import { + GlFilteredSearchToken, + GlAvatar, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +import { ANY_AUTHOR, DEBOUNCE_DELAY } from '../constants'; + +export default { + anyAuthor: ANY_AUTHOR, + components: { + GlFilteredSearchToken, + GlAvatar, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + authors: this.config.initialAuthors || [], + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data.toLowerCase(); + }, + activeAuthor() { + return this.authors.find(author => author.username.toLowerCase() === this.currentValue); + }, + }, + methods: { + fetchAuthorBySearchTerm(searchTerm) { + const fetchPromise = this.config.fetchPath + ? this.config.fetchAuthors(this.config.fetchPath, searchTerm) + : this.config.fetchAuthors(searchTerm); + + fetchPromise + .then(res => { + // We'd want to avoid doing this check but + // users.json and /groups/:id/members & /projects/:id/users + // return response differently. + this.authors = Array.isArray(res) ? res : res.data; + }) + .catch(() => createFlash(__('There was a problem fetching users.'))) + .finally(() => { + this.loading = false; + }); + }, + searchAuthors: debounce(function debouncedSearch({ data }) { + this.fetchAuthorBySearchTerm(data); + }, DEBOUNCE_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchAuthors" + > + <template #view="{ inputValue }"> + <gl-avatar + v-if="activeAuthor" + :size="16" + :src="activeAuthor.avatar_url" + shape="circle" + class="gl-mr-2" + /> + <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span> + </template> + <template #suggestions> + <gl-filtered-search-suggestion :value="$options.anyAuthor">{{ + __('Any') + }}</gl-filtered-search-suggestion> + <gl-dropdown-divider /> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="author in authors" + :key="author.username" + :value="author.username" + > + <div class="d-flex"> + <gl-avatar :size="32" :src="author.avatar_url" /> + <div> + <div>{{ author.name }}</div> + <div>@{{ author.username }}</div> + </div> + </div> + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue index 508f43afe61..a7fba5e760b 100644 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue @@ -1,6 +1,5 @@ <script> -import escape from 'lodash/escape'; -import sanitize from 'sanitize-html'; +import { escape } from 'lodash'; import Tribute from 'tributejs'; import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from '~/lib/utils/common_utils'; @@ -11,11 +10,11 @@ import { spriteIcon } from '~/lib/utils/common_utils'; * @param original An object from the array returned from the `autocomplete_sources/members` API * @returns {string} An HTML template */ -function createMenuItemTemplate({ original }) { +function menuItemTemplate({ original }) { const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : ''; const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass} - align-items-center d-inline-flex justify-content-center`; + gl-display-inline-flex gl-align-items-center gl-justify-content-center`; const avatarTag = original.avatar_url ? `<img @@ -24,42 +23,20 @@ function createMenuItemTemplate({ original }) { class="${avatarClasses}"/>` : `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`; - const name = escape(sanitize(original.name)); + const name = escape(original.name); const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; const icon = original.mentionsDisabled - ? spriteIcon('notifications-off', 's16 vertical-align-middle prepend-left-5') + ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3') : ''; return `${avatarTag} ${original.username} - <small class="small font-weight-normal gl-reset-color">${name}${count}</small> + <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small> ${icon}`; } -/** - * Creates the list of users to show in the mentions dropdown. - * - * @param inputText The text entered by the user in the mentions input field - * @param processValues Callback function to set the list of users to show in the mentions dropdown - */ -function getMembers(inputText, processValues) { - if (this.members) { - processValues(this.members); - } else if (this.dataSources.members) { - axios - .get(this.dataSources.members) - .then(response => { - this.members = response.data; - processValues(response.data); - }) - .catch(() => {}); - } else { - processValues([]); - } -} - export default { name: 'GlMentions', props: { @@ -72,30 +49,49 @@ export default { data() { return { members: undefined, - options: { - trigger: '@', - fillAttr: 'username', - lookup(value) { - return value.name + value.username; - }, - menuItemTemplate: createMenuItemTemplate.bind(this), - values: getMembers.bind(this), - }, }; }, mounted() { + this.tribute = new Tribute({ + trigger: '@', + fillAttr: 'username', + lookup: value => value.name + value.username, + menuItemTemplate, + values: this.getMembers, + }); + const input = this.$slots.default[0].elm; - this.tribute = new Tribute(this.options); this.tribute.attach(input); }, beforeDestroy() { const input = this.$slots.default[0].elm; - if (this.tribute) { - this.tribute.detach(input); - } + this.tribute.detach(input); + }, + methods: { + /** + * Creates the list of users to show in the mentions dropdown. + * + * @param inputText - The text entered by the user in the mentions input field + * @param processValues - Callback function to set the list of users to show in the mentions dropdown + */ + getMembers(inputText, processValues) { + if (this.members) { + processValues(this.members); + } else if (this.dataSources.members) { + axios + .get(this.dataSources.members) + .then(response => { + this.members = response.data; + processValues(response.data); + }) + .catch(() => {}); + } else { + processValues([]); + } + }, }, - render(h) { - return h('div', this.$slots.default); + render(createElement) { + return createElement('div', this.$slots.default); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index 4f1b1c758b2..63de1e009fd 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -85,7 +85,7 @@ export default { class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0" :aria-label="__('Confidential')" /> - <a :href="computedPath" class="sortable-link">{{ title }}</a> + <a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a> </div> <!-- Info area: meta, path, and assignees --> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 8007ccb91d5..0e05f4a4622 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -134,7 +134,7 @@ export default { addMultipleToDiscussionWarning() { return sprintf( __( - '%{icon}You are about to add %{usersTag} people to the discussion. Proceed with caution.', + '%{icon}You are about to add %{usersTag} people to the discussion. They will all receive a notification.', ), { icon: '<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>', @@ -245,11 +245,11 @@ export default { <div class="zen-backdrop"> <slot name="textarea"></slot> <a - class="zen-control zen-control-leave js-zen-leave" + class="zen-control zen-control-leave js-zen-leave gl-text-gray-700" href="#" - :aria-label="__('Enter zen mode')" + :aria-label="__('Leave zen mode')" > - <icon :size="32" name="screen-normal" /> + <icon :size="16" name="screen-normal" /> </a> <markdown-toolbar :markdown-docs-path="markdownDocsPath" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 665637f3b9e..aa1abb5adb6 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -158,7 +158,7 @@ export default { <div class="d-inline-block ml-md-2 ml-0"> <toolbar-button :prepend="true" - tag="* " + tag="- " :button-title="__('Add a bullet list')" icon="list-bulleted" /> @@ -170,7 +170,7 @@ export default { /> <toolbar-button :prepend="true" - tag="* [ ] " + tag="- [ ] " :button-title="__('Add a task list')" icon="list-task" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index a7cd292e01d..6dac448d5de 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -13,6 +13,11 @@ export default { type: Object, required: true, }, + batchSuggestionsInfo: { + type: Array, + required: false, + default: () => [], + }, disabled: { type: Boolean, required: false, @@ -24,6 +29,14 @@ export default { }, }, computed: { + batchSuggestionsCount() { + return this.batchSuggestionsInfo.length; + }, + isBatched() { + return Boolean( + this.batchSuggestionsInfo.find(({ suggestionId }) => suggestionId === this.suggestion.id), + ); + }, lines() { return selectDiffLines(this.suggestion.diff_lines); }, @@ -32,6 +45,15 @@ export default { applySuggestion(callback) { this.$emit('apply', { suggestionId: this.suggestion.id, callback }); }, + applySuggestionBatch() { + this.$emit('applyBatch'); + }, + addSuggestionToBatch() { + this.$emit('addToBatch', this.suggestion.id); + }, + removeSuggestionFromBatch() { + this.$emit('removeFromBatch', this.suggestion.id); + }, }, }; </script> @@ -42,8 +64,14 @@ export default { class="qa-suggestion-diff-header js-suggestion-diff-header" :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled" :is-applied="suggestion.applied" + :is-batched="isBatched" + :is-applying-batch="suggestion.is_applying_batch" + :batch-suggestions-count="batchSuggestionsCount" :help-page-path="helpPagePath" @apply="applySuggestion" + @applyBatch="applySuggestionBatch" + @addToBatch="addSuggestionToBatch" + @removeFromBatch="removeSuggestionFromBatch" /> <table class="mb-3 md-suggestion-diff js-syntax-highlight code"> <tbody> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index af438ce5619..e26ff51e01e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -1,11 +1,19 @@ <script> import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; +import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { Icon, GlDeprecatedButton, GlLoadingIcon }, directives: { 'gl-tooltip': GlTooltipDirective }, + mixins: [glFeatureFlagsMixin()], props: { + batchSuggestionsCount: { + type: Number, + required: false, + default: 0, + }, canApply: { type: Boolean, required: false, @@ -16,6 +24,16 @@ export default { required: true, default: false, }, + isBatched: { + type: Boolean, + required: false, + default: false, + }, + isApplyingBatch: { + type: Boolean, + required: false, + default: false, + }, helpPagePath: { type: String, required: true, @@ -23,17 +41,54 @@ export default { }, data() { return { - isApplying: false, + isApplyingSingle: false, }; }, + computed: { + canBeBatched() { + return Boolean(this.glFeatures.batchSuggestions); + }, + isApplying() { + return this.isApplyingSingle || this.isApplyingBatch; + }, + tooltipMessage() { + return this.canApply + ? __('This also resolves the discussion') + : __("Can't apply as this line has changed or the suggestion already matches its content."); + }, + tooltipMessageBatch() { + return !this.canBeBatched + ? __("Suggestions that change line count can't be added to batches, yet.") + : this.tooltipMessage; + }, + isDisableButton() { + return this.isApplying || !this.canApply; + }, + applyingSuggestionsMessage() { + if (this.isApplyingSingle || this.batchSuggestionsCount < 2) { + return __('Applying suggestion...'); + } + return __('Applying suggestions...'); + }, + }, methods: { applySuggestion() { if (!this.canApply) return; - this.isApplying = true; + this.isApplyingSingle = true; this.$emit('apply', this.applySuggestionCallback); }, applySuggestionCallback() { - this.isApplying = false; + this.isApplyingSingle = false; + }, + applySuggestionBatch() { + if (!this.canApply) return; + this.$emit('applyBatch'); + }, + addSuggestionToBatch() { + this.$emit('addToBatch'); + }, + removeSuggestionFromBatch() { + this.$emit('removeFromBatch'); }, }, }; @@ -47,20 +102,52 @@ export default { <icon name="question-o" css-classes="link-highlight" /> </a> </div> - <span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span> - <div v-if="isApplying" class="d-flex align-items-center text-secondary"> + <div v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</div> + <div v-else-if="isApplying" class="d-flex align-items-center text-secondary"> <gl-loading-icon class="d-flex-center mr-2" /> - <span>{{ __('Applying suggestion') }}</span> + <span>{{ applyingSuggestionsMessage }}</span> + </div> + <div v-else-if="canApply && canBeBatched && isBatched" class="d-flex align-items-center"> + <gl-deprecated-button + class="btn-inverted js-remove-from-batch-btn btn-grouped" + :disabled="isApplying" + @click="removeSuggestionFromBatch" + > + {{ __('Remove from batch') }} + </gl-deprecated-button> + <gl-deprecated-button + v-gl-tooltip.viewport="__('This also resolves all related threads')" + class="btn-inverted js-apply-batch-btn btn-grouped" + :disabled="isApplying" + variant="success" + @click="applySuggestionBatch" + > + {{ __('Apply suggestions') }} + <span class="badge badge-pill badge-pill-success"> + {{ batchSuggestionsCount }} + </span> + </gl-deprecated-button> + </div> + <div v-else class="d-flex align-items-center"> + <span v-if="canBeBatched" v-gl-tooltip.viewport="tooltipMessageBatch" tabindex="0"> + <gl-deprecated-button + class="btn-inverted js-add-to-batch-btn btn-grouped" + :disabled="isDisableButton" + @click="addSuggestionToBatch" + > + {{ __('Add suggestion to batch') }} + </gl-deprecated-button> + </span> + <span v-gl-tooltip.viewport="tooltipMessage" tabindex="0"> + <gl-deprecated-button + class="btn-inverted js-apply-btn btn-grouped" + :disabled="isDisableButton" + variant="success" + @click="applySuggestion" + > + {{ __('Apply suggestion') }} + </gl-deprecated-button> + </span> </div> - <gl-deprecated-button - v-else-if="canApply" - v-gl-tooltip.viewport="__('This also resolves the discussion')" - class="btn-inverted js-apply-btn" - :disabled="isApplying" - variant="success" - @click="applySuggestion" - > - {{ __('Apply suggestion') }} - </gl-deprecated-button> </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 20a14d78f9b..9527c5114f2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -16,6 +16,11 @@ export default { required: false, default: () => [], }, + batchSuggestionsInfo: { + type: Array, + required: false, + default: () => [], + }, noteHtml: { type: String, required: true, @@ -68,18 +73,30 @@ export default { this.isRendered = true; }, generateDiff(suggestionIndex) { - const { suggestions, disabled, helpPagePath } = this; + const { suggestions, disabled, batchSuggestionsInfo, helpPagePath } = this; const suggestion = suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {}; const SuggestionDiffComponent = Vue.extend(SuggestionDiff); const suggestionDiff = new SuggestionDiffComponent({ - propsData: { disabled, suggestion, helpPagePath }, + propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath }, }); suggestionDiff.$on('apply', ({ suggestionId, callback }) => { this.$emit('apply', { suggestionId, callback, flashContainer: this.$el }); }); + suggestionDiff.$on('applyBatch', () => { + this.$emit('applyBatch', { flashContainer: this.$el }); + }); + + suggestionDiff.$on('addToBatch', suggestionId => { + this.$emit('addToBatch', suggestionId); + }); + + suggestionDiff.$on('removeFromBatch', suggestionId => { + this.$emit('removeFromBatch', suggestionId); + }); + return suggestionDiff; }, reset() { diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 486d4f6b609..330785c9319 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,11 +1,13 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui'; export default { components: { + GlButton, GlLink, GlLoadingIcon, + GlSprintf, + GlIcon, }, props: { markdownDocsPath: { @@ -35,45 +37,69 @@ export default { <div class="comment-toolbar clearfix"> <div class="toolbar-text"> <template v-if="!hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{ + <gl-link :href="markdownDocsPath" target="_blank">{{ __('Markdown is supported') }}</gl-link> </template> <template v-if="hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{ - __('Markdown') - }}</gl-link> - and - <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">{{ - __('quick actions') - }}</gl-link> - are supported + <gl-sprintf + :message=" + __( + '%{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd} and %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd} are supported', + ) + " + > + <template #markdownDocsLink="{content}"> + <gl-link :href="markdownDocsPath" target="_blank">{{ content }}</gl-link> + </template> + <template #quickActionsDocsLink="{content}"> + <gl-link :href="quickActionsDocsPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> </template> </div> <span v-if="canAttachFile" class="uploading-container"> <span class="uploading-progress-container hide"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i> + <template> + <gl-icon name="media" :size="16" /> + </template> <span class="attaching-file-message"></span> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> <span class="uploading-progress">0%</span> <gl-loading-icon inline class="align-text-bottom" /> </span> <span class="uploading-error-container hide"> <span class="uploading-error-icon"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i> + <template> + <gl-icon name="media" :size="16" /> + </template> </span> <span class="uploading-error-message"></span> - <button class="retry-uploading-link" type="button">{{ __('Try again') }}</button> or - <button class="attach-new-file markdown-selector" type="button"> - {{ __('attach a new file') }} - </button> + + <gl-sprintf + :message=" + __( + '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}', + ) + " + > + <template #retryButton="{content}"> + <button class="retry-uploading-link" type="button">{{ content }}</button> + </template> + <template #newFileButton="{content}"> + <button class="attach-new-file markdown-selector" type="button">{{ content }}</button> + </template> + </gl-sprintf> </span> - <button class="markdown-selector button-attach-file btn-link" tabindex="-1" type="button"> - <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i - ><span class="text-attach-file">{{ __('Attach a file') }}</span> - </button> - <button class="btn btn-default btn-sm hide button-cancel-uploading-files" type="button"> + <gl-button class="markdown-selector button-attach-file" variant="link"> + <template> + <gl-icon name="media" :size="16" /> + </template> + <span class="text-attach-file">{{ __('Attach a file') }}</span> + </gl-button> + <gl-button class="btn btn-default btn-sm hide button-cancel-uploading-files" variant="link"> {{ __('Cancel') }} - </button> + </gl-button> </span> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index ec7d7e94e5c..b6271a95008 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -132,7 +132,7 @@ export default { </pre> <pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre> <gl-deprecated-button - v-if="canDeleteDescriptionVersion" + v-if="displayDeleteButton" ref="deleteDescriptionVersionButton" v-gl-tooltip :title="__('Remove description history')" diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue index fd45ac52647..15a5ce85046 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -1,6 +1,7 @@ <script> import { debounce } from 'lodash'; import { GlLoadingIcon, GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; +import { __, n__, sprintf } from '~/locale'; import ProjectListItem from './project_list_item.vue'; const SEARCH_INPUT_TIMEOUT_MS = 500; @@ -24,28 +25,23 @@ export default { }, showNoResultsMessage: { type: Boolean, - required: false, - default: false, + required: true, }, showMinimumSearchQueryMessage: { type: Boolean, - required: false, - default: false, + required: true, }, showLoadingIndicator: { type: Boolean, - required: false, - default: false, + required: true, }, showSearchErrorMessage: { type: Boolean, - required: false, - default: false, + required: true, }, totalResults: { type: Number, - required: false, - default: 0, + required: true, }, }, data() { @@ -53,6 +49,20 @@ export default { searchQuery: '', }; }, + computed: { + legendText() { + const count = this.projectSearchResults.length; + const total = this.totalResults; + + if (total > 0) { + return sprintf(__('Showing %{count} of %{total} projects'), { count, total }); + } + + return sprintf(n__('Showing %{count} project', 'Showing %{count} projects', count), { + count, + }); + }, + }, methods: { projectClicked(project) { this.$emit('projectClicked', project); @@ -87,17 +97,23 @@ export default { :total-items="totalResults" @bottomReached="bottomReached" > - <div v-if="!showLoadingIndicator" slot="items" class="d-flex flex-column"> - <project-list-item - v-for="project in projectSearchResults" - :key="project.id" - :selected="isSelected(project)" - :project="project" - :matcher="searchQuery" - class="js-project-list-item" - @click="projectClicked(project)" - /> - </div> + <template v-if="!showLoadingIndicator" #items> + <div class="d-flex flex-column"> + <project-list-item + v-for="project in projectSearchResults" + :key="project.id" + :selected="isSelected(project)" + :project="project" + :matcher="searchQuery" + class="js-project-list-item" + @click="projectClicked(project)" + /> + </div> + </template> + + <template #default> + {{ legendText }} + </template> </gl-infinite-scroll> <div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message"> {{ __('Sorry, no projects matched your search') }} diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js index 457f1806452..1566c2c784b 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js @@ -1,5 +1,9 @@ import { __ } from '~/locale'; -import { generateToolbarItem } from './toolbar_service'; +import { generateToolbarItem } from './editor_service'; + +export const CUSTOM_EVENTS = { + openAddImageModal: 'gl_openAddImageModal', +}; /* eslint-disable @gitlab/require-i18n-strings */ const TOOLBAR_ITEM_CONFIGS = [ @@ -10,7 +14,6 @@ const TOOLBAR_ITEM_CONFIGS = [ { isDivider: true }, { icon: 'quote', command: 'Blockquote', tooltip: __('Insert a quote') }, { icon: 'link', event: 'openPopupAddLink', tooltip: __('Add a link') }, - { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') }, { isDivider: true }, { icon: 'list-bulleted', command: 'UL', tooltip: __('Add a bullet list') }, { icon: 'list-numbered', command: 'OL', tooltip: __('Add a numbered list') }, @@ -20,8 +23,10 @@ const TOOLBAR_ITEM_CONFIGS = [ { isDivider: true }, { icon: 'dash', command: 'HR', tooltip: __('Add a line') }, { icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') }, + { icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') }, { isDivider: true }, { icon: 'code', command: 'Code', tooltip: __('Insert inline code') }, + { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') }, ]; export const EDITOR_OPTIONS = { @@ -29,6 +34,7 @@ export const EDITOR_OPTIONS = { }; export const EDITOR_TYPES = { + markdown: 'markdown', wysiwyg: 'wysiwyg', }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js new file mode 100644 index 00000000000..278cd50a947 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import ToolbarItem from './toolbar_item.vue'; + +const buildWrapper = propsData => { + const instance = new Vue({ + render(createElement) { + return createElement(ToolbarItem, propsData); + }, + }); + + instance.$mount(); + return instance.$el; +}; + +export const generateToolbarItem = config => { + const { icon, classes, event, command, tooltip, isDivider } = config; + + if (isDivider) { + return 'divider'; + } + + return { + type: 'button', + options: { + el: buildWrapper({ props: { icon, tooltip }, class: classes }), + event, + command, + }, + }; +}; + +export const addCustomEventListener = (editorApi, event, handler) => { + editorApi.eventManager.addEventType(event); + editorApi.eventManager.listen(event, handler); +}; + +export const removeCustomEventListener = (editorApi, event, handler) => + editorApi.eventManager.removeEventHandler(event, handler); + +export const addImage = ({ editor }, image) => editor.exec('AddImage', image); + +export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown'); diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue new file mode 100644 index 00000000000..40063065926 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue @@ -0,0 +1,74 @@ +<script> +import { isSafeURL } from '~/lib/utils/url_utility'; +import { GlModal, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlModal, + GlFormGroup, + GlFormInput, + }, + data() { + return { + error: null, + imageUrl: null, + altText: null, + modalTitle: __('Image Details'), + okTitle: __('Insert'), + urlLabel: __('Image URL'), + descriptionLabel: __('Description'), + }; + }, + methods: { + show() { + this.error = null; + this.imageUrl = null; + this.altText = null; + + this.$refs.modal.show(); + }, + onOk(event) { + if (!this.isValid()) { + event.preventDefault(); + return; + } + + const { imageUrl, altText } = this; + + this.$emit('addImage', { imageUrl, altText: altText || __('image') }); + }, + isValid() { + if (!isSafeURL(this.imageUrl)) { + this.error = __('Please provide a valid URL'); + this.$refs.urlInput.$el.focus(); + return false; + } + + return true; + }, + }, +}; +</script> +<template> + <gl-modal + ref="modal" + modal-id="add-image-modal" + :title="modalTitle" + :ok-title="okTitle" + @ok="onOk" + > + <gl-form-group + :label="urlLabel" + label-for="url-input" + :state="!Boolean(error)" + :invalid-feedback="error" + > + <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" /> + </gl-form-group> + + <gl-form-group :label="descriptionLabel" label-for="description-input"> + <gl-form-input id="description-input" ref="descriptionInput" v-model="altText" /> + </gl-form-group> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue index ba3696c8ad1..5c310fc059b 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -2,7 +2,21 @@ import 'codemirror/lib/codemirror.css'; import '@toast-ui/editor/dist/toastui-editor.css'; -import { EDITOR_OPTIONS, EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE } from './constants'; +import AddImageModal from './modals/add_image_modal.vue'; +import { + EDITOR_OPTIONS, + EDITOR_TYPES, + EDITOR_HEIGHT, + EDITOR_PREVIEW_STYLE, + CUSTOM_EVENTS, +} from './constants'; + +import { + addCustomEventListener, + removeCustomEventListener, + addImage, + getMarkdown, +} from './editor_service'; export default { components: { @@ -10,6 +24,7 @@ export default { import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then( toast => toast.Editor, ), + AddImageModal, }, props: { value: { @@ -37,29 +52,85 @@ export default { default: EDITOR_PREVIEW_STYLE, }, }, + data() { + return { + editorApi: null, + previousMode: null, + }; + }, computed: { editorOptions() { return { ...EDITOR_OPTIONS, ...this.options }; }, + editorInstance() { + return this.$refs.editor; + }, + }, + watch: { + value(newVal) { + const isSameMode = this.previousMode === this.editorApi.currentMode; + if (!isSameMode) { + /* + The ToastUI Editor consumes its content via the `initial-value` prop and then internally + manages changes. If we desire the `v-model` to work as expected, we need to manually call + `setMarkdown`. However, if we do this in each v-model change we'll continually prevent + the editor from internally managing changes. Thus we use the `previousMode` flag as + confirmation to actually update its internals. This is initially designed so that front + matter is excluded from editing in wysiwyg mode, but included in markdown mode. + */ + this.editorInstance.invoke('setMarkdown', newVal); + this.previousMode = this.editorApi.currentMode; + } + }, + }, + beforeDestroy() { + removeCustomEventListener( + this.editorApi, + CUSTOM_EVENTS.openAddImageModal, + this.onOpenAddImageModal, + ); + + this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode); }, methods: { onContentChanged() { - this.$emit('input', this.getMarkdown()); + this.$emit('input', getMarkdown(this.editorInstance)); + }, + onLoad(editorApi) { + this.editorApi = editorApi; + + addCustomEventListener( + this.editorApi, + CUSTOM_EVENTS.openAddImageModal, + this.onOpenAddImageModal, + ); + + this.editorApi.eventManager.listen('changeMode', this.onChangeMode); + }, + onOpenAddImageModal() { + this.$refs.addImageModal.show(); + }, + onAddImage(image) { + addImage(this.editorInstance, image); }, - getMarkdown() { - return this.$refs.editor.invoke('getMarkdown'); + onChangeMode(newMode) { + this.$emit('modeChange', newMode); }, }, }; </script> <template> - <toast-editor - ref="editor" - :initial-value="value" - :options="editorOptions" - :preview-style="previewStyle" - :initial-edit-type="initialEditType" - :height="height" - @change="onContentChanged" - /> + <div> + <toast-editor + ref="editor" + :initial-value="value" + :options="editorOptions" + :preview-style="previewStyle" + :initial-edit-type="initialEditType" + :height="height" + @change="onContentChanged" + @load="onLoad" + /> + <add-image-modal ref="addImageModal" @addImage="onAddImage" /> + </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue index 58aaeef45f2..4271f6053ed 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue @@ -1,20 +1,27 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; export default { components: { GlIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { icon: { type: String, required: true, }, + tooltip: { + type: String, + required: true, + }, }, }; </script> <template> - <button class="p-0 gl-display-flex toolbar-button"> - <gl-icon class="gl-mx-auto" :name="icon" /> + <button v-gl-tooltip="{ title: tooltip }" class="p-0 gl-display-flex toolbar-button"> + <gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" /> </button> </template> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js deleted file mode 100644 index fff90f3e3fb..00000000000 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js +++ /dev/null @@ -1,32 +0,0 @@ -import Vue from 'vue'; -import ToolbarItem from './toolbar_item.vue'; - -const buildWrapper = propsData => { - const instance = new Vue({ - render(createElement) { - return createElement(ToolbarItem, propsData); - }, - }); - - instance.$mount(); - return instance.$el; -}; - -// eslint-disable-next-line import/prefer-default-export -export const generateToolbarItem = config => { - const { icon, classes, event, command, tooltip, isDivider } = config; - - if (isDivider) { - return 'divider'; - } - - return { - type: 'button', - options: { - el: buildWrapper({ props: { icon }, class: classes }), - event, - command, - tooltip, - }, - }; -}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js index ab652c9356a..e94e7d46f85 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js @@ -1,5 +1,6 @@ -// eslint-disable-next-line import/prefer-default-export export const DropdownVariant = { Sidebar: 'sidebar', Standalone: 'standalone', }; + +export const LIST_BUFFER_SIZE = 5; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index 1ef2e8b3bed..af16088b6b9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -3,15 +3,20 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; import LabelItem from './label_item.vue'; +import { LIST_BUFFER_SIZE } from './constants'; + export default { + LIST_BUFFER_SIZE, components: { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink, + SmartVirtualList, LabelItem, }, data() { @@ -139,10 +144,18 @@ export default { <gl-search-box-by-type v-model="searchKey" :autofocus="true" /> </div> <div v-show="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content"> - <ul class="list-unstyled mb-0"> + <smart-virtual-list + :length="visibleLabels.length" + :remain="$options.LIST_BUFFER_SIZE" + :size="$options.LIST_BUFFER_SIZE" + wclass="list-unstyled mb-0" + wtag="ul" + class="h-100" + > <li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left"> <label-item :label="label" + :is-label-set="label.set" :highlight="index === currentHighlightItem" @clickLabel="handleLabelClick(label)" /> @@ -150,7 +163,7 @@ export default { <li v-show="!visibleLabels.length" class="p-2 text-center"> {{ __('No matching results') }} </li> - </ul> + </smart-virtual-list> </div> <div v-if="isDropdownVariantSidebar" class="dropdown-footer"> <ul class="list-unstyled"> @@ -162,9 +175,9 @@ export default { > </li> <li> - <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">{{ - footerManageLabelTitle - }}</gl-link> + <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item"> + {{ footerManageLabelTitle }} + </gl-link> </li> </ul> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue index c95221d71b5..002e741ab96 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue @@ -11,6 +11,10 @@ export default { type: Object, required: true, }, + isLabelSet: { + type: Boolean, + required: true, + }, highlight: { type: Boolean, required: false, @@ -19,7 +23,7 @@ export default { }, data() { return { - isSet: this.label.set, + isSet: this.isLabelSet, }; }, computed: { @@ -29,6 +33,16 @@ export default { }; }, }, + watch: { + /** + * This watcher assures that if user used + * `Enter` key to set/unset label, changes + * are reflected here too. + */ + isLabelSet(value) { + this.isSet = value; + }, + }, methods: { handleClick() { this.isSet = !this.isSet; diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue new file mode 100644 index 00000000000..389d42f0829 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/url_sync.vue @@ -0,0 +1,25 @@ +<script> +import { historyPushState } from '~/lib/utils/common_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; + +export default { + props: { + query: { + type: Object, + required: true, + }, + }, + watch: { + query: { + immediate: true, + deep: true, + handler(newQuery) { + historyPushState(setUrlParams(newQuery, window.location.href, true)); + }, + }, + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js index c93b3d37a63..a740a3fa6b9 100644 --- a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js @@ -5,6 +5,7 @@ * Components need to have `scope`, `page` and `requestData` */ import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/common_utils'; +import { validateParams } from '~/pipelines/utils'; export default { methods: { @@ -35,18 +36,7 @@ export default { }, onChangeWithFilter(params) { - const { username, ref } = this.requestData; - const paramsData = params; - - if (username) { - paramsData.username = username; - } - - if (ref) { - paramsData.ref = ref; - } - - return paramsData; + return { ...params, ...validateParams(this.requestData) }; }, updateInternalState(parameters) { diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js index 4fad34d22d8..c628a67f7f5 100644 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -144,7 +144,9 @@ const mixins = { return 'merge-request-status closed issue-token-state-icon-closed'; } - return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed'; + return this.isOpen + ? 'issue-token-state-icon-open gl-text-green-500' + : 'issue-token-state-icon-closed gl-text-blue-500'; }, computedLinkElementType() { return this.path.length > 0 ? 'a' : 'span'; diff --git a/app/assets/stylesheets/application_dark.scss b/app/assets/stylesheets/application_dark.scss new file mode 100644 index 00000000000..72196d71969 --- /dev/null +++ b/app/assets/stylesheets/application_dark.scss @@ -0,0 +1,3 @@ +@import "./themes/dark"; + +@import "./application"; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index 1c15400542a..a6d56819140 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -111,7 +111,7 @@ kbd { code { padding: 2px 4px; color: $code-color; - background-color: $gray-100; + background-color: $gray-50; border-radius: $border-radius-default; .code > & { @@ -187,7 +187,7 @@ h3.popover-header { // Add to .label so that old system notes that are saved to the db // will still receive the correct styling -.badge, +.badge:not(.gl-badge), .label { padding: 4px 5px; font-size: 12px; diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss index 312123aeef9..6bb7e9d215e 100644 --- a/app/assets/stylesheets/components/avatar.scss +++ b/app/assets/stylesheets/components/avatar.scss @@ -70,7 +70,7 @@ $avatar-sizes: ( $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $identicon-blue, $identicon-teal, $identicon-orange, $gray-darker; -.avatar-circle { +%avatar-circle { float: left; margin-right: $gl-padding; border-radius: $avatar-radius; @@ -84,7 +84,7 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i } .avatar { - @extend .avatar-circle; + @extend %avatar-circle; transition-property: none; width: 40px; @@ -100,10 +100,7 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i margin-left: 2px; flex-shrink: 0; - &.s16 { - margin-right: 4px; - } - + &.s16, &.s24 { margin-right: 4px; } @@ -154,7 +151,7 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i } .avatar-container { - @extend .avatar-circle; + @extend %avatar-circle; overflow: hidden; display: flex; diff --git a/app/assets/stylesheets/components/dashboard_skeleton.scss b/app/assets/stylesheets/components/dashboard_skeleton.scss index ce33aa94df3..64091201221 100644 --- a/app/assets/stylesheets/components/dashboard_skeleton.scss +++ b/app/assets/stylesheets/components/dashboard_skeleton.scss @@ -67,10 +67,10 @@ background-repeat: no-repeat; background-size: cover; background-image: linear-gradient(to right, - $gray-100 0%, + $gray-50 0%, $gray-10 20%, - $gray-100 40%, - $gray-100 100%); + $gray-50 40%, + $gray-50 100%); border-radius: $gl-padding; height: $gl-padding; margin-top: -$gl-padding-8; diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss index 1061aae2bbb..380b2280490 100644 --- a/app/assets/stylesheets/components/design_management/design.scss +++ b/app/assets/stylesheets/components/design_management/design.scss @@ -1,3 +1,7 @@ +.layout-page.design-detail-layout { + max-height: 100vh; +} + .design-detail { background-color: rgba($black, 0.9); @@ -5,8 +9,30 @@ top: 35px; } - .inactive { - opacity: 0.5; + .design-pin { + transition: opacity 0.5s ease; + + &.inactive { + @include gl-opacity-5; + + &:hover { + @include gl-opacity-10; + } + } + } + + .badge.badge-pill { + display: flex; + height: 28px; + width: 28px; + background-color: $blue-400; + color: $white; + border: $white 1px solid; + border-radius: 50%; + + &.resolved { + background-color: $gray-700; + } } } @@ -40,14 +66,31 @@ min-width: 400px; flex-basis: 28%; + .link-inherit-color { + &:hover, + &:active, + &:focus { + color: inherit; + text-decoration: none; + } + } + + .toggle-comments { + line-height: 20px; + border-top: 1px solid $border-color; + + &.expanded { + border-bottom: 1px solid $border-color; + } + + .toggle-comments-button:focus { + text-decoration: none; + color: $blue-600; + } + } + .badge.badge-pill { margin-left: $gl-padding; - background-color: $blue-400; - color: $white; - border: $white 1px solid; - min-height: 28px; - padding: 7px 10px; - border-radius: $gl-padding; } .design-discussion { diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss index fcaa1b054ed..1e78781f4b8 100644 --- a/app/assets/stylesheets/components/popover.scss +++ b/app/assets/stylesheets/components/popover.scss @@ -1,7 +1,7 @@ .popover { max-width: $popover-max-width; border: 1px solid $gray-200; - box-shadow: 0 2px 3px 1px $gray-200; + box-shadow: $popover-box-shadow; font-size: $gl-font-size-small; /** diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 61f971a3185..956f34f7a8b 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -23,20 +23,17 @@ $item-remove-button-space: 42px; .sortable-link { white-space: normal; } + + .item-assignees .avatar { + height: $gl-padding; + width: $gl-padding; + } } .item-body { position: relative; line-height: $gl-line-height; - .issue-token-state-icon-open { - color: $green-500; - } - - .issue-token-state-icon-closed { - color: $blue-500; - } - .merge-request-status.closed { color: $red-500; } @@ -68,7 +65,6 @@ $item-remove-button-space: 42px; .sortable-link { color: $gray-900; - font-weight: normal; } } @@ -276,10 +272,6 @@ $item-remove-button-space: 42px; /* Small devices (landscape phones, 768px and up) */ @include media-breakpoint-up(md) { - .item-body .item-contents { - max-width: 95%; - } - .related-items-tree .item-contents, .item-body .item-title { max-width: 100%; @@ -348,6 +340,11 @@ $item-remove-button-space: 42px; } .item-assignees { + .avatar { + height: $gl-padding-24; + width: $gl-padding-24; + } + .avatar-counter { height: $gl-padding-24; min-width: $gl-padding-24; @@ -366,6 +363,10 @@ $item-remove-button-space: 42px; .sortable-link { line-height: 1.3; } + + .item-info-area { + flex-basis: auto; + } } @media only screen and (min-width: 1500px) { diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss index eca0f1114af..bedd06ec9a1 100644 --- a/app/assets/stylesheets/components/rich_content_editor.scss +++ b/app/assets/stylesheets/components/rich_content_editor.scss @@ -1,4 +1,8 @@ -// Overrides styles from ToastUI editor +/** +* Overrides styles from ToastUI editor +*/ + +// Toolbar buttons .tui-editor-defaultUI-toolbar .toolbar-button { color: $gl-gray-600; border: 0; @@ -9,3 +13,19 @@ border: 0; } } + +// Contextual menu's & popups +.tui-editor-defaultUI .tui-popup-wrapper { + @include gl-overflow-hidden; + @include gl-rounded-base; + @include gl-border-gray-400; + + hr { + @include gl-m-0; + @include gl-bg-gray-400; + } + + button { + @include gl-text-gray-800; + } +} diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 13174687e5d..136ff82e0f8 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -93,7 +93,6 @@ } .dropdown-menu-toggle, -.avatar-circle, .header-user-avatar { @include transition(border-color); } @@ -177,7 +176,7 @@ a { [class^='skeleton-line-'] { position: relative; - background-color: $gray-100; + background-color: $gray-50; height: 10px; overflow: hidden; @@ -192,10 +191,10 @@ a { background-repeat: no-repeat; background-size: cover; background-image: linear-gradient(to right, - $gray-100 0%, + $gray-50 0%, $gray-10 20%, - $gray-100 40%, - $gray-100 100%); + $gray-50 40%, + $gray-50 100%); height: 10px; } } diff --git a/app/assets/stylesheets/framework/badges.scss b/app/assets/stylesheets/framework/badges.scss index c036267a7c8..5b8a4bf964e 100644 --- a/app/assets/stylesheets/framework/badges.scss +++ b/app/assets/stylesheets/framework/badges.scss @@ -1,6 +1,14 @@ -.badge.badge-pill { +.badge.badge-pill:not(.gl-badge) { font-weight: $gl-font-weight-normal; background-color: $badge-bg; color: $gray-800; vertical-align: baseline; + + // Do not use this! + // This is a temporary workaround until the new GlBadge component + // is available: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/481 + &.badge-pill-success { + background-color: rgba($green-500, 0.2); + color: $green; + } } diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss index 7dd7ab339dd..a1e757afe56 100644 --- a/app/assets/stylesheets/framework/blank.scss +++ b/app/assets/stylesheets/framework/blank.scss @@ -116,3 +116,17 @@ } } } + +.experiment-new-project-page-blank-state { + @include media-breakpoint-down(md) { + flex-direction: column; + justify-content: center; + text-align: center; + } +} + +$experiment-new-project-indigo-700: #41419f; + +.experiment-new-project-page-blank-state-title { + color: $experiment-new-project-indigo-700; +} diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss index 9903d10d27c..534ada08b85 100644 --- a/app/assets/stylesheets/framework/broadcast_messages.scss +++ b/app/assets/stylesheets/framework/broadcast_messages.scss @@ -14,8 +14,6 @@ } .broadcast-banner-message { - @extend .broadcast-message; - @extend .alert-warning; text-align: center; .broadcast-message-dismiss { @@ -24,8 +22,6 @@ } .broadcast-notification-message { - @extend .broadcast-message; - position: fixed; bottom: $gl-padding; right: $gl-padding; @@ -42,7 +38,6 @@ } .broadcast-message-dismiss { - height: 100%; color: $gray-800; } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 93361c21642..849ca4a79f8 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -111,7 +111,7 @@ pre { hr { margin: 24px 0; - border-top: 1px solid darken($gray-normal, 8%); + border-top: 1px solid $gray-darker; } .str-truncated { @@ -135,7 +135,7 @@ hr { text-overflow: ellipsis; white-space: nowrap; - > div:not(.block), + > div:not(.block):not(.select2-display-none), .str-truncated { display: inline; } @@ -396,21 +396,13 @@ img.emoji { 🚨 Do not use these classes — they are deprecated and being removed. 🚨 See https://gitlab.com/gitlab-org/gitlab/-/issues/217418 for more details. **/ -.prepend-top-0 { margin-top: 0; } -.prepend-top-2 { margin-top: 2px; } -.prepend-top-4 { margin-top: $gl-padding-4; } .prepend-top-5 { margin-top: 5px; } -.prepend-top-8 { margin-top: $grid-size; } .prepend-top-10 { margin-top: 10px; } .prepend-top-15 { margin-top: 15px; } .prepend-top-default { margin-top: $gl-padding !important; } .prepend-top-16 { margin-top: 16px; } .prepend-top-20 { margin-top: 20px; } -.prepend-top-32 { margin-top: 32px; } -.prepend-left-2 { margin-left: 2px; } -.prepend-left-4 { margin-left: 4px; } .prepend-left-5 { margin-left: 5px; } -.prepend-left-8 { margin-left: 8px; } .prepend-left-10 { margin-left: 10px; } .prepend-left-15 { margin-left: 15px; } .prepend-left-default { margin-left: $gl-padding; } @@ -420,7 +412,6 @@ img.emoji { .append-right-2 { margin-right: 2px; } .append-right-4 { margin-right: 4px; } .append-right-5 { margin-right: 5px; } -.append-right-8 { margin-right: 8px; } .append-right-10 { margin-right: 10px; } .append-right-15 { margin-right: 15px; } .append-right-default { margin-right: $gl-padding; } @@ -428,11 +419,7 @@ img.emoji { .append-right-32 { margin-right: 32px; } .append-right-48 { margin-right: 48px; } .prepend-right-32 { margin-right: 32px; } -.append-bottom-0 { margin-bottom: 0; } -.append-bottom-2 { margin-bottom: 2px; } -.append-bottom-4 { margin-bottom: $gl-padding-4; } .append-bottom-5 { margin-bottom: 5px; } -.append-bottom-8 { margin-bottom: $grid-size; } .append-bottom-10 { margin-bottom: 10px; } .append-bottom-15 { margin-bottom: 15px; } .append-bottom-20 { margin-bottom: 20px; } @@ -521,31 +508,6 @@ img.emoji { } /** - The zero-indexed classes will not change and do not need to be updated. - These can be removed when the Gitlab UI class include is merged. -**/ - -.gl-p-0 { - padding: 0; -} - -.gl-pl-0 { - padding-left: 0; -} - -.gl-pr-0 { - padding-right: 0; -} - -.gl-pt-0 { - padding-top: 0; -} - -.gl-pb-0 { - padding-bottom: 0; -} - -/** * Removes browser specific clear icon from input fields in * Internet Explorer 10, Internet Explorer 11, and Microsoft Edge. * This is intended for elements which add a customized clear icon. @@ -602,7 +564,7 @@ img.emoji { bottom: 40px; right: 40px; font-size: $gl-font-size-small; - background: $gray-100; + background: $gray-50; width: 200px; border-radius: 24px; box-shadow: 0 2px 4px $issue-boards-card-shadow; @@ -637,8 +599,6 @@ img.emoji { .gl-font-lg { font-size: $gl-font-size-large; } .gl-font-base { font-size: $gl-font-size-14; } -.gl-line-height-24 { line-height: $gl-line-height-24; } - .gl-font-size-0 { font-size: 0; } .gl-font-size-28 { font-size: $gl-font-size-28; } .gl-font-size-42 { font-size: $gl-font-size-42; } diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 11064f18418..e4bee01f61f 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -137,7 +137,7 @@ .badge.badge-pill:not(.fly-out-badge), .sidebar-context-title, .nav-item-name { - display: none; + @include gl-sr-only; } .sidebar-top-level-items > li > a { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 1df9818a877..485a4879c43 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -657,6 +657,7 @@ .dropdown-input-field, .default-dropdown-input { + background-color: $input-bg; display: block; width: 100%; min-height: 30px; @@ -940,7 +941,7 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { position: absolute; top: 13px; right: 25px; - color: $gray-100; + color: $gray-50; } } @@ -979,7 +980,7 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { &:hover { .frequent-items-item-avatar-container .avatar { - border-color: $gray-100; + border-color: $gray-50; } } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 7ee3e68ceea..eef6d9031f8 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -480,7 +480,7 @@ span.idiff { padding-bottom: $gl-padding; .discussion-reply-holder { - border-bottom: 1px solid $gray-100; + border-bottom: 1px solid $gray-50; border-radius: 0; } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 5f6a26d0a14..9bba5c0614a 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -96,7 +96,7 @@ } .name { - background-color: $white-normal; + background-color: $gray-50; color: $gl-text-color-secondary; border-radius: 2px 0 0 2px; margin-right: 1px; @@ -259,6 +259,7 @@ flex: 1; position: relative; min-width: 0; + background-color: $input-bg; } .filtered-search-input-dropdown-menu { @@ -449,3 +450,17 @@ font-size: 13px; } } + +.vue-filtered-search-bar-container { + @include media-breakpoint-up(md) { + .sort-dropdown-container { + margin-left: 10px; + } + } + + @include media-breakpoint-down(sm) { + .sort-dropdown-container { + margin-top: 10px; + } + } +} diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index 6a2f36d2509..8d5afe1d312 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -314,12 +314,12 @@ body { $gray-800, $gray-700, $gray-700, - $gray-100, + $gray-50, $gray-700 ); .navbar-gitlab { - background-color: $gray-100; + background-color: $gray-50; box-shadow: 0 1px 0 0 $border-color; .logo-text svg { @@ -388,4 +388,49 @@ body { color: $gray-900; } } + + &.gl-dark { + .logo-text svg { + fill: $gl-text-color; + } + + .navbar-gitlab { + background-color: $gray-50; + + .navbar-sub-nav, + .navbar-nav { + li { + > a:hover, + > a:focus, + > button:hover, + > button:focus { + color: $gl-text-color; + background-color: $gray-200; + } + } + + li.active, + li.dropdown.show { + > a, + > button { + color: $gl-text-color; + background-color: $gray-200; + } + } + } + + .search { + form { + background-color: $gray-100; + box-shadow: inset 0 0 0 1px $border-color; + + &:active, + &:hover { + background-color: $gray-100; + box-shadow: inset 0 0 0 1px $blue-200; + } + } + } + } + } } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 97698fefbee..2a97009e605 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -123,7 +123,7 @@ .markdown-area { border-radius: 0; background: $white; - border: 1px solid $gray-100; + border: 1px solid $gray-50; min-height: 140px; max-height: 500px; padding: 5px; diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 3c428cc352f..c2ab6f5b8c5 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -12,6 +12,7 @@ .select2-container.select2-drop-above { .select2-choice { background: $white; + color: $gl-text-color; border-color: $input-border; height: 34px; padding: $gl-vert-padding $gl-input-padding; @@ -58,6 +59,42 @@ } } + // Essentially we’re doing @include form-control-focus here (from + // bootstrap/scss/mixins/_forms.scss), except that the bootstrap mixin adds a + // `&:focus` selector and we’re never actually focusing the .select2-choice + // link nor the .select2-container, the Select2 library focuses an off-screen + // .select2-focusser element instead. + &.select2-container-active:not(.select2-dropdown-open) { + .select2-choice { + color: $input-focus-color; + background-color: $input-focus-bg; + border-color: $input-focus-border-color; + outline: 0; + } + + // Reusable focus “glow” box-shadow + @mixin form-control-focus-glow { + @if $enable-shadows { + box-shadow: $input-box-shadow, $input-focus-box-shadow; + } @else { + box-shadow: $input-focus-box-shadow; + } + } + + // Apply the focus “glow” shadow to the .select2-container if it also has + // the .block-truncated class as that applies an overflow: hidden, thereby + // hiding the glow of the nested .select2-choice element. + &.block-truncated { + @include form-control-focus-glow; + } + + // Apply the glow directly to the .select2-choice link if we’re not + // block-truncating the container. + &:not(.block-truncated) .select2-choice { + @include form-control-focus-glow; + } + } + &.is-invalid { ~ .invalid-feedback { display: block; @@ -72,6 +109,7 @@ .select2-drop, .select2-drop.select2-drop-above { + background: $white; box-shadow: 0 2px 4px $dropdown-shadow-color; border-radius: $border-radius-base; border: 1px solid $border-color; @@ -166,7 +204,8 @@ input { padding: $grid-size; - background: $white image-url('select2.png'); + background: transparent image-url('select2.png'); + color: $gl-text-color; background-clip: content-box; background-origin: content-box; background-repeat: no-repeat; diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss index 5c298d5a588..4f66d6bf354 100644 --- a/app/assets/stylesheets/framework/system_messages.scss +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -83,12 +83,20 @@ // right sidebar eg: mr page .nav-sidebar, .right-sidebar, - // navless pages' footer eg: login page - // navless pages' footer border eg: login page + // 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; } + + .content-wrapper { + margin-bottom: 16px; + } + + .boards-list { + height: calc(100vh - #{$header-height + $breadcrumb-min-height + $performance-bar-height + $system-footer-height + $gl-padding-32}); + } } .fullscreen-layout { diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index 5739f048e86..5bc2874ea05 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -47,7 +47,7 @@ table { } th { - @include gl-bg-gray-100; + @include gl-bg-gray-50; border-bottom: 0; &.wide { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 1afcbc6d514..6e07a2b5de1 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -545,6 +545,24 @@ } } } + + /* AsciiDoc(tor) built-in alignment roles */ + + .text-left { + text-align: left !important; + } + + .text-right { + text-align: right !important; + } + + .text-center { + text-align: center !important; + } + + .text-justify { + text-align: justify !important; + } } /** diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index ac4d431ea57..1536c5c3022 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -73,108 +73,106 @@ $size-scale: ( 'xl': #{70 * $grid-size} ); -/* - * Color schema - */ -$darken-normal-factor: 7%; -$darken-dark-factor: 10%; -$darken-border-factor: 5%; -$darken-border-dashed-factor: 25%; - -$white: #fff; -$white-normal: #f0f0f0; -$white-dark: #eaeaea; -$white-transparent: rgba(255, 255, 255, 0.8); - -$gray-lightest: #fdfdfd; -$gray-light: #fafafa; -$gray-lighter: #f9f9f9; -$gray-normal: #f5f5f5; -$gray-dark: darken($gray-light, $darken-dark-factor); -$gray-darker: #eee; -$gray-darkest: #c4c4c4; - -$purple: #6d49cb; -$purple-light: #ede8fb; - -$black: #000; -$black-transparent: rgba(0, 0, 0, 0.3); -$almost-black: #242424; - -$t-gray-a-02: rgba($black, 0.02); -$t-gray-a-04: rgba($black, 0.04); -$t-gray-a-06: rgba($black, 0.06); -$t-gray-a-08: rgba($black, 0.08); - -$gl-gray-100: #ddd; -$gl-gray-200: #ccc; -$gl-gray-350: #aaa; -$gl-gray-400: #999; -$gl-gray-500: #777; -$gl-gray-600: #666; -$gl-gray-700: #555; -$gl-gray-800: #333; - -$green-50: #f1fdf6; -$green-100: #dcf5e7; -$green-200: #b3e6c8; -$green-300: #75d09b; -$green-400: #37b96d; -$green-500: #1aaa55; -$green-600: #168f48; -$green-700: #12753a; -$green-800: #0e5a2d; -$green-900: #0a4020; -$green-950: #072b15; - -$blue-50: #f6fafe; -$blue-100: #e4f0fb; -$blue-200: #b8d6f4; -$blue-300: #73afea; -$blue-400: #418cd8; -$blue-500: #1f78d1; -$blue-600: #1b69b6; -$blue-700: #17599c; -$blue-800: #134a81; -$blue-900: #0f3b66; -$blue-950: #0a2744; - -$orange-50: #fffaf4; -$orange-100: #fff1de; -$orange-200: #fed69f; -$orange-300: #fdbc60; -$orange-400: #fca429; -$orange-500: #fc9403; -$orange-600: #de7e00; -$orange-700: #c26700; -$orange-800: #a35200; -$orange-900: #853c00; -$orange-950: #592800; - -$red-50: #fef6f5; -$red-100: #fbe5e1; -$red-200: #f2b4a9; -$red-300: #ea8271; -$red-400: #e05842; -$red-500: #db3b21; -$red-600: #c0341d; -$red-700: #a62d19; -$red-800: #8b2615; -$red-900: #711e11; -$red-950: #4b140b; - -$gray-10: #fafafa; -$gray-50: #f0f0f0; -$gray-100: #f2f2f2; -$gray-200: #dfdfdf; -$gray-300: #ccc; -$gray-400: #bababa; -$gray-500: #a7a7a7; -$gray-600: #919191; -$gray-700: #707070; -$gray-800: #4f4f4f; -$gray-900: #2e2e2e; -$gray-950: #1f1f1f; +// Color schema +$darken-normal-factor: 7% !default; +$darken-dark-factor: 10% !default; +$darken-border-factor: 5% !default; +$darken-border-dashed-factor: 25% !default; + +$white: #fff !default; +$white-normal: #f0f0f0 !default; +$white-dark: #eaeaea !default; +$white-transparent: rgba(255, 255, 255, 0.8) !default; + +$gray-lightest: #fdfdfd !default; +$gray-light: #fafafa !default; +$gray-lighter: #f9f9f9 !default; +$gray-normal: #f5f5f5 !default; +$gray-dark: darken($gray-light, $darken-dark-factor) !default; +$gray-darker: #eee !default; +$gray-darkest: #c4c4c4 !default; + +$purple: #6d49cb !default; +$purple-light: #ede8fb !default; + +$black: #000 !default; +$black-transparent: rgba(0, 0, 0, 0.3) !default; +$almost-black: #242424 !default; + +$t-gray-a-02: rgba($black, 0.02) !default; +$t-gray-a-04: rgba($black, 0.04) !default; +$t-gray-a-06: rgba($black, 0.06) !default; +$t-gray-a-08: rgba($black, 0.08) !default; + +$gl-gray-100: #ddd !default; +$gl-gray-200: #ccc !default; +$gl-gray-350: #aaa !default; +$gl-gray-400: #999 !default; +$gl-gray-500: #777 !default; +$gl-gray-600: #666 !default; +$gl-gray-700: #555 !default; +$gl-gray-800: #333 !default; + +$green-50: #f1fdf6 !default; +$green-100: #dcf5e7 !default; +$green-200: #263a2e !default; +$green-300: #75d09b !default; +$green-400: #37b96d !default; +$green-500: #1aaa55 !default; +$green-600: #168f48 !default; +$green-700: #12753a !default; +$green-800: #0e5a2d !default; +$green-900: #0a4020 !default; +$green-950: #072b15 !default; + +$blue-50: #f6fafe !default; +$blue-100: #e4f0fb !default; +$blue-200: #b8d6f4 !default; +$blue-300: #73afea !default; +$blue-400: #418cd8 !default; +$blue-500: #1f78d1 !default; +$blue-600: #1b69b6 !default; +$blue-700: #17599c !default; +$blue-800: #134a81 !default; +$blue-900: #0f3b66 !default; +$blue-950: #0a2744 !default; + +$orange-50: #fffaf4 !default; +$orange-100: #fff1de !default; +$orange-200: #fed69f !default; +$orange-300: #fdbc60 !default; +$orange-400: #fca429 !default; +$orange-500: #fc9403 !default; +$orange-600: #de7e00 !default; +$orange-700: #c26700 !default; +$orange-800: #a35200 !default; +$orange-900: #853c00 !default; +$orange-950: #592800 !default; + +$red-50: #fcf1ef !default; +$red-100: #fdd4cd !default; +$red-200: #fcb5aa !default; +$red-300: #f57f6c !default; +$red-400: #ec5941 !default; +$red-500: #dd2b0e !default; +$red-600: #c91c00 !default; +$red-700: #ae1800 !default; +$red-800: #8d1300 !default; +$red-900: #660e00 !default; +$red-950: #4d0a00 !default; + +$gray-10: #fafafa !default; +$gray-50: #f0f0f0 !default; +$gray-100: #dbdbdb !default; +$gray-200: #dfdfdf !default; +$gray-300: #ccc !default; +$gray-400: #bababa !default; +$gray-500: #a7a7a7 !default; +$gray-600: #919191 !default; +$gray-700: #707070 !default; +$gray-800: #4f4f4f !default; +$gray-900: #2e2e2e !default; +$gray-950: #1f1f1f !default; $greens: ( '50': $green-50, @@ -325,8 +323,8 @@ $theme-light-red-500: #c24b38; $theme-light-red-600: #b03927; $theme-light-red-700: #a62e21; -$border-white-light: darken($white, $darken-border-factor); -$border-white-normal: darken($white-normal, $darken-border-factor); +$border-white-light: darken($white, $darken-border-factor) !default; +$border-white-normal: darken($white-normal, $darken-border-factor) !default; $border-gray-light: darken($gray-light, $darken-border-factor); $border-gray-normal: darken($gray-normal, $darken-border-factor); @@ -335,7 +333,7 @@ $border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor); /* * UI elements */ -$border-color: #e5e5e5; +$border-color: $gray-200; $shadow-color: $t-gray-a-08; $well-expand-item: #e8f2f7; $well-inner-border: #eef0f2; @@ -356,7 +354,7 @@ $gl-text-color-secondary: $gray-700; $gl-text-color-tertiary: $gray-600; $gl-text-color-quaternary: #d6d6d6; $gl-text-color-inverted: $white; -$gl-text-color-secondary-inverted: rgba(255, 255, 255, 0.85); +$gl-text-color-secondary-inverted: rgba($white, 0.85); $gl-text-color-disabled: $gray-600; $gl-grayish-blue: #7f8fa4; $gl-gray-dark: #313236; @@ -435,7 +433,6 @@ $layout-link-gray: #7e7c7c; $btn-side-margin: 10px; $btn-sm-side-margin: 7px; $btn-margin-5: 5px; -$sidebar-block-hover-color: #ebebeb; $count-arrow-border: #dce0e5; $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; @@ -491,8 +488,8 @@ $line-number-select: #fbf2da; $line-target-blue: $blue-50; $line-select-yellow: #fcf8e7; $line-select-yellow-dark: #f0e2bd; -$dark-diff-match-bg: rgba(255, 255, 255, 0.3); -$dark-diff-match-color: rgba(255, 255, 255, 0.1); +$dark-diff-match-bg: rgba($white, 0.3); +$dark-diff-match-color: rgba($white, 0.1); $diff-image-info-color: #808080; $diff-view-modes-color: #808080; $diff-view-modes-border: #c1c1c1; @@ -520,7 +517,7 @@ $dropdown-shadow-color: rgba(#000, 0.1); $dropdown-title-btn-color: #bfbfbf; $dropdown-input-fa-color: #c7c7c7; $dropdown-input-focus-shadow: rgba($blue-300, 0.4); -$dropdown-loading-bg: rgba(#fff, 0.6); +$dropdown-loading-bg: rgba($white, 0.6); $dropdown-chevron-size: 10px; $dropdown-toggle-active-border-color: darken($border-color, 14%); $dropdown-fade-mask-height: 32px; @@ -534,9 +531,9 @@ $filtered-search-term-shadow-color: rgba(0, 0, 0, 0.09); /* * Contextual Sidebar */ -$link-active-background: rgba(0, 0, 0, 0.04); -$link-hover-background: rgba(0, 0, 0, 0.06); -$inactive-badge-background: rgba(0, 0, 0, 0.08); +$link-active-background: rgba($black, 0.04); +$link-hover-background: rgba($gray-900, 0.06); +$inactive-badge-background: rgba($black, 0.08); $sidebar-toggle-height: 60px; $sidebar-toggle-width: 40px; $sidebar-milestone-toggle-bottom-margin: 10px; @@ -544,8 +541,8 @@ $sidebar-milestone-toggle-bottom-margin: 10px; /* * Buttons */ -$btn-active-gray: #ececec; -$btn-active-gray-light: #e4e7ed; +$btn-active-gray: $gray-50; +$btn-active-gray-light: $gray-50; $btn-white-active: #848484; $gl-btn-padding: 10px; $gl-btn-line-height: 16px; @@ -602,12 +599,12 @@ $note-icon-gutter-width: 55px; /* * Identicon */ -$identicon-red: #ffebee; -$identicon-purple: #f3e5f5; -$identicon-indigo: #e8eaf6; -$identicon-blue: #e3f2fd; -$identicon-teal: #e0f2f1; -$identicon-orange: #fbe9e7; +$identicon-red: #ffebee !default; +$identicon-purple: #f3e5f5 !default; +$identicon-indigo: #e8eaf6 !default; +$identicon-blue: #e3f2fd !default; +$identicon-teal: #e0f2f1 !default; +$identicon-orange: #fbe9e7 !default; /* * Calendar @@ -719,8 +716,8 @@ $accepting-mr-label-color: #69d100; /* * Issues */ -$issues-today-bg: #f3fff2; -$issues-today-border: #e1e8d5; +$issues-today-bg: #f3fff2 !default; +$issues-today-border: #e1e8d5 !default; $compare-display-color: #888; /* @@ -871,6 +868,7 @@ $priority-label-empty-state-width: 114px; Popovers */ $popover-max-width: 384px; +$popover-box-shadow: 0 2px 3px 1px $gray-200; /* Issues Analytics diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index ef75dabbda4..c7a50bdb5a3 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -55,3 +55,26 @@ $tooltip-padding-y: 0.5rem; $tooltip-padding-x: 0.75rem; $tooltip-arrow-height: 0.5rem; $tooltip-arrow-width: 1rem; +$b-table-sort-icon-bg-descending: url('data:image/svg+xml, <svg \ + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="4 0 8 16"> \ + <path style="fill: #666;" fill-rule="evenodd" d="M11.707085,11.7071 \ + L7.999975,15.4142 L4.292875,11.7071 C3.902375,11.3166 3.902375, \ + 10.6834 4.292875,10.2929 C4.683375,9.90237 \ + 5.316575,9.90237 5.707075,10.2929 L6.999975, \ + 11.5858 L6.999975,2 C6.999975,1.44771 \ + 7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771 \ + 8.999975,2 L8.999975,11.5858 L10.292865,10.2929 C10.683395 \ + ,9.90237 11.316555,9.90237 11.707085,10.2929 \ + C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 Z"/> \ + </svg>') !default; +$b-table-sort-icon-bg-ascending: url('data:image/svg+xml,<svg \ + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="4 0 8 16"> \ + <path style="fill: #666;" fill-rule="evenodd" d="M4.29289,4.2971 L8,0.59 \ + L11.7071,4.2971 C12.0976,4.6876 \ + 12.0976,5.3208 11.7071,5.7113 C11.3166,6.10183 10.6834, \ + 6.10183 10.2929,5.7113 L9,4.4184 L9,14.0042 C9,14.55649 \ + 8.55228,15.0042 8,15.0042 C7.44772,15.0042 7,14.55649 \ + 7,14.0042 L7,4.4184 L5.70711,5.7113 C5.31658,6.10183 4.68342,6.10183 4.29289,5.7113 \ + C3.90237,5.3208 3.90237,4.6876 4.29289,4.2971 Z"/> \ + </svg> ') !default; +$b-table-sort-icon-bg-not-sorted: ''; diff --git a/app/assets/stylesheets/notify.scss b/app/assets/stylesheets/notify.scss index 2bf823993d7..6320c10fb51 100644 --- a/app/assets/stylesheets/notify.scss +++ b/app/assets/stylesheets/notify.scss @@ -36,5 +36,5 @@ pre.commit-message { } .gl-label-text-dark { - color: $gl-gray-800; + color: $gl-text-color; } diff --git a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss index 5675835a622..0b847902525 100644 --- a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss @@ -27,8 +27,7 @@ z-index: 2; } - .is-readonly, - .editor.original { + .is-readonly .editor.original { .view-lines { cursor: default; } diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss index e4c01c2bd6c..2b82b2226c6 100644 --- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss @@ -1,9 +1,15 @@ // ------- // Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes // ------- -.ide.theme-dark { - a:not(.btn) { - color: var(--ide-link-color); +.ide { + $bs-input-focus-border: #80bdff; + $bs-input-focus-box-shadow: rgba(0, 123, 255, 0.25); + + a:not(.btn), + .btn-link:hover, + .btn-link:focus, + .btn-link:active { + color: var(--ide-link-color, $blue-600); } h1, @@ -19,156 +25,207 @@ .context-header > a, input, textarea, - .md-area.is-focused, .dropdown-menu li button, .dropdown-menu-selectable li a.is-active, .dropdown-menu-inner-title, - .dropdown-menu-inner-content, - .nav-links:not(.quick-links) li:not(.md-header-toolbar) a, - .nav-links:not(.quick-links) li:not(.md-header-toolbar) a:hover, - .nav-links:not(.quick-links) li:not(.md-header-toolbar) a.active .badge.badge-pill, - .nav-links:not(.quick-links) li:not(.md-header-toolbar) a:hover .badge.badge-pill, - .badge.badge-pill, .bs-callout, .ide-pipeline .top-bar, - .ide-pipeline .top-bar .controllers .controllers-buttons { - color: var(--ide-text-color); + .ide-pipeline .top-bar .controllers .controllers-buttons, + .controllers-buttons svg, + .nav-links li a.active, + .md-area.is-focused { + color: var(--ide-text-color, $gl-text-color); } - .drag-handle:hover, - .card-header .badge.badge-pill { - background-color: var(--ide-dropdown-hover-background); + .badge.badge-pill { + color: var(--ide-text-color, $gray-800); + background-color: var(--ide-background, $badge-bg); } + .nav-links:not(.quick-links) li:not(.md-header-toolbar) a, + .dropdown-menu-inner-content, .file-row .file-row-icon svg, - .file-row:hover .file-row-icon svg, - .controllers-buttons svg { - color: var(--ide-text-color-secondary); + .file-row:hover .file-row-icon svg { + color: var(--ide-text-color-secondary, $gl-text-color-secondary); + } + + .nav-links:not(.quick-links) li:not(.md-header-toolbar) { + &:hover a, + &.active a, + a:hover, + a.active { + &, + .badge.badge-pill { + color: var(--ide-text-color, $black); + border-color: var(--ide-input-border, $gray-darkest); + } + } + } + + .drag-handle:hover { + background-color: var(--ide-dropdown-hover-background, $white-normal); + } + + .card-header { + background-color: var(--ide-background, $white); + + .badge.badge-pill { + background-color: var(--ide-dropdown-hover-background, $badge-bg); + } } .text-secondary { - color: var(--ide-text-color-secondary) !important; + color: var(--ide-text-color-secondary, $gl-text-color-secondary) !important; } input[type='search']::placeholder, input[type='text']::placeholder, - textarea::placeholder, + textarea::placeholder { + color: var(--ide-input-border, $gl-text-color-tertiary); + } + .dropdown-input .fa { - color: var(--ide-input-border); + color: var(--ide-input-border, $dropdown-input-fa-color); } .ide-nav-form .input-icon { - color: var(--ide-input-border); + color: var(--ide-input-border, $dropdown-input-fa-color); + } + + code { + background-color: var(--ide-background, $gray-100); } - code, - .badge.badge-pill, - .card-header, .bs-callout, .ide-pipeline .top-bar, .ide-terminal .top-bar { - background-color: var(--ide-background); + background-color: var(--ide-background, $gray-light); } .bs-callout { - border-color: var(--ide-dropdown-background); + border-color: var(--ide-dropdown-background, $border-color); code { - background-color: var(--ide-dropdown-background); + background-color: var(--ide-dropdown-background, $gray-100); } } - .nav-links:not(.quick-links) li:not(.md-header-toolbar) a:hover { - border-color: var(--ide-dropdown-hover-background); + .common-note-form .md-area { + border-color: var(--ide-input-border, $border-color); } - .common-note-form .md-area { - border-color: var(--ide-input-border); + .md table:not(.code) tr th { + background-color: var(--ide-highlight-background, $gray-100); } &, - .md table:not(.code) tr th, - .common-note-form .md-area, - .card { - background-color: var(--ide-highlight-background); + .card, + .common-note-form .md-area { + background-color: var(--ide-highlight-background, $white); } .card, .card-header, .ide-terminal .top-bar, .ide-pipeline .top-bar { - border-color: var(--ide-border-color); + border-color: var(--ide-border-color, $border-color); + } + + hr { + border-color: var(--ide-border-color, darken($gray-normal, 8%)); } - hr, .md h1, .md h2, .md blockquote, - pre, .md table:not(.code) tbody td, .md table:not(.code) tr th, - .nav-links:not(.quick-links) { - border-color: var(--ide-border-color-alt); + .nav-links:not(.quick-links), + .common-note-form .md-area.is-focused .nav-links { + border-color: var(--ide-border-color-alt, $white-dark); } - .ide-sidebar-link.active { - color: var(--ide-highlight-accent); - box-shadow: inset 3px 0 var(--ide-highlight-accent); + pre { + border-color: var(--ide-border-color-alt, $gray-200); - &.is-right { - box-shadow: inset -3px 0 var(--ide-highlight-accent); + code { + background-color: var(--ide-border-color, inherit); } } - .nav-links li.active a, - .nav-links li a.active { - border-color: var(--ide-highlight-accent); - color: var(--ide-text-color); - } + // highlight accents (based on navigation theme) should only apply + // in the default white theme and "none" theme. + &:not(.theme-white):not(.theme-none) { + .ide-sidebar-link.active { + color: var(--ide-highlight-accent, $gl-text-color); + box-shadow: inset 3px 0 var(--ide-highlight-accent, $gl-text-color); + + &.is-right { + box-shadow: inset -3px 0 var(--ide-highlight-accent, $gl-text-color); + } + } + + .nav-links li.active a, + .nav-links li a.active { + border-color: var(--ide-highlight-accent, $gl-text-color); + } - .avatar-container { - &, - .avatar { - color: var(--ide-text-color); - background-color: var(--ide-highlight-background); - border-color: var(--ide-highlight-background); + .dropdown-menu .nav-links li a.active { + border-color: var(--ide-highlight-accent, $gl-text-color); + } + + // for other themes, suppress different avatar default colors for simplicity + .avatar-container { + &, + .avatar { + color: var(--ide-text-color, $gl-text-color); + background-color: var(--ide-highlight-background, $white); + border-color: var(--ide-highlight-background, rgba($black, $gl-avatar-border-opacity)); + } } } input[type='text'], input[type='search'], .filtered-search-box { - border-color: var(--ide-input-border); - background: var(--ide-input-background) !important; + border-color: var(--ide-input-border, $border-color); + background: var(--ide-input-background, $white) !important; + } + + input[type='text']:not([disabled]):not([readonly]):focus, + .md-area.is-focused { + border-color: var(--ide-input-border, $bs-input-focus-border); + box-shadow: 0 0 0 3px var(--ide-dropdown-background, $bs-input-focus-box-shadow); } input[type='text'], input[type='search'], .filtered-search-box, textarea { - color: var(--ide-input-color) !important; + color: var(--ide-input-color, $gl-text-color) !important; } .filtered-search-box input[type='search'] { - border-color: transparent; + border-color: transparent !important; + box-shadow: none !important; } .filtered-search-token .value-container, .filtered-search-term .value-container { - background-color: var(--ide-dropdown-hover-background); - color: var(--ide-text-color); + background-color: var(--ide-dropdown-hover-background, $white-normal); + color: var(--ide-text-color, $gl-text-color); &:hover { - background-color: var(--ide-input-border); + background-color: var(--ide-input-border, $gray-200); } } @function calc-btn-hover-padding($original-padding, $original-border: 1px) { - @return calc(#{$original-padding + $original-border} - var(--ide-btn-hover-border-width)); + @return calc(#{$original-padding + $original-border} - var(--ide-btn-hover-border-width, #{$original-border})); } .btn:not(.btn-link):not([disabled]):hover { - border-width: var(--ide-btn-hover-border-width); + border-width: var(--ide-btn-hover-border-width, 1px); padding: calc-btn-hover-padding(6px) calc-btn-hover-padding(10px); } @@ -180,53 +237,71 @@ padding: calc-btn-hover-padding(6px) 0; } - .btn-inverted, .btn-default, .dropdown, .dropdown-menu-toggle { - background-color: var(--ide-input-background) !important; - color: var(--ide-input-color) !important; - border-color: var(--ide-btn-default-border); + background-color: var(--ide-input-background, $white) !important; + color: var(--ide-input-color, $gl-text-color) !important; + border-color: var(--ide-btn-default-border, $border-color); } - .btn-inverted, - .btn-default { + .dropdown-menu-toggle { + border-color: var(--ide-btn-default-border, $gray-darkest); + &:hover, &:focus { - border-color: var(--ide-btn-default-hover-border) !important; + background-color: var(--ide-dropdown-btn-hover-background, $gray-darker) !important; + border-color: var(--ide-dropdown-btn-hover-border, $gray-darkest) !important; } } - .dropdown, - .dropdown-menu-toggle { + // In IDE, the only inverted buttons are `.btn-remove` + .btn-inverted.btn-remove { + color: var(--ide-input-color, $red-500) !important; + background-color: var(--ide-input-background, $white) !important; + border-color: var(--ide-btn-default-border, $red-500); + &:hover, &:focus { - background-color: var(--ide-dropdown-btn-hover-background) !important; - border-color: var(--ide-dropdown-btn-hover-border) !important; + color: var(--ide-input-color, $red-700) !important; + background-color: var(--ide-input-background, $red-100) !important; + border-color: var(--ide-btn-default-hover-border, $red-500) !important; } - } - .dropdown-menu { - color: var(--ide-text-color); - border-color: var(--ide-background); - background-color: var(--ide-dropdown-background); + &:active { + color: var(--ide-input-color, $red-800) !important; + background-color: var(--ide-input-background, $red-200) !important; + border-color: var(--ide-btn-default-hover-border, $red-600) !important; + } + } - .divider, - .nav-links:not(.quick-links) { - background-color: var(--ide-dropdown-hover-background); - border-color: var(--ide-dropdown-hover-background); + .btn-default { + &:hover, + &:focus { + border-color: var(--ide-btn-default-hover-border, $border-white-normal) !important; + background-color: var(--ide-input-background, $white-normal) !important; } - .nav-links li a.active { - border-color: var(--ide-highlight-accent); + &:active, + .active { + border-color: var(--ide-btn-default-hover-border, $border-white-normal) !important; + background-color: var(--ide-input-background, $white-dark) !important; } + } - .nav-links:not(.quick-links) li:not(.md-header-toolbar) a { - color: var(--ide-text-color); + .dropdown-menu { + color: var(--ide-text-color, $gl-text-color); + border-color: var(--ide-background, $border-color); + background-color: var(--ide-dropdown-background, $white); - &.active { - color: var(--ide-text-color); - } + .nav-links:not(.quick-links) { + background-color: var(--ide-dropdown-hover-background, $white); + border-color: var(--ide-dropdown-hover-background, $border-color); + } + + .divider { + background-color: var(--ide-dropdown-hover-background, $gray-200); + border-color: var(--ide-dropdown-hover-background, $gray-200); } li > a:not(.disable-hover):hover, @@ -234,75 +309,88 @@ li button:not(.disable-hover):hover, li button:not(.disable-hover):focus, li button.is-focused { - background-color: var(--ide-dropdown-hover-background); - color: var(--ide-text-color); + background-color: var(--ide-dropdown-hover-background, $gray-darker); + color: var(--ide-text-color, $gl-text-color); } } .dropdown-title, .dropdown-input { - border-color: var(--ide-dropdown-hover-background) !important; + border-color: var(--ide-dropdown-hover-background, $gray-200) !important; } .btn-primary, .btn-info { - background-color: var(--ide-btn-primary-background); - border-color: var(--ide-btn-primary-border) !important; + background-color: var(--ide-btn-primary-background, $blue-500); + border-color: var(--ide-btn-primary-border, $blue-600) !important; &:hover, &:focus { - border-color: var(--ide-btn-primary-hover-border) !important; + background-color: var(--ide-btn-primary-background, $blue-600); + border-color: var(--ide-btn-primary-hover-border, $blue-700) !important; + } + + &:active, + &.active { + background-color: var(--ide-btn-primary-background, $blue-700); + border-color: var(--ide-btn-primary-hover-border, $blue-800) !important; } } .btn-success { - background-color: var(--ide-btn-success-background); - border-color: var(--ide-btn-success-border) !important; + background-color: var(--ide-btn-success-background, $green-500); + border-color: var(--ide-btn-success-border, $green-600) !important; &:hover, &:focus { - border-color: var(--ide-btn-success-hover-border) !important; + background-color: var(--ide-btn-success-background, $green-600); + border-color: var(--ide-btn-success-hover-border, $green-700) !important; + } + + &:active, + &.active { + background-color: var(--ide-btn-success-background, $green-700); + border-color: var(--ide-btn-success-hover-border, $green-800) !important; } } .btn[disabled] { - background: var(--ide-btn-default-background) !important; - border: 1px solid var(--ide-btn-disabled-border) !important; - color: var(--ide-btn-disabled-color) !important; + background-color: var(--ide-btn-default-background, $gray-light) !important; + border: 1px solid var(--ide-btn-disabled-border, $gray-200) !important; + color: var(--ide-btn-disabled-color, $gl-text-color-disabled) !important; } - pre code, .md table:not(.code) tbody { - background-color: var(--ide-border-color); + background-color: var(--ide-border-color, $white); } .animation-container { [class^='skeleton-line-'] { - background-color: var(--ide-animation-gradient-1); + background-color: var(--ide-animation-gradient-1, $gray-100); &::after { background-image: linear-gradient(to right, - var(--ide-animation-gradient-1) 0%, - var(--ide-animation-gradient-2) 20%, - var(--ide-animation-gradient-1) 40%, - var(--ide-animation-gradient-1) 100%); + var(--ide-animation-gradient-1, $gray-100) 0%, + var(--ide-animation-gradient-2, $gray-10) 20%, + var(--ide-animation-gradient-1, $gray-100) 40%, + var(--ide-animation-gradient-1, $gray-100) 100%); } } } .idiff.addition { - background-color: var(--ide-diff-insert); + background-color: var(--ide-diff-insert, $line-added-dark); } .idiff.deletion { - background-color: var(--ide-diff-remove); + background-color: var(--ide-diff-remove, $line-removed-dark); } -} -.navbar.theme-dark { - border-bottom-color: transparent; + ~ .popover { + box-shadow: none; + } } -.theme-dark ~ .popover { - box-shadow: none; +.navbar:not(.theme-white):not(.theme-none) { + border-bottom-color: transparent; } diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 61914740ac0..9c92f891834 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -5,6 +5,7 @@ @import './ide_theme_overrides'; @import './ide_themes/dark'; +@import './ide_themes/solarized-dark'; $search-list-icon-width: 18px; $ide-activity-bar-width: 60px; @@ -24,6 +25,13 @@ $ide-commit-header-height: 48px; @include str-truncated(250px); } +.ide-layout { + // Fix for iOS 13+, the height of the page is actually less than + // 100vh because of the presence of the bottom bar + max-height: 100%; + position: fixed; +} + .ide-view { position: relative; margin-top: 0; @@ -65,6 +73,7 @@ $ide-commit-header-height: 48px; flex-direction: column; flex: 1; border-left: 1px solid var(--ide-border-color, $white-dark); + border-right: 1px solid var(--ide-border-color, $white-dark); overflow: hidden; } @@ -88,7 +97,7 @@ $ide-commit-header-height: 48px; &.active { background-color: var(--ide-highlight-background, $white); - border-bottom-color: var(--ide-border-color, $white); + border-bottom-color: transparent; } &:not(.disabled) { @@ -281,7 +290,6 @@ $ide-commit-header-height: 48px; .multi-file-commit-panel { display: flex; position: relative; - width: 340px; padding: 0; background-color: var(--ide-background, $gray-light); @@ -386,7 +394,7 @@ $ide-commit-header-height: 48px; &:hover, &:focus { - background: var(--ide-background, $gray-100); + background: var(--ide-background, $gray-50); outline: 0; } @@ -558,7 +566,7 @@ $ide-commit-header-height: 48px; &:hover { color: var(--ide-text-color, $gl-text-color); - background-color: var(--ide-background-hover, $gray-100); + background-color: var(--ide-background-hover, $gray-50); } &:focus { @@ -584,14 +592,15 @@ $ide-commit-header-height: 48px; background: var(--ide-highlight-background, $white); } - &.is-right { - padding-right: $gl-padding; - padding-left: $gl-padding + 1px; + } - &::after { - right: auto; - left: -1px; - } + &.is-right { + padding-right: $gl-padding; + padding-left: $gl-padding + 1px; + + &::after { + right: auto; + left: -1px; } } } @@ -872,26 +881,21 @@ $ide-commit-header-height: 48px; } .ide-sidebar { - width: auto; min-width: 60px; } .ide-right-sidebar { - .ide-activity-bar { - border-left: 1px solid var(--ide-border-color, $white-dark); - } - .multi-file-commit-panel-inner { - width: 350px; padding: $grid-size 0; background-color: var(--ide-highlight-background, $white); - border-left: 1px solid var(--ide-border-color, $white-dark); + border-right: 1px solid var(--ide-border-color, $white-dark); } .ide-right-sidebar-jobs-detail { padding-bottom: 0; } + .ide-right-sidebar-terminal, .ide-right-sidebar-clientside { padding: 0; } @@ -901,7 +905,7 @@ $ide-commit-header-height: 48px; @include ide-trace-view(); svg { - --svg-status-bg: var(--ide-background, $white); + --svg-status-bg: var(--ide-background, #{$white}); } .empty-state { @@ -1043,7 +1047,7 @@ $ide-commit-header-height: 48px; .ide-entry-dropdown-toggle { padding: $gl-padding-4; color: var(--ide-text-color, $gl-text-color); - background-color: var(--ide-background, $gray-100); + background-color: var(--ide-background, $gray-50); &:hover { background-color: var(--ide-file-row-btn-hover-background, $gray-200); @@ -1144,12 +1148,12 @@ $ide-commit-header-height: 48px; } .file-row.is-active { - background: var(--ide-background, $gray-100); + background: var(--ide-background, $gray-50); } .file-row:hover, .file-row:focus { - background: var(--ide-background, $gray-100); + background: var(--ide-background, $gray-50); .ide-new-btn { display: block; @@ -1159,3 +1163,22 @@ $ide-commit-header-height: 48px; fill: var(--ide-text-color-secondary, $gl-text-color-secondary); } } + +.ide-terminal { + @include ide-trace-view(); + + .terminal-wrapper { + background: $black; + color: $gray-darkest; + overflow: hidden; + } + + .xterm { + height: 100%; + padding: $grid-size; + } + + .xterm-viewport { + overflow-y: auto; + } +} diff --git a/app/assets/stylesheets/page_bundles/ide_themes/README.md b/app/assets/stylesheets/page_bundles/ide_themes/README.md index 535179cc4c2..82e89aef49b 100644 --- a/app/assets/stylesheets/page_bundles/ide_themes/README.md +++ b/app/assets/stylesheets/page_bundles/ide_themes/README.md @@ -32,19 +32,7 @@ To add a new theme, follow the following steps: 3. Copy over all the CSS variables from `_dark.scss` to `_solarized_dark.scss` and assign them your own values. Put them under the selector `.ide.theme-solarized-dark`. 4. Import this newly created SCSS file in `ide.scss` file in the parent directory. -5. To make sure the variables apply to to your theme, add the selector `.ide.theme-solarized-dark` to the top - of `_ide_theme_overrides.scss` file. The file should now look like this: - - ```scss - .ide.theme-dark, - .ide.theme-solarized-dark { - /* file contents */ - } - ``` - - This step is temporary until all CSS variables in that file have their - default values assigned. -6. That's it! Raise a merge request with your newly added theme. +5. That's it! Raise a merge request with your newly added theme. ## Modifying Monaco Themes diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss new file mode 100644 index 00000000000..a58a0ed9475 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss @@ -0,0 +1,50 @@ +// ------- +// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes +// ------- +.ide.theme-solarized-dark { + --ide-border-color: #002c38; + --ide-border-color-alt: var(--ide-background); + --ide-highlight-accent: #fff; + --ide-text-color: #ddd; + --ide-text-color-secondary: #ddd; + --ide-background: #004152; + --ide-background-hover: #003b4d; + --ide-highlight-background: #003240; + --ide-link-color: #73b9ff; + --ide-footer-background: var(--ide-highlight-background); + + --ide-input-border: #d8d8d8; + --ide-input-background: transparent; + --ide-input-color: #fff; + + --ide-btn-default-background: transparent; + --ide-btn-default-border: var(--ide-input-border); + --ide-btn-default-hover-border: #d8d8d8; + + --ide-btn-primary-background: #1068bf; + --ide-btn-primary-border: #428fdc; + --ide-btn-primary-hover-border: #63a6e9; + + --ide-btn-success-background: #217645; + --ide-btn-success-border: #108548; + --ide-btn-success-hover-border: #2da160; + + --ide-btn-disabled-border: rgba(223, 223, 223, 0.24); + --ide-btn-disabled-color: rgba(145, 145, 145, 0.48); + + --ide-btn-hover-border-width: 2px; + + --ide-dropdown-background: #004c61; + --ide-dropdown-hover-background: #00617a; + + --ide-dropdown-btn-hover-border: #e9ecef; + --ide-dropdown-btn-hover-background: var(--ide-background-hover); + + --ide-file-row-btn-hover-background: #005a73; + + --ide-diff-insert: rgba(155, 185, 85, 0.2); + --ide-diff-remove: rgba(255, 0, 0, 0.2); + + --ide-animation-gradient-1: var(--ide-file-row-btn-hover-background); + --ide-animation-gradient-2: var(--ide-dropdown-hover-background); + } diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/pages/alert_management/details.scss index 89219e41644..591a26e5941 100644 --- a/app/assets/stylesheets/pages/alert_management/details.scss +++ b/app/assets/stylesheets/pages/alert_management/details.scss @@ -35,8 +35,39 @@ } @include media-breakpoint-down(xs) { - .alert-details-create-issue-button { + .alert-details-issue-button { width: 100%; } } + + .toggle-sidebar-mobile-button { + right: 0; + } + + .dropdown-menu-toggle { + &:hover { + background-color: $white; + } + } + + .assignee-dropdown-item { + .dropdown-item { + display: flex; + align-items: center; + + &::before { + top: 50% !important; + } + + &.is-active { + &:last-child { + border-bottom: 1px solid $gray-200; + } + } + } + } + + .note-header-info { + margin-top: 1px; + } } diff --git a/app/assets/stylesheets/pages/alert_management/list.scss b/app/assets/stylesheets/pages/alert_management/list.scss index dc181342def..c1ea9b7604a 100644 --- a/app/assets/stylesheets/pages/alert_management/list.scss +++ b/app/assets/stylesheets/pages/alert_management/list.scss @@ -1,22 +1,4 @@ .alert-management-list { - // consider adding these stateful variants to @gitlab-ui - // https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1178 - .hover-bg-blue-50:hover { - background-color: $blue-50; - } - - .hover-gl-cursor-pointer:hover { - cursor: pointer; - } - - .hover-gl-border-b-solid:hover { - @include gl-border-b-solid; - } - - .hover-gl-border-blue-200:hover { - border-color: $blue-200; - } - // these styles need to be deleted once GlTable component looks in GitLab same as in @gitlab/ui table { color: $gray-700; @@ -26,25 +8,52 @@ outline: none; } + > :not([aria-sort='none']).b-table-sort-icon-left:hover::before { + content: '' !important; + } + td, th { - @include gl-p-5; - border: 0; // Remove cell border styling so that we can set border styling per row - - &.event-count { - @include gl-pr-9; - } + // TODO: There is no gl-pl-9 utlity for this padding, to be done and then removed. + padding-left: 1.25rem; + @include gl-py-5; + @include gl-outline-none; + @include gl-relative; } th { background-color: transparent; font-weight: $gl-font-weight-bold; color: $gl-gray-600; + + &:hover::before { + left: 3%; + top: 34%; + @include gl-absolute; + content: url("data:image/svg+xml,%3Csvg \ + xmlns='http://www.w3.org/2000/svg' \ + width='14' height='14' viewBox='0 0 16 \ + 16'%3E%3Cpath fill='%23BABABA' fill-rule='evenodd' \ + d='M11.707085,11.7071 L7.999975,15.4142 L4.292875,11.7071 \ + C3.902375,11.3166 3.902375,10.6834 \ + 4.292875,10.2929 C4.683375,9.90237 \ + 5.316575,9.90237 5.707075,10.2929 \ + L6.999975,11.5858 L6.999975,2 C6.999975,1.44771 \ + 7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771 \ + 8.999975,2 L8.999975,11.5858 L10.292865,10.2929 \ + C10.683395,9.90237 11.316555,9.90237 11.707085,10.2929 \ + C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 \ + Z'/%3E%3C/svg%3E%0A"); + } } + } - &:last-child { - td { - @include gl-border-0; + @include media-breakpoint-up(md) { + tr { + &:last-child { + td { + @include gl-border-0; + } } } } @@ -52,21 +61,31 @@ @include media-breakpoint-down(sm) { .alert-management-table { - .table-col { - min-height: 68px; + tr { + border-top: 0; - &:last-child { - background-color: $gray-10; + .table-col { + min-height: 68px; - &::before { - content: none !important; - } + &:last-child { + background-color: $gray-10; + + &::before { + content: none !important; + } - div { - width: 100% !important; - padding: 0 !important; + div { + width: 100% !important; + padding: 0 !important; + } } } + + &:hover { + background-color: $white; + border-color: $white; + border-bottom-style: none; + } } } } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index d755170fe1f..3e680c59910 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -84,17 +84,22 @@ .board-title-caret { cursor: pointer; border-radius: $border-radius-default; - padding: 4px; + line-height: $gl-spacing-scale-5; + height: $gl-spacing-scale-5; + + &.btn svg { + top: 0; + } &:hover { - background-color: $gray-dark; + background-color: $gray-50; transition: background-color 0.1s linear; } } &:not(.is-collapsed) { .board-title-caret { - margin: 0 $gl-padding-4 0 -10px; + margin-right: $gl-padding-4; } } @@ -155,7 +160,7 @@ .board-inner { font-size: $issue-boards-font-size; background: $gray-light; - border: 1px solid $border-color; + border: 1px solid $gray-100; } .board-header { @@ -186,8 +191,8 @@ .board-title { align-items: center; font-size: 1em; - border-bottom: 1px solid $border-color; - padding: $gl-padding-8 $gl-padding; + border-bottom: 1px solid $gray-100; + padding: $gl-padding-8; .js-max-issue-size::before { content: '/'; @@ -199,13 +204,13 @@ } .board-delete { - margin-right: 10px; color: $gray-darkest; background-color: transparent; outline: 0; &:hover { color: $blue-600; + box-shadow: none; } } @@ -246,7 +251,7 @@ .board-card { background: $white; - border: 1px solid $gray-200; + border: 1px solid $gray-100; box-shadow: 0 1px 2px $issue-boards-card-shadow; line-height: $gl-padding; list-style: none; @@ -541,7 +546,8 @@ cursor: help; } -.board-labels-toggle-wrapper { +.board-labels-toggle-wrapper, +.board-swimlanes-toggle-wrapper { /** * Make the wrapper the same height as a button so it aligns properly when the * filtered-search-box input element increases in size on Linux smaller breakpoints @@ -572,3 +578,8 @@ top: 0; } } + +.board-epics-swimlanes { + overflow-x: auto; + min-height: 600px; +} diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index ddd1a373e2a..98d74a9aaa2 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -446,7 +446,7 @@ table.code { vertical-align: top; span { - white-space: pre-wrap; + white-space: break-spaces; &.context-cell { display: inline-block; diff --git a/app/assets/stylesheets/pages/experience_level.scss b/app/assets/stylesheets/pages/experience_level.scss new file mode 100644 index 00000000000..e57ad6321a5 --- /dev/null +++ b/app/assets/stylesheets/pages/experience_level.scss @@ -0,0 +1,29 @@ +.signup-page[data-page^='registrations:experience_levels'] { + $card-shadow-color: rgba($black, 0.2); + + .page-wrap { + background-color: $white; + } + + .card-deck { + max-width: 828px; + } + + .card { + transition: box-shadow 0.3s ease-in-out; + } + + .card:hover { + box-shadow: 0 $gl-spacing-scale-3 $gl-spacing-scale-5 $card-shadow-color; + } + + @media (min-width: $breakpoint-sm) { + .card-deck .card { + margin: 0 $gl-spacing-scale-3; + } + } + + .stretched-link:hover { + text-decoration: none; + } +} diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index beb0ea27de0..c309c8d157a 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -14,17 +14,12 @@ flex-direction: column; margin: 0; - .group-row-contents .controls > .btn:last-child { - margin: 0; - } - li { .title { font-weight: 600; } a { - color: $gray-900; text-decoration: none; &:hover { @@ -42,8 +37,6 @@ } .group-row { - @include basic-list-stats; - .description p { margin-bottom: 0; color: $gl-text-color-secondary; @@ -56,6 +49,12 @@ } } +.save-group-loader { + margin-top: $gl-padding-50; + margin-bottom: $gl-padding-50; + color: $gl-gray-700; +} + .group-nav-container .nav-controls { .group-filter-form { flex: 1 1 auto; @@ -454,29 +453,10 @@ table.pipeline-project-metrics tr td { min-width: 30px; } - > span:last-child { - margin-right: 0; - } - .stat-value { margin: 2px 0 0 5px; } } - - .controls { - flex-basis: 90px; - - > .btn { - margin: 0 $btn-side-margin 0 0; - color: $gl-text-color-secondary; - } - } - - .metadata { - @include media-breakpoint-up(md) { - flex-basis: 240px; - } - } } .project-row-contents .stats { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index b241d0a2bdc..b1e849143b0 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -396,7 +396,7 @@ overflow: hidden; &:hover { - background-color: $sidebar-block-hover-color; + background-color: $gray-200; } &.issuable-sidebar-header { @@ -754,7 +754,8 @@ margin-right: 10px; min-width: 0; - .issue-weight-icon { + .issue-weight-icon, + .issue-estimate-icon { vertical-align: sub; } } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 0dd25ec5360..0c349ab73a3 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -304,6 +304,72 @@ ul.related-merge-requests > li { } } +.issue-sticky-header { + @include gl-left-0; + @include gl-w-full; + top: $header-height; + + // collapsed right sidebar + @include media-breakpoint-up(sm) { + width: calc(100% - #{$gutter-collapsed-width}); + } + + .issue-sticky-header-text { + max-width: $limited-layout-width; + } +} + +.with-performance-bar .issue-sticky-header { + top: $header-height + $performance-bar-height; +} + +@include media-breakpoint-up(md) { + // collapsed left sidebar + collapsed right sidebar + .issue-sticky-header { + left: $contextual-sidebar-collapsed-width; + width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width}); + } + + // collapsed left sidebar + expanded right sidebar + .right-sidebar-expanded .issue-sticky-header { + width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width}); + } +} + +@include media-breakpoint-up(xl) { + // expanded left sidebar + collapsed right sidebar + .issue-sticky-header { + left: $contextual-sidebar-width; + width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-collapsed-width}); + } + + // collapsed left sidebar + collapsed right sidebar + .page-with-icon-sidebar .issue-sticky-header { + left: $contextual-sidebar-collapsed-width; + width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width}); + } + + // expanded left sidebar + expanded right sidebar + .right-sidebar-expanded .issue-sticky-header { + width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-width}); + } + + // collapsed left sidebar + expanded right sidebar + .right-sidebar-expanded.page-with-icon-sidebar .issue-sticky-header { + width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width}); + } +} + +.issuable-header-slide-enter-active, +.issuable-header-slide-leave-active { + @include gl-transition-slow; +} + +.issuable-header-slide-enter, +.issuable-header-slide-leave-to { + transform: translateY(-100%); +} + .issuable-list-root { .gl-label-link { text-decoration: none; diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 22c1cb127cd..c3bac053a0a 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -86,7 +86,7 @@ justify-content: space-between; padding: $gl-padding; border-radius: $border-radius-default; - border: 1px solid $gray-100; + border: 1px solid $gray-50; &:last-child { margin-bottom: 0; @@ -276,7 +276,7 @@ } .label-badge-gray { - background-color: $gray-100; + background-color: $gray-50; } .label-links { diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 67a8f689e9d..81a70470c65 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -96,14 +96,21 @@ margin: 0; } - .omniauth-btn { - margin-bottom: $gl-padding; + form { width: 48%; - padding: $gl-padding-8; + padding: 0; + border: 0; + background: none; + margin-bottom: $gl-padding; @include media-breakpoint-down(md) { width: 100%; } + } + + .omniauth-btn { + width: 100%; + padding: $gl-padding-8; img { width: $default-icon-size; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 57afe45a74b..c3f3dbc223b 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -253,11 +253,11 @@ table { background-color: $gray-light; border-radius: 0 0 3px 3px; padding: $gl-padding; - border-top: 1px solid $gray-100; + border-top: 1px solid $gray-50; + .new-note { background-color: $gray-light; - border-top: 1px solid $gray-100; + border-top: 1px solid $gray-50; } &.is-replying { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index bed147aa3a7..e8cdfd717c0 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -5,7 +5,7 @@ $note-form-margin-left: 72px; @mixin vertical-line($left) { &::before { content: ''; - border-left: 2px solid $gray-100; + border-left: 2px solid $gray-50; position: absolute; top: 0; bottom: 0; @@ -83,8 +83,8 @@ $note-form-margin-left: 72px; .replies-toggle { background-color: $gray-light; padding: $gl-padding-8 $gl-padding; - border-top: 1px solid $gray-100; - border-bottom: 1px solid $gray-100; + border-top: 1px solid $gray-50; + border-bottom: 1px solid $gray-50; .collapse-replies-btn:hover { color: $blue-600; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 154717f9776..43d766db9e0 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -669,7 +669,8 @@ .ci-action-icon-container { position: absolute; right: 5px; - top: 5px; + top: 50%; + transform: translateY(-50%); // Action Icons in big pipeline-graph nodes &.ci-action-icon-wrapper { @@ -920,7 +921,7 @@ button.mini-pipeline-graph-dropdown-toggle { .ci-status-icon { - @extend .append-right-8; + @include gl-mr-3; position: relative; diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss index 45e62913f37..3bab84af492 100644 --- a/app/assets/stylesheets/pages/profiles/preferences.scss +++ b/app/assets/stylesheets/pages/profiles/preferences.scss @@ -15,6 +15,10 @@ } .application-theme { + $ui-dark-bg: #2e2e2e; + $ui-light-bg: #dfdfdf; + $ui-dark-mode-bg: #1f1f1f; + label { margin: 0 $gl-padding-32 $gl-padding 0; text-align: center; @@ -60,11 +64,17 @@ } &.ui-dark { - background-color: $gray-900; + background-color: $ui-dark-bg; + border: solid 1px $border-color; } &.ui-light { - background-color: $gray-200; + background-color: $ui-light-bg; + } + + &.gl-dark { + background-color: $ui-dark-mode-bg; + border: solid 1px $border-color; } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index c0a1cf10fe4..438f6c2546e 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -396,7 +396,7 @@ margin-right: $gl-padding-4; margin-bottom: $gl-padding-4; color: $gl-text-color-secondary; - background-color: $gray-100; + background-color: $gray-50; line-height: $gl-btn-line-height; &:hover { diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index 0f56b98a78d..26db1fb9f58 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -21,6 +21,14 @@ } } } + + .links-section { + .gl-hover-text-blue-600-children:hover { + * { + @include gl-text-blue-600; + } + } + } } .draggable { diff --git a/app/assets/stylesheets/pages/storage_quota.scss b/app/assets/stylesheets/pages/storage_quota.scss new file mode 100644 index 00000000000..347bd1316c0 --- /dev/null +++ b/app/assets/stylesheets/pages/storage_quota.scss @@ -0,0 +1,17 @@ +.storage-type-usage { + &:first-child { + @include gl-rounded-top-left-base; + @include gl-rounded-bottom-left-base; + } + + &:last-child { + @include gl-rounded-top-right-base; + @include gl-rounded-bottom-right-base; + } + + &:not(:last-child) { + @include gl-border-r-2; + @include gl-border-r-solid; + @include gl-border-white; + } +} diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss new file mode 100644 index 00000000000..1f2a7645495 --- /dev/null +++ b/app/assets/stylesheets/themes/_dark.scss @@ -0,0 +1,134 @@ +$gray-10: #1f1f1f; +$gray-50: #2e2e2e; +$gray-100: #4f4f4f; +$gray-200: #707070; +$gray-300: #919191; +$gray-400: #a7a7a7; +$gray-500: #bababa; +$gray-600: #ccc; +$gray-700: #dfdfdf; +$gray-800: #f2f2f2; +$gray-900: #fafafa; +$gray-950: #fff; + +$gl-gray-100: #333; +$gl-gray-200: #555; +$gl-gray-350: #666; +$gl-gray-400: #777; +$gl-gray-500: #999; +$gl-gray-600: #aaa; +$gl-gray-700: #ccc; +$gl-gray-800: #ddd; + +$green-50: #072b15; +$green-100: #0a4020; +$green-200: #0e5a2d; +$green-300: #12753a; +$green-400: #168f48; +$green-500: #1aaa55; +$green-600: #37b96d; +$green-700: #75d09b; +$green-800: #b3e6c8; +$green-900: #dcf5e7; +$green-950: #f1fdf6; + +$blue-50: #0a2744; +$blue-100: #0f3b66; +$blue-200: #134a81; +$blue-300: #17599c; +$blue-400: #1b69b6; +$blue-500: #1f78d1; +$blue-600: #418cd8; +$blue-700: #73afea; +$blue-800: #b8d6f4; +$blue-900: #e4f0fb; +$blue-950: #f6fafe; + +$orange-50: #592800; +$orange-100: #853c00; +$orange-200: #a35200; +$orange-300: #c26700; +$orange-400: #de7e00; +$orange-500: #fc9403; +$orange-600: #fca429; +$orange-700: #fdbc60; +$orange-800: #fed69f; +$orange-900: #fff1de; +$orange-950: #fffaf4; + +$red-50: #4b140b; +$red-100: #711e11; +$red-200: #8b2615; +$red-300: #a62d19; +$red-400: #c0341d; +$red-500: #db3b21; +$red-600: #e05842; +$red-700: #ea8271; +$red-800: #f2b4a9; +$red-900: #fbe5e1; +$red-950: #fef6f5; + +$indigo-50: #1a1a40; +$indigo-100: #292961; +$indigo-200: #393982; +$indigo-300: #4b4ba3; +$indigo-400: #5b5bbd; +$indigo-500: #6666c4; +$indigo-600: #7c7ccc; +$indigo-700: #a6a6de; +$indigo-800: #d1d1f0; +$indigo-900: #ebebfa; +$indigo-950: #f7f7ff; + +$gray-lightest: #222; +$gray-light: $gray-50; +$gray-lighter: #303030; +$gray-normal: #333; +$gray-dark: $gray-100; +$gray-darker: #4f4f4f; +$gray-darkest: #c4c4c4; + +$black: #fff; +$white: #333; +$white-light: #2b2b2b; +$white-normal: #333; +$white-dark: #444; + +$border-white-light: $gray-900; +$border-white-normal: $gray-900; + +$body-bg: $gray-50; +$input-bg: $gray-100; +$input-focus-bg: $gray-100; +$input-color: $gray-900; +$input-group-addon-bg: $gray-900; + +$tooltip-bg: $gray-800; +$tooltip-color: $gray-10; + +$popover-color: $gray-950; +$popover-box-shadow: 0 2px 3px 1px $gray-700; +$popover-arrow-outer-color: $gray-800; + +$secondary: $gray-600; + +$issues-today-bg: #333838; +$issues-today-border: #333a40; + +.gl-label { + filter: brightness(0.9) contrast(1.1); +} + +// white-ish text for light labels +// and for scoped label value (the right section) +.gl-label-text-light.gl-label-text-light, +.gl-label-text-dark + .gl-label-text-dark { + color: $gray-900; +} + +// duplicated class as the original .atwho-view style is added later +.atwho-view.atwho-view { + background-color: $white; + color: $gray-900; + border-color: $gray-800; +} diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 8cf5c533f1f..176d64272c2 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -81,68 +81,22 @@ .gl-h-32 { height: px-to-rem($grid-size * 4); } .gl-h-64 { height: px-to-rem($grid-size * 8); } -.gl-shim-h-2 { - height: px-to-rem(4px); -} - -.gl-shim-w-5 { - width: px-to-rem(16px); -} - -.gl-shim-pb-3 { - padding-bottom: 8px; -} - -.gl-shim-pt-5 { - padding-top: 16px; -} - -.gl-shim-mx-2 { - margin-left: 4px; - margin-right: 4px; +.d-sm-table-column { + @include media-breakpoint-up(sm) { + display: table-column !important; + } } .gl-text-purple { color: $purple; } -.gl-text-gray-800 { color: $gray-800; } .gl-bg-purple-light { background-color: $purple-light; } -// Classes using mixins coming from @gitlab-ui -// can be removed once the mixins are added. -// See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details. -.gl-bg-blue-50 { @include gl-bg-blue-50; } -.gl-bg-red-100 { @include gl-bg-red-100; } -.gl-bg-orange-100 { @include gl-bg-orange-100; } -.gl-bg-gray-50 { @include gl-bg-gray-50; } -.gl-bg-gray-100 { @include gl-bg-gray-100; } -.gl-bg-green-100 { @include gl-bg-green-100;} -.gl-bg-blue-500 { @include gl-bg-blue-500; } -.gl-bg-green-500 { @include gl-bg-green-500; } -.gl-bg-theme-indigo-500 { @include gl-bg-theme-indigo-500; } -.gl-bg-red-500 { @include gl-bg-red-500; } -.gl-bg-orange-500 { @include gl-bg-orange-500; } - -.gl-text-blue-500 { @include gl-text-blue-500; } -.gl-text-gray-500 { @include gl-text-gray-500; } -.gl-text-gray-700 { @include gl-text-gray-700; } -.gl-text-gray-900 { @include gl-text-gray-900; } -.gl-text-red-700 { @include gl-text-red-700; } -.gl-text-red-900 { @include gl-text-red-900; } -.gl-text-orange-400 { @include gl-text-orange-400; } -.gl-text-orange-500 { @include gl-text-orange-500; } -.gl-text-orange-600 { @include gl-text-orange-600; } -.gl-text-orange-700 { @include gl-text-orange-700; } -.gl-text-green-500 { @include gl-text-green-500; } -.gl-text-green-700 { @include gl-text-green-700; } - -.gl-align-items-center { @include gl-align-items-center; } - -.d-sm-table-column { - @include media-breakpoint-up(sm) { - display: table-column !important; - } +// move this to GitLab UI once onboarding experiment is considered a success +.gl-py-8 { + padding-top: $gl-spacing-scale-8; + padding-bottom: $gl-spacing-scale-8; } -.gl-white-space-normal { @include gl-white-space-normal; } -.gl-word-break-all { @include gl-word-break-all; } -.gl-reset-line-height { @include gl-reset-line-height; } -.gl-reset-text-align { @include gl-reset-text-align; } +// move this to GitLab UI once onboarding experiment is considered a success +.gl-pl-7 { + padding-left: $gl-spacing-scale-7; +} |