diff options
Diffstat (limited to 'app/assets/javascripts')
558 files changed, 15706 insertions, 2602 deletions
diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue new file mode 100644 index 00000000000..d0932ad80e1 --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue @@ -0,0 +1,14 @@ +<script> +import { GlDatepicker } from '@gitlab/ui'; + +export default { + name: 'ExpiresAtField', + components: { GlDatepicker }, +}; +</script> + +<template> + <gl-datepicker :target="null" :min-date="new Date()"> + <slot></slot> + </gl-datepicker> +</template> diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js new file mode 100644 index 00000000000..9bdb2940956 --- /dev/null +++ b/app/assets/javascripts/access_tokens/index.js @@ -0,0 +1,12 @@ +import Vue from 'vue'; +import ExpiresAtField from './components/expires_at_field.vue'; + +const initExpiresAtField = () => { + // eslint-disable-next-line no-new + new Vue({ + el: document.querySelector('.js-access-tokens-expires-at'), + components: { ExpiresAtField }, + }); +}; + +export default initExpiresAtField; diff --git a/app/assets/javascripts/actioncable_consumer.js b/app/assets/javascripts/actioncable_consumer.js new file mode 100644 index 00000000000..5658ffc1a38 --- /dev/null +++ b/app/assets/javascripts/actioncable_consumer.js @@ -0,0 +1,3 @@ +import { createConsumer } from '@rails/actioncable'; + +export default createConsumer(); diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue new file mode 100644 index 00000000000..89db7db77d5 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -0,0 +1,236 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { + GlAlert, + 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'; + +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.', + ), + fullAlertDetailsTitle: s__('AlertManagement|Alert details'), + overviewTitle: s__('AlertManagement|Overview'), + reportedAt: s__('AlertManagement|Reported %{when}'), + reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), + }, + severityLabels: ALERTS_SEVERITY_LABELS, + components: { + GlAlert, + GlIcon, + GlLoadingIcon, + GlSprintf, + GlDropdown, + GlDropdownItem, + GlTab, + GlTabs, + GlButton, + GlTable, + TimeAgoTooltip, + }, + mixins: [glFeatureFlagsMixin()], + props: { + alertId: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + newIssuePath: { + type: String, + required: true, + }, + }, + apollo: { + alert: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query, + variables() { + return { + fullPath: this.projectPath, + alertId: this.alertId, + }; + }, + update(data) { + return data?.project?.alertManagementAlerts?.nodes?.[0] ?? null; + }, + error(error) { + this.errored = true; + Sentry.captureException(error); + }, + }, + }, + data() { + return { alert: null, errored: false, isErrorDismissed: false }; + }, + computed: { + loading() { + return this.$apollo.queries.alert.loading; + }, + reportedAtMessage() { + return this.alert?.monitoringTool + ? this.$options.i18n.reportedAtWithTool + : this.$options.i18n.reportedAt; + }, + showErrorMsg() { + return this.errored && !this.isErrorDismissed; + }, + }, + methods: { + dismissError() { + this.isErrorDismissed = true; + }, + updateAlertStatus(status) { + this.$apollo + .mutate({ + mutation: updateAlertStatus, + variables: { + iid: this.alertId, + status: status.toUpperCase(), + projectPath: this.projectPath, + }, + }) + .catch(() => { + createFlash( + s__( + 'AlertManagement|There was an error while updating the status of the alert. Please try again.', + ), + ); + }); + }, + }, +}; +</script> +<template> + <div> + <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError"> + {{ $options.i18n.errorMsg }} + </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 + 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" + > + <div + data-testid="alert-header" + class="gl-display-flex gl-align-items-center gl-justify-content-center" + > + <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> + </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> + </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" + data-testid="createIssueBtn" + :href="newIssuePath" + category="primary" + variant="success" + > + {{ s__('AlertManagement|Create issue') }} + </gl-button> + </div> + <div + v-if="alert" + class="gl-display-flex gl-justify-content-space-between gl-align-items-center" + > + <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> + <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> + </gl-tab> + <gl-tab data-testid="fullDetailsTab" :title="$options.i18n.fullAlertDetailsTitle"> + <gl-table + class="alert-management-details-table" + :items="[{ key: 'Value', ...alert }]" + :show-empty="true" + :busy="loading" + stacked + > + <template #empty> + {{ s__('AlertManagement|No alert data to display.') }} + </template> + <template #table-busy> + <gl-loading-icon size="lg" color="dark" class="mt-3" /> + </template> + </gl-table> + </gl-tab> + </gl-tabs> + </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 new file mode 100644 index 00000000000..74fc19ff3d4 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue @@ -0,0 +1,303 @@ +<script> +import { + GlEmptyState, + GlDeprecatedButton, + GlLoadingIcon, + GlTable, + GlAlert, + GlIcon, + GlDropdown, + GlDropdownItem, + GlTabs, + GlTab, +} from '@gitlab/ui'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; +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 updateAlertStatus from '../graphql/mutations/update_alert_status.graphql'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; + +const tdClass = 'table-col d-flex d-md-table-cell align-items-center'; +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'; + +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.", + ), + errorMsg: s__( + "AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.", + ), + }, + fields: [ + { + key: 'severity', + label: s__('AlertManagement|Severity'), + tdClass: `${tdClass} rounded-top text-capitalize`, + }, + { + key: 'startedAt', + label: s__('AlertManagement|Start time'), + tdClass, + }, + { + key: 'endedAt', + label: s__('AlertManagement|End time'), + tdClass, + }, + { + key: 'title', + label: s__('AlertManagement|Alert'), + thClass: 'w-30p', + tdClass, + }, + { + key: 'eventCount', + label: s__('AlertManagement|Events'), + thClass: 'text-right event-count', + tdClass: `${tdClass} text-md-right event-count`, + }, + { + key: 'status', + thClass: 'w-15p', + label: s__('AlertManagement|Status'), + tdClass: `${tdClass} rounded-bottom`, + }, + ], + statuses: { + [ALERTS_STATUS.TRIGGERED]: s__('AlertManagement|Triggered'), + [ALERTS_STATUS.ACKNOWLEDGED]: s__('AlertManagement|Acknowledged'), + [ALERTS_STATUS.RESOLVED]: s__('AlertManagement|Resolved'), + }, + severityLabels: ALERTS_SEVERITY_LABELS, + statusTabs: ALERTS_STATUS_TABS, + components: { + GlEmptyState, + GlLoadingIcon, + GlTable, + GlAlert, + GlDeprecatedButton, + TimeAgo, + GlDropdown, + GlDropdownItem, + GlIcon, + GlTabs, + GlTab, + }, + mixins: [glFeatureFlagsMixin()], + props: { + projectPath: { + type: String, + required: true, + }, + alertManagementEnabled: { + type: Boolean, + required: true, + }, + enableAlertManagementPath: { + type: String, + required: true, + }, + userCanEnableAlertManagement: { + type: Boolean, + required: true, + }, + emptyAlertSvgPath: { + type: String, + required: true, + }, + }, + apollo: { + alerts: { + query: getAlerts, + variables() { + return { + projectPath: this.projectPath, + statuses: this.statusFilter, + }; + }, + update(data) { + return data.project.alertManagementAlerts.nodes; + }, + error() { + this.errored = true; + }, + }, + }, + data() { + return { + alerts: null, + errored: false, + isAlertDismissed: false, + isErrorAlertDismissed: false, + statusFilter: this.$options.statusTabs[4].filters, + }; + }, + computed: { + showNoAlertsMsg() { + return !this.errored && !this.loading && !this.alerts?.length && !this.isAlertDismissed; + }, + showErrorMsg() { + return this.errored && !this.isErrorAlertDismissed; + }, + loading() { + return this.$apollo.queries.alerts.loading; + }, + }, + methods: { + filterAlertsByStatus(tabIndex) { + this.statusFilter = this.$options.statusTabs[tabIndex].filters; + }, + capitalizeFirstCharacter, + updateAlertStatus(status, iid) { + this.$apollo + .mutate({ + mutation: updateAlertStatus, + variables: { + iid, + status: status.toUpperCase(), + projectPath: this.projectPath, + }, + }) + .then(() => { + this.$apollo.queries.alerts.refetch(); + }) + .catch(() => { + createFlash( + s__( + 'AlertManagement|There was an error while updating the status of the alert. Please try again.', + ), + ); + }); + }, + navigateToAlertDetails({ iid }) { + return visitUrl(joinPaths(window.location.pathname, iid, 'details')); + }, + }, +}; +</script> +<template> + <div> + <div v-if="alertManagementEnabled" class="alert-management-list"> + <gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true"> + {{ $options.i18n.noAlertsMsg }} + </gl-alert> + <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true"> + {{ $options.i18n.errorMsg }} + </gl-alert> + + <gl-tabs v-if="glFeatures.alertListStatusFilteringEnabled" @input="filterAlertsByStatus"> + <gl-tab v-for="tab in $options.statusTabs" :key="tab.status"> + <template slot="title"> + <span>{{ tab.title }}</span> + </template> + </gl-tab> + </gl-tabs> + + <h4 class="d-block d-md-none my-3"> + {{ s__('AlertManagement|Alerts') }} + </h4> + <gl-table + class="alert-management-table mt-3" + :items="alerts" + :fields="$options.fields" + :show-empty="true" + :busy="loading" + stacked="md" + :tbody-tr-class="$options.bodyTrClass" + @row-clicked="navigateToAlertDetails" + > + <template #cell(severity)="{ item }"> + <div + class="d-inline-flex align-items-center justify-content-between" + data-testid="severityField" + > + <gl-icon + class="mr-2" + :size="12" + :name="`severity-${item.severity.toLowerCase()}`" + :class="`icon-${item.severity.toLowerCase()}`" + /> + {{ $options.severityLabels[item.severity] }} + </div> + </template> + + <template #cell(startedAt)="{ item }"> + <time-ago v-if="item.startedAt" :time="item.startedAt" /> + </template> + + <template #cell(endedAt)="{ item }"> + <time-ago v-if="item.endedAt" :time="item.endedAt" /> + </template> + + <template #cell(title)="{ item }"> + <div class="gl-max-w-full text-truncate">{{ item.title }}</div> + </template> + + <template #cell(status)="{ item }"> + <gl-dropdown + :text="capitalizeFirstCharacter(item.status.toLowerCase())" + class="w-100" + right + > + <gl-dropdown-item + v-for="(label, field) in $options.statuses" + :key="field" + @click="updateAlertStatus(label, item.iid)" + > + <span class="d-flex"> + <gl-icon + class="flex-shrink-0 append-right-4" + :class="{ invisible: label.toUpperCase() !== item.status }" + name="mobile-issue-close" + /> + {{ label }} + </span> + </gl-dropdown-item> + </gl-dropdown> + </template> + + <template #empty> + {{ s__('AlertManagement|No alerts to display.') }} + </template> + + <template #table-busy> + <gl-loading-icon size="lg" color="dark" class="mt-3" /> + </template> + </gl-table> + </div> + <gl-empty-state + v-else + :title="s__('AlertManagement|Surface alerts in GitLab')" + :svg-path="emptyAlertSvgPath" + > + <template #description> + <div class="d-block"> + <span>{{ + s__( + 'AlertManagement|Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.', + ) + }}</span> + <a href="/help/user/project/operations/alert_management.html" target="_blank"> + {{ s__('AlertManagement|More information') }} + </a> + </div> + <div v-if="userCanEnableAlertManagement" class="d-block center pt-4"> + <gl-deprecated-button + category="primary" + variant="success" + :href="enableAlertManagementPath" + > + {{ s__('AlertManagement|Authorize external service') }} + </gl-deprecated-button> + </div> + </template> + </gl-empty-state> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js new file mode 100644 index 00000000000..9df01d9d0b5 --- /dev/null +++ b/app/assets/javascripts/alert_management/constants.js @@ -0,0 +1,46 @@ +import { s__ } from '~/locale'; + +export const ALERTS_SEVERITY_LABELS = { + CRITICAL: s__('AlertManagement|Critical'), + HIGH: s__('AlertManagement|High'), + MEDIUM: s__('AlertManagement|Medium'), + LOW: s__('AlertManagement|Low'), + INFO: s__('AlertManagement|Info'), + 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], + }, + { + title: s__('AlertManagement|Triggered'), + status: ALERTS_STATUS.TRIGGERED, + filters: [ALERTS_STATUS.TRIGGERED], + }, + { + title: s__('AlertManagement|Acknowledged'), + status: ALERTS_STATUS.ACKNOWLEDGED, + filters: [ALERTS_STATUS.ACKNOWLEDGED], + }, + { + title: s__('AlertManagement|Resolved'), + status: ALERTS_STATUS.RESOLVED, + filters: [ALERTS_STATUS.RESOLVED], + }, + { + title: s__('AlertManagement|All alerts'), + status: ALERTS_STATUS.ALL, + filters: [ALERTS_STATUS.TRIGGERED, ALERTS_STATUS.ACKNOWLEDGED, ALERTS_STATUS.RESOLVED], + }, +]; diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js new file mode 100644 index 00000000000..d3523e0a29d --- /dev/null +++ b/app/assets/javascripts/alert_management/details.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import AlertDetails from './components/alert_details.vue'; + +Vue.use(VueApollo); + +export default selector => { + const domEl = document.querySelector(selector); + const { alertId, projectPath, newIssuePath } = domEl.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + cacheConfig: { + dataIdFromObject: object => { + // eslint-disable-next-line no-underscore-dangle + if (object.__typename === 'AlertManagementAlert') { + return object.iid; + } + return defaultDataIdFromObject(object); + }, + }, + }, + ), + }); + + // eslint-disable-next-line no-new + new Vue({ + el: selector, + apolloProvider, + components: { + AlertDetails, + }, + render(createElement) { + return createElement('alert-details', { + props: { + alertId, + projectPath, + newIssuePath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql new file mode 100644 index 00000000000..df802616e97 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql @@ -0,0 +1,11 @@ +#import "./listItem.fragment.graphql" + +fragment AlertDetailItem on AlertManagementAlert { + ...AlertListItem + createdAt + monitoringTool + service + description + updatedAt + details +} diff --git a/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql new file mode 100644 index 00000000000..fffe07b0cfd --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql @@ -0,0 +1,9 @@ +fragment AlertListItem on AlertManagementAlert { + iid + title + severity + status + startedAt + endedAt + eventCount +} 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 new file mode 100644 index 00000000000..009ae0b2930 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql @@ -0,0 +1,9 @@ +mutation ($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) { + updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) { + errors + alert { + iid, + status, + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql new file mode 100644 index 00000000000..7c77715fad2 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql @@ -0,0 +1,11 @@ +#import "../fragments/detailItem.fragment.graphql" + +query alertDetails($fullPath: ID!, $alertId: String) { + project(fullPath: $fullPath) { + alertManagementAlerts(iid: $alertId) { + nodes { + ...AlertDetailItem + } + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql new file mode 100644 index 00000000000..54b66389d5b --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql @@ -0,0 +1,11 @@ +#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/list.js b/app/assets/javascripts/alert_management/list.js new file mode 100644 index 00000000000..cae6a536b56 --- /dev/null +++ b/app/assets/javascripts/alert_management/list.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import AlertManagementList from './components/alert_management_list.vue'; + +Vue.use(VueApollo); + +export default () => { + const selector = '#js-alert_management'; + + const domEl = document.querySelector(selector); + const { projectPath, enableAlertManagementPath, emptyAlertSvgPath } = domEl.dataset; + let { alertManagementEnabled, userCanEnableAlertManagement } = domEl.dataset; + + alertManagementEnabled = parseBoolean(alertManagementEnabled); + userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + cacheConfig: { + dataIdFromObject: object => { + // eslint-disable-next-line no-underscore-dangle + if (object.__typename === 'AlertManagementAlert') { + return object.iid; + } + return defaultDataIdFromObject(object); + }, + }, + }, + ), + }); + + return new Vue({ + el: selector, + apolloProvider, + components: { + AlertManagementList, + }, + render(createElement) { + return createElement('alert-management-list', { + props: { + projectPath, + enableAlertManagementPath, + emptyAlertSvgPath, + alertManagementEnabled, + userCanEnableAlertManagement, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/alert_management/services/index.js b/app/assets/javascripts/alert_management/services/index.js new file mode 100644 index 00000000000..787603d3e7a --- /dev/null +++ b/app/assets/javascripts/alert_management/services/index.js @@ -0,0 +1,7 @@ +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 785598142fe..410c5c00e8a 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 @@ -6,7 +6,7 @@ import { GlModal, GlModalDirective, } from '@gitlab/ui'; -import { escape as esc } from 'lodash'; +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'; @@ -65,7 +65,7 @@ export default { 'AlertService|%{linkStart}Learn more%{linkEnd} about configuring this endpoint to receive alerts.', ), { - linkStart: `<a href="${esc( + linkStart: `<a href="${escape( this.learnMoreUrl, )}" target="_blank" rel="noopener noreferrer">`, linkEnd: '</a>', diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 6301f6a3910..e527659a939 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -23,6 +23,8 @@ const Api = { projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', projectRunnersPath: '/api/:version/projects/:id/runners', projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches', + projectSearchPath: '/api/:version/projects/:id/search', + projectMilestonesPath: '/api/:version/projects/:id/milestones', mergeRequestsPath: '/api/:version/merge_requests', groupLabelsPath: '/groups/:namespace_path/-/labels', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', @@ -46,6 +48,7 @@ const Api = { mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', adminStatisticsPath: '/api/:version/application/statistics', pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id', + pipelinesPath: '/api/:version/projects/:id/pipelines/', environmentsPath: '/api/:version/projects/:id/environments', rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw', @@ -74,13 +77,11 @@ const Api = { const url = Api.buildUrl(Api.groupsPath); return axios .get(url, { - params: Object.assign( - { - search: query, - per_page: DEFAULT_PER_PAGE, - }, - options, - ), + params: { + search: query, + per_page: DEFAULT_PER_PAGE, + ...options, + }, }) .then(({ data }) => { callback(data); @@ -247,6 +248,23 @@ const Api = { .then(({ data }) => data); }, + projectSearch(id, options = {}) { + const url = Api.buildUrl(Api.projectSearchPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url, { + params: { + search: options.search, + scope: options.scope, + }, + }); + }, + + projectMilestones(id) { + const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url); + }, + mergeRequests(params = {}) { const url = Api.buildUrl(Api.mergeRequestsPath); @@ -281,7 +299,7 @@ const Api = { }; return axios .get(url, { - params: Object.assign({}, defaults, options), + params: { ...defaults, ...options }, }) .then(({ data }) => callback(data)) .catch(() => flash(__('Something went wrong while fetching projects'))); @@ -364,13 +382,11 @@ const Api = { users(query, options) { const url = Api.buildUrl(this.usersPath); return axios.get(url, { - params: Object.assign( - { - search: query, - per_page: DEFAULT_PER_PAGE, - }, - options, - ), + params: { + search: query, + per_page: DEFAULT_PER_PAGE, + ...options, + }, }); }, @@ -401,7 +417,7 @@ const Api = { }; return axios .get(url, { - params: Object.assign({}, defaults, options), + params: { ...defaults, ...options }, }) .then(({ data }) => callback(data)) .catch(() => flash(__('Something went wrong while fetching projects'))); @@ -502,6 +518,15 @@ const Api = { return axios.get(url); }, + // Return all pipelines for a project or filter by query params + pipelines(id, options = {}) { + const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url, { + params: options, + }); + }, + environments(id) { const url = Api.buildUrl(this.environmentsPath).replace(':id', encodeURIComponent(id)); return axios.get(url); diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 07d79ea1c70..5f50fcc112e 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -3,7 +3,7 @@ import AccessorUtilities from './lib/utils/accessor'; export default class Autosave { - constructor(field, key, fallbackKey) { + constructor(field, key, fallbackKey, lockVersion) { this.field = field; this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); @@ -12,6 +12,8 @@ export default class Autosave { } this.key = `autosave/${key}`; this.fallbackKey = fallbackKey; + this.lockVersionKey = `${this.key}/lockVersion`; + this.lockVersion = lockVersion; this.field.data('autosave', this); this.restore(); this.field.on('input', () => this.save()); @@ -40,6 +42,11 @@ export default class Autosave { } } + getSavedLockVersion() { + if (!this.isLocalStorageAvailable) return; + return window.localStorage.getItem(this.lockVersionKey); + } + save() { if (!this.field.length) return; @@ -49,6 +56,9 @@ export default class Autosave { if (this.fallbackKey) { window.localStorage.setItem(this.fallbackKey, text); } + if (this.lockVersion !== undefined) { + window.localStorage.setItem(this.lockVersionKey, this.lockVersion); + } return window.localStorage.setItem(this.key, text); } @@ -58,6 +68,7 @@ export default class Autosave { reset() { if (!this.isLocalStorageAvailable) return; + window.localStorage.removeItem(this.lockVersionKey); window.localStorage.removeItem(this.fallbackKey); return window.localStorage.removeItem(this.key); } diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 67164997bd8..8381b050900 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this, @gitlab/require-i18n-strings */ import $ from 'jquery'; -import _ from 'underscore'; +import { uniq } from 'lodash'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; import { __ } from './locale'; @@ -513,7 +513,7 @@ export class AwardsHandler { addEmojiToFrequentlyUsedList(emoji) { if (this.emoji.isEmojiNameValid(emoji)) { - this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji)); + this.frequentlyUsedEmojis = uniq(this.getFrequentlyUsedEmojis().concat(emoji)); Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 }); } } @@ -522,9 +522,7 @@ export class AwardsHandler { return ( this.frequentlyUsedEmojis || (() => { - const frequentlyUsedEmojis = _.uniq( - (Cookies.get('frequently_used_emojis') || '').split(','), - ); + const frequentlyUsedEmojis = uniq((Cookies.get('frequently_used_emojis') || '').split(',')); this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(inputName => this.emoji.isEmojiNameValid(inputName), ); diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js index c3541e62568..48bcba7bcca 100644 --- a/app/assets/javascripts/behaviors/copy_to_clipboard.js +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -17,10 +17,11 @@ function showTooltip(target, title) { } function genericSuccess(e) { - showTooltip(e.trigger, __('Copied')); // Clear the selection and blur the trigger so it loses its border e.clearSelection(); $(e.trigger).blur(); + + showTooltip(e.trigger, __('Copied')); } /** diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js index 7e020139fe7..f8465111959 100644 --- a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js +++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this */ import { Mark } from 'tiptap'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; // Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter export default class InlineHTML extends Mark { @@ -35,7 +35,7 @@ export default class InlineHTML extends Mark { mixable: true, open(state, mark) { return `<${mark.attrs.tag}${ - mark.attrs.title ? ` title="${state.esc(esc(mark.attrs.title))}"` : '' + mark.attrs.title ? ` title="${state.esc(escape(mark.attrs.title))}"` : '' }>`; }, close(state, mark) { diff --git a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js index 665a7216424..278dd857ab8 100644 --- a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js +++ b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js @@ -47,7 +47,8 @@ export default class PasteMarkdownTable { const htmlData = this.data.getData('text/html'); this.doc = new DOMParser().parseFromString(htmlData, 'text/html'); - const tables = this.doc.querySelectorAll('table'); + // Avoid formatting lines that were copied from a diff + const tables = this.doc.querySelectorAll('table:not(.diff-wrap-lines)'); // We're only looking for exactly one table. If there happens to be // multiple tables, it's possible an application copied data into diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 137cc7b4669..01627b7206d 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -16,7 +16,7 @@ $.fn.renderGFM = function renderGFM() { renderMath(this.find('.js-render-math')); renderMermaid(this.find('.js-render-mermaid')); highlightCurrentUser(this.find('.gfm-project_member').get()); - initUserPopovers(this.find('.gfm-project_member').get()); + initUserPopovers(this.find('.js-user-link').get()); initMRPopovers(this.find('.gfm-merge_request').get()); renderMetrics(this.find('.js-render-metrics').get()); return this; diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index fe63ebd470d..057cdb6cc4c 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -24,13 +24,23 @@ let mermaidModule = {}; function importMermaidModule() { return import(/* webpackChunkName: 'mermaid' */ 'mermaid') .then(mermaid => { + let theme = 'neutral'; + + if ( + window.gon?.user_color_scheme === 'dark' && + // if on the Web IDE page + document.querySelector('.ide') + ) { + theme = 'dark'; + } + mermaid.initialize({ // mermaid core options mermaid: { startOnLoad: false, }, // mermaidAPI options - theme: 'neutral', + theme, flowchart: { useMaxWidth: true, htmlLabels: false, diff --git a/app/assets/javascripts/behaviors/markdown/render_metrics.js b/app/assets/javascripts/behaviors/markdown/render_metrics.js index 9260a89bd52..37cbce46b6f 100644 --- a/app/assets/javascripts/behaviors/markdown/render_metrics.js +++ b/app/assets/javascripts/behaviors/markdown/render_metrics.js @@ -1,15 +1,12 @@ import Vue from 'vue'; -import EmbedGroup from '~/monitoring/components/embeds/embed_group.vue'; import { createStore } from '~/monitoring/stores/embed_group/'; // TODO: Handle copy-pasting - https://gitlab.com/gitlab-org/gitlab-foss/issues/64369. export default function renderMetrics(elements) { if (!elements.length) { - return; + return Promise.resolve(); } - const EmbedGroupComponent = Vue.extend(EmbedGroup); - const wrapperList = []; elements.forEach(element => { @@ -31,14 +28,20 @@ export default function renderMetrics(elements) { element.parentNode.removeChild(element); }); - wrapperList.forEach(wrapper => { - // eslint-disable-next-line no-new - new EmbedGroupComponent({ - el: wrapper, - store: createStore(), - propsData: { - urls: wrapper.urls, - }, + return import( + /* webpackChunkName: 'gfm_metrics' */ '~/monitoring/components/embeds/embed_group.vue' + ).then(({ default: EmbedGroup }) => { + const EmbedGroupComponent = Vue.extend(EmbedGroup); + + wrapperList.forEach(wrapper => { + // eslint-disable-next-line no-new + new EmbedGroupComponent({ + el: wrapper, + store: createStore(), + propsData: { + urls: wrapper.urls, + }, + }); }); }); } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js index d5d8edd5ac0..c35a073b291 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js @@ -22,7 +22,7 @@ function eventHasModifierKeys(event) { export default class ShortcutsBlob extends Shortcuts { constructor(opts) { - const options = Object.assign({}, defaults, opts); + const options = { ...defaults, ...opts }; super(options.skipResetBindings); this.options = options; diff --git a/app/assets/javascripts/blob/blob_fork_suggestion.js b/app/assets/javascripts/blob/blob_fork_suggestion.js index 476b9405a9e..44dfbfcfe1c 100644 --- a/app/assets/javascripts/blob/blob_fork_suggestion.js +++ b/app/assets/javascripts/blob/blob_fork_suggestion.js @@ -17,7 +17,7 @@ const defaults = { class BlobForkSuggestion { constructor(options) { - this.elementMap = Object.assign({}, defaults, options); + this.elementMap = { ...defaults, ...options }; this.onOpenButtonClick = this.onOpenButtonClick.bind(this); this.onCancelButtonClick = this.onCancelButtonClick.bind(this); } diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index 7d5d48cfc31..4f433bd8dfd 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -3,12 +3,19 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers'; import BlobContentError from './blob_content_error.vue'; +import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from './constants'; + export default { components: { GlLoadingIcon, BlobContentError, }, props: { + blob: { + type: Object, + required: false, + default: () => ({}), + }, content: { type: String, default: '', @@ -37,6 +44,8 @@ export default { return this.activeViewer.renderError; }, }, + BLOB_RENDER_EVENT_LOAD, + BLOB_RENDER_EVENT_SHOW_SOURCE, }; </script> <template> @@ -44,7 +53,13 @@ export default { <gl-loading-icon v-if="loading" size="md" color="dark" class="my-4 mx-auto" /> <template v-else> - <blob-content-error v-if="viewerError" :viewer-error="viewerError" /> + <blob-content-error + v-if="viewerError" + :viewer-error="viewerError" + :blob="blob" + @[$options.BLOB_RENDER_EVENT_LOAD]="$emit($options.BLOB_RENDER_EVENT_LOAD)" + @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="$emit($options.BLOB_RENDER_EVENT_SHOW_SOURCE)" + /> <component :is="viewer" v-else diff --git a/app/assets/javascripts/blob/components/blob_content_error.vue b/app/assets/javascripts/blob/components/blob_content_error.vue index 0f1af0a962d..44dc4a6c727 100644 --- a/app/assets/javascripts/blob/components/blob_content_error.vue +++ b/app/assets/javascripts/blob/components/blob_content_error.vue @@ -1,15 +1,84 @@ <script> +import { __ } from '~/locale'; +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { BLOB_RENDER_ERRORS } from './constants'; + export default { + components: { + GlSprintf, + GlLink, + }, props: { viewerError: { type: String, required: true, }, + blob: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + notStoredExternally() { + return this.viewerError !== BLOB_RENDER_ERRORS.REASONS.EXTERNAL.id; + }, + renderErrorReason() { + const defaultReasonPath = Object.keys(BLOB_RENDER_ERRORS.REASONS).find( + reason => BLOB_RENDER_ERRORS.REASONS[reason].id === this.viewerError, + ); + const defaultReason = BLOB_RENDER_ERRORS.REASONS[defaultReasonPath].text; + return this.notStoredExternally + ? defaultReason + : defaultReason[this.blob.externalStorage || 'default']; + }, + renderErrorOptions() { + const load = { + ...BLOB_RENDER_ERRORS.OPTIONS.LOAD, + condition: this.shouldShowLoadBtn, + }; + const showSource = { + ...BLOB_RENDER_ERRORS.OPTIONS.SHOW_SOURCE, + condition: this.shouldShowSourceBtn, + }; + const download = { + ...BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD, + href: this.blob.rawPath, + }; + return [load, showSource, download]; + }, + shouldShowLoadBtn() { + return this.viewerError === BLOB_RENDER_ERRORS.REASONS.COLLAPSED.id; + }, + shouldShowSourceBtn() { + return this.blob.richViewer && this.blob.renderedAsText && this.notStoredExternally; + }, }, + errorMessage: __( + 'This content could not be displayed because %{reason}. You can %{options} instead.', + ), }; </script> <template> <div class="file-content code"> - <div class="text-center py-4" v-html="viewerError"></div> + <div class="text-center py-4"> + <gl-sprintf :message="$options.errorMessage"> + <template #reason>{{ renderErrorReason }}</template> + <template #options> + <template v-for="option in renderErrorOptions"> + <span v-if="option.condition" :key="option.text"> + <gl-link + :href="option.href" + :target="option.target" + :data-test-id="`option-${option.id}`" + @click="option.event && $emit(option.event)" + >{{ option.text }}</gl-link + > + {{ option.conjunction }} + </span> + </template> + </template> + </gl-sprintf> + </div> </div> </template> diff --git a/app/assets/javascripts/blob/components/blob_edit_header.vue b/app/assets/javascripts/blob/components/blob_edit_header.vue index e9b5ceda479..e1e1d76f721 100644 --- a/app/assets/javascripts/blob/components/blob_edit_header.vue +++ b/app/assets/javascripts/blob/components/blob_edit_header.vue @@ -5,6 +5,7 @@ export default { components: { GlFormInput, }, + inheritAttrs: false, props: { value: { type: String, @@ -27,8 +28,9 @@ export default { s__('Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby') " name="snippet_file_name" - class="form-control js-snippet-file-name qa-snippet-file-name" + class="form-control js-snippet-file-name" type="text" + v-bind="$attrs" @change="$emit('input', name)" /> </div> diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index b7d9600ec40..e5e01caa9a5 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -66,7 +66,7 @@ export default { </template> </blob-filepath> - <div class="file-actions d-none d-sm-block"> + <div class="file-actions d-none d-sm-flex"> <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" /> <slot name="actions"></slot> diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue index 6c6a22e2b36..e9be7fbcf9b 100644 --- a/app/assets/javascripts/blob/components/blob_header_filepath.vue +++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue @@ -28,12 +28,14 @@ export default { <div class="file-header-content d-flex align-items-center lh-100"> <slot name="filepathPrepend"></slot> - <file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" /> - <strong - v-if="blob.name" - class="file-title-name qa-file-title-name mr-1 js-blob-header-filepath" - >{{ blob.name }}</strong - > + <template v-if="blob.path"> + <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" + >{{ blob.path }}</strong + > + </template> <small class="mr-2">{{ blobSize }}</small> diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue index 7155a1d35b1..ed03213d7cf 100644 --- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue +++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue @@ -45,7 +45,7 @@ export default { }; </script> <template> - <gl-button-group class="js-blob-viewer-switcher ml-2"> + <gl-button-group class="js-blob-viewer-switcher mx-2"> <gl-deprecated-button v-gl-tooltip.hover :aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE" diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js index d3fed9e51e9..93dceacabdd 100644 --- a/app/assets/javascripts/blob/components/constants.js +++ b/app/assets/javascripts/blob/components/constants.js @@ -1,4 +1,5 @@ -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; export const BTN_COPY_CONTENTS_TITLE = __('Copy file contents'); export const BTN_RAW_TITLE = __('Open raw'); @@ -9,3 +10,56 @@ export const SIMPLE_BLOB_VIEWER_TITLE = __('Display source'); export const RICH_BLOB_VIEWER = 'rich'; export const RICH_BLOB_VIEWER_TITLE = __('Display rendered file'); + +export const BLOB_RENDER_EVENT_LOAD = 'force-content-fetch'; +export const BLOB_RENDER_EVENT_SHOW_SOURCE = 'force-switch-viewer'; + +export const BLOB_RENDER_ERRORS = { + REASONS: { + COLLAPSED: { + id: 'collapsed', + text: sprintf(__('it is larger than %{limit}'), { + limit: numberToHumanSize(1048576), // 1MB in bytes + }), + }, + TOO_LARGE: { + id: 'too_large', + text: sprintf(__('it is larger than %{limit}'), { + limit: numberToHumanSize(104857600), // 100MB in bytes + }), + }, + EXTERNAL: { + id: 'server_side_but_stored_externally', + text: { + lfs: __('it is stored in LFS'), + build_artifact: __('it is stored as a job artifact'), + default: __('it is stored externally'), + }, + }, + }, + OPTIONS: { + LOAD: { + id: 'load', + text: __('load it anyway'), + conjunction: __('or'), + href: '#', + target: '', + event: BLOB_RENDER_EVENT_LOAD, + }, + SHOW_SOURCE: { + id: 'show_source', + text: __('view the source'), + conjunction: __('or'), + href: '#', + target: '', + event: BLOB_RENDER_EVENT_SHOW_SOURCE, + }, + DOWNLOAD: { + id: 'download', + text: __('download it'), + conjunction: '', + target: '_blank', + condition: true, + }, + }, +}; diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue index 5023496e2c3..1e9e36feecc 100644 --- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue +++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue @@ -72,9 +72,6 @@ export default { dismissCookieName() { return `${this.trackLabel}_${this.dismissKey}`; }, - commitCookieName() { - return `suggest_gitlab_ci_yml_commit_${this.dismissKey}`; - }, }, mounted() { if ( diff --git a/app/assets/javascripts/blob/utils.js b/app/assets/javascripts/blob/utils.js index dc2ec642e59..840a3dbe450 100644 --- a/app/assets/javascripts/blob/utils.js +++ b/app/assets/javascripts/blob/utils.js @@ -1,22 +1,15 @@ -/* global ace */ import Editor from '~/editor/editor_lite'; export function initEditorLite({ el, blobPath, blobContent }) { if (!el) { throw new Error(`"el" parameter is required to initialize Editor`); } - let editor; - - if (window?.gon?.features?.monacoSnippets) { - editor = new Editor(); - editor.createInstance({ - el, - blobPath, - blobContent, - }); - } else { - editor = ace.edit(el); - } + const editor = new Editor(); + editor.createInstance({ + el, + blobPath, + blobContent, + }); return editor; } diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index ac02c229a07..517a13ceb27 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -113,9 +113,6 @@ export default Vue.extend({ // eslint-disable-next-line @gitlab/require-i18n-strings return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; }, - helpLink() { - return boardsStore.scopedLabels.helpLink; - }, }, watch: { filter: { diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 10c855675db..fb854616a04 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -113,9 +113,6 @@ export default { // eslint-disable-next-line @gitlab/require-i18n-strings return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; }, - helpLink() { - return boardsStore.scopedLabels.helpLink; - }, }, watch: { filter: { @@ -286,7 +283,6 @@ export default { :background-color="list.label.color" :description="list.label.description" :scoped="showScopedLabels(list.label)" - :scoped-labels-documentation-link="helpLink" :size="!list.isExpanded ? 'sm' : ''" :title="list.label.title" tooltip-placement="bottom" @@ -353,7 +349,7 @@ export default { v-if="isSettingsShown" ref="settingsBtn" :aria-label="__(`List settings`)" - class="no-drag rounded-right" + class="no-drag rounded-right js-board-settings-button" title="List settings" type="button" @click="openSidebarSettings" diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index c0df8b72095..fbe221041c1 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -58,11 +58,6 @@ export default { required: false, default: false, }, - scopedLabelsDocumentationLink: { - type: String, - required: false, - default: '#', - }, }, data() { return { @@ -182,7 +177,7 @@ export default { @cancel="cancel" @submit="submit" > - <template slot="body"> + <template #body> <p v-if="isDeleteForm">{{ __('Are you sure you want to delete this board?') }}</p> <form v-else class="js-board-config-modal" @submit.prevent> <div v-if="!readonly" class="append-bottom-20"> @@ -208,7 +203,6 @@ export default { :can-admin-board="canAdminBoard" :milestone-path="milestonePath" :labels-path="labelsPath" - :scoped-labels-documentation-link="scopedLabelsDocumentationLink" :enable-scoped-labels="enableScopedLabels" :project-id="projectId" :group-id="groupId" diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 66a5e134205..c8953158811 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -51,7 +51,7 @@ export default Vue.extend({ return Object.keys(this.issue).length; }, milestoneTitle() { - return this.issue.milestone ? this.issue.milestone.title : __('No Milestone'); + return this.issue.milestone ? this.issue.milestone.title : __('No milestone'); }, canRemove() { return !this.list.preset; @@ -70,9 +70,6 @@ export default Vue.extend({ selectedLabels() { return this.hasLabels ? this.issue.labels.map(l => l.title).join(',') : ''; }, - helpLink() { - return boardsStore.scopedLabels.helpLink; - }, }, watch: { detail: { diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index f2c976be7ae..80db9930259 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -86,11 +86,6 @@ export default { required: false, default: false, }, - scopedLabelsDocumentationLink: { - type: String, - required: false, - default: '#', - }, }, data() { return { @@ -348,7 +343,6 @@ export default { :scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled" :weights="weights" :enable-scoped-labels="enabledScopedLabels" - :scoped-labels-documentation-link="scopedLabelsDocumentationLink" /> </span> </div> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index daaa12c096b..a589fb325b2 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -102,9 +102,6 @@ export default { orderedLabels() { return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title'); }, - helpLink() { - return boardsStore.scopedLabels.helpLink; - }, }, methods: { isIndexLessThanlimit(index) { @@ -181,7 +178,6 @@ export default { :description="label.description" size="sm" :scoped="showScopedLabel(label)" - :scoped-labels-documentation-link="helpLink" @click="filterByLabel(label)" /> </template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index dcecfe5e1bb..f577a168e75 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,3 +1,8 @@ +export const BoardType = { + project: 'project', + group: 'group', +}; + export const ListType = { assignee: 'assignee', milestone: 'milestone', @@ -8,6 +13,9 @@ export const ListType = { blank: 'blank', }; +export const inactiveListId = 0; + export default { + BoardType, ListType, }; diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index b1b4b1c5508..ca85e54eb89 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -1,6 +1,6 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import FilteredSearchContainer from '../filtered_search/container'; -import FilteredSearchManager from '../filtered_search/filtered_search_manager'; +import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; import boardsStore from './stores/boards_store'; export default class FilteredSearchBoards extends FilteredSearchManager { diff --git a/app/assets/javascripts/boards/icons/fullscreen_collapse.svg b/app/assets/javascripts/boards/icons/fullscreen_collapse.svg new file mode 100644 index 00000000000..6bd773dc4c5 --- /dev/null +++ b/app/assets/javascripts/boards/icons/fullscreen_collapse.svg @@ -0,0 +1 @@ +<svg width="17" height="17" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg"><path d="M.147 15.496l2.146-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.146-1.428-1.428zM14.996.646l1.428 1.43-2.146 2.145 1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125l1.286 1.286L14.996.647zm-13.42 0L3.72 2.794l1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286L.147 2.075 1.575.647zm14.848 14.85l-1.428 1.428-2.146-2.146-1.286 1.286c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616l-1.286 1.286 2.146 2.146z" fill-rule="evenodd"/></svg> diff --git a/app/assets/javascripts/boards/icons/fullscreen_expand.svg b/app/assets/javascripts/boards/icons/fullscreen_expand.svg new file mode 100644 index 00000000000..306073b8af2 --- /dev/null +++ b/app/assets/javascripts/boards/icons/fullscreen_expand.svg @@ -0,0 +1 @@ +<svg width="15" height="15" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg"><path d="M8.591 5.056l2.147-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.147-1.429-1.43zM5.018 8.553l1.429 1.43L4.3 12.127l1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125L2.872 10.7l2.146-2.147zm4.964 0l2.146 2.147 1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286-2.147-2.146 1.43-1.429zM6.447 5.018l-1.43 1.429L2.873 4.3 1.586 5.586c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616L4.3 2.872l2.147 2.146z" fill-rule="evenodd"/></svg> diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index a12db7a5f1a..9ff7575ae09 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -6,7 +6,6 @@ import 'ee_else_ce/boards/models/list'; 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 toggleFocusMode from 'ee_else_ce/boards/toggle_focus'; import toggleLabels from 'ee_else_ce/boards/toggle_labels'; import { setPromotionState, @@ -16,11 +15,15 @@ import { getBoardsModalData, } from 'ee_else_ce/boards/ee_functions'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import Flash from '~/flash'; import { __ } from '~/locale'; import './models/label'; import './models/assignee'; +import { BoardType } from './constants'; +import toggleFocusMode from '~/boards/toggle_focus'; import FilteredSearchBoards from '~/boards/filtered_search_boards'; import eventHub from '~/boards/eventhub'; import sidebarEventHub from '~/sidebar/event_hub'; @@ -37,7 +40,16 @@ import { convertObjectPropsToCamelCase, parseBoolean, } from '~/lib/utils/common_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; +import projectBoardQuery from './queries/project_board.query.graphql'; +import groupQuery from './queries/group_board.query.graphql'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); let issueBoardsApp; @@ -79,18 +91,22 @@ export default () => { import('ee_component/boards/components/board_settings_sidebar.vue'), }, store, - data: { - state: boardsStore.state, - loading: true, - boardsEndpoint: $boardApp.dataset.boardsEndpoint, - recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint, - listsEndpoint: $boardApp.dataset.listsEndpoint, - boardId: $boardApp.dataset.boardId, - disabled: parseBoolean($boardApp.dataset.disabled), - issueLinkBase: $boardApp.dataset.issueLinkBase, - rootPath: $boardApp.dataset.rootPath, - bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, - detailIssue: boardsStore.detail, + apolloProvider, + data() { + return { + state: boardsStore.state, + loading: 0, + boardsEndpoint: $boardApp.dataset.boardsEndpoint, + recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint, + listsEndpoint: $boardApp.dataset.listsEndpoint, + boardId: $boardApp.dataset.boardId, + disabled: parseBoolean($boardApp.dataset.disabled), + issueLinkBase: $boardApp.dataset.issueLinkBase, + rootPath: $boardApp.dataset.rootPath, + bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, + detailIssue: boardsStore.detail, + parent: $boardApp.dataset.parent, + }; }, computed: { detailIssueVisible() { @@ -124,31 +140,56 @@ export default () => { this.filterManager.setup(); boardsStore.disabled = this.disabled; - boardsStore - .all() - .then(res => res.data) - .then(lists => { - lists.forEach(listObj => { - let { position } = listObj; - if (listObj.list_type === 'closed') { - position = Infinity; - } else if (listObj.list_type === 'backlog') { - position = -1; + + if (gon.features.graphqlBoardLists) { + this.$apollo.addSmartQuery('lists', { + query() { + return this.parent === BoardType.group ? groupQuery : projectBoardQuery; + }, + variables() { + return { + fullPath: this.state.endpoints.fullPath, + boardId: `gid://gitlab/Board/${this.boardId}`, + }; + }, + update(data) { + return this.getNodes(data); + }, + result({ data, error }) { + if (error) { + throw error; } - boardsStore.addList({ - ...listObj, - position, - }); - }); + const lists = this.getNodes(data); + + lists.forEach(list => + boardsStore.addList({ + ...list, + id: getIdFromGraphQLId(list.id), + }), + ); - boardsStore.addBlankState(); - setPromotionState(boardsStore); - this.loading = false; - }) - .catch(() => { - Flash(__('An error occurred while fetching the board lists. Please try again.')); + boardsStore.addBlankState(); + setPromotionState(boardsStore); + }, + error() { + Flash(__('An error occurred while fetching the board lists. Please try again.')); + }, }); + } else { + boardsStore + .all() + .then(res => res.data) + .then(lists => { + lists.forEach(list => boardsStore.addList(list)); + boardsStore.addBlankState(); + setPromotionState(boardsStore); + this.loading = false; + }) + .catch(() => { + Flash(__('An error occurred while fetching the board lists. Please try again.')); + }); + } }, methods: { updateTokens() { @@ -233,6 +274,9 @@ export default () => { }); } }, + getNodes(data) { + return data[this.parent]?.board?.lists.nodes; + }, }, }); @@ -261,7 +305,7 @@ export default () => { return { modal: ModalStore.store, store: boardsStore.state, - ...getBoardsModalData($boardApp), + ...getBoardsModalData(), canAdminList: this.$options.el.hasAttribute('data-can-admin-list'), }; }, @@ -325,7 +369,7 @@ export default () => { }); } - toggleFocusMode(ModalStore, boardsStore, $boardApp); + toggleFocusMode(ModalStore, boardsStore); toggleLabels(); mountMultipleBoardsSwitcher(); }; diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index 68ea28e68d9..fceb8c9d48e 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -19,14 +19,15 @@ export function getBoardSortableDefaultOptions(obj) { const touchEnabled = 'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch); - const defaultSortOptions = Object.assign({}, sortableConfig, { + const defaultSortOptions = { + ...sortableConfig, filter: '.no-drag', delay: touchEnabled ? 100 : 0, scrollSensitivity: touchEnabled ? 60 : 100, scrollSpeed: 20, onStart: sortableStart, onEnd: sortableEnd, - }); + }; Object.keys(obj).forEach(key => { defaultSortOptions[key] = obj[key]; diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js index 5f5758583bb..1e822d06bfd 100644 --- a/app/assets/javascripts/boards/models/assignee.js +++ b/app/assets/javascripts/boards/models/assignee.js @@ -3,7 +3,7 @@ export default class ListAssignee { this.id = obj.id; this.name = obj.name; this.username = obj.username; - this.avatar = obj.avatar_url || obj.avatar || gon.default_avatar_url; + this.avatar = obj.avatarUrl || obj.avatar_url || obj.avatar || gon.default_avatar_url; this.path = obj.path; this.state = obj.state; this.webUrl = obj.web_url || obj.webUrl; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index d099c4b930c..878f49cc6be 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -15,7 +15,7 @@ class ListIssue { this.labels = []; this.assignees = []; this.selected = false; - this.position = obj.relative_position || Infinity; + this.position = obj.position || obj.relative_position || Infinity; this.isFetching = { subscriptions: true, }; @@ -99,31 +99,7 @@ class ListIssue { } update() { - const data = { - issue: { - milestone_id: this.milestone ? this.milestone.id : null, - due_date: this.dueDate, - assignee_ids: this.assignees.length > 0 ? this.assignees.map(u => u.id) : [0], - label_ids: this.labels.map(label => label.id), - }, - }; - - if (!data.issue.label_ids.length) { - data.issue.label_ids = ['']; - } - - const projectPath = this.project ? this.project.path : ''; - return axios.patch(`${this.path}.json`, data).then(({ data: body = {} } = {}) => { - /** - * Since post implementation of Scoped labels, server can reject - * same key-ed labels. To keep the UI and server Model consistent, - * we're just assigning labels that server echo's back to us when we - * PATCH the said object. - */ - if (body) { - this.labels = convertObjectPropsToCamelCase(body.labels, { deep: true }); - } - }); + return boardsStore.updateIssue(this); } } diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 990b648190a..31c372b7a75 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,10 +1,9 @@ -/* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow */ +/* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return */ import ListIssue from 'ee_else_ce/boards/models/issue'; import { __ } from '~/locale'; import ListLabel from './label'; import ListAssignee from './assignee'; -import { urlParamsToObject } from '~/lib/utils/common_utils'; import flash from '~/flash'; import boardsStore from '../stores/boards_store'; import ListMilestone from './milestone'; @@ -40,8 +39,8 @@ class List { this.id = obj.id; this._uid = this.guid(); this.position = obj.position; - this.title = obj.list_type === 'backlog' ? __('Open') : obj.title; - this.type = obj.list_type; + this.title = (obj.list_type || obj.listType) === 'backlog' ? __('Open') : obj.title; + this.type = obj.list_type || obj.listType; const typeInfo = this.getTypeInfo(this.type); this.preset = Boolean(typeInfo.isPreset); @@ -52,14 +51,12 @@ class List { this.loadingMore = false; this.issues = obj.issues || []; this.issuesSize = obj.issuesSize ? obj.issuesSize : 0; - this.maxIssueCount = Object.hasOwnProperty.call(obj, 'max_issue_count') - ? obj.max_issue_count - : 0; + this.maxIssueCount = obj.maxIssueCount || obj.max_issue_count || 0; if (obj.label) { this.label = new ListLabel(obj.label); - } else if (obj.user) { - this.assignee = new ListAssignee(obj.user); + } else if (obj.user || obj.assignee) { + this.assignee = new ListAssignee(obj.user || obj.assignee); this.title = this.assignee.name; } else if (IS_EE && obj.milestone) { this.milestone = new ListMilestone(obj.milestone); @@ -113,34 +110,7 @@ class List { } getIssues(emptyIssues = true) { - const data = { - ...urlParamsToObject(boardsStore.filter.path), - page: this.page, - }; - - if (this.label && data.label_name) { - data.label_name = data.label_name.filter(label => label !== this.label.title); - } - - if (emptyIssues) { - this.loading = true; - } - - return boardsStore - .getIssuesForList(this.id, data) - .then(res => res.data) - .then(data => { - this.loading = false; - this.issuesSize = data.size; - - if (emptyIssues) { - this.issues = []; - } - - this.createIssues(data.issues); - - return data; - }); + return boardsStore.getListIssues(this, emptyIssues); } newIssue(issue) { @@ -164,48 +134,7 @@ class List { } addIssue(issue, listFrom, newIndex) { - let moveBeforeId = null; - let moveAfterId = null; - - if (!this.findIssue(issue.id)) { - if (newIndex !== undefined) { - this.issues.splice(newIndex, 0, issue); - - if (this.issues[newIndex - 1]) { - moveBeforeId = this.issues[newIndex - 1].id; - } - - if (this.issues[newIndex + 1]) { - moveAfterId = this.issues[newIndex + 1].id; - } - } else { - this.issues.push(issue); - } - - if (this.label) { - issue.addLabel(this.label); - } - - if (this.assignee) { - if (listFrom && listFrom.type === 'assignee') { - issue.removeAssignee(listFrom.assignee); - } - issue.addAssignee(this.assignee); - } - - if (IS_EE && this.milestone) { - if (listFrom && listFrom.type === 'milestone') { - issue.removeMilestone(listFrom.milestone); - } - issue.addMilestone(this.milestone); - } - - if (listFrom) { - this.issuesSize += 1; - - this.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId); - } - } + boardsStore.addListIssue(this, issue, listFrom, newIndex); } moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) { diff --git a/app/assets/javascripts/boards/queries/board_list.fragment.graphql b/app/assets/javascripts/boards/queries/board_list.fragment.graphql new file mode 100644 index 00000000000..bbf3314377e --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_list.fragment.graphql @@ -0,0 +1,5 @@ +#import "./board_list_shared.fragment.graphql" + +fragment BoardListFragment on BoardList { + ...BoardListShared +} diff --git a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql new file mode 100644 index 00000000000..6ba6c05d6d9 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql @@ -0,0 +1,15 @@ +fragment BoardListShared on BoardList { + id, + title, + position, + listType, + collapsed, + label { + id, + title, + color, + textColor, + description, + descriptionHtml + } +} diff --git a/app/assets/javascripts/boards/queries/group_board.query.graphql b/app/assets/javascripts/boards/queries/group_board.query.graphql new file mode 100644 index 00000000000..cb42cb3f73d --- /dev/null +++ b/app/assets/javascripts/boards/queries/group_board.query.graphql @@ -0,0 +1,13 @@ +#import "ee_else_ce/boards/queries/board_list.fragment.graphql" + +query GroupBoard($fullPath: ID!, $boardId: ID!) { + group(fullPath: $fullPath) { + board(id: $boardId) { + lists { + nodes { + ...BoardListFragment + } + } + } + } +} diff --git a/app/assets/javascripts/boards/queries/project_board.query.graphql b/app/assets/javascripts/boards/queries/project_board.query.graphql new file mode 100644 index 00000000000..4620a7e0fd5 --- /dev/null +++ b/app/assets/javascripts/boards/queries/project_board.query.graphql @@ -0,0 +1,13 @@ +#import "ee_else_ce/boards/queries/board_list.fragment.graphql" + +query ProjectBoard($fullPath: ID!, $boardId: ID!) { + project(fullPath: $fullPath) { + board(id: $boardId) { + lists { + nodes { + ...BoardListFragment + } + } + } + } +} diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index e5447080e37..fdbd7e89bfb 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -6,7 +6,12 @@ import { sortBy } from 'lodash'; import Vue from 'vue'; import Cookies from 'js-cookie'; import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee'; -import { getUrlParamsArray, parseBoolean } from '~/lib/utils/common_utils'; +import { + urlParamsToObject, + getUrlParamsArray, + parseBoolean, + convertObjectPropsToCamelCase, +} from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -23,7 +28,6 @@ const boardsStore = { limitToHours: false, }, scopedLabels: { - helpLink: '', enabled: false, }, filter: { @@ -75,7 +79,15 @@ const boardsStore = { this.state.currentPage = page; }, addList(listObj) { - const list = new List(listObj); + const listType = listObj.listType || listObj.list_type; + let { position } = listObj; + if (listType === ListType.closed) { + position = Infinity; + } else if (listType === ListType.backlog) { + position = -1; + } + + const list = new List({ ...listObj, position }); this.state.lists = sortBy([...this.state.lists, list], 'position'); return list; }, @@ -121,6 +133,50 @@ const boardsStore = { path: '', }); }, + addListIssue(list, issue, listFrom, newIndex) { + let moveBeforeId = null; + let moveAfterId = null; + + if (!list.findIssue(issue.id)) { + if (newIndex !== undefined) { + list.issues.splice(newIndex, 0, issue); + + if (list.issues[newIndex - 1]) { + moveBeforeId = list.issues[newIndex - 1].id; + } + + if (list.issues[newIndex + 1]) { + moveAfterId = list.issues[newIndex + 1].id; + } + } else { + list.issues.push(issue); + } + + if (list.label) { + issue.addLabel(list.label); + } + + if (list.assignee) { + if (listFrom && listFrom.type === 'assignee') { + issue.removeAssignee(listFrom.assignee); + } + issue.addAssignee(list.assignee); + } + + if (IS_EE && list.milestone) { + if (listFrom && listFrom.type === 'milestone') { + issue.removeMilestone(listFrom.milestone); + } + issue.addMilestone(list.milestone); + } + + if (listFrom) { + list.issuesSize += 1; + + list.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId); + } + } + }, welcomeIsHidden() { return parseBoolean(Cookies.get('issue_board_welcome_hidden')); }, @@ -487,6 +543,36 @@ const boardsStore = { }); }, + getListIssues(list, emptyIssues = true) { + const data = { + ...urlParamsToObject(this.filter.path), + page: list.page, + }; + + if (list.label && data.label_name) { + data.label_name = data.label_name.filter(label => label !== list.label.title); + } + + if (emptyIssues) { + list.loading = true; + } + + return this.getIssuesForList(list.id, data) + .then(res => res.data) + .then(data => { + list.loading = false; + list.issuesSize = data.size; + + if (emptyIssues) { + list.issues = []; + } + + list.createIssues(data.issues); + + return data; + }); + }, + getIssuesForList(id, filter = {}) { const data = { id }; Object.keys(filter).forEach(key => { @@ -632,6 +718,28 @@ const boardsStore = { issue.assignees = obj.assignees.map(a => new ListAssignee(a)); } }, + updateIssue(issue) { + const data = { + issue: { + milestone_id: issue.milestone ? issue.milestone.id : null, + due_date: issue.dueDate, + assignee_ids: issue.assignees.length > 0 ? issue.assignees.map(({ id }) => id) : [0], + label_ids: issue.labels.length > 0 ? issue.labels.map(({ id }) => id) : [''], + }, + }; + + return axios.patch(`${issue.path}.json`, data).then(({ data: body = {} } = {}) => { + /** + * Since post implementation of Scoped labels, server can reject + * same key-ed labels. To keep the UI and server Model consistent, + * we're just assigning labels that server echo's back to us when we + * PATCH the said object. + */ + if (body) { + issue.labels = convertObjectPropsToCamelCase(body.labels, { deep: true }); + } + }); + }, }; BoardsStoreEE.initEESpecific(boardsStore); diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 731aea996fb..10aac2f649e 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,4 +1,6 @@ +import { inactiveListId } from '~/boards/constants'; + export default () => ({ isShowingLabels: true, - activeListId: 0, + activeListId: inactiveListId, }); diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js index 2d1ec238274..a437a34c948 100644 --- a/app/assets/javascripts/boards/toggle_focus.js +++ b/app/assets/javascripts/boards/toggle_focus.js @@ -1 +1,45 @@ -export default () => {}; +import $ from 'jquery'; +import Vue from 'vue'; +import collapseIcon from './icons/fullscreen_collapse.svg'; +import expandIcon from './icons/fullscreen_expand.svg'; + +export default (ModalStore, boardsStore) => { + const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board'); + + return new Vue({ + el: document.getElementById('js-toggle-focus-btn'), + data: { + modal: ModalStore.store, + store: boardsStore.state, + isFullscreen: false, + }, + methods: { + toggleFocusMode() { + $(this.$refs.toggleFocusModeButton).tooltip('hide'); + issueBoardsContent.classList.toggle('is-focused'); + + this.isFullscreen = !this.isFullscreen; + }, + }, + template: ` + <div class="board-extra-actions"> + <a + href="#" + class="btn btn-default has-tooltip prepend-left-10 js-focus-mode-btn" + data-qa-selector="focus_mode_button" + role="button" + aria-label="Toggle focus mode" + title="Toggle focus mode" + ref="toggleFocusModeButton" + @click="toggleFocusMode"> + <span v-show="isFullscreen"> + ${collapseIcon} + </span> + <span v-show="!isFullscreen"> + ${expandIcon} + </span> + </a> + </div> + `, + }); +}; diff --git a/app/assets/javascripts/broadcast_notification.js b/app/assets/javascripts/broadcast_notification.js index dc5401199dc..97da6fa34da 100644 --- a/app/assets/javascripts/broadcast_notification.js +++ b/app/assets/javascripts/broadcast_notification.js @@ -3,10 +3,10 @@ import Cookies from 'js-cookie'; const handleOnDismiss = ({ currentTarget }) => { currentTarget.removeEventListener('click', handleOnDismiss); const { - dataset: { id }, + dataset: { id, expireDate }, } = currentTarget; - Cookies.set(`hide_broadcast_message_${id}`, true); + Cookies.set(`hide_broadcast_message_${id}`, true, { expires: new Date(expireDate) }); const notification = document.querySelector(`.js-broadcast-notification-${id}`); notification.parentNode.removeChild(notification); diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js index da33e092086..470649e63fb 100644 --- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js @@ -1,4 +1,4 @@ -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import axios from '../lib/utils/axios_utils'; import { s__ } from '../locale'; import Flash from '../flash'; @@ -10,7 +10,7 @@ function generateErrorBoxContent(errors) { const errorList = [].concat(errors).map( errorString => ` <li> - ${esc(errorString)} + ${escape(errorString)} </li> `, ); 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 8f5acd4a0a0..f6ade0867cd 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 @@ -46,6 +46,7 @@ export default { 'isGroup', 'maskableRegex', 'selectedEnvironment', + 'isProtectedByDefault', ]), canSubmit() { return ( @@ -123,6 +124,7 @@ export default { 'addWildCardScope', 'resetSelectedEnvironment', 'setSelectedEnvironment', + 'setVariableProtected', ]), deleteVarAndClose() { this.deleteVariable(this.variableBeingEdited); @@ -147,6 +149,11 @@ export default { } this.hideModal(); }, + setVariableProtectedByDefault() { + if (this.isProtectedByDefault && !this.variableBeingEdited) { + this.setVariableProtected(); + } + }, }, }; </script> @@ -159,6 +166,7 @@ export default { static lazy @hidden="resetModalHandler" + @shown="setVariableProtectedByDefault" > <form> <ci-key-field diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index 5fe1e32e37e..a4db6481720 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -4,7 +4,7 @@ import { __ } from '~/locale'; export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable'; export const displayText = { - variableText: __('Var'), + variableText: __('Variable'), fileText: __('File'), allEnvironmentsText: __('All (default)'), }; diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js index 58501b216c1..2b4a56a4e6d 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci_variable_list/index.js @@ -5,14 +5,16 @@ import { parseBoolean } from '~/lib/utils/common_utils'; export default () => { const el = document.getElementById('js-ci-project-variables'); - const { endpoint, projectId, group, maskableRegex } = el.dataset; + const { endpoint, projectId, group, maskableRegex, protectedByDefault } = el.dataset; const isGroup = parseBoolean(group); + const isProtectedByDefault = parseBoolean(protectedByDefault); const store = createStore({ endpoint, projectId, isGroup, maskableRegex, + isProtectedByDefault, }); return new Vue({ diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js index a22fa03e16d..d9129c919f8 100644 --- a/app/assets/javascripts/ci_variable_list/store/actions.js +++ b/app/assets/javascripts/ci_variable_list/store/actions.js @@ -20,6 +20,10 @@ export const resetEditing = ({ commit, dispatch }) => { commit(types.RESET_EDITING); }; +export const setVariableProtected = ({ commit }) => { + commit(types.SET_VARIABLE_PROTECTED); +}; + export const requestAddVariable = ({ commit }) => { commit(types.REQUEST_ADD_VARIABLE); }; diff --git a/app/assets/javascripts/ci_variable_list/store/mutation_types.js b/app/assets/javascripts/ci_variable_list/store/mutation_types.js index 0b41c20bce7..ccf8fbd3cb5 100644 --- a/app/assets/javascripts/ci_variable_list/store/mutation_types.js +++ b/app/assets/javascripts/ci_variable_list/store/mutation_types.js @@ -2,6 +2,7 @@ export const TOGGLE_VALUES = 'TOGGLE_VALUES'; export const VARIABLE_BEING_EDITED = 'VARIABLE_BEING_EDITED'; export const RESET_EDITING = 'RESET_EDITING'; export const CLEAR_MODAL = 'CLEAR_MODAL'; +export const SET_VARIABLE_PROTECTED = 'SET_VARIABLE_PROTECTED'; export const REQUEST_VARIABLES = 'REQUEST_VARIABLES'; export const RECEIVE_VARIABLES_SUCCESS = 'RECEIVE_VARIABLES_SUCCESS'; diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js index 7ee7d7bdc26..7d9cd0dd727 100644 --- a/app/assets/javascripts/ci_variable_list/store/mutations.js +++ b/app/assets/javascripts/ci_variable_list/store/mutations.js @@ -104,4 +104,8 @@ export default { [types.SET_SELECTED_ENVIRONMENT](state, environment) { state.selectedEnvironment = environment; }, + + [types.SET_VARIABLE_PROTECTED](state) { + state.variable.protected = true; + }, }; diff --git a/app/assets/javascripts/ci_variable_list/store/state.js b/app/assets/javascripts/ci_variable_list/store/state.js index 8c0b9c6966f..2fffd115589 100644 --- a/app/assets/javascripts/ci_variable_list/store/state.js +++ b/app/assets/javascripts/ci_variable_list/store/state.js @@ -5,6 +5,7 @@ export default () => ({ projectId: null, isGroup: null, maskableRegex: null, + isProtectedByDefault: null, isLoading: false, isDeleting: false, variable: { diff --git a/app/assets/javascripts/close_reopen_report_toggle.js b/app/assets/javascripts/close_reopen_report_toggle.js index 882d20671cc..bcddce6e727 100644 --- a/app/assets/javascripts/close_reopen_report_toggle.js +++ b/app/assets/javascripts/close_reopen_report_toggle.js @@ -2,7 +2,7 @@ import DropLab from './droplab/drop_lab'; import ISetter from './droplab/plugins/input_setter'; // Todo: Remove this when fixing issue in input_setter plugin -const InputSetter = Object.assign({}, ISetter); +const InputSetter = { ...ISetter }; class CloseReopenReportToggle { constructor(opts = {}) { diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 1b11ec355bb..3699a3b8b2b 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -14,6 +14,7 @@ import { INGRESS_DOMAIN_SUFFIX, CROSSPLANE, KNATIVE, + FLUENTD, } from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; @@ -49,6 +50,7 @@ export default class Clusters { installElasticStackPath, installCrossplanePath, installPrometheusPath, + installFluentdPath, managePrometheusPath, clusterEnvironmentsPath, hasRbac, @@ -102,6 +104,7 @@ export default class Clusters { updateKnativeEndpoint: updateKnativePath, installElasticStackEndpoint: installElasticStackPath, clusterEnvironmentsEndpoint: clusterEnvironmentsPath, + installFluentdEndpoint: installFluentdPath, }); this.installApplication = this.installApplication.bind(this); @@ -265,6 +268,7 @@ export default class Clusters { eventHub.$on('setIngressModSecurityEnabled', data => this.setIngressModSecurityEnabled(data)); eventHub.$on('setIngressModSecurityMode', data => this.setIngressModSecurityMode(data)); eventHub.$on('resetIngressModSecurityChanges', id => this.resetIngressModSecurityChanges(id)); + eventHub.$on('setFluentdSettings', data => this.setFluentdSettings(data)); // Add event listener to all the banner close buttons this.addBannerCloseHandler(this.unreachableContainer, 'unreachable'); this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure'); @@ -281,6 +285,7 @@ export default class Clusters { eventHub.$off('setIngressModSecurityEnabled'); eventHub.$off('setIngressModSecurityMode'); eventHub.$off('resetIngressModSecurityChanges'); + eventHub.$off('setFluentdSettings'); } initPolling(method, successCallback, errorCallback) { @@ -320,7 +325,7 @@ export default class Clusters { handleClusterStatusSuccess(data) { const prevStatus = this.store.state.status; - const prevApplicationMap = Object.assign({}, this.store.state.applications); + const prevApplicationMap = { ...this.store.state.applications }; this.store.updateStateFromServer(data.data); @@ -506,6 +511,12 @@ export default class Clusters { }); } + setFluentdSettings(settings = {}) { + Object.entries(settings).forEach(([key, value]) => { + this.store.updateAppProperty(FLUENTD, key, value); + }); + } + toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) { if (externalIp !== newExternalIp) { this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp); diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 723030c5b8b..f11502a7dde 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +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'; @@ -14,6 +14,7 @@ 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'; @@ -22,6 +23,7 @@ import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../con import eventHub from '~/clusters/event_hub'; import CrossplaneProviderStack from './crossplane_provider_stack.vue'; import IngressModsecuritySettings from './ingress_modsecurity_settings.vue'; +import FluentdOutputSettings from './fluentd_output_settings.vue'; export default { components: { @@ -31,6 +33,7 @@ export default { KnativeDomainEditor, CrossplaneProviderStack, IngressModsecuritySettings, + FluentdOutputSettings, }, props: { type: { @@ -102,6 +105,7 @@ export default { meltanoLogo, prometheusLogo, elasticStackLogo, + fluentdLogo, }), computed: { isProjectCluster() { @@ -134,7 +138,7 @@ export default { }, ingressDescription() { return sprintf( - esc( + escape( s__( `ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{pricingLink}.`, ), @@ -142,14 +146,14 @@ export default { { pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer"> - ${esc(s__('ClusterIntegration|pricing'))}</a>`, + ${escape(s__('ClusterIntegration|pricing'))}</a>`, }, false, ); }, certManagerDescription() { return sprintf( - esc( + 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 @@ -159,14 +163,14 @@ export default { { letsEncrypt: `<a href="https://letsencrypt.org/" target="_blank" rel="noopener noreferrer"> - ${esc(s__("ClusterIntegration|Let's Encrypt"))}</a>`, + ${escape(s__("ClusterIntegration|Let's Encrypt"))}</a>`, }, false, ); }, crossplaneDescription() { return sprintf( - esc( + 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.`, @@ -175,7 +179,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity { gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ee/user/clusters/applications.html#crossplane" target="_blank" rel="noopener noreferrer"> - ${esc(s__('ClusterIntegration|Gitlab Integration'))}</a>`, + ${escape(s__('ClusterIntegration|Gitlab Integration'))}</a>`, kubectl: `<code>kubectl</code>`, }, false, @@ -184,7 +188,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity prometheusDescription() { return sprintf( - esc( + escape( s__( `ClusterIntegration|Prometheus is an open-source monitoring system with %{gitlabIntegrationLink} to monitor deployed applications.`, @@ -193,7 +197,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity { gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" target="_blank" rel="noopener noreferrer"> - ${esc(s__('ClusterIntegration|GitLab Integration'))}</a>`, + ${escape(s__('ClusterIntegration|GitLab Integration'))}</a>`, }, false, ); @@ -219,11 +223,11 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity installedVia() { if (this.cloudRun) { return sprintf( - esc(s__(`ClusterIntegration|installed via %{installed_via}`)), + escape(s__(`ClusterIntegration|installed via %{installed_via}`)), { installed_via: `<a href="${ this.cloudRunHelpPath - }" target="_blank" rel="noopener noreferrer">${esc( + }" target="_blank" rel="noopener noreferrer">${escape( s__('ClusterIntegration|Cloud Run'), )}</a>`, }, @@ -658,7 +662,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :uninstall-successful="applications.elastic_stack.uninstallSuccessful" :uninstall-failed="applications.elastic_stack.uninstallFailed" :disabled="!helmInstalled" - title-link="https://github.com/helm/charts/tree/master/stable/elastic-stack" + title-link="https://gitlab.com/gitlab-org/charts/elastic-stack" > <div slot="description"> <p> @@ -670,6 +674,51 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity </p> </div> </application-row> + + <application-row + id="fluentd" + :logo-url="fluentdLogo" + :title="applications.fluentd.title" + :status="applications.fluentd.status" + :status-reason="applications.fluentd.statusReason" + :request-status="applications.fluentd.requestStatus" + :request-reason="applications.fluentd.requestReason" + :installed="applications.fluentd.installed" + :install-failed="applications.fluentd.installFailed" + :install-application-request-params="{ + host: applications.fluentd.host, + port: applications.fluentd.port, + protocol: applications.fluentd.protocol, + waf_log_enabled: applications.fluentd.wafLogEnabled, + cilium_log_enabled: applications.fluentd.ciliumLogEnabled, + }" + :uninstallable="applications.fluentd.uninstallable" + :uninstall-successful="applications.fluentd.uninstallSuccessful" + :uninstall-failed="applications.fluentd.uninstallFailed" + :disabled="!helmInstalled" + :updateable="false" + title-link="https://github.com/helm/charts/tree/master/stable/fluentd" + > + <div slot="description"> + <p> + {{ + s__( + `ClusterIntegration|Fluentd is an open source data collector, which lets you unify the data collection and consumption for a better use and understanding of data. It requires at least one of the following logs to be successfully installed.`, + ) + }} + </p> + + <fluentd-output-settings + :port="applications.fluentd.port" + :protocol="applications.fluentd.protocol" + :host="applications.fluentd.host" + :waf-log-enabled="applications.fluentd.wafLogEnabled" + :cilium-log-enabled="applications.fluentd.ciliumLogEnabled" + :status="applications.fluentd.status" + :update-failed="applications.fluentd.updateFailed" + /> + </div> + </application-row> </div> </section> </template> diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue new file mode 100644 index 00000000000..1884b501a20 --- /dev/null +++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue @@ -0,0 +1,241 @@ +<script> +import { __ } from '~/locale'; +import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; +import { + GlAlert, + GlDeprecatedButton, + GlDropdown, + GlDropdownItem, + GlFormCheckbox, +} from '@gitlab/ui'; +import eventHub from '~/clusters/event_hub'; +import { mapValues } from 'lodash'; + +const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS; + +export default { + components: { + GlAlert, + GlDeprecatedButton, + GlDropdown, + GlDropdownItem, + GlFormCheckbox, + }, + props: { + protocols: { + type: Array, + required: false, + default: () => ['TCP', 'UDP'], + }, + status: { + type: String, + required: false, + default: '', + }, + updateFailed: { + type: Boolean, + required: false, + }, + protocol: { + type: String, + required: false, + default: () => __('Protocol'), + }, + port: { + type: Number, + required: false, + default: 514, + }, + host: { + type: String, + required: false, + default: '', + }, + wafLogEnabled: { + type: Boolean, + required: false, + }, + ciliumLogEnabled: { + type: Boolean, + required: false, + }, + }, + data: () => ({ + currentServerSideSettings: { + host: null, + port: null, + protocol: null, + wafLogEnabled: null, + ciliumLogEnabled: null, + }, + }), + computed: { + isSaving() { + return [UPDATING].includes(this.status); + }, + saveButtonDisabled() { + return [UNINSTALLING, UPDATING, INSTALLING].includes(this.status); + }, + saveButtonLabel() { + return this.isSaving ? __('Saving') : __('Save changes'); + }, + /** + * Returns true either when: + * - The application is getting updated. + * - The user has changed some of the settings for an application which is + * neither getting installed nor updated. + */ + showButtons() { + return this.isSaving || (this.changedByUser && [INSTALLED, UPDATED].includes(this.status)); + }, + protocolName() { + if (this.protocol) { + return this.protocol.toUpperCase(); + } + return __('Protocol'); + }, + changedByUser() { + return Object.entries(this.currentServerSideSettings).some(([key, value]) => { + return value !== null && value !== this[key]; + }); + }, + }, + watch: { + status() { + this.resetCurrentServerSideSettings(); + }, + }, + methods: { + updateApplication() { + eventHub.$emit('updateApplication', { + id: FLUENTD, + params: { + port: this.port, + protocol: this.protocol, + host: this.host, + waf_log_enabled: this.wafLogEnabled, + cilium_log_enabled: this.ciliumLogEnabled, + }, + }); + }, + resetCurrentServerSideSettings() { + this.currentServerSideSettings = mapValues(this.currentServerSideSettings, () => { + return null; + }); + }, + resetStatus() { + const newSettings = mapValues(this.currentServerSideSettings, (value, key) => { + return value === null ? this[key] : value; + }); + eventHub.$emit('setFluentdSettings', { + ...newSettings, + isEditingSettings: false, + }); + }, + updateCurrentServerSideSettings(settings) { + Object.keys(settings).forEach(key => { + if (this.currentServerSideSettings[key] === null) { + this.currentServerSideSettings[key] = this[key]; + } + }); + }, + setFluentdSettings(settings) { + this.updateCurrentServerSideSettings(settings); + eventHub.$emit('setFluentdSettings', { + ...settings, + isEditingSettings: true, + }); + }, + selectProtocol(protocol) { + this.setFluentdSettings({ protocol }); + }, + hostChanged(host) { + this.setFluentdSettings({ host }); + }, + portChanged(port) { + this.setFluentdSettings({ port: Number(port) }); + }, + wafLogChanged(wafLogEnabled) { + this.setFluentdSettings({ wafLogEnabled }); + }, + ciliumLogChanged(ciliumLogEnabled) { + this.setFluentdSettings({ ciliumLogEnabled }); + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="updateFailed" class="mb-3" variant="danger" :dismissible="false"> + {{ + s__( + 'ClusterIntegration|Something went wrong while trying to save your settings. Please try again.', + ) + }} + </gl-alert> + <div class="form-horizontal"> + <div class="form-group"> + <label for="fluentd-host"> + <strong>{{ s__('ClusterIntegration|SIEM Hostname') }}</strong> + </label> + <input + id="fluentd-host" + :value="host" + type="text" + class="form-control" + @input="hostChanged($event.target.value)" + /> + </div> + <div class="form-group"> + <label for="fluentd-port"> + <strong>{{ s__('ClusterIntegration|SIEM Port') }}</strong> + </label> + <input + id="fluentd-port" + :value="port" + type="number" + class="form-control" + @input="portChanged($event.target.value)" + /> + </div> + <div class="form-group"> + <label for="fluentd-protocol"> + <strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong> + </label> + <gl-dropdown :text="protocolName" class="w-100"> + <gl-dropdown-item + v-for="(value, index) in protocols" + :key="index" + @click="selectProtocol(value.toLowerCase())" + > + {{ value }} + </gl-dropdown-item> + </gl-dropdown> + </div> + <div class="form-group flex flex-wrap"> + <gl-form-checkbox :checked="wafLogEnabled" @input="wafLogChanged"> + <strong>{{ s__('ClusterIntegration|Send ModSecurity Logs') }}</strong> + </gl-form-checkbox> + <gl-form-checkbox :checked="ciliumLogEnabled" @input="ciliumLogChanged"> + <strong>{{ s__('ClusterIntegration|Send Cilium Logs') }}</strong> + </gl-form-checkbox> + </div> + <div v-if="showButtons" class="mt-3"> + <gl-deprecated-button + ref="saveBtn" + class="mr-1" + variant="success" + :loading="isSaving" + :disabled="saveButtonDisabled" + @click="updateApplication" + > + {{ saveButtonLabel }} + </gl-deprecated-button> + <gl-deprecated-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus"> + {{ __('Cancel') }} + </gl-deprecated-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue index 95eb427a49c..c2f963f0b34 100644 --- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue +++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { s__, __ } from '../../locale'; import { APPLICATION_STATUS, INGRESS, LOGGING_MODE, BLOCKING_MODE } from '~/clusters/constants'; import { @@ -87,7 +87,7 @@ export default { ); }, ingressModSecurityDescription() { - return esc(this.ingressModSecurityHelpPath); + return escape(this.ingressModSecurityHelpPath); }, saving() { return [UPDATING].includes(this.ingress.status); diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue index b35adae3352..271f9f74838 100644 --- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue +++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import SplitButton from '~/vue_shared/components/split_button.vue'; import { GlModal, GlDeprecatedButton, GlFormInput } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; @@ -82,7 +82,7 @@ export default { ) : s__('ClusterIntegration|To remove your integration, type %{clusterName} to confirm:'), { - clusterName: `<code>${esc(this.clusterName)}</code>`, + clusterName: `<code>${escape(this.clusterName)}</code>`, }, false, ); diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 6c3046fc56b..60e179c54eb 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -53,6 +53,7 @@ export const CERT_MANAGER = 'cert_manager'; export const CROSSPLANE = 'crossplane'; export const PROMETHEUS = 'prometheus'; export const ELASTIC_STACK = 'elastic_stack'; +export const FLUENTD = 'fluentd'; export const APPLICATIONS = [ HELM, @@ -63,6 +64,7 @@ export const APPLICATIONS = [ CERT_MANAGER, PROMETHEUS, ELASTIC_STACK, + FLUENTD, ]; export const INGRESS_DOMAIN_SUFFIX = '.nip.io'; diff --git a/app/assets/javascripts/clusters/event_hub.js b/app/assets/javascripts/clusters/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/clusters/event_hub.js +++ b/app/assets/javascripts/clusters/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index 333fb293a15..2a6c6965dab 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -13,6 +13,7 @@ export default class ClusterService { jupyter: this.options.installJupyterEndpoint, knative: this.options.installKnativeEndpoint, elastic_stack: this.options.installElasticStackEndpoint, + fluentd: this.options.installFluentdEndpoint, }; this.appUpdateEndpointMap = { knative: this.options.updateKnativeEndpoint, diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index b09fd6800b6..9d354e66661 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -13,6 +13,7 @@ import { UPDATE_EVENT, UNINSTALL_EVENT, ELASTIC_STACK, + FLUENTD, } from '../constants'; import transitionApplicationState from '../services/application_state_machine'; @@ -103,6 +104,16 @@ export default class ClusterStore { ...applicationInitialState, title: s__('ClusterIntegration|Elastic Stack'), }, + fluentd: { + ...applicationInitialState, + title: s__('ClusterIntegration|Fluentd'), + host: null, + port: null, + protocol: null, + wafLogEnabled: null, + ciliumLogEnabled: null, + isEditingSettings: false, + }, }, environments: [], fetchingEnvironments: false, @@ -253,6 +264,14 @@ export default class ClusterStore { } else if (appId === ELASTIC_STACK) { this.state.applications.elastic_stack.version = version; this.state.applications.elastic_stack.updateAvailable = updateAvailable; + } else if (appId === FLUENTD) { + if (!this.state.applications.fluentd.isEditingSettings) { + this.state.applications.fluentd.port = serverAppEntry.port; + this.state.applications.fluentd.host = serverAppEntry.host; + this.state.applications.fluentd.protocol = serverAppEntry.protocol; + this.state.applications.fluentd.wafLogEnabled = serverAppEntry.waf_log_enabled; + this.state.applications.fluentd.ciliumLogEnabled = serverAppEntry.cilium_log_enabled; + } } }); } diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 46dacf30f39..af3f1437c64 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -1,61 +1,78 @@ <script> import { mapState, mapActions } from 'vuex'; -import { GlTable, GlLoadingIcon, GlBadge } from '@gitlab/ui'; +import { GlBadge, GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui'; import tooltip from '~/vue_shared/directives/tooltip'; import { CLUSTER_TYPES, STATUSES } from '../constants'; import { __, sprintf } from '~/locale'; export default { components: { - GlTable, - GlLoadingIcon, GlBadge, + GlLink, + GlLoadingIcon, + GlPagination, + GlTable, }, directives: { tooltip, }, - fields: [ - { - key: 'name', - label: __('Kubernetes cluster'), - }, - { - key: 'environmentScope', - label: __('Environment scope'), - }, - { - key: 'size', - label: __('Size'), - }, - { - key: 'cpu', - label: __('Total cores (vCPUs)'), + computed: { + ...mapState(['clusters', 'clustersPerPage', 'loading', 'page', 'totalCulsters']), + currentPage: { + get() { + return this.page; + }, + set(newVal) { + this.setPage(newVal); + this.fetchClusters(); + }, }, - { - key: 'memory', - label: __('Total memory (GB)'), + fields() { + return [ + { + key: 'name', + label: __('Kubernetes cluster'), + }, + { + 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: 'cluster_type', + label: __('Cluster level'), + formatter: value => CLUSTER_TYPES[value], + }, + ]; }, - { - key: 'clusterType', - label: __('Cluster level'), - formatter: value => CLUSTER_TYPES[value], + hasClusters() { + return this.clustersPerPage > 0; }, - ], - computed: { - ...mapState(['clusters', 'loading']), }, mounted() { - // TODO - uncomment this once integrated with BE - // this.fetchClusters(); + this.fetchClusters(); }, methods: { - ...mapActions(['fetchClusters']), + ...mapActions(['fetchClusters', 'setPage']), statusClass(status) { - return STATUSES[status].className; + const iconClass = STATUSES[status] || STATUSES.default; + return iconClass.className; }, statusTitle(status) { - const { title } = STATUSES[status]; - return sprintf(__('Status: %{title}'), { title }, false); + const iconTitle = STATUSES[status] || STATUSES.default; + return sprintf(__('Status: %{title}'), { title: iconTitle.title }, false); }, }, }; @@ -63,37 +80,46 @@ export default { <template> <gl-loading-icon v-if="loading" size="md" class="mt-3" /> - <gl-table - v-else - :items="clusters" - :fields="$options.fields" - stacked="md" - variant="light" - class="qa-clusters-table" - > - <template #cell(name)="{ item }"> - <div class="d-flex flex-row-reverse flex-md-row js-status"> - {{ item.name }} - <gl-loading-icon - v-if="item.status === 'deleting'" - 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-8 gl-h-8 mr-2 ml-md-2" - :class="statusClass(item.status)" - :title="statusTitle(item.status)" - ></div> - </div> - </template> - <template #cell(clusterType)="{value}"> - <gl-badge variant="light"> - {{ value }} - </gl-badge> - </template> - </gl-table> + + <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"> + {{ item.name }} + </gl-link> + + <gl-loading-icon + v-if="item.status === 'deleting'" + 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(cluster_type)="{value}"> + <gl-badge variant="light"> + {{ value }} + </gl-badge> + </template> + </gl-table> + + <gl-pagination + v-if="hasClusters" + v-model="currentPage" + :per-page="clustersPerPage" + :total-items="totalCulsters" + :prev-text="__('Prev')" + :next-text="__('Next')" + align="center" + /> + </section> </template> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 9428f08176c..eebcaa086f9 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -7,8 +7,9 @@ export const CLUSTER_TYPES = { }; export const STATUSES = { + default: { className: 'bg-white', title: __('Unknown') }, disabled: { className: 'disabled', title: __('Disabled') }, - connected: { className: 'bg-success', title: __('Connected') }, + created: { className: 'bg-success', title: __('Connected') }, unreachable: { className: 'bg-danger', title: __('Unreachable') }, authentication_failure: { className: 'bg-warning', title: __('Authentication Failure') }, deleting: { title: __('Deleting') }, diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 79bc9932438..919625f69b4 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -1,36 +1,35 @@ -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; import axios from '~/lib/utils/axios_utils'; -import Visibility from 'visibilityjs'; import flash from '~/flash'; import { __ } from '~/locale'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import * as types from './mutation_types'; export const fetchClusters = ({ state, commit }) => { const poll = new Poll({ resource: { - fetchClusters: endpoint => axios.get(endpoint), + fetchClusters: paginatedEndPoint => axios.get(paginatedEndPoint), }, - data: state.endpoint, + data: `${state.endpoint}?page=${state.page}`, method: 'fetchClusters', - successCallback: ({ data }) => { - commit(types.SET_CLUSTERS_DATA, convertObjectPropsToCamelCase(data, { deep: true })); - commit(types.SET_LOADING_STATE, false); + successCallback: ({ data, headers }) => { + if (data.clusters) { + const normalizedHeaders = normalizeHeaders(headers); + const paginationInformation = parseIntPagination(normalizedHeaders); + + commit(types.SET_CLUSTERS_DATA, { data, paginationInformation }); + commit(types.SET_LOADING_STATE, false); + poll.stop(); + } }, errorCallback: () => flash(__('An error occurred while loading clusters')), }); - if (!Visibility.hidden()) { - poll.makeRequest(); - } + poll.makeRequest(); +}; - Visibility.change(() => { - if (!Visibility.hidden()) { - poll.restart(); - } else { - poll.stop(); - } - }); +export const setPage = ({ commit }, page) => { + commit(types.SET_PAGE, page); }; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/clusters_list/store/mutation_types.js b/app/assets/javascripts/clusters_list/store/mutation_types.js index f056f3ab7d9..a5275f28c13 100644 --- a/app/assets/javascripts/clusters_list/store/mutation_types.js +++ b/app/assets/javascripts/clusters_list/store/mutation_types.js @@ -1,2 +1,3 @@ export const SET_CLUSTERS_DATA = 'SET_CLUSTERS_DATA'; export const SET_LOADING_STATE = 'SET_LOADING_STATE'; +export const SET_PAGE = 'SET_PAGE'; diff --git a/app/assets/javascripts/clusters_list/store/mutations.js b/app/assets/javascripts/clusters_list/store/mutations.js index ffd3c4601bf..2a9df9f38f0 100644 --- a/app/assets/javascripts/clusters_list/store/mutations.js +++ b/app/assets/javascripts/clusters_list/store/mutations.js @@ -4,9 +4,15 @@ export default { [types.SET_LOADING_STATE](state, value) { state.loading = value; }, - [types.SET_CLUSTERS_DATA](state, clusters) { + [types.SET_CLUSTERS_DATA](state, { data, paginationInformation }) { Object.assign(state, { - clusters, + clusters: data.clusters, + clustersPerPage: paginationInformation.perPage, + hasAncestorClusters: data.has_ancestor_clusters, + totalCulsters: paginationInformation.total, }); }, + [types.SET_PAGE](state, value) { + state.page = Number(value) || 1; + }, }; diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js index ed032ed8435..d590ea09e66 100644 --- a/app/assets/javascripts/clusters_list/store/state.js +++ b/app/assets/javascripts/clusters_list/store/state.js @@ -1,5 +1,9 @@ export default (initialState = {}) => ({ endpoint: initialState.endpoint, - loading: false, // TODO - set this to true once integrated with BE + hasAncestorClusters: false, + loading: true, clusters: [], + clustersPerPage: 0, + page: 1, + totalCulsters: 0, }); diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue index d738c914125..85ec0a60ec5 100644 --- a/app/assets/javascripts/code_navigation/components/app.vue +++ b/app/assets/javascripts/code_navigation/components/app.vue @@ -8,7 +8,12 @@ export default { Popover, }, computed: { - ...mapState(['currentDefinition', 'currentDefinitionPosition', 'definitionPathPrefix']), + ...mapState([ + 'currentDefinition', + 'currentDefinitionPosition', + 'currentBlobPath', + 'definitionPathPrefix', + ]), }, mounted() { this.body = document.body; @@ -44,5 +49,6 @@ export default { :position="currentDefinitionPosition" :data="currentDefinition" :definition-path-prefix="definitionPathPrefix" + :blob-path="currentBlobPath" /> </template> diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue index b4d9bc7b181..7147ce227e8 100644 --- a/app/assets/javascripts/code_navigation/components/popover.vue +++ b/app/assets/javascripts/code_navigation/components/popover.vue @@ -1,9 +1,9 @@ <script> -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; export default { components: { - GlDeprecatedButton, + GlButton, }, props: { position: { @@ -18,6 +18,10 @@ export default { type: String, required: true, }, + blobPath: { + type: String, + required: true, + }, }, data() { return { @@ -32,9 +36,18 @@ export default { }; }, definitionPath() { - return ( - this.data.definition_path && `${this.definitionPathPrefix}/${this.data.definition_path}` - ); + if (!this.data.definition_path) { + return null; + } + + if (this.isDefinitionCurrentBlob) { + return `#${this.data.definition_path.split('#').pop()}`; + } + + return `${this.definitionPathPrefix}/${this.data.definition_path}`; + }, + isDefinitionCurrentBlob() { + return this.data.definition_path.indexOf(this.blobPath) === 0; }, }, watch: { @@ -77,9 +90,15 @@ export default { </p> </div> <div v-if="definitionPath" class="popover-body"> - <gl-deprecated-button :href="definitionPath" target="_blank" class="w-100" variant="default"> + <gl-button + :href="definitionPath" + :target="isDefinitionCurrentBlob ? null : '_blank'" + class="w-100" + variant="default" + data-testid="go-to-definition-btn" + > {{ __('Go to definition') }} - </gl-deprecated-button> + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js index 6ecede32944..7b2669691bd 100644 --- a/app/assets/javascripts/code_navigation/store/actions.js +++ b/app/assets/javascripts/code_navigation/store/actions.js @@ -30,7 +30,9 @@ export default { }); }, showBlobInteractionZones({ state }, path) { - Object.values(state.data[path]).forEach(d => addInteractionClass(path, d)); + if (state.data && state.data[path]) { + Object.values(state.data[path]).forEach(d => addInteractionClass(path, d)); + } }, showDefinition({ commit, state }, { target: el }) { let definition; @@ -52,7 +54,8 @@ export default { return; } - const data = state.data[blobEl.dataset.path]; + const blobPath = blobEl.dataset.path; + const data = state.data[blobPath]; if (!data) return; @@ -72,6 +75,6 @@ export default { setCurrentHoverElement(el); } - commit(types.SET_CURRENT_DEFINITION, { definition, position }); + commit(types.SET_CURRENT_DEFINITION, { definition, position, blobPath }); }, }; diff --git a/app/assets/javascripts/code_navigation/store/mutations.js b/app/assets/javascripts/code_navigation/store/mutations.js index 84b1c264418..07b190c7476 100644 --- a/app/assets/javascripts/code_navigation/store/mutations.js +++ b/app/assets/javascripts/code_navigation/store/mutations.js @@ -15,8 +15,9 @@ export default { [types.REQUEST_DATA_ERROR](state) { state.loading = false; }, - [types.SET_CURRENT_DEFINITION](state, { definition, position }) { + [types.SET_CURRENT_DEFINITION](state, { definition, position, blobPath }) { state.currentDefinition = definition; state.currentDefinitionPosition = position; + state.currentBlobPath = blobPath; }, }; diff --git a/app/assets/javascripts/code_navigation/store/state.js b/app/assets/javascripts/code_navigation/store/state.js index ffe44ec5381..569d2f7b319 100644 --- a/app/assets/javascripts/code_navigation/store/state.js +++ b/app/assets/javascripts/code_navigation/store/state.js @@ -4,4 +4,5 @@ export default () => ({ data: null, currentDefinition: null, currentDefinitionPosition: null, + currentBlobPath: null, }); diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js index a259667bb75..2fcd40a901d 100644 --- a/app/assets/javascripts/comment_type_toggle.js +++ b/app/assets/javascripts/comment_type_toggle.js @@ -2,7 +2,7 @@ import DropLab from './droplab/drop_lab'; import ISetter from './droplab/plugins/input_setter'; // Todo: Remove this when fixing issue in input_setter plugin -const InputSetter = Object.assign({}, ISetter); +const InputSetter = { ...ISetter }; class CommentTypeToggle { constructor(opts = {}) { diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index fb8b1c17407..ddb129f36f4 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-else-return, consistent-return, one-var, no-return-assign */ +/* eslint-disable func-names, consistent-return, one-var, no-return-assign */ import $ from 'jquery'; @@ -201,9 +201,8 @@ export default class ImageFile { if (domImg) { if (domImg.complete) { return callback.call(this, domImg.naturalWidth, domImg.naturalHeight); - } else { - return img.on('load', () => callback.call(this, domImg.naturalWidth, domImg.naturalHeight)); } + return img.on('load', () => callback.call(this, domImg.naturalWidth, domImg.naturalHeight)); } } } diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index ad0f6cc1496..e0d012cef23 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -1,4 +1,3 @@ -import 'underscore'; import './polyfills'; import './jquery'; import './bootstrap'; diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index a23707209dc..4539b9a39ef 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-else-return */ +/* eslint-disable func-names */ import $ from 'jquery'; import { __ } from './locale'; @@ -52,9 +52,8 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = ( return $('<li />') .addClass('dropdown-header') .text(ref.header); - } else { - return $('<li />').append(link); } + return $('<li />').append(link); }, id(obj, $el) { return $el.attr('data-ref'); diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue index 444640980af..92a5423d5ea 100644 --- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue +++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue @@ -39,7 +39,7 @@ export default { <template> <gl-dropdown toggle-class="d-flex align-items-center w-100" class="w-100"> - <template slot="button-content"> + <template #button-content> <span class="str-truncated-100 mr-2"> <icon name="lock" /> {{ dropdownText }} diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index 74b5a62f754..d6c402fcb5d 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -1,6 +1,6 @@ <script> import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { GlFormInput, GlFormCheckbox } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; @@ -137,7 +137,7 @@ export default { : s__('ClusterIntegration|Create Kubernetes cluster'); }, kubernetesIntegrationHelpText() { - const escapedUrl = esc(this.kubernetesIntegrationHelpPath); + const escapedUrl = escape(this.kubernetesIntegrationHelpPath); return sprintf( s__( @@ -256,7 +256,7 @@ export default { ); }, gitlabManagedHelpText() { - const escapedUrl = esc(this.gitlabManagedClusterHelpPath); + const escapedUrl = escape(this.gitlabManagedClusterHelpPath); return sprintf( s__( diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue index 47cc4e4ce67..e063f9edfd9 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue @@ -1,6 +1,6 @@ <script> import { GlFormInput } from '@gitlab/ui'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { mapState, mapActions } from 'vuex'; import { sprintf, s__, __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -42,7 +42,7 @@ export default { : s__('ClusterIntegration|Authenticate with AWS'); }, accountAndExternalIdsHelpText() { - const escapedUrl = esc(this.accountAndExternalIdsHelpPath); + const escapedUrl = escape(this.accountAndExternalIdsHelpPath); return sprintf( s__( @@ -59,7 +59,7 @@ export default { ); }, provisionRoleArnHelpText() { - const escapedUrl = esc(this.createRoleArnHelpPath); + const escapedUrl = escape(this.createRoleArnHelpPath); return sprintf( s__( diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue index 6d8e6bbac11..b0bec10f64d 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { mapState, mapGetters, mapActions } from 'vuex'; import { s__, sprintf } from '~/locale'; @@ -65,7 +65,7 @@ export default { s__(message), { docsLinkEnd: ' <i class="fa fa-external-link" aria-hidden="true"></i></a>', - docsLinkStart: `<a href="${esc( + docsLinkStart: `<a href="${escape( this.docsUrl, )}" target="_blank" rel="noopener noreferrer">`, }, diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js index 523e5592fd0..ec09dafebcb 100644 --- a/app/assets/javascripts/create_item_dropdown.js +++ b/app/assets/javascripts/create_item_dropdown.js @@ -1,4 +1,4 @@ -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import '~/gl_dropdown'; export default class CreateItemDropdown { @@ -37,14 +37,14 @@ export default class CreateItemDropdown { }, selectable: true, toggleLabel(selected) { - return selected && 'id' in selected ? esc(selected.title) : this.defaultToggleLabel; + return selected && 'id' in selected ? escape(selected.title) : this.defaultToggleLabel; }, fieldName: this.fieldName, text(item) { - return esc(item.text); + return escape(item.text); }, id(item) { - return esc(item.id); + return escape(item.id); }, onFilter: this.toggleCreateNewButton.bind(this), clicked: options => { diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index ba585444ba5..801566d2f2f 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -13,7 +13,7 @@ import { import confidentialMergeRequestState from './confidential_merge_request/state'; // Todo: Remove this when fixing issue in input_setter plugin -const InputSetter = Object.assign({}, ISetter); +const InputSetter = { ...ISetter }; const CREATE_MERGE_REQUEST = 'create-mr'; const CREATE_BRANCH = 'create-branch'; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 304a0726597..4f9069f61a5 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -84,7 +84,7 @@ export default { events.forEach(item => { if (!item) return; - const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item); + const eventItem = { ...DEFAULT_EVENT_OBJECTS[stage.slug], ...item }; eventItem.totalTime = eventItem.total_time; diff --git a/app/assets/javascripts/deploy_keys/eventhub.js b/app/assets/javascripts/deploy_keys/eventhub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/deploy_keys/eventhub.js +++ b/app/assets/javascripts/deploy_keys/eventhub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/design_management/components/app.vue b/app/assets/javascripts/design_management/components/app.vue new file mode 100644 index 00000000000..98240aef810 --- /dev/null +++ b/app/assets/javascripts/design_management/components/app.vue @@ -0,0 +1,3 @@ +<template> + <router-view /> +</template> diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue new file mode 100644 index 00000000000..1fd902c9ed7 --- /dev/null +++ b/app/assets/javascripts/design_management/components/delete_button.vue @@ -0,0 +1,64 @@ +<script> +import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; + +export default { + name: 'DeleteButton', + components: { + GlDeprecatedButton, + GlModal, + }, + directives: { + GlModalDirective, + }, + props: { + isDeleting: { + type: Boolean, + required: false, + default: false, + }, + buttonClass: { + type: String, + required: false, + default: '', + }, + buttonVariant: { + type: String, + required: false, + default: '', + }, + hasSelectedDesigns: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + modalId: uniqueId('design-deletion-confirmation-'), + }; + }, +}; +</script> + +<template> + <div> + <gl-modal + :modal-id="modalId" + :title="s__('DesignManagement|Delete designs confirmation')" + :ok-title="s__('DesignManagement|Delete')" + ok-variant="danger" + @ok="$emit('deleteSelectedDesigns')" + > + <p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p> + </gl-modal> + <gl-deprecated-button + v-gl-modal-directive="modalId" + :variant="buttonVariant" + :disabled="isDeleting || !hasSelectedDesigns" + :class="buttonClass" + > + <slot></slot> + </gl-deprecated-button> + </div> +</template> diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue new file mode 100644 index 00000000000..ad3f2736c4a --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_destroyer.vue @@ -0,0 +1,66 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import destroyDesignMutation from '../graphql/mutations/destroyDesign.mutation.graphql'; +import { updateStoreAfterDesignsDelete } from '../utils/cache_update'; + +export default { + components: { + ApolloMutation, + }, + props: { + filenames: { + type: Array, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + iid: { + type: String, + required: true, + }, + }, + computed: { + projectQueryBody() { + return { + query: getDesignListQuery, + variables: { fullPath: this.projectPath, iid: this.iid, atVersion: null }, + }; + }, + }, + methods: { + updateStoreAfterDelete( + store, + { + data: { designManagementDelete }, + }, + ) { + updateStoreAfterDesignsDelete( + store, + designManagementDelete, + this.projectQueryBody, + this.filenames, + ); + }, + }, + destroyDesignMutation, +}; +</script> + +<template> + <apollo-mutation + #default="{ mutate, loading, error }" + :mutation="$options.destroyDesignMutation" + :variables="{ + filenames, + projectPath, + iid, + }" + :update="updateStoreAfterDelete" + v-on="$listeners" + > + <slot v-bind="{ mutate, loading, error }"></slot> + </apollo-mutation> +</template> diff --git a/app/assets/javascripts/design_management/components/design_note_pin.vue b/app/assets/javascripts/design_management/components/design_note_pin.vue new file mode 100644 index 00000000000..50ea69d52ce --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_note_pin.vue @@ -0,0 +1,61 @@ +<script> +import { __, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'DesignNotePin', + components: { + Icon, + }, + props: { + position: { + type: Object, + required: true, + }, + label: { + type: String, + required: false, + default: null, + }, + repositioning: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + isNewNote() { + return this.label === null; + }, + pinStyle() { + return this.repositioning ? { ...this.position, cursor: 'move' } : this.position; + }, + pinLabel() { + return this.isNewNote + ? __('Comment form position') + : sprintf(__("Comment '%{label}' position"), { label: this.label }); + }, + }, +}; +</script> + +<template> + <button + :style="pinStyle" + :aria-label="pinLabel" + :class="{ + 'btn-transparent comment-indicator': isNewNote, + 'js-image-badge badge badge-pill': !isNewNote, + }" + class="position-absolute" + type="button" + @mousedown="$emit('mousedown', $event)" + @mouseup="$emit('mouseup', $event)" + @click="$emit('click', $event)" + > + <icon v-if="isNewNote" name="image-comment-dark" /> + <template v-else> + {{ label }} + </template> + </button> +</template> 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 new file mode 100644 index 00000000000..c6c5ee88a93 --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -0,0 +1,169 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import allVersionsMixin from '../../mixins/all_versions'; +import createNoteMutation from '../../graphql/mutations/createNote.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'; + +export default { + components: { + ApolloMutation, + DesignNote, + ReplyPlaceholder, + DesignReplyForm, + }, + mixins: [allVersionsMixin], + props: { + discussion: { + type: Object, + required: true, + }, + noteableId: { + type: String, + required: true, + }, + designId: { + type: String, + required: true, + }, + discussionIndex: { + type: Number, + required: true, + }, + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + }, + apollo: { + activeDiscussion: { + query: activeDiscussionQuery, + result({ data }) { + const discussionId = data.activeDiscussion.id; + // 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 ( + discussionId && + data.activeDiscussion.source === ACTIVE_DISCUSSION_SOURCE_TYPES.pin && + discussionId === this.discussion.notes[0].id + ) { + this.$el.scrollIntoView({ + behavior: 'smooth', + inline: 'start', + }); + } + }, + }, + }, + data() { + return { + discussionComment: '', + isFormRendered: false, + activeDiscussion: {}, + }; + }, + computed: { + mutationPayload() { + return { + noteableId: this.noteableId, + body: this.discussionComment, + discussionId: this.discussion.id, + }; + }, + designVariables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + filenames: [this.$route.params.id], + atVersion: this.designsVersion, + }; + }, + isDiscussionHighlighted() { + return this.discussion.notes[0].id === this.activeDiscussion.id; + }, + }, + methods: { + addDiscussionComment( + store, + { + data: { createNote }, + }, + ) { + updateStoreAfterAddDiscussionComment( + store, + createNote, + getDesignQuery, + this.designVariables, + this.discussion.id, + ); + }, + onDone() { + this.discussionComment = ''; + this.hideForm(); + }, + onError(err) { + this.$emit('error', err); + }, + hideForm() { + this.isFormRendered = false; + this.discussionComment = ''; + }, + showForm() { + this.isFormRendered = true; + }, + }, + createNoteMutation, +}; +</script> + +<template> + <div class="design-discussion-wrapper"> + <div class="badge badge-pill" type="button">{{ discussionIndex }}</div> + <div + class="design-discussion bordered-box position-relative" + data-qa-selector="design_discussion_content" + > + <design-note + v-for="note in discussion.notes" + :key="note.id" + :note="note" + :markdown-preview-path="markdownPreviewPath" + :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" + @error="$emit('updateNoteError', $event)" + /> + <div class="reply-wrapper"> + <reply-placeholder + v-if="!isFormRendered" + class="qa-discussion-reply" + :button-text="__('Reply...')" + @onClick="showForm" + /> + <apollo-mutation + v-else + #default="{ mutate, loading }" + :mutation="$options.createNoteMutation" + :variables="{ + input: mutationPayload, + }" + :update="addDiscussionComment" + @done="onDone" + @error="onError" + > + <design-reply-form + v-model="discussionComment" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + @submitForm="mutate" + @cancelForm="hideForm" + /> + </apollo-mutation> + </div> + </div> + </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 new file mode 100644 index 00000000000..c1c19c0a597 --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -0,0 +1,148 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import DesignReplyForm from './design_reply_form.vue'; +import { findNoteId } from '../../utils/design_management_utils'; +import { hasErrors } from '../../utils/cache_update'; + +export default { + components: { + UserAvatarLink, + TimelineEntryItem, + TimeAgoTooltip, + DesignReplyForm, + ApolloMutation, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + note: { + type: Object, + required: true, + }, + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + noteText: this.note.body, + isEditing: false, + }; + }, + computed: { + author() { + return this.note.author; + }, + noteAnchorId() { + return findNoteId(this.note.id); + }, + isNoteLinked() { + return this.$route.hash === `#note_${this.noteAnchorId}`; + }, + mutationPayload() { + return { + id: this.note.id, + body: this.noteText, + }; + }, + }, + mounted() { + if (this.isNoteLinked) { + this.$refs.anchor.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' }); + } + }, + methods: { + hideForm() { + this.isEditing = false; + this.noteText = this.note.body; + }, + onDone({ data }) { + this.hideForm(); + if (hasErrors(data.updateNote)) { + this.$emit('error', data.errors[0]); + } + }, + }, + updateNoteMutation, +}; +</script> + +<template> + <timeline-entry-item :id="`note_${noteAnchorId}`" ref="anchor" class="design-note note-form"> + <user-avatar-link + :link-href="author.webUrl" + :img-src="author.avatarUrl" + :img-alt="author.username" + :img-size="40" + /> + <div class="d-flex justify-content-between"> + <div> + <a + v-once + :href="author.webUrl" + class="js-user-link" + :data-user-id="author.id" + :data-username="author.username" + > + <span class="note-header-author-name bold">{{ author.name }}</span> + <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> + <span class="note-headline-light">@{{ author.username }}</span> + </a> + <span class="note-headline-light note-headline-meta"> + <span class="system-note-message"> <slot></slot> </span> + <template v-if="note.createdAt"> + <span class="system-note-separator"></span> + <a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`"> + <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" /> + </a> + </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> + <div + v-if="!isEditing" + class="note-text js-note-text md" + data-qa-selector="note_content" + v-html="note.bodyHtml" + ></div> + <apollo-mutation + v-else + #default="{ mutate, loading }" + :mutation="$options.updateNoteMutation" + :variables="{ + input: mutationPayload, + }" + @error="$emit('error', $event)" + @done="onDone" + > + <design-reply-form + v-model="noteText" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + :is-new-comment="false" + class="mt-5" + @submitForm="mutate" + @cancelForm="hideForm" + /> + </apollo-mutation> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue new file mode 100644 index 00000000000..40be9867fee --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -0,0 +1,137 @@ +<script> +import { GlDeprecatedButton, GlModal } from '@gitlab/ui'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { s__ } from '~/locale'; + +export default { + name: 'DesignReplyForm', + components: { + MarkdownField, + GlDeprecatedButton, + GlModal, + }, + props: { + markdownPreviewPath: { + type: String, + required: false, + default: '', + }, + value: { + type: String, + required: true, + }, + isSaving: { + type: Boolean, + required: true, + }, + isNewComment: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + formText: this.value, + }; + }, + computed: { + hasValue() { + return this.value.trim().length > 0; + }, + modalSettings() { + if (this.isNewComment) { + return { + title: s__('DesignManagement|Cancel comment confirmation'), + okTitle: s__('DesignManagement|Discard comment'), + cancelTitle: s__('DesignManagement|Keep comment'), + content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'), + }; + } + return { + title: s__('DesignManagement|Cancel comment update confirmation'), + okTitle: s__('DesignManagement|Cancel changes'), + cancelTitle: s__('DesignManagement|Keep changes'), + content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'), + }; + }, + buttonText() { + return this.isNewComment + ? s__('DesignManagement|Comment') + : s__('DesignManagement|Save comment'); + }, + }, + mounted() { + this.$refs.textarea.focus(); + }, + methods: { + submitForm() { + if (this.hasValue) this.$emit('submitForm'); + }, + cancelComment() { + if (this.hasValue && this.formText !== this.value) { + this.$refs.cancelCommentModal.show(); + } else { + this.$emit('cancelForm'); + } + }, + }, +}; +</script> + +<template> + <form class="new-note common-note-form" @submit.prevent> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :can-attach-file="false" + :enable-autocomplete="true" + :textarea-value="value" + markdown-docs-path="/help/user/markdown" + class="bordered-box" + > + <template #textarea> + <textarea + ref="textarea" + :value="value" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-supports-quick-actions="false" + data-qa-selector="note_textarea" + :aria-label="__('Description')" + :placeholder="__('Write a comment…')" + @input="$emit('input', $event.target.value)" + @keydown.meta.enter="submitForm" + @keydown.ctrl.enter="submitForm" + @keyup.esc.stop="cancelComment" + > + </textarea> + </template> + </markdown-field> + <div class="note-form-actions d-flex justify-content-between"> + <gl-deprecated-button + ref="submitButton" + :disabled="!hasValue || isSaving" + variant="success" + type="submit" + data-track-event="click_button" + data-qa-selector="save_comment_button" + @click="$emit('submitForm')" + > + {{ buttonText }} + </gl-deprecated-button> + <gl-deprecated-button ref="cancelButton" @click="cancelComment">{{ + __('Cancel') + }}</gl-deprecated-button> + </div> + <gl-modal + ref="cancelCommentModal" + ok-variant="danger" + :title="modalSettings.title" + :ok-title="modalSettings.okTitle" + :cancel-title="modalSettings.cancelTitle" + modal-id="cancel-comment-modal" + @ok="$emit('cancelForm')" + >{{ modalSettings.content }} + </gl-modal> + </form> +</template> diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue new file mode 100644 index 00000000000..beb51647821 --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -0,0 +1,279 @@ +<script> +import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql'; +import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; +import DesignNotePin from './design_note_pin.vue'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; + +export default { + name: 'DesignOverlay', + components: { + DesignNotePin, + }, + props: { + dimensions: { + type: Object, + required: true, + }, + position: { + type: Object, + required: true, + }, + notes: { + type: Array, + required: false, + default: () => [], + }, + currentCommentForm: { + type: Object, + required: false, + default: null, + }, + disableCommenting: { + type: Boolean, + required: false, + default: false, + }, + }, + apollo: { + activeDiscussion: { + query: activeDiscussionQuery, + }, + }, + data() { + return { + movingNoteNewPosition: null, + movingNoteStartPosition: null, + activeDiscussion: {}, + }; + }, + computed: { + overlayStyle() { + const cursor = this.disableCommenting ? 'unset' : undefined; + + return { + cursor, + width: `${this.dimensions.width}px`, + height: `${this.dimensions.height}px`, + ...this.position, + }; + }, + isMovingCurrentComment() { + return Boolean(this.movingNoteStartPosition && !this.movingNoteStartPosition.noteId); + }, + currentCommentPositionStyle() { + return this.isMovingCurrentComment && this.movingNoteNewPosition + ? this.getNotePositionStyle(this.movingNoteNewPosition) + : this.getNotePositionStyle(this.currentCommentForm); + }, + }, + methods: { + setNewNoteCoordinates({ x, y }) { + this.$emit('openCommentForm', { x, y }); + }, + getNoteRelativePosition(position) { + const { x, y, width, height } = position; + const widthRatio = this.dimensions.width / width; + const heightRatio = this.dimensions.height / height; + return { + left: Math.round(x * widthRatio), + top: Math.round(y * heightRatio), + }; + }, + getNotePositionStyle(position) { + const { left, top } = this.getNoteRelativePosition(position); + return { + left: `${left}px`, + top: `${top}px`, + }; + }, + getMovingNotePositionDelta(e) { + let deltaX = 0; + let deltaY = 0; + + if (this.movingNoteStartPosition) { + const { clientX, clientY } = this.movingNoteStartPosition; + deltaX = e.clientX - clientX; + deltaY = e.clientY - clientY; + } + + return { + deltaX, + deltaY, + }; + }, + isMovingNote(noteId) { + const movingNoteId = this.movingNoteStartPosition?.noteId; + return Boolean(movingNoteId && movingNoteId === noteId); + }, + canMoveNote(note) { + const { userPermissions } = note; + const { adminNote } = userPermissions || {}; + + return Boolean(adminNote); + }, + isPositionInOverlay(position) { + const { top, left } = this.getNoteRelativePosition(position); + const { height, width } = this.dimensions; + + return top >= 0 && top <= height && left >= 0 && left <= width; + }, + onNewNoteMove(e) { + if (!this.isMovingCurrentComment) return; + + const { deltaX, deltaY } = this.getMovingNotePositionDelta(e); + const x = this.currentCommentForm.x + deltaX; + const y = this.currentCommentForm.y + deltaY; + + const movingNoteNewPosition = { + x, + y, + width: this.dimensions.width, + height: this.dimensions.height, + }; + + if (!this.isPositionInOverlay(movingNoteNewPosition)) { + this.onNewNoteMouseup(); + return; + } + + this.movingNoteNewPosition = movingNoteNewPosition; + }, + onExistingNoteMove(e) { + const note = this.notes.find(({ id }) => id === this.movingNoteStartPosition.noteId); + if (!note) return; + + const { position } = note; + const { width, height } = position; + const widthRatio = this.dimensions.width / width; + const heightRatio = this.dimensions.height / height; + + const { deltaX, deltaY } = this.getMovingNotePositionDelta(e); + const x = position.x * widthRatio + deltaX; + const y = position.y * heightRatio + deltaY; + + const movingNoteNewPosition = { + x, + y, + width: this.dimensions.width, + height: this.dimensions.height, + }; + + if (!this.isPositionInOverlay(movingNoteNewPosition)) { + this.onExistingNoteMouseup(); + return; + } + + this.movingNoteNewPosition = movingNoteNewPosition; + }, + onNewNoteMouseup() { + if (!this.movingNoteNewPosition) return; + + const { x, y } = this.movingNoteNewPosition; + this.setNewNoteCoordinates({ x, y }); + }, + onExistingNoteMouseup(note) { + if (!this.movingNoteStartPosition || !this.movingNoteNewPosition) { + this.updateActiveDiscussion(note.id); + this.$emit('closeCommentForm'); + return; + } + + const { x, y } = this.movingNoteNewPosition; + this.$emit('moveNote', { + noteId: this.movingNoteStartPosition.noteId, + discussionId: this.movingNoteStartPosition.discussionId, + coordinates: { x, y }, + }); + }, + onNoteMousedown({ clientX, clientY }, note) { + if (note && !this.canMoveNote(note)) return; + + this.movingNoteStartPosition = { + noteId: note?.id, + discussionId: note?.discussion.id, + clientX, + clientY, + }; + }, + onOverlayMousemove(e) { + if (!this.movingNoteStartPosition) return; + + if (this.isMovingCurrentComment) { + this.onNewNoteMove(e); + } else { + this.onExistingNoteMove(e); + } + }, + onNoteMouseup(note) { + if (!this.movingNoteStartPosition) return; + + if (this.isMovingCurrentComment) { + this.onNewNoteMouseup(); + } else { + this.onExistingNoteMouseup(note); + } + + this.movingNoteStartPosition = null; + this.movingNoteNewPosition = null; + }, + onAddCommentMouseup({ offsetX, offsetY }) { + if (this.disableCommenting) return; + if (this.activeDiscussion.id) { + this.updateActiveDiscussion(); + } + + this.setNewNoteCoordinates({ x: offsetX, y: offsetY }); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin, + }, + }); + }, + isNoteInactive(note) { + return this.activeDiscussion.id && this.activeDiscussion.id !== note.id; + }, + }, +}; +</script> + +<template> + <div + class="position-absolute image-diff-overlay frame" + :style="overlayStyle" + @mousemove="onOverlayMousemove" + @mouseleave="onNoteMouseup" + > + <button + v-show="!disableCommenting" + type="button" + class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button" + 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)" + /> + <design-note-pin + v-if="currentCommentForm" + :position="currentCommentPositionStyle" + :repositioning="isMovingCurrentComment" + @mousedown.stop="onNoteMousedown" + @mouseup.stop="onNoteMouseup" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management/components/design_presentation.vue b/app/assets/javascripts/design_management/components/design_presentation.vue new file mode 100644 index 00000000000..5c113b3dbed --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_presentation.vue @@ -0,0 +1,314 @@ +<script> +import { throttle } from 'lodash'; +import DesignImage from './image.vue'; +import DesignOverlay from './design_overlay.vue'; + +const CLICK_DRAG_BUFFER_PX = 2; + +export default { + components: { + DesignImage, + DesignOverlay, + }, + props: { + image: { + type: String, + required: false, + default: '', + }, + imageName: { + type: String, + required: false, + default: '', + }, + discussions: { + type: Array, + required: true, + }, + isAnnotating: { + type: Boolean, + required: false, + default: false, + }, + scale: { + type: Number, + required: false, + default: 1, + }, + }, + data() { + return { + overlayDimensions: null, + overlayPosition: null, + currentAnnotationPosition: null, + zoomFocalPoint: { + x: 0, + y: 0, + width: 0, + height: 0, + }, + initialLoad: true, + lastDragPosition: null, + isDraggingDesign: false, + }; + }, + computed: { + discussionStartingNotes() { + return this.discussions.map(discussion => discussion.notes[0]); + }, + currentCommentForm() { + return (this.isAnnotating && this.currentAnnotationPosition) || null; + }, + presentationStyle() { + return { + cursor: this.isDraggingDesign ? 'grabbing' : undefined, + }; + }, + }, + beforeDestroy() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + presentationViewport.removeEventListener('scroll', this.scrollThrottled, false); + }, + mounted() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + this.scrollThrottled = throttle(() => { + this.shiftZoomFocalPoint(); + }, 400); + + presentationViewport.addEventListener('scroll', this.scrollThrottled, false); + }, + methods: { + syncCurrentAnnotationPosition() { + if (!this.currentAnnotationPosition) return; + + const widthRatio = this.overlayDimensions.width / this.currentAnnotationPosition.width; + const heightRatio = this.overlayDimensions.height / this.currentAnnotationPosition.height; + const x = this.currentAnnotationPosition.x * widthRatio; + const y = this.currentAnnotationPosition.y * heightRatio; + + this.currentAnnotationPosition = this.getAnnotationPositon({ x, y }); + }, + setOverlayDimensions(overlayDimensions) { + this.overlayDimensions = overlayDimensions; + + // every time we set overlay dimensions, we need to + // update the current annotation as well + this.syncCurrentAnnotationPosition(); + }, + setOverlayPosition() { + if (!this.overlayDimensions) { + this.overlayPosition = {}; + } + + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + // default to center + this.overlayPosition = { + left: `calc(50% - ${this.overlayDimensions.width / 2}px)`, + top: `calc(50% - ${this.overlayDimensions.height / 2}px)`, + }; + + // if the overlay overflows, then don't center + if (this.overlayDimensions.width > presentationViewport.offsetWidth) { + this.overlayPosition.left = '0'; + } + if (this.overlayDimensions.height > presentationViewport.offsetHeight) { + this.overlayPosition.top = '0'; + } + }, + /** + * Return a point that represents the center of an + * overflowing child element w.r.t it's parent + */ + getViewportCenter() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return {}; + + // get height of scroll bars (i.e. the max values for scrollTop, scrollLeft) + const scrollBarWidth = presentationViewport.scrollWidth - presentationViewport.offsetWidth; + const scrollBarHeight = presentationViewport.scrollHeight - presentationViewport.offsetHeight; + + // determine how many child pixels have been scrolled + const xScrollRatio = + presentationViewport.scrollLeft > 0 ? presentationViewport.scrollLeft / scrollBarWidth : 0; + const yScrollRatio = + presentationViewport.scrollTop > 0 ? presentationViewport.scrollTop / scrollBarHeight : 0; + const xScrollOffset = + (presentationViewport.scrollWidth - presentationViewport.offsetWidth - 0) * xScrollRatio; + const yScrollOffset = + (presentationViewport.scrollHeight - presentationViewport.offsetHeight - 0) * yScrollRatio; + + const viewportCenterX = presentationViewport.offsetWidth / 2; + const viewportCenterY = presentationViewport.offsetHeight / 2; + const focalPointX = viewportCenterX + xScrollOffset; + const focalPointY = viewportCenterY + yScrollOffset; + + return { + x: focalPointX, + y: focalPointY, + }; + }, + /** + * Scroll the viewport such that the focal point is positioned centrally + */ + scrollToFocalPoint() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return; + + const scrollX = this.zoomFocalPoint.x - presentationViewport.offsetWidth / 2; + const scrollY = this.zoomFocalPoint.y - presentationViewport.offsetHeight / 2; + + presentationViewport.scrollTo(scrollX, scrollY); + }, + scaleZoomFocalPoint() { + const { x, y, width, height } = this.zoomFocalPoint; + const widthRatio = this.overlayDimensions.width / width; + const heightRatio = this.overlayDimensions.height / height; + + this.zoomFocalPoint = { + x: Math.round(x * widthRatio * 100) / 100, + y: Math.round(y * heightRatio * 100) / 100, + ...this.overlayDimensions, + }; + }, + shiftZoomFocalPoint() { + this.zoomFocalPoint = { + ...this.getViewportCenter(), + ...this.overlayDimensions, + }; + }, + onImageResize(imageDimensions) { + this.setOverlayDimensions(imageDimensions); + this.setOverlayPosition(); + + this.$nextTick(() => { + if (this.initialLoad) { + // set focal point on initial load + this.shiftZoomFocalPoint(); + this.initialLoad = false; + } else { + this.scaleZoomFocalPoint(); + this.scrollToFocalPoint(); + } + }); + }, + getAnnotationPositon(coordinates) { + const { x, y } = coordinates; + const { width, height } = this.overlayDimensions; + return { + x: Math.round(x), + y: Math.round(y), + width: Math.round(width), + height: Math.round(height), + }; + }, + openCommentForm(coordinates) { + this.currentAnnotationPosition = this.getAnnotationPositon(coordinates); + this.$emit('openCommentForm', this.currentAnnotationPosition); + }, + closeCommentForm() { + this.currentAnnotationPosition = null; + this.$emit('closeCommentForm'); + }, + moveNote({ noteId, discussionId, coordinates }) { + const position = this.getAnnotationPositon(coordinates); + this.$emit('moveNote', { noteId, discussionId, position }); + }, + onPresentationMousedown({ clientX, clientY }) { + if (!this.isDesignOverflowing()) return; + + this.lastDragPosition = { + x: clientX, + y: clientY, + }; + }, + getDragDelta(clientX, clientY) { + return { + deltaX: this.lastDragPosition.x - clientX, + deltaY: this.lastDragPosition.y - clientY, + }; + }, + exceedsDragThreshold(clientX, clientY) { + const { deltaX, deltaY } = this.getDragDelta(clientX, clientY); + + return Math.abs(deltaX) > CLICK_DRAG_BUFFER_PX || Math.abs(deltaY) > CLICK_DRAG_BUFFER_PX; + }, + shouldDragDesign(clientX, clientY) { + return ( + this.lastDragPosition && + (this.isDraggingDesign || this.exceedsDragThreshold(clientX, clientY)) + ); + }, + onPresentationMousemove({ clientX, clientY }) { + const { presentationViewport } = this.$refs; + if (!presentationViewport || !this.shouldDragDesign(clientX, clientY)) return; + + this.isDraggingDesign = true; + + const { scrollLeft, scrollTop } = presentationViewport; + const { deltaX, deltaY } = this.getDragDelta(clientX, clientY); + presentationViewport.scrollTo(scrollLeft + deltaX, scrollTop + deltaY); + + this.lastDragPosition = { + x: clientX, + y: clientY, + }; + }, + onPresentationMouseup() { + this.lastDragPosition = null; + this.isDraggingDesign = false; + }, + isDesignOverflowing() { + const { presentationViewport } = this.$refs; + if (!presentationViewport) return false; + + return ( + presentationViewport.scrollWidth > presentationViewport.offsetWidth || + presentationViewport.scrollHeight > presentationViewport.offsetHeight + ); + }, + }, +}; +</script> + +<template> + <div + ref="presentationViewport" + class="h-100 w-100 p-3 overflow-auto position-relative" + :style="presentationStyle" + @mousedown="onPresentationMousedown" + @mousemove="onPresentationMousemove" + @mouseup="onPresentationMouseup" + @mouseleave="onPresentationMouseup" + @touchstart="onPresentationMousedown" + @touchmove="onPresentationMousemove" + @touchend="onPresentationMouseup" + @touchcancel="onPresentationMouseup" + > + <div class="h-100 w-100 d-flex align-items-center position-relative"> + <design-image + v-if="image" + :image="image" + :name="imageName" + :scale="scale" + @resize="onImageResize" + /> + <design-overlay + v-if="overlayDimensions && overlayPosition" + :dimensions="overlayDimensions" + :position="overlayPosition" + :notes="discussionStartingNotes" + :current-comment-form="currentCommentForm" + :disable-commenting="isDraggingDesign" + @openCommentForm="openCommentForm" + @closeCommentForm="closeCommentForm" + @moveNote="moveNote" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/design_management/components/design_scaler.vue b/app/assets/javascripts/design_management/components/design_scaler.vue new file mode 100644 index 00000000000..55dee74bef5 --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_scaler.vue @@ -0,0 +1,65 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +const SCALE_STEP_SIZE = 0.2; +const DEFAULT_SCALE = 1; +const MIN_SCALE = 1; +const MAX_SCALE = 2; + +export default { + components: { + GlIcon, + }, + data() { + return { + scale: DEFAULT_SCALE, + }; + }, + computed: { + disableReset() { + return this.scale <= MIN_SCALE; + }, + disableDecrease() { + return this.scale === DEFAULT_SCALE; + }, + disableIncrease() { + return this.scale >= MAX_SCALE; + }, + }, + methods: { + setScale(scale) { + if (scale < MIN_SCALE) { + return; + } + + this.scale = Math.round(scale * 100) / 100; + this.$emit('scale', this.scale); + }, + incrementScale() { + this.setScale(this.scale + SCALE_STEP_SIZE); + }, + decrementScale() { + this.setScale(this.scale - SCALE_STEP_SIZE); + }, + resetScale() { + this.setScale(DEFAULT_SCALE); + }, + }, +}; +</script> + +<template> + <div class="design-scaler btn-group" role="group"> + <button class="btn" :disabled="disableDecrease" @click="decrementScale"> + <span class="d-flex-center gl-icon s16"> + – + </span> + </button> + <button class="btn" :disabled="disableReset" @click="resetScale"> + <gl-icon name="redo" /> + </button> + <button class="btn" :disabled="disableIncrease" @click="incrementScale"> + <gl-icon name="plus" /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue new file mode 100644 index 00000000000..91b7b576e0c --- /dev/null +++ b/app/assets/javascripts/design_management/components/image.vue @@ -0,0 +1,110 @@ +<script> +import { throttle } from 'lodash'; +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + props: { + image: { + type: String, + required: false, + default: '', + }, + name: { + type: String, + required: false, + default: '', + }, + scale: { + type: Number, + required: false, + default: 1, + }, + }, + data() { + return { + baseImageSize: null, + imageStyle: null, + imageError: false, + }; + }, + watch: { + scale(val) { + this.zoom(val); + }, + }, + beforeDestroy() { + window.removeEventListener('resize', this.resizeThrottled, false); + }, + mounted() { + this.onImgLoad(); + + this.resizeThrottled = throttle(() => { + // NOTE: if imageStyle is set, then baseImageSize + // won't change due to resize. We must still emit a + // `resize` event so that the parent can handle + // resizes appropriately (e.g. for design_overlay) + this.setBaseImageSize(); + }, 400); + window.addEventListener('resize', this.resizeThrottled, false); + }, + methods: { + onImgLoad() { + requestIdleCallback(this.setBaseImageSize, { timeout: 1000 }); + }, + onImgError() { + this.imageError = true; + }, + setBaseImageSize() { + const { contentImg } = this.$refs; + if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return; + + this.baseImageSize = { + height: contentImg.offsetHeight, + width: contentImg.offsetWidth, + }; + this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height }); + }, + onResize({ width, height }) { + this.$emit('resize', { width, height }); + }, + zoom(amount) { + if (amount === 1) { + this.imageStyle = null; + this.$nextTick(() => { + this.setBaseImageSize(); + }); + return; + } + const width = this.baseImageSize.width * amount; + const height = this.baseImageSize.height * amount; + + this.imageStyle = { + width: `${width}px`, + height: `${height}px`, + }; + + this.onResize({ width, height }); + }, + }, +}; +</script> + +<template> + <div class="m-auto js-design-image"> + <gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" /> + <img + v-show="!imageError" + ref="contentImg" + class="mh-100" + :src="image" + :alt="name" + :style="imageStyle" + :class="{ 'img-fluid': !imageStyle }" + @error="onImgError" + @load="onImgLoad" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue new file mode 100644 index 00000000000..eaa641d85d6 --- /dev/null +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -0,0 +1,174 @@ +<script> +import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; +import { n__, __ } from '~/locale'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + GlLoadingIcon, + GlIntersectionObserver, + GlIcon, + Icon, + Timeago, + }, + props: { + id: { + type: [Number, String], + required: true, + }, + event: { + type: String, + required: true, + }, + notesCount: { + type: Number, + required: true, + }, + image: { + type: String, + required: true, + }, + filename: { + type: String, + required: true, + }, + updatedAt: { + type: String, + required: false, + default: null, + }, + isUploading: { + type: Boolean, + required: false, + default: true, + }, + imageV432x230: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + imageLoading: true, + imageError: false, + wasInView: false, + }; + }, + computed: { + icon() { + const normalizedEvent = this.event.toLowerCase(); + const icons = { + creation: { + name: 'file-addition-solid', + classes: 'text-success-500', + tooltip: __('Added in this version'), + }, + modification: { + name: 'file-modified-solid', + classes: 'text-primary-500', + tooltip: __('Modified in this version'), + }, + deletion: { + name: 'file-deletion-solid', + classes: 'text-danger-500', + tooltip: __('Deleted in this version'), + }, + }; + + return icons[normalizedEvent] ? icons[normalizedEvent] : {}; + }, + notesLabel() { + return n__('%d comment', '%d comments', this.notesCount); + }, + imageLink() { + return this.wasInView ? this.imageV432x230 || this.image : ''; + }, + showLoadingSpinner() { + return this.imageLoading || this.isUploading; + }, + showImageErrorIcon() { + return this.wasInView && this.imageError; + }, + showImage() { + return !this.showLoadingSpinner && !this.showImageErrorIcon; + }, + }, + methods: { + onImageLoad() { + this.imageLoading = false; + this.imageError = false; + }, + onImageError() { + this.imageLoading = false; + this.imageError = true; + }, + onAppear() { + // do nothing if image has previously + // been in view + if (this.wasInView) { + return; + } + + this.wasInView = true; + this.imageLoading = true; + }, + }, + DESIGN_ROUTE_NAME, +}; +</script> + +<template> + <router-link + :to="{ + name: $options.DESIGN_ROUTE_NAME, + params: { id: filename }, + query: $route.query, + }" + class="card cursor-pointer text-plain js-design-list-item design-list-item" + > + <div class="card-body p-0 d-flex-center overflow-hidden position-relative"> + <div v-if="icon.name" class="design-event position-absolute"> + <span :title="icon.tooltip" :aria-label="icon.tooltip"> + <icon :name="icon.name" :size="18" :class="icon.classes" /> + </span> + </div> + <gl-intersection-observer @appear="onAppear"> + <gl-loading-icon v-if="showLoadingSpinner" size="md" /> + <gl-icon + v-else-if="showImageErrorIcon" + name="media-broken" + class="text-secondary" + :size="32" + /> + <img + v-show="showImage" + :src="imageLink" + :alt="filename" + class="block mx-auto mw-100 mh-100 design-img" + data-qa-selector="design_image" + @load="onImageLoad" + @error="onImageError" + /> + </gl-intersection-observer> + </div> + <div class="card-footer d-flex w-100"> + <div class="d-flex flex-column str-truncated-100"> + <span class="bold str-truncated-100" data-qa-selector="design_file_name">{{ + filename + }}</span> + <span v-if="updatedAt" class="str-truncated-100"> + {{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" /> + </span> + </div> + <div v-if="notesCount" class="ml-auto d-flex align-items-center text-secondary"> + <icon name="comments" class="ml-1" /> + <span :aria-label="notesLabel" class="ml-1"> + {{ notesCount }} + </span> + </div> + </div> + </router-link> +</template> diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue new file mode 100644 index 00000000000..ea9f7300981 --- /dev/null +++ b/app/assets/javascripts/design_management/components/toolbar/index.vue @@ -0,0 +1,126 @@ +<script> +import { GlDeprecatedButton } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import Pagination from './pagination.vue'; +import DeleteButton from '../delete_button.vue'; +import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql'; +import appDataQuery from '../../graphql/queries/appData.query.graphql'; +import { DESIGNS_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + Icon, + Pagination, + DeleteButton, + GlDeprecatedButton, + }, + mixins: [timeagoMixin], + props: { + id: { + type: String, + required: true, + }, + isDeleting: { + type: Boolean, + required: true, + }, + filename: { + type: String, + required: false, + default: '', + }, + updatedAt: { + type: String, + required: false, + default: null, + }, + updatedBy: { + type: Object, + required: false, + default: () => ({}), + }, + isLatestVersion: { + type: Boolean, + required: true, + }, + image: { + type: String, + required: true, + }, + }, + data() { + return { + permissions: { + createDesign: false, + }, + projectPath: '', + issueIid: null, + }; + }, + apollo: { + appData: { + query: appDataQuery, + manual: true, + result({ data: { projectPath, issueIid } }) { + this.projectPath = projectPath; + this.issueIid = issueIid; + }, + }, + permissions: { + query: permissionsQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + }; + }, + update: data => data.project.issue.userPermissions, + }, + }, + computed: { + updatedText() { + return sprintf(__('Updated %{updated_at} by %{updated_by}'), { + updated_at: this.timeFormatted(this.updatedAt), + updated_by: this.updatedBy.name, + }); + }, + canDeleteDesign() { + return this.permissions.createDesign; + }, + }, + DESIGNS_ROUTE_NAME, +}; +</script> + +<template> + <header class="d-flex p-2 bg-white align-items-center js-design-header"> + <router-link + :to="{ + name: $options.DESIGNS_ROUTE_NAME, + query: $route.query, + }" + :aria-label="s__('DesignManagement|Go back to designs')" + class="mr-3 text-plain d-flex justify-content-center align-items-center" + > + <icon :size="18" name="close" /> + </router-link> + <div class="overflow-hidden d-flex align-items-center"> + <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2> + <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small> + </div> + <pagination :id="id" class="ml-auto flex-shrink-0" /> + <gl-deprecated-button :href="image" class="mr-2"> + <icon :size="18" name="download" /> + </gl-deprecated-button> + <delete-button + v-if="isLatestVersion && canDeleteDesign" + :is-deleting="isDeleting" + button-variant="danger" + @deleteSelectedDesigns="$emit('delete')" + > + <icon :size="18" name="remove" /> + </delete-button> + </header> +</template> diff --git a/app/assets/javascripts/design_management/components/toolbar/pagination.vue b/app/assets/javascripts/design_management/components/toolbar/pagination.vue new file mode 100644 index 00000000000..bf62a8f66a6 --- /dev/null +++ b/app/assets/javascripts/design_management/components/toolbar/pagination.vue @@ -0,0 +1,83 @@ +<script> +/* global Mousetrap */ +import 'mousetrap'; +import { s__, sprintf } from '~/locale'; +import PaginationButton from './pagination_button.vue'; +import allDesignsMixin from '../../mixins/all_designs'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + PaginationButton, + }, + mixins: [allDesignsMixin], + props: { + id: { + type: String, + required: true, + }, + }, + computed: { + designsCount() { + return this.designs.length; + }, + currentIndex() { + return this.designs.findIndex(design => design.filename === this.id); + }, + paginationText() { + return sprintf(s__('DesignManagement|%{current_design} of %{designs_count}'), { + current_design: this.currentIndex + 1, + designs_count: this.designsCount, + }); + }, + previousDesign() { + if (!this.designsCount) return null; + + return this.designs[this.currentIndex - 1]; + }, + nextDesign() { + if (!this.designsCount) return null; + + return this.designs[this.currentIndex + 1]; + }, + }, + mounted() { + Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign)); + Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign)); + }, + beforeDestroy() { + Mousetrap.unbind(['left', 'right'], this.navigateToDesign); + }, + methods: { + navigateToDesign(design) { + if (design) { + this.$router.push({ + name: DESIGN_ROUTE_NAME, + params: { id: design.filename }, + query: this.$route.query, + }); + } + }, + }, +}; +</script> + +<template> + <div v-if="designsCount" class="d-flex align-items-center"> + {{ paginationText }} + <div class="btn-group ml-3 mr-3"> + <pagination-button + :design="previousDesign" + :title="s__('DesignManagement|Go to previous design')" + icon-name="angle-left" + class="js-previous-design" + /> + <pagination-button + :design="nextDesign" + :title="s__('DesignManagement|Go to next design')" + icon-name="angle-right" + class="js-next-design" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/design_management/components/toolbar/pagination_button.vue b/app/assets/javascripts/design_management/components/toolbar/pagination_button.vue new file mode 100644 index 00000000000..f00ecefca01 --- /dev/null +++ b/app/assets/javascripts/design_management/components/toolbar/pagination_button.vue @@ -0,0 +1,48 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { DESIGN_ROUTE_NAME } from '../../router/constants'; + +export default { + components: { + Icon, + }, + props: { + design: { + type: Object, + required: false, + default: null, + }, + title: { + type: String, + required: true, + }, + iconName: { + type: String, + required: true, + }, + }, + computed: { + designLink() { + if (!this.design) return {}; + + return { + name: DESIGN_ROUTE_NAME, + params: { id: this.design.filename }, + query: this.$route.query, + }; + }, + }, +}; +</script> + +<template> + <router-link + :to="designLink" + :disabled="!design" + :class="{ disabled: !design }" + :aria-label="title" + class="btn btn-default" + > + <icon :name="iconName" /> + </router-link> +</template> diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue new file mode 100644 index 00000000000..e3c5e369170 --- /dev/null +++ b/app/assets/javascripts/design_management/components/upload/button.vue @@ -0,0 +1,58 @@ +<script> +import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; + +export default { + components: { + GlDeprecatedButton, + GlLoadingIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + isSaving: { + type: Boolean, + required: true, + }, + }, + methods: { + openFileUpload() { + this.$refs.fileUpload.click(); + }, + onFileUploadChange(e) { + this.$emit('upload', e.target.files); + }, + }, + VALID_DESIGN_FILE_MIMETYPE, +}; +</script> + +<template> + <div> + <gl-deprecated-button + v-gl-tooltip.hover + :title=" + s__( + 'DesignManagement|Adding a design with the same filename replaces the file in a new version.', + ) + " + :disabled="isSaving" + variant="success" + @click="openFileUpload" + > + {{ s__('DesignManagement|Add designs') }} + <gl-loading-icon v-if="isSaving" inline class="ml-1" /> + </gl-deprecated-button> + + <input + ref="fileUpload" + type="file" + name="design_file" + :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype" + class="hide" + multiple + @change="onFileUploadChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue new file mode 100644 index 00000000000..e2e1fc8bfad --- /dev/null +++ b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue @@ -0,0 +1,134 @@ +<script> +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import createFlash from '~/flash'; +import uploadDesignMutation from '../../graphql/mutations/uploadDesign.mutation.graphql'; +import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages'; +import { isValidDesignFile } from '../../utils/design_management_utils'; +import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; + +export default { + components: { + GlIcon, + GlLink, + GlSprintf, + }, + data() { + return { + dragCounter: 0, + isDragDataValid: false, + }; + }, + computed: { + dragging() { + return this.dragCounter !== 0; + }, + }, + methods: { + isValidUpload(files) { + return files.every(isValidDesignFile); + }, + isValidDragDataType({ dataTransfer }) { + return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE)); + }, + ondrop({ dataTransfer = {} }) { + this.dragCounter = 0; + // User already had feedback when dropzone was active, so bail here + if (!this.isDragDataValid) { + return; + } + + const { files } = dataTransfer; + if (!this.isValidUpload(Array.from(files))) { + createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR); + return; + } + + this.$emit('change', files); + }, + ondragenter(e) { + this.dragCounter += 1; + this.isDragDataValid = this.isValidDragDataType(e); + }, + ondragleave() { + this.dragCounter -= 1; + }, + openFileUpload() { + this.$refs.fileUpload.click(); + }, + onDesignInputChange(e) { + this.$emit('change', e.target.files); + }, + }, + uploadDesignMutation, + VALID_DESIGN_FILE_MIMETYPE, +}; +</script> + +<template> + <div + class="w-100 position-relative" + @dragstart.prevent.stop + @dragend.prevent.stop + @dragover.prevent.stop + @dragenter.prevent.stop="ondragenter" + @dragleave.prevent.stop="ondragleave" + @drop.prevent.stop="ondrop" + > + <slot> + <button + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + @click="openFileUpload" + > + <div class="d-flex-center flex-column text-center"> + <gl-icon name="doc-new" :size="48" class="mb-4" /> + <p> + <gl-sprintf + :message=" + __( + '%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}.', + ) + " + > + <template #lineOne="{ content }" + ><span class="d-block">{{ content }}</span> + </template> + + <template #link="{ content }"> + <gl-link class="h-100 w-100" @click.stop="openFileUpload">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> + </button> + + <input + ref="fileUpload" + type="file" + name="design_file" + :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype" + class="hide" + multiple + @change="onDesignInputChange" + /> + </slot> + <transition name="design-dropzone-fade"> + <div + v-show="dragging" + class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + > + <div v-show="!isDragDataValid" class="mw-50 text-center"> + <h3>{{ __('Oh no!') }}</h3> + <span>{{ + __( + 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.', + ) + }}</span> + </div> + <div v-show="isDragDataValid" class="mw-50 text-center"> + <h3>{{ __('Incoming!') }}</h3> + <span>{{ __('Drop your designs to start your upload.') }}</span> + </div> + </div> + </transition> + </div> +</template> diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue new file mode 100644 index 00000000000..993eac6f37f --- /dev/null +++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue @@ -0,0 +1,76 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import allVersionsMixin from '../../mixins/all_versions'; +import { findVersionId } from '../../utils/design_management_utils'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + }, + mixins: [allVersionsMixin], + computed: { + queryVersion() { + return this.$route.query.version; + }, + currentVersionIdx() { + if (!this.queryVersion) return 0; + + const idx = this.allVersions.findIndex( + version => this.findVersionId(version.node.id) === this.queryVersion, + ); + + // if the currentVersionId isn't a valid version (i.e. not in allVersions) + // then return the latest version (index 0) + return idx !== -1 ? idx : 0; + }, + currentVersionId() { + if (this.queryVersion) return this.queryVersion; + + const currentVersion = this.allVersions[this.currentVersionIdx]; + return this.findVersionId(currentVersion.node.id); + }, + dropdownText() { + if (this.isLatestVersion) { + return __('Showing Latest Version'); + } + // allVersions is sorted in reverse chronological order (latest first) + const currentVersionNumber = this.allVersions.length - this.currentVersionIdx; + + return sprintf(__('Showing Version #%{versionNumber}'), { + versionNumber: currentVersionNumber, + }); + }, + }, + methods: { + findVersionId, + }, +}; +</script> + +<template> + <gl-dropdown :text="dropdownText" variant="link" class="design-version-dropdown"> + <gl-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id"> + <router-link + class="d-flex js-version-link" + :to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }" + > + <div class="flex-grow-1 ml-2"> + <div> + <strong + >{{ __('Version') }} {{ allVersions.length - index }} + <span v-if="findVersionId(version.node.id) === latestVersionId" + >({{ __('latest') }})</span + > + </strong> + </div> + </div> + <i + v-if="findVersionId(version.node.id) === currentVersionId" + class="fa fa-check pull-right" + ></i> + </router-link> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js new file mode 100644 index 00000000000..59d34669ad7 --- /dev/null +++ b/app/assets/javascripts/design_management/constants.js @@ -0,0 +1,14 @@ +// WARNING: replace this with something +// more sensical as per https://gitlab.com/gitlab-org/gitlab/issues/118611 +export const VALID_DESIGN_FILE_MIMETYPE = { + mimetype: 'image/*', + regex: /image\/.+/, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types +export const VALID_DATA_TRANSFER_TYPE = 'Files'; + +export const ACTIVE_DISCUSSION_SOURCE_TYPES = { + pin: 'pin', + discussion: 'discussion', +}; diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js new file mode 100644 index 00000000000..fae337aa75b --- /dev/null +++ b/app/assets/javascripts/design_management/graphql.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { uniqueId } from 'lodash'; +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import createDefaultClient from '~/lib/graphql'; +import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql'; +import typeDefs from './graphql/typedefs.graphql'; + +Vue.use(VueApollo); + +const resolvers = { + Mutation: { + updateActiveDiscussion: (_, { id = null, source }, { cache }) => { + const data = cache.readQuery({ query: activeDiscussionQuery }); + data.activeDiscussion = { + __typename: 'ActiveDiscussion', + id, + source, + }; + cache.writeQuery({ query: activeDiscussionQuery, data }); + }, + }, +}; + +const defaultClient = createDefaultClient( + resolvers, + // This config is added temporarily to resolve an issue with duplicate design IDs. + // Should be removed as soon as https://gitlab.com/gitlab-org/gitlab/issues/13495 is resolved + { + cacheConfig: { + dataIdFromObject: object => { + // eslint-disable-next-line no-underscore-dangle, @gitlab/require-i18n-strings + if (object.__typename === 'Design') { + return object.id && object.image ? `${object.id}-${object.image}` : uniqueId(); + } + return defaultDataIdFromObject(object); + }, + }, + typeDefs, + }, +); + +export default new VueApollo({ + defaultClient, +}); diff --git a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql new file mode 100644 index 00000000000..ca5b5a52c71 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql @@ -0,0 +1,22 @@ +#import "./designNote.fragment.graphql" +#import "./designList.fragment.graphql" +#import "./diffRefs.fragment.graphql" + +fragment DesignItem on Design { + ...DesignListItem + fullPath + diffRefs { + ...DesignDiffRefs + } + discussions { + nodes { + id + replyId + notes { + nodes { + ...DesignNote + } + } + } + } +} diff --git a/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql new file mode 100644 index 00000000000..bc3132f9b42 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql @@ -0,0 +1,8 @@ +fragment DesignListItem on Design { + id + event + filename + notesCount + image + imageV432x230 +} diff --git a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql new file mode 100644 index 00000000000..2ad84f9cb17 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql @@ -0,0 +1,28 @@ +#import "./diffRefs.fragment.graphql" +#import "~/graphql_shared/fragments/author.fragment.graphql" +#import "./note_permissions.fragment.graphql" + +fragment DesignNote on Note { + id + author { + ...Author + } + body + bodyHtml + createdAt + position { + diffRefs { + ...DesignDiffRefs + } + x + y + height + width + } + userPermissions { + ...DesignNotePermissions + } + discussion { + id + } +} diff --git a/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql new file mode 100644 index 00000000000..984a55814b0 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql @@ -0,0 +1,5 @@ +fragment DesignDiffRefs on DiffRefs { + baseSha + startSha + headSha +} diff --git a/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql new file mode 100644 index 00000000000..c243e39f3d3 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql @@ -0,0 +1,3 @@ +fragment DesignNotePermissions on NotePermissions { + adminNote +} diff --git a/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql new file mode 100644 index 00000000000..7eb40b12f51 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql @@ -0,0 +1,4 @@ +fragment VersionListItem on DesignVersion { + id + sha +} diff --git a/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql new file mode 100644 index 00000000000..9e2931b23f2 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql @@ -0,0 +1,21 @@ +#import "../fragments/designNote.fragment.graphql" + +mutation createImageDiffNote($input: CreateImageDiffNoteInput!) { + createImageDiffNote(input: $input) { + note { + ...DesignNote + discussion { + id + replyId + notes { + edges { + node { + ...DesignNote + } + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql new file mode 100644 index 00000000000..3ae478d658e --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/designNote.fragment.graphql" + +mutation createNote($input: CreateNoteInput!) { + createNote(input: $input) { + note { + ...DesignNote + } + errors + } +} diff --git a/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql new file mode 100644 index 00000000000..0b3cf636cdb --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/version.fragment.graphql" + +mutation destroyDesign($filenames: [String!]!, $projectPath: ID!, $iid: ID!) { + designManagementDelete(input: { projectPath: $projectPath, iid: $iid, filenames: $filenames }) { + version { + ...VersionListItem + } + errors + } +} diff --git a/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql new file mode 100644 index 00000000000..cdb2264d233 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/designNote.fragment.graphql" + +mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) { + updateImageDiffNote(input: $input) { + errors + note { + ...DesignNote + } + } +} diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql new file mode 100644 index 00000000000..343de4e3025 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateActiveDiscussion($id: String, $source: String) { + updateActiveDiscussion (id: $id, source: $source ) @client +} diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql new file mode 100644 index 00000000000..d96b2f3934a --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/designNote.fragment.graphql" + +mutation updateNote($input: UpdateNoteInput!) { + updateNote(input: $input) { + note { + ...DesignNote + } + errors + } +} diff --git a/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql new file mode 100644 index 00000000000..904acef599b --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql @@ -0,0 +1,21 @@ +#import "../fragments/design.fragment.graphql" + +mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { + designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) { + designs { + ...DesignItem + versions { + edges { + node { + id + sha + } + } + }, + } + skippedDesigns { + filename + } + errors + } +} diff --git a/app/assets/javascripts/design_management/graphql/queries/active_discussion.query.graphql b/app/assets/javascripts/design_management/graphql/queries/active_discussion.query.graphql new file mode 100644 index 00000000000..111023cea68 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/queries/active_discussion.query.graphql @@ -0,0 +1,6 @@ +query activeDiscussion { + activeDiscussion @client { + id + source + } +} diff --git a/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql b/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql new file mode 100644 index 00000000000..e1269761206 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql @@ -0,0 +1,4 @@ +query projectFullPath { + projectPath @client + issueIid @client +} diff --git a/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql b/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql new file mode 100644 index 00000000000..a87b256dc95 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql @@ -0,0 +1,10 @@ +query permissions($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + id + issue(iid: $iid) { + userPermissions { + createDesign + } + } + } +} diff --git a/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql b/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql new file mode 100644 index 00000000000..07a9af55787 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql @@ -0,0 +1,31 @@ +#import "../fragments/design.fragment.graphql" +#import "~/graphql_shared/fragments/author.fragment.graphql" + +query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [String!]) { + project(fullPath: $fullPath) { + id + issue(iid: $iid) { + designCollection { + designs(atVersion: $atVersion, filenames: $filenames) { + edges { + node { + ...DesignItem + issue { + title + webPath + webUrl + participants { + edges { + node { + ...Author + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql new file mode 100644 index 00000000000..857f205ab07 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql @@ -0,0 +1,26 @@ +#import "../fragments/designList.fragment.graphql" +#import "../fragments/version.fragment.graphql" + +query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) { + project(fullPath: $fullPath) { + id + issue(iid: $iid) { + designCollection { + designs(atVersion: $atVersion) { + edges { + node { + ...DesignListItem + } + } + } + versions { + edges { + node { + ...VersionListItem + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/design_management/graphql/typedefs.graphql b/app/assets/javascripts/design_management/graphql/typedefs.graphql new file mode 100644 index 00000000000..fdbad4a90e0 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/typedefs.graphql @@ -0,0 +1,12 @@ +type ActiveDiscussion { + id: ID + source: String +} + +extend type Query { + activeDiscussion: ActiveDiscussion +} + +extend type Mutation { + updateActiveDiscussion(id: ID!, source: String!): Boolean +} diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js new file mode 100644 index 00000000000..eb00e1742ea --- /dev/null +++ b/app/assets/javascripts/design_management/index.js @@ -0,0 +1,58 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import createRouter from './router'; +import App from './components/app.vue'; +import apolloProvider from './graphql'; +import getDesignListQuery from './graphql/queries/get_design_list.query.graphql'; +import { DESIGNS_ROUTE_NAME, ROOT_ROUTE_NAME } from './router/constants'; + +export default () => { + const el = document.querySelector('.js-design-management'); + const badge = document.querySelector('.js-designs-count'); + const { issueIid, projectPath, issuePath } = el.dataset; + const router = createRouter(issuePath); + + $('.js-issue-tabs').on('shown.bs.tab', ({ target: { id } }) => { + if (id === 'designs' && router.currentRoute.name === ROOT_ROUTE_NAME) { + router.push({ name: DESIGNS_ROUTE_NAME }); + } else if (id === 'discussion') { + router.push({ name: ROOT_ROUTE_NAME }); + } + }); + + apolloProvider.clients.defaultClient.cache.writeData({ + data: { + projectPath, + issueIid, + activeDiscussion: { + __typename: 'ActiveDiscussion', + id: null, + source: null, + }, + }, + }); + + apolloProvider.clients.defaultClient + .watchQuery({ + query: getDesignListQuery, + variables: { + fullPath: projectPath, + iid: issueIid, + atVersion: null, + }, + }) + .subscribe(({ data }) => { + if (badge) { + badge.textContent = data.project.issue.designCollection.designs.edges.length; + } + }); + + return new Vue({ + el, + router, + apolloProvider, + render(createElement) { + return createElement(App); + }, + }); +}; diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js new file mode 100644 index 00000000000..f7d6551c46c --- /dev/null +++ b/app/assets/javascripts/design_management/mixins/all_designs.js @@ -0,0 +1,49 @@ +import { propertyOf } from 'lodash'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import { extractNodes } from '../utils/design_management_utils'; +import allVersionsMixin from './all_versions'; +import { DESIGNS_ROUTE_NAME } from '../router/constants'; + +export default { + mixins: [allVersionsMixin], + apollo: { + designs: { + query: getDesignListQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + atVersion: this.designsVersion, + }; + }, + update: data => { + const designEdges = propertyOf(data)(['project', 'issue', 'designCollection', 'designs']); + if (designEdges) { + return extractNodes(designEdges); + } + return []; + }, + error() { + this.error = true; + }, + result() { + if (this.$route.query.version && !this.hasValidVersion) { + createFlash( + s__( + 'DesignManagement|Requested design version does not exist. Showing latest version instead', + ), + ); + this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } }); + } + }, + }, + }, + data() { + return { + designs: [], + error: false, + }; + }, +}; diff --git a/app/assets/javascripts/design_management/mixins/all_versions.js b/app/assets/javascripts/design_management/mixins/all_versions.js new file mode 100644 index 00000000000..41c93064c26 --- /dev/null +++ b/app/assets/javascripts/design_management/mixins/all_versions.js @@ -0,0 +1,62 @@ +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import appDataQuery from '../graphql/queries/appData.query.graphql'; +import { findVersionId } from '../utils/design_management_utils'; + +export default { + apollo: { + appData: { + query: appDataQuery, + manual: true, + result({ data: { projectPath, issueIid } }) { + this.projectPath = projectPath; + this.issueIid = issueIid; + }, + }, + allVersions: { + query: getDesignListQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + atVersion: null, + }; + }, + update: data => data.project.issue.designCollection.versions.edges, + }, + }, + computed: { + hasValidVersion() { + return ( + this.$route.query.version && + this.allVersions && + this.allVersions.some(version => version.node.id.endsWith(this.$route.query.version)) + ); + }, + designsVersion() { + return this.hasValidVersion + ? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}` + : null; + }, + latestVersionId() { + const latestVersion = this.allVersions[0]; + return latestVersion && findVersionId(latestVersion.node.id); + }, + isLatestVersion() { + if (this.allVersions.length > 0) { + return ( + !this.$route.query.version || + !this.latestVersionId || + this.$route.query.version === this.latestVersionId + ); + } + return true; + }, + }, + data() { + return { + allVersions: [], + projectPath: '', + issueIid: null, + }; + }, +}; diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue new file mode 100644 index 00000000000..7ff3271394d --- /dev/null +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -0,0 +1,400 @@ +<script> +import { ApolloMutation } from 'vue-apollo'; +import Mousetrap from 'mousetrap'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +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 getDesignQuery from '../../graphql/queries/getDesign.query.graphql'; +import appDataQuery from '../../graphql/queries/appData.query.graphql'; +import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql'; +import updateImageDiffNoteMutation from '../../graphql/mutations/updateImageDiffNote.mutation.graphql'; +import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql'; +import { + extractDiscussions, + extractDesign, + extractParticipants, + updateImageDiffNoteOptimisticResponse, +} from '../../utils/design_management_utils'; +import { + updateStoreAfterAddImageDiffNote, + updateStoreAfterUpdateImageDiffNote, +} from '../../utils/cache_update'; +import { + ADD_DISCUSSION_COMMENT_ERROR, + ADD_IMAGE_DIFF_NOTE_ERROR, + UPDATE_IMAGE_DIFF_NOTE_ERROR, + DESIGN_NOT_FOUND_ERROR, + DESIGN_VERSION_NOT_EXIST_ERROR, + UPDATE_NOTE_ERROR, + designDeletionError, +} from '../../utils/error_messages'; +import { trackDesignDetailView } from '../../utils/tracking'; +import { DESIGNS_ROUTE_NAME } from '../../router/constants'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; + +export default { + components: { + ApolloMutation, + DesignPresentation, + DesignDiscussion, + DesignScaler, + DesignDestroyer, + Toolbar, + DesignReplyForm, + GlLoadingIcon, + GlAlert, + Participants, + }, + mixins: [allVersionsMixin], + props: { + id: { + type: String, + required: true, + }, + }, + data() { + return { + design: {}, + comment: '', + annotationCoordinates: null, + projectPath: '', + errorMessage: '', + issueIid: '', + scale: 1, + }; + }, + apollo: { + appData: { + query: appDataQuery, + manual: true, + result({ data: { projectPath, issueIid } }) { + this.projectPath = projectPath; + this.issueIid = issueIid; + }, + }, + design: { + query: getDesignQuery, + // We want to see cached design version if we have one, and fetch newer version on the background to update discussions + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + variables() { + return this.designVariables; + }, + update: data => extractDesign(data), + result(res) { + this.onDesignQueryResult(res); + }, + error() { + this.onQueryError(DESIGN_NOT_FOUND_ERROR); + }, + }, + }, + computed: { + isFirstLoading() { + // We only want to show spinner on initial design load (when opened from a deep link to design) + // If we already have cached a design, loading shouldn't be indicated to user + return this.$apollo.queries.design.loading && !this.design.filename; + }, + discussions() { + 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, + iid: this.issueIid, + filenames: [this.$route.params.id], + atVersion: this.designsVersion, + }; + }, + mutationPayload() { + const { x, y, width, height } = this.annotationCoordinates; + return { + noteableId: this.design.id, + body: this.comment, + position: { + headSha: this.design.diffRefs.headSha, + baseSha: this.design.diffRefs.baseSha, + startSha: this.design.diffRefs.startSha, + x, + y, + width, + height, + paths: { + newPath: this.design.fullPath, + }, + }, + }; + }, + issue() { + return { + ...this.design.issue, + webPath: this.design.issue.webPath.substr(1), + }; + }, + isAnnotating() { + return Boolean(this.annotationCoordinates); + }, + }, + mounted() { + Mousetrap.bind('esc', this.closeDesign); + }, + beforeDestroy() { + Mousetrap.unbind('esc', this.closeDesign); + }, + methods: { + addImageDiffNoteToStore( + store, + { + data: { createImageDiffNote }, + }, + ) { + updateStoreAfterAddImageDiffNote( + store, + createImageDiffNote, + getDesignQuery, + this.designVariables, + ); + }, + updateImageDiffNoteInStore( + store, + { + data: { updateImageDiffNote }, + }, + ) { + return updateStoreAfterUpdateImageDiffNote( + store, + updateImageDiffNote, + getDesignQuery, + this.designVariables, + ); + }, + onMoveNote({ noteId, discussionId, position }) { + const discussion = this.discussions.find(({ id }) => id === discussionId); + const note = discussion.notes.find( + ({ discussion: noteDiscussion }) => noteDiscussion.id === discussionId, + ); + + const mutationPayload = { + optimisticResponse: updateImageDiffNoteOptimisticResponse(note, { + position, + }), + variables: { + input: { + id: noteId, + position, + }, + }, + mutation: updateImageDiffNoteMutation, + update: this.updateImageDiffNoteInStore, + }; + + return this.$apollo.mutate(mutationPayload).catch(e => this.onUpdateImageDiffNoteError(e)); + }, + onDesignQueryResult({ data, loading }) { + // On the initial load with cache-and-network policy data is undefined while loading is true + // To prevent throwing an error, we don't perform any logic until loading is false + if (loading) { + return; + } + + if (!data || !extractDesign(data)) { + this.onQueryError(DESIGN_NOT_FOUND_ERROR); + } else if (this.$route.query.version && !this.hasValidVersion) { + this.onQueryError(DESIGN_VERSION_NOT_EXIST_ERROR); + } + }, + onQueryError(message) { + // because we redirect user to /designs (the issue page), + // we want to create these flashes on the issue page + createFlash(message); + this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME }); + }, + onError(message, e) { + this.errorMessage = message; + throw e; + }, + onCreateImageDiffNoteError(e) { + this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e); + }, + onUpdateNoteError(e) { + this.onError(UPDATE_NOTE_ERROR, e); + }, + onDesignDiscussionError(e) { + this.onError(ADD_DISCUSSION_COMMENT_ERROR, e); + }, + onUpdateImageDiffNoteError(e) { + this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e); + }, + onDesignDeleteError(e) { + this.onError(designDeletionError({ singular: true }), e); + }, + openCommentForm(annotationCoordinates) { + this.annotationCoordinates = annotationCoordinates; + }, + closeCommentForm() { + this.comment = ''; + this.annotationCoordinates = null; + }, + closeDesign() { + this.$router.push({ + name: this.$options.DESIGNS_ROUTE_NAME, + query: this.$route.query, + }); + }, + trackEvent() { + // TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue + trackDesignDetailView( + 'issue-design-collection', + 'issue', + this.$route.query.version || this.latestVersionId, + this.isLatestVersion, + ); + }, + updateActiveDiscussion(id) { + this.$apollo.mutate({ + mutation: updateActiveDiscussionMutation, + variables: { + id, + source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion, + }, + }); + }, + }, + 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(); + }, + createImageDiffNoteMutation, + DESIGNS_ROUTE_NAME, +}; +</script> + +<template> + <div + class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" + > + <gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" /> + <template v-else> + <div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"> + <design-destroyer + :filenames="[design.filename]" + :project-path="projectPath" + :iid="issueIid" + @done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })" + @error="onDesignDeleteError" + > + <template #default="{ mutate, loading }"> + <toolbar + :id="id" + :is-deleting="loading" + :is-latest-version="isLatestVersion" + v-bind="design" + @delete="mutate" + /> + </template> + </design-destroyer> + + <div v-if="errorMessage" class="p-3"> + <gl-alert variant="danger" @dismiss="errorMessage = null"> + {{ errorMessage }} + </gl-alert> + </div> + <design-presentation + :image="design.image" + :image-name="design.filename" + :discussions="discussions" + :is-annotating="isAnnotating" + :scale="scale" + @openCommentForm="openCommentForm" + @closeCommentForm="closeCommentForm" + @moveNote="onMoveNote" + /> + + <div class="design-scaler-wrapper position-absolute mb-4 d-flex-center"> + <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)" + /> + <apollo-mutation + v-if="annotationCoordinates" + #default="{ mutate, loading }" + :mutation="$options.createImageDiffNoteMutation" + :variables="{ + input: mutationPayload, + }" + :update="addImageDiffNoteToStore" + @done="closeCommentForm" + @error="onCreateImageDiffNoteError" + > + <design-reply-form + v-model="comment" + :is-saving="loading" + :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> + </template> + </div> +</template> diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue new file mode 100644 index 00000000000..7d419bc3ded --- /dev/null +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -0,0 +1,323 @@ +<script> +import { GlLoadingIcon, GlDeprecatedButton, GlAlert } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { s__, sprintf } from '~/locale'; +import UploadButton from '../components/upload/button.vue'; +import DeleteButton from '../components/delete_button.vue'; +import Design from '../components/list/item.vue'; +import DesignDestroyer from '../components/design_destroyer.vue'; +import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue'; +import DesignDropzone from '../components/upload/design_dropzone.vue'; +import uploadDesignMutation from '../graphql/mutations/uploadDesign.mutation.graphql'; +import permissionsQuery from '../graphql/queries/design_permissions.query.graphql'; +import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import allDesignsMixin from '../mixins/all_designs'; +import { + UPLOAD_DESIGN_ERROR, + EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, + EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, + designUploadSkippedWarning, + designDeletionError, +} from '../utils/error_messages'; +import { updateStoreAfterUploadDesign } from '../utils/cache_update'; +import { + designUploadOptimisticResponse, + isValidDesignFile, +} from '../utils/design_management_utils'; +import { getFilename } from '~/lib/utils/file_upload'; +import { DESIGNS_ROUTE_NAME } from '../router/constants'; + +const MAXIMUM_FILE_UPLOAD_LIMIT = 10; + +export default { + components: { + GlLoadingIcon, + GlAlert, + GlDeprecatedButton, + UploadButton, + Design, + DesignDestroyer, + DesignVersionDropdown, + DeleteButton, + DesignDropzone, + }, + mixins: [allDesignsMixin], + apollo: { + permissions: { + query: permissionsQuery, + variables() { + return { + fullPath: this.projectPath, + iid: this.issueIid, + }; + }, + update: data => data.project.issue.userPermissions, + }, + }, + data() { + return { + permissions: { + createDesign: false, + }, + filesToBeSaved: [], + selectedDesigns: [], + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading; + }, + isSaving() { + return this.filesToBeSaved.length > 0; + }, + canCreateDesign() { + return this.permissions.createDesign; + }, + showToolbar() { + return this.canCreateDesign && this.allVersions.length > 0; + }, + hasDesigns() { + return this.designs.length > 0; + }, + hasSelectedDesigns() { + return this.selectedDesigns.length > 0; + }, + canDeleteDesigns() { + return this.isLatestVersion && this.hasSelectedDesigns; + }, + projectQueryBody() { + return { + query: getDesignListQuery, + variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null }, + }; + }, + selectAllButtonText() { + return this.hasSelectedDesigns + ? s__('DesignManagement|Deselect all') + : s__('DesignManagement|Select all'); + }, + }, + mounted() { + this.toggleOnPasteListener(this.$route.name); + }, + methods: { + resetFilesToBeSaved() { + this.filesToBeSaved = []; + }, + /** + * Determine if a design upload is valid, given [files] + * @param {Array<File>} files + */ + isValidDesignUpload(files) { + if (!this.canCreateDesign) return false; + + if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) { + createFlash( + sprintf( + s__( + 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.', + ), + { + upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT, + }, + ), + ); + + return false; + } + return true; + }, + onUploadDesign(files) { + // convert to Array so that we have Array methods (.map, .some, etc.) + this.filesToBeSaved = Array.from(files); + if (!this.isValidDesignUpload(this.filesToBeSaved)) return null; + + const mutationPayload = { + optimisticResponse: designUploadOptimisticResponse(this.filesToBeSaved), + variables: { + files: this.filesToBeSaved, + projectPath: this.projectPath, + iid: this.issueIid, + }, + context: { + hasUpload: true, + }, + mutation: uploadDesignMutation, + update: this.afterUploadDesign, + }; + + return this.$apollo + .mutate(mutationPayload) + .then(res => this.onUploadDesignDone(res)) + .catch(() => this.onUploadDesignError()); + }, + afterUploadDesign( + store, + { + data: { designManagementUpload }, + }, + ) { + updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody); + }, + onUploadDesignDone(res) { + const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || []; + const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles); + if (skippedWarningMessage) { + createFlash(skippedWarningMessage, 'warning'); + } + + // if this upload resulted in a new version being created, redirect user to the latest version + if (!this.isLatestVersion) { + this.$router.push({ name: DESIGNS_ROUTE_NAME }); + } + this.resetFilesToBeSaved(); + }, + onUploadDesignError() { + this.resetFilesToBeSaved(); + createFlash(UPLOAD_DESIGN_ERROR); + }, + changeSelectedDesigns(filename) { + if (this.isDesignSelected(filename)) { + this.selectedDesigns = this.selectedDesigns.filter(design => design !== filename); + } else { + this.selectedDesigns.push(filename); + } + }, + toggleDesignsSelection() { + if (this.hasSelectedDesigns) { + this.selectedDesigns = []; + } else { + this.selectedDesigns = this.designs.map(design => design.filename); + } + }, + isDesignSelected(filename) { + return this.selectedDesigns.includes(filename); + }, + isDesignToBeSaved(filename) { + return this.filesToBeSaved.some(file => file.name === filename); + }, + canSelectDesign(filename) { + return this.isLatestVersion && this.canCreateDesign && !this.isDesignToBeSaved(filename); + }, + onDesignDelete() { + this.selectedDesigns = []; + if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME }); + }, + onDesignDeleteError() { + const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 }); + createFlash(errorMessage); + }, + onExistingDesignDropzoneChange(files, existingDesignFilename) { + const filesArr = Array.from(files); + + if (filesArr.length > 1) { + createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE); + return; + } + + if (!filesArr.some(({ name }) => existingDesignFilename === name)) { + createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE); + return; + } + + this.onUploadDesign(files); + }, + onDesignPaste(event) { + const { clipboardData } = event; + const files = Array.from(clipboardData.files); + if (clipboardData && files.length > 0) { + if (!files.some(isValidDesignFile)) { + return; + } + event.preventDefault(); + let filename = getFilename(event); + if (!filename || filename === 'image.png') { + filename = `design_${Date.now()}.png`; + } + const newFile = new File([files[0]], filename); + this.onUploadDesign([newFile]); + } + }, + toggleOnPasteListener(route) { + if (route === DESIGNS_ROUTE_NAME) { + document.addEventListener('paste', this.onDesignPaste); + } else { + document.removeEventListener('paste', this.onDesignPaste); + } + }, + }, + beforeRouteUpdate(to, from, next) { + this.toggleOnPasteListener(to.name); + this.selectedDesigns = []; + next(); + }, + beforeRouteLeave(to, from, next) { + this.toggleOnPasteListener(to.name); + next(); + }, +}; +</script> + +<template> + <div> + <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex"> + <div class="d-flex justify-content-between align-items-center w-100"> + <design-version-dropdown /> + <div :class="['qa-selector-toolbar', { 'd-flex': hasDesigns, 'd-none': !hasDesigns }]"> + <gl-deprecated-button + v-if="isLatestVersion" + variant="link" + class="mr-2 js-select-all" + @click="toggleDesignsSelection" + >{{ selectAllButtonText }}</gl-deprecated-button + > + <design-destroyer + #default="{ mutate, loading }" + :filenames="selectedDesigns" + :project-path="projectPath" + :iid="issueIid" + @done="onDesignDelete" + @error="onDesignDeleteError" + > + <delete-button + v-if="isLatestVersion" + :is-deleting="loading" + button-class="btn-danger btn-inverted mr-2" + :has-selected-designs="hasSelectedDesigns" + @deleteSelectedDesigns="mutate()" + > + {{ s__('DesignManagement|Delete selected') }} + <gl-loading-icon v-if="loading" inline class="ml-1" /> + </delete-button> + </design-destroyer> + <upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" /> + </div> + </div> + </header> + <div class="mt-4"> + <gl-loading-icon v-if="isLoading" size="md" /> + <gl-alert v-else-if="error" variant="danger" :dismissible="false"> + {{ __('An error occurred while loading designs. Please try again.') }} + </gl-alert> + <ol v-else class="list-unstyled row"> + <li class="col-md-6 col-lg-4 mb-3"> + <design-dropzone class="design-list-item" @change="onUploadDesign" /> + </li> + <li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-4 mb-3"> + <design-dropzone @change="onExistingDesignDropzoneChange($event, design.filename)" + ><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)" + /></design-dropzone> + + <input + v-if="canSelectDesign(design.filename)" + :checked="isDesignSelected(design.filename)" + type="checkbox" + class="design-checkbox" + @change="changeSelectedDesigns(design.filename)" + /> + </li> + </ol> + </div> + <router-view /> + </div> +</template> diff --git a/app/assets/javascripts/design_management/router/constants.js b/app/assets/javascripts/design_management/router/constants.js new file mode 100644 index 00000000000..abeef520e33 --- /dev/null +++ b/app/assets/javascripts/design_management/router/constants.js @@ -0,0 +1,3 @@ +export const ROOT_ROUTE_NAME = 'root'; +export const DESIGNS_ROUTE_NAME = 'designs'; +export const DESIGN_ROUTE_NAME = 'design'; diff --git a/app/assets/javascripts/design_management/router/index.js b/app/assets/javascripts/design_management/router/index.js new file mode 100644 index 00000000000..7dc92f55d47 --- /dev/null +++ b/app/assets/javascripts/design_management/router/index.js @@ -0,0 +1,22 @@ +import $ from 'jquery'; +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, + }); + + router.beforeEach(({ meta: { el } }, from, next) => { + $(`#${el}`).tab('show'); + + next(); + }); + + return router; +} diff --git a/app/assets/javascripts/design_management/router/routes.js b/app/assets/javascripts/design_management/router/routes.js new file mode 100644 index 00000000000..788910e5514 --- /dev/null +++ b/app/assets/javascripts/design_management/router/routes.js @@ -0,0 +1,44 @@ +import Home from '../pages/index.vue'; +import DesignDetail from '../pages/design/index.vue'; +import { ROOT_ROUTE_NAME, DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants'; + +export default [ + { + name: ROOT_ROUTE_NAME, + path: '/', + component: Home, + meta: { + el: 'discussion', + }, + }, + { + name: DESIGNS_ROUTE_NAME, + path: '/designs', + component: Home, + meta: { + el: 'designs', + }, + children: [ + { + name: DESIGN_ROUTE_NAME, + path: ':id', + component: DesignDetail, + meta: { + el: 'designs', + }, + beforeEnter( + { + params: { id }, + }, + from, + next, + ) { + if (typeof id === 'string') { + next(); + } + }, + props: ({ params: { id } }) => ({ id }), + }, + ], + }, +]; diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js new file mode 100644 index 00000000000..01c073bddc2 --- /dev/null +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -0,0 +1,272 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +import createFlash from '~/flash'; +import { extractCurrentDiscussion, extractDesign } from './design_management_utils'; +import { + ADD_IMAGE_DIFF_NOTE_ERROR, + UPDATE_IMAGE_DIFF_NOTE_ERROR, + ADD_DISCUSSION_COMMENT_ERROR, + designDeletionError, +} from './error_messages'; + +const deleteDesignsFromStore = (store, query, selectedDesigns) => { + const data = store.readQuery(query); + + const changedDesigns = data.project.issue.designCollection.designs.edges.filter( + ({ node }) => !selectedDesigns.includes(node.filename), + ); + data.project.issue.designCollection.designs.edges = [...changedDesigns]; + + store.writeQuery({ + ...query, + data, + }); +}; + +/** + * Adds a new version of designs to store + * + * @param {Object} store + * @param {Object} query + * @param {Object} version + */ +const addNewVersionToStore = (store, query, version) => { + if (!version) return; + + const data = store.readQuery(query); + const newEdge = { node: version, __typename: 'DesignVersionEdge' }; + + data.project.issue.designCollection.versions.edges = [ + newEdge, + ...data.project.issue.designCollection.versions.edges, + ]; + + store.writeQuery({ + ...query, + data, + }); +}; + +const addDiscussionCommentToStore = (store, createNote, query, queryVariables, discussionId) => { + const data = store.readQuery({ + query, + variables: queryVariables, + }); + + const design = extractDesign(data); + const currentDiscussion = extractCurrentDiscussion(design.discussions, discussionId); + currentDiscussion.notes.nodes = [...currentDiscussion.notes.nodes, createNote.note]; + + design.notesCount += 1; + if ( + !design.issue.participants.edges.some( + participant => participant.node.username === createNote.note.author.username, + ) + ) { + design.issue.participants.edges = [ + ...design.issue.participants.edges, + { + __typename: 'UserEdge', + node: { + __typename: 'User', + ...createNote.note.author, + }, + }, + ]; + } + store.writeQuery({ + query, + variables: queryVariables, + data: { + ...data, + design: { + ...design, + }, + }, + }); +}; + +const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) => { + const data = store.readQuery({ + query, + variables, + }); + const newDiscussion = { + __typename: 'Discussion', + id: createImageDiffNote.note.discussion.id, + replyId: createImageDiffNote.note.discussion.replyId, + notes: { + __typename: 'NoteConnection', + nodes: [createImageDiffNote.note], + }, + }; + const design = extractDesign(data); + const notesCount = design.notesCount + 1; + design.discussions.nodes = [...design.discussions.nodes, newDiscussion]; + if ( + !design.issue.participants.edges.some( + participant => participant.node.username === createImageDiffNote.note.author.username, + ) + ) { + design.issue.participants.edges = [ + ...design.issue.participants.edges, + { + __typename: 'UserEdge', + node: { + __typename: 'User', + ...createImageDiffNote.note.author, + }, + }, + ]; + } + store.writeQuery({ + query, + variables, + data: { + ...data, + design: { + ...design, + notesCount, + }, + }, + }); +}; + +const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => { + const data = store.readQuery({ + query, + variables, + }); + + const design = extractDesign(data); + const discussion = extractCurrentDiscussion( + design.discussions, + updateImageDiffNote.note.discussion.id, + ); + + discussion.notes = { + ...discussion.notes, + nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)], + }; + + store.writeQuery({ + query, + variables, + data: { + ...data, + design, + }, + }); +}; + +const addNewDesignToStore = (store, designManagementUpload, query) => { + const data = store.readQuery(query); + + const newDesigns = data.project.issue.designCollection.designs.edges.reduce((acc, design) => { + if (!acc.find(d => d.filename === design.node.filename)) { + acc.push(design.node); + } + + return acc; + }, designManagementUpload.designs); + + let newVersionNode; + const findNewVersions = designManagementUpload.designs.find(design => design.versions); + + if (findNewVersions) { + const findNewVersionsEdges = findNewVersions.versions.edges; + + if (findNewVersionsEdges && findNewVersionsEdges.length) { + newVersionNode = [findNewVersionsEdges[0]]; + } + } + + const newVersions = [ + ...(newVersionNode || []), + ...data.project.issue.designCollection.versions.edges, + ]; + + const updatedDesigns = { + __typename: 'DesignCollection', + designs: { + __typename: 'DesignConnection', + edges: newDesigns.map(design => ({ + __typename: 'DesignEdge', + node: design, + })), + }, + versions: { + __typename: 'DesignVersionConnection', + edges: newVersions, + }, + }; + + data.project.issue.designCollection = updatedDesigns; + + store.writeQuery({ + ...query, + data, + }); +}; + +const onError = (data, message) => { + createFlash(message); + throw new Error(data.errors); +}; + +export const hasErrors = ({ errors = [] }) => errors?.length; + +/** + * Updates a store after design deletion + * + * @param {Object} store + * @param {Object} data + * @param {Object} query + * @param {Array} designs + */ +export const updateStoreAfterDesignsDelete = (store, data, query, designs) => { + if (hasErrors(data)) { + onError(data, designDeletionError({ singular: designs.length === 1 })); + } else { + deleteDesignsFromStore(store, query, designs); + addNewVersionToStore(store, query, data.version); + } +}; + +export const updateStoreAfterAddDiscussionComment = ( + store, + data, + query, + queryVariables, + discussionId, +) => { + if (hasErrors(data)) { + onError(data, ADD_DISCUSSION_COMMENT_ERROR); + } else { + addDiscussionCommentToStore(store, data, query, queryVariables, discussionId); + } +}; + +export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariables) => { + if (hasErrors(data)) { + onError(data, ADD_IMAGE_DIFF_NOTE_ERROR); + } else { + addImageDiffNoteToStore(store, data, query, queryVariables); + } +}; + +export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => { + if (hasErrors(data)) { + onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR); + } else { + updateImageDiffNoteInStore(store, data, query, queryVariables); + } +}; + +export const updateStoreAfterUploadDesign = (store, data, query) => { + if (hasErrors(data)) { + onError(data, data.errors[0]); + } else { + addNewDesignToStore(store, data, query); + } +}; diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js new file mode 100644 index 00000000000..e6d8796ffa4 --- /dev/null +++ b/app/assets/javascripts/design_management/utils/design_management_utils.js @@ -0,0 +1,125 @@ +import { uniqueId } from 'lodash'; +import { VALID_DESIGN_FILE_MIMETYPE } from '../constants'; + +export const isValidDesignFile = ({ type }) => + (type.match(VALID_DESIGN_FILE_MIMETYPE.regex) || []).length > 0; + +/** + * Returns formatted array that doesn't contain + * `edges`->`node` nesting + * + * @param {Array} elements + */ + +export const extractNodes = elements => elements.edges.map(({ node }) => node); + +/** + * Returns formatted array of discussions that doesn't contain + * `edges`->`node` nesting for child notes + * + * @param {Array} discussions + */ + +export const extractDiscussions = discussions => + discussions.nodes.map(discussion => ({ + ...discussion, + notes: discussion.notes.nodes, + })); + +/** + * Returns a discussion with the given id from discussions array + * + * @param {Array} discussions + */ + +export const extractCurrentDiscussion = (discussions, id) => + discussions.nodes.find(discussion => discussion.id === id); + +export const findVersionId = id => (id.match('::Version/(.+$)') || [])[1]; + +export const findNoteId = id => (id.match('DiffNote/(.+$)') || [])[1]; + +export const extractDesigns = data => data.project.issue.designCollection.designs.edges; + +export const extractDesign = data => (extractDesigns(data) || [])[0]?.node; + +/** + * Generates optimistic response for a design upload mutation + * @param {Array<File>} files + */ +export const designUploadOptimisticResponse = files => { + const designs = files.map(file => ({ + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Design', + id: -uniqueId(), + image: '', + imageV432x230: '', + filename: file.name, + fullPath: '', + notesCount: 0, + event: 'NONE', + diffRefs: { + __typename: 'DiffRefs', + baseSha: '', + startSha: '', + headSha: '', + }, + discussions: { + __typename: 'DesignDiscussion', + nodes: [], + }, + versions: { + __typename: 'DesignVersionConnection', + edges: { + __typename: 'DesignVersionEdge', + node: { + __typename: 'DesignVersion', + id: -uniqueId(), + sha: -uniqueId(), + }, + }, + }, + })); + + return { + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + designManagementUpload: { + __typename: 'DesignManagementUploadPayload', + designs, + skippedDesigns: [], + errors: [], + }, + }; +}; + +/** + * Generates optimistic response for a design upload mutation + * @param {Array<File>} files + */ +export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({ + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + updateImageDiffNote: { + __typename: 'UpdateImageDiffNotePayload', + note: { + ...note, + position: { + ...note.position, + ...position, + }, + }, + errors: [], + }, +}); + +const normalizeAuthor = author => ({ + ...author, + web_url: author.webUrl, + avatar_url: author.avatarUrl, +}); + +export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node)); diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js new file mode 100644 index 00000000000..7666c726c2f --- /dev/null +++ b/app/assets/javascripts/design_management/utils/error_messages.js @@ -0,0 +1,95 @@ +import { __, s__, n__, sprintf } from '~/locale'; + +export const ADD_DISCUSSION_COMMENT_ERROR = s__( + 'DesignManagement|Could not add a new comment. Please try again.', +); + +export const ADD_IMAGE_DIFF_NOTE_ERROR = s__( + 'DesignManagement|Could not create new discussion. Please try again.', +); + +export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__( + 'DesignManagement|Could not update discussion. Please try again.', +); + +export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.'); + +export const UPLOAD_DESIGN_ERROR = s__( + 'DesignManagement|Error uploading a new design. Please try again.', +); + +export const UPLOAD_DESIGN_INVALID_FILETYPE_ERROR = __( + 'Could not upload your designs as one or more files uploaded are not supported.', +); + +export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.'); + +export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.'); + +const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped.'); + +const ALL_DESIGNS_SKIPPED_MESSAGE = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__( + 'The designs you tried uploading did not change.', +)}`; + +export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __( + 'You can only upload one design when dropping onto an existing design.', +); + +export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __( + 'You must upload a file with the same file name when dropping onto an existing design.', +); + +const MAX_SKIPPED_FILES_LISTINGS = 5; + +const oneDesignSkippedMessage = filename => + `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), { + filename, + })}`; + +/** + * Return warning message indicating that some (but not all) uploaded + * files were skipped. + * @param {Array<{ filename }>} skippedFiles + */ +const someDesignsSkippedMessage = skippedFiles => { + const designsSkippedMessage = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__( + 'Some of the designs you tried uploading did not change:', + )}`; + + const moreText = sprintf(s__(`DesignManagement|and %{moreCount} more.`), { + moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS, + }); + + return `${designsSkippedMessage} ${skippedFiles + .slice(0, MAX_SKIPPED_FILES_LISTINGS) + .map(({ filename }) => filename) + .join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`; +}; + +export const designDeletionError = ({ singular = true } = {}) => { + const design = singular ? __('a design') : __('designs'); + return sprintf(s__('Could not delete %{design}. Please try again.'), { + design, + }); +}; + +/** + * Return warning message, if applicable, that one, some or all uploaded + * files were skipped. + * @param {Array<{ filename }>} uploadedDesigns + * @param {Array<{ filename }>} skippedFiles + */ +export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => { + if (skippedFiles.length === 0) { + return null; + } + + if (skippedFiles.length === uploadedDesigns.length) { + const { filename } = skippedFiles[0]; + + return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length); + } + + return someDesignsSkippedMessage(skippedFiles); +}; diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js new file mode 100644 index 00000000000..39c20376271 --- /dev/null +++ b/app/assets/javascripts/design_management/utils/tracking.js @@ -0,0 +1,28 @@ +import Tracking from '~/tracking'; + +function assembleDesignPayload(payloadArr) { + return { + value: { + 'internal-object-refrerer': payloadArr[0], + 'design-collection-owner': payloadArr[1], + 'design-version-number': payloadArr[2], + 'design-is-current-version': payloadArr[3], + }, + }; +} + +// Tracking Constants +const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; + +// eslint-disable-next-line import/prefer-default-export +export function trackDesignDetailView( + referer = '', + owner = '', + designVersion = 1, + latestVersion = false, +) { + Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', { + label: 'design_viewed', + ...assembleDesignPayload([referer, owner, designVersion, latestVersion]), + }); +} diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js index 84e07598fed..dd60e2c7684 100644 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js @@ -1,4 +1,3 @@ -/* eslint-disable no-else-return, no-lonely-if */ /* global CommentsStore */ import $ from 'jquery'; @@ -22,27 +21,19 @@ const CommentAndResolveBtn = Vue.extend({ showButton() { if (this.discussion) { return this.discussion.isResolvable(); - } else { - return false; } + return false; }, isDiscussionResolved() { return this.discussion.isResolved(); }, buttonText() { - if (this.isDiscussionResolved) { - if (this.textareaIsEmpty) { - return __('Unresolve thread'); - } else { - return __('Comment & unresolve thread'); - } - } else { - if (this.textareaIsEmpty) { - return __('Resolve thread'); - } else { - return __('Comment & resolve thread'); - } + if (this.textareaIsEmpty) { + return this.isDiscussionResolved ? __('Unresolve thread') : __('Resolve thread'); } + return this.isDiscussionResolved + ? __('Comment & unresolve thread') + : __('Comment & resolve thread'); }, }, created() { 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 fa5f8ea4005..0c521fa29bd 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, no-else-return, guard-for-in, no-restricted-syntax, no-lonely-if, no-continue */ +/* eslint-disable func-names, guard-for-in, no-restricted-syntax, no-lonely-if, no-continue */ /* global CommentsStore */ import $ from 'jquery'; @@ -25,9 +25,8 @@ const JumpToDiscussion = Vue.extend({ buttonText() { if (this.discussionId) { return __('Jump to next unresolved thread'); - } else { - return __('Jump to first unresolved thread'); } + return __('Jump to first unresolved thread'); }, allResolved() { return this.unresolvedDiscussionCount === 0; @@ -36,12 +35,10 @@ const JumpToDiscussion = Vue.extend({ if (this.discussionId) { if (this.unresolvedDiscussionCount > 1) { return true; - } else { - return this.discussionId !== this.lastResolvedId; } - } else { - return this.unresolvedDiscussionCount >= 1; + return this.discussionId !== this.lastResolvedId; } + return this.unresolvedDiscussionCount >= 1; }, lastResolvedId() { let lastId; diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 072bcaaad97..941365d9d1d 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -7,6 +7,7 @@ import createFlash from '~/flash'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { isSingleViewStyle } from '~/helpers/diffs_helper'; +import { updateHistory } from '~/lib/utils/url_utility'; import eventHub from '../../notes/event_hub'; import CompareVersions from './compare_versions.vue'; import DiffFile from './diff_file.vue'; @@ -140,6 +141,20 @@ export default { }, }, watch: { + commit(newCommit, oldCommit) { + const commitChangedAfterRender = newCommit && !this.isLoading; + const commitIsDifferent = oldCommit && newCommit.id !== oldCommit.id; + const url = window?.location ? String(window.location) : ''; + + if (commitChangedAfterRender && commitIsDifferent) { + updateHistory({ + title: document.title, + url: url.replace(oldCommit.id, newCommit.id), + }); + this.refetchDiffData(); + this.adjustView(); + } + }, diffViewType() { if (this.needsReload() || this.needsFirstLoad()) { this.refetchDiffData(); @@ -209,6 +224,7 @@ export default { methods: { ...mapActions(['startTaskList']), ...mapActions('diffs', [ + 'moveToNeighboringCommit', 'setBaseConfig', 'fetchDiffFiles', 'fetchDiffFilesMeta', @@ -329,9 +345,16 @@ export default { break; } }); + + if (this.commit && this.glFeatures.mrCommitNeighborNav) { + Mousetrap.bind('c', () => this.moveToNeighboringCommit({ direction: 'next' })); + Mousetrap.bind('x', () => this.moveToNeighboringCommit({ direction: 'previous' })); + } }, removeEventListeners() { Mousetrap.unbind(['[', 'k', ']', 'j']); + Mousetrap.unbind('c'); + Mousetrap.unbind('x'); }, jumpToFile(step) { const targetIndex = this.currentDiffIndex + step; diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 9d4edd84f25..ee93ca020e8 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -1,10 +1,18 @@ <script> +import { mapActions } from 'vuex'; +import { GlButtonGroup, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; + +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import Icon from '~/vue_shared/components/icon.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; + import initUserPopovers from '../../user_popovers'; +import { setUrlParams } from '../../lib/utils/url_utility'; /** * CommitItem @@ -18,7 +26,16 @@ import initUserPopovers from '../../user_popovers'; * coexist, but there is an issue to remove the duplication. * https://gitlab.com/gitlab-org/gitlab-foss/issues/51613 * + * EXCEPTION WARNING + * 1. The commit navigation buttons (next neighbor, previous neighbor) + * are not duplicated because: + * - We don't have the same data available on the Rails side (yet, + * without backend work) + * - This Vue component should always be what's used when in the + * context of an MR diff, so the HAML should never have any idea + * about navigating among commits. */ + export default { components: { UserAvatarLink, @@ -26,7 +43,14 @@ export default { ClipboardButton, TimeAgoTooltip, CommitPipelineStatus, + GlButtonGroup, + GlButton, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { commit: { type: Object, @@ -54,12 +78,28 @@ export default { authorAvatar() { return this.author.avatar_url || this.commit.author_gravatar_url; }, + nextCommitUrl() { + return this.commit.next_commit_id + ? setUrlParams({ commit_id: this.commit.next_commit_id }) + : ''; + }, + previousCommitUrl() { + return this.commit.prev_commit_id + ? setUrlParams({ commit_id: this.commit.prev_commit_id }) + : ''; + }, + hasNeighborCommits() { + return this.commit.next_commit_id || this.commit.prev_commit_id; + }, }, created() { this.$nextTick(() => { initUserPopovers(this.$el.querySelectorAll('.js-user-link')); }); }, + methods: { + ...mapActions('diffs', ['moveToNeighboringCommit']), + }, }; </script> @@ -123,6 +163,41 @@ export default { class="btn btn-default" /> </div> + <div + v-if="hasNeighborCommits && glFeatures.mrCommitNeighborNav" + class="commit-nav-buttons ml-3" + > + <gl-button-group> + <gl-button + :href="previousCommitUrl" + :disabled="!commit.prev_commit_id" + @click.prevent="moveToNeighboringCommit({ direction: 'previous' })" + > + <span + v-if="!commit.prev_commit_id" + v-gl-tooltip + class="h-100 w-100 position-absolute" + :title="__('You\'re at the first commit')" + ></span> + <gl-icon name="chevron-left" /> + {{ __('Prev') }} + </gl-button> + <gl-button + :href="nextCommitUrl" + :disabled="!commit.next_commit_id" + @click.prevent="moveToNeighboringCommit({ direction: 'next' })" + > + <span + v-if="!commit.next_commit_id" + v-gl-tooltip + class="h-100 w-100 position-absolute" + :title="__('You\'re at the last commit')" + ></span> + {{ __('Next') }} + <gl-icon name="chevron-right" /> + </gl-button> + </gl-button-group> + </div> </div> </div> </li> diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index b0460bacff2..b6a0724c201 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -85,9 +85,11 @@ export default { :help-page-path="helpPagePath" @noteDeleted="deleteNoteHandler" > - <span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill"> - {{ index + 1 }} - </span> + <template v-if="renderAvatarBadge" #avatar-badge> + <span class="badge badge-pill"> + {{ index + 1 }} + </span> + </template> </noteable-discussion> </ul> </div> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 82ca3749ac1..54852b113ae 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { GlLoadingIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import createFlash from '~/flash'; @@ -46,7 +46,7 @@ export default { return sprintf( __('You can %{linkStart}view the blob%{linkEnd} instead.'), { - linkStart: `<a href="${esc(this.file.view_path)}">`, + linkStart: `<a href="${escape(this.file.view_path)}">`, linkEnd: '</a>', }, false, diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index d601c3769a3..61bbf13aa53 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { mapActions, mapGetters } from 'vuex'; import { GlDeprecatedButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import { polyfillSticky } from '~/lib/utils/sticky'; @@ -91,7 +91,7 @@ export default { return this.expanded ? 'chevron-down' : 'chevron-right'; }, viewFileButtonText() { - const truncatedContentSha = esc(truncateSha(this.diffFile.content_sha)); + const truncatedContentSha = escape(truncateSha(this.diffFile.content_sha)); return sprintf( s__('MergeRequests|View file @ %{commitId}'), { commitId: truncatedContentSha }, @@ -99,7 +99,7 @@ export default { ); }, viewReplacedFileButtonText() { - const truncatedBaseSha = esc(truncateSha(this.diffFile.diff_refs.base_sha)); + const truncatedBaseSha = escape(truncateSha(this.diffFile.diff_refs.base_sha)); return sprintf( s__('MergeRequests|View replaced file @ %{commitId}'), { diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue index 91e296f8572..21fdb19287d 100644 --- a/app/assets/javascripts/diffs/components/edit_button.vue +++ b/app/assets/javascripts/diffs/components/edit_button.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui'; +import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; export default { @@ -13,7 +14,8 @@ export default { props: { editPath: { type: String, - required: true, + required: false, + default: '', }, canCurrentUserFork: { type: Boolean, @@ -25,6 +27,18 @@ export default { default: false, }, }, + computed: { + tooltipTitle() { + if (this.isDisabled) { + return __("Can't edit as source branch was deleted"); + } + + return __('Edit file'); + }, + isDisabled() { + return !this.editPath; + }, + }, methods: { handleEditClick(evt) { if (this.canCurrentUserFork && !this.canModifyBlob) { @@ -37,13 +51,15 @@ export default { </script> <template> - <gl-deprecated-button - v-gl-tooltip.top - :href="editPath" - :title="__('Edit file')" - class="js-edit-blob" - @click.native="handleEditClick" - > - <icon name="pencil" /> - </gl-deprecated-button> + <span v-gl-tooltip.top :title="tooltipTitle"> + <gl-deprecated-button + :href="editPath" + :disabled="isDisabled" + :class="{ 'cursor-not-allowed': isDisabled }" + class="rounded-0 js-edit-blob" + @click.native="handleEditClick" + > + <icon name="pencil" /> + </gl-deprecated-button> + </span> </template> diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue index 5fd68471094..94c2695a945 100644 --- a/app/assets/javascripts/diffs/components/no_changes.vue +++ b/app/assets/javascripts/diffs/components/no_changes.vue @@ -1,6 +1,6 @@ <script> import { mapGetters } from 'vuex'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { GlDeprecatedButton } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; @@ -24,8 +24,8 @@ export default { { ref_start: '<span class="ref-name">', ref_end: '</span>', - source_branch: esc(this.getNoteableData.source_branch), - target_branch: esc(this.getNoteableData.target_branch), + source_branch: escape(this.getNoteableData.source_branch), + target_branch: escape(this.getNoteableData.target_branch), }, false, ); diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 93c242e32ac..1975d6996a5 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -16,6 +16,7 @@ import { idleCallback, allDiscussionWrappersExpanded, prepareDiffData, + prepareLineForRenamedFile, } from './utils'; import * as types from './mutation_types'; import { @@ -627,6 +628,42 @@ export const toggleFullDiff = ({ dispatch, getters, state }, filePath) => { } }; +export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { diffFile }) { + return axios + .get(diffFile.context_lines_path, { + params: { + full: true, + from_merge_request: true, + }, + }) + .then(({ data }) => { + const lines = data.map((line, index) => + prepareLineForRenamedFile({ + diffViewType: state.diffViewType, + line, + diffFile, + index, + }), + ); + + commit(types.SET_DIFF_FILE_VIEWER, { + filePath: diffFile.file_path, + viewer: { + ...diffFile.alternate_viewer, + collapsed: false, + }, + }); + commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines }); + + dispatch('startRenderDiffsQueue'); + }) + .catch(error => { + dispatch('receiveFullDiffError', diffFile.file_path); + + throw error; + }); +} + export const setFileCollapsed = ({ commit }, { filePath, collapsed }) => commit(types.SET_FILE_COLLAPSED, { filePath, collapsed }); @@ -642,5 +679,48 @@ export const setSuggestPopoverDismissed = ({ commit, state }) => createFlash(s__('MergeRequest|Error dismissing suggestion popover. Please try again.')); }); +export function changeCurrentCommit({ dispatch, commit, state }, { commitId }) { + /* eslint-disable @gitlab/require-i18n-strings */ + if (!commitId) { + return Promise.reject(new Error('`commitId` is a required argument')); + } else if (!state.commit) { + return Promise.reject(new Error('`state` must already contain a valid `commit`')); + } + /* eslint-enable @gitlab/require-i18n-strings */ + + // this is less than ideal, see: https://gitlab.com/gitlab-org/gitlab/-/issues/215421 + const commitRE = new RegExp(state.commit.id, 'g'); + + commit(types.SET_DIFF_FILES, []); + commit(types.SET_BASE_CONFIG, { + ...state, + endpoint: state.endpoint.replace(commitRE, commitId), + endpointBatch: state.endpointBatch.replace(commitRE, commitId), + endpointMetadata: state.endpointMetadata.replace(commitRE, commitId), + }); + + return dispatch('fetchDiffFilesMeta'); +} + +export function moveToNeighboringCommit({ dispatch, state }, { direction }) { + const previousCommitId = state.commit?.prev_commit_id; + const nextCommitId = state.commit?.next_commit_id; + const canMove = { + next: !state.isLoading && nextCommitId, + previous: !state.isLoading && previousCommitId, + }; + let commitId; + + if (direction === 'next' && canMove.next) { + commitId = nextCommitId; + } else if (direction === 'previous' && canMove.previous) { + commitId = previousCommitId; + } + + if (commitId) { + dispatch('changeCurrentCommit', { commitId }); + } +} + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js index acc8874dad8..1e8e736c028 100644 --- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js +++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js @@ -40,10 +40,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => { }; }; - if (gon.features?.diffCompareWithHead) { - return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion]; - } - return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion]; + return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion]; }; export const diffCompareDropdownSourceVersions = (state, getters) => { diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 699c61b3ddd..4b1dbc34902 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -41,6 +41,8 @@ export const SET_CURRENT_VIEW_DIFF_FILE_LINES = 'SET_CURRENT_VIEW_DIFF_FILE_LINE export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINES'; export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE'; +export const SET_DIFF_FILE_VIEWER = 'SET_DIFF_FILE_VIEWER'; + export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER'; export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 104686993a8..7e89d041c21 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -383,6 +383,11 @@ export default { file.renderingLines = !file.renderingLines; }, + [types.SET_DIFF_FILE_VIEWER](state, { filePath, viewer }) { + const file = findDiffFile(state.diffFiles, filePath, 'file_path'); + + file.viewer = viewer; + }, [types.SET_SHOW_SUGGEST_POPOVER](state) { state.showSuggestPopover = false; }, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index dd8dec49a37..2be71c77087 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -233,7 +233,7 @@ export function trimFirstCharOfLineContent(line = {}) { // eslint-disable-next-line no-param-reassign delete line.text; - const parsedLine = Object.assign({}, line); + const parsedLine = { ...line }; if (line.rich_text) { const firstChar = parsedLine.rich_text.charAt(0); @@ -303,6 +303,42 @@ function prepareLine(line) { } } +export function prepareLineForRenamedFile({ line, diffViewType, diffFile, index = 0 }) { + /* + Renamed files are a little different than other diffs, which + is why this is distinct from `prepareDiffFileLines` below. + + We don't get any of the diff file context when we get the diff + (so no "inline" vs. "parallel", no "line_code", etc.). + + We can also assume that both the left and the right of each line + (for parallel diff view type) are identical, because the file + is renamed, not modified. + + This should be cleaned up as part of the effort around flattening our data + ==> https://gitlab.com/groups/gitlab-org/-/epics/2852#note_304803402 + */ + const lineNumber = index + 1; + const cleanLine = { + ...line, + line_code: `${diffFile.file_hash}_${lineNumber}_${lineNumber}`, + new_line: lineNumber, + old_line: lineNumber, + }; + + prepareLine(cleanLine); // WARNING: In-Place Mutations! + + if (diffViewType === PARALLEL_DIFF_VIEW_TYPE) { + return { + left: { ...cleanLine }, + right: { ...cleanLine }, + line_code: cleanLine.line_code, + }; + } + + return cleanLine; +} + function prepareDiffFileLines(file) { const inlineLines = file.highlighted_diff_lines; const parallelLines = file.parallel_diff_lines; @@ -437,7 +473,11 @@ export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) { // This method will check whether the discussion is still applicable // to the diff line in question regarding different versions of the MR export function isDiscussionApplicableToLine({ discussion, diffPosition, latestDiff }) { - const { line_code, ...diffPositionCopy } = diffPosition; + const { line_code, ...dp } = diffPosition; + // Removing `line_range` from diffPosition because the backend does not + // yet consistently return this property. This check can be removed, + // once this is addressed. see https://gitlab.com/gitlab-org/gitlab/-/issues/213010 + const { line_range: dpNotUsed, ...diffPositionCopy } = dp; if (discussion.original_position && discussion.position) { const discussionPositions = [ @@ -446,7 +486,14 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD ...(discussion.positions || []), ]; - return discussionPositions.some(position => isEqual(position, diffPositionCopy)); + const removeLineRange = position => { + const { line_range: pNotUsed, ...positionNoLineRange } = position; + return positionNoLineRange; + }; + + return discussionPositions + .map(removeLineRange) + .some(position => isEqual(position, diffPositionCopy)); } // eslint-disable-next-line diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index f839e9acf04..9a0b85bd610 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import Dropzone from 'dropzone'; -import _ from 'underscore'; +import { escape } from 'lodash'; import './behaviors/preview_markdown'; import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table'; import csrf from './lib/utils/csrf'; @@ -16,7 +16,7 @@ Dropzone.autoDiscover = false; * @param {String|Object} res */ function getErrorMessage(res) { - if (!res || _.isString(res)) { + if (!res || typeof res === 'string') { return res; } @@ -233,7 +233,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { }; addFileToForm = path => { - $(form).append(`<input type="hidden" name="files[]" value="${_.escape(path)}">`); + $(form).append(`<input type="hidden" name="files[]" value="${escape(path)}">`); }; const showSpinner = () => $uploadingProgressContainer.removeClass('hide'); diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js index 663d14bcfcb..020ed6dc867 100644 --- a/app/assets/javascripts/editor/editor_lite.js +++ b/app/assets/javascripts/editor/editor_lite.js @@ -1,6 +1,8 @@ import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; +import languages from '~/ide/lib/languages'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; +import { registerLanguages } from '~/ide/utils'; import { clearDomElement } from './utils'; export default class Editor { @@ -17,6 +19,8 @@ export default class Editor { }; Editor.setupMonacoTheme(); + + registerLanguages(...languages); } static setupMonacoTheme() { diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue index 5c03c008faf..f0723e96ddf 100644 --- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue +++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue @@ -3,7 +3,7 @@ * Render modal to confirm rollback/redeploy. */ -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { GlModal } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; @@ -30,7 +30,7 @@ export default { : s__('Environments|Rollback environment %{name}?'); return sprintf(title, { - name: esc(this.environment.name), + name: escape(this.environment.name), }); }, @@ -50,10 +50,10 @@ export default { }, modalText() { - const linkStart = `<a class="commit-sha mr-0" href="${esc(this.commitUrl)}">`; - const commitId = esc(this.commitShortSha); + const linkStart = `<a class="commit-sha mr-0" href="${escape(this.commitUrl)}">`; + const commitId = escape(this.commitShortSha); const linkEnd = '</a>'; - const name = esc(this.name); + const name = escape(this.name); const body = this.environment.isLastDeployment ? s__( 'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?', diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index 0a978ab5869..899d7ec8521 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -1,8 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import containerMixin from 'ee_else_ce/environments/mixins/container_mixin'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import EnvironmentTable from '../components/environments_table.vue'; +import EnvironmentTable from './environments_table.vue'; export default { components: { @@ -10,8 +9,12 @@ export default { TablePagination, GlLoadingIcon, }, - mixins: [containerMixin], props: { + canaryDeploymentFeatureId: { + type: String, + required: false, + default: null, + }, isLoading: { type: Boolean, required: true, @@ -28,6 +31,31 @@ export default { type: Boolean, required: true, }, + deployBoardsHelpPath: { + type: String, + required: false, + default: '', + }, + helpCanaryDeploymentsPath: { + type: String, + required: false, + default: '', + }, + lockPromotionSvgPath: { + type: String, + required: false, + default: '', + }, + showCanaryDeploymentCallout: { + type: Boolean, + required: false, + default: false, + }, + userCalloutsPath: { + type: String, + required: false, + default: '', + }, }, methods: { onChangePage(page) { diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue index f731dc49a5b..29aab268fd3 100644 --- a/app/assets/javascripts/environments/components/delete_environment_modal.vue +++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue @@ -52,7 +52,7 @@ export default { footer-primary-button-variant="danger" @submit="onSubmit" > - <template slot="header"> + <template #header> <h4 class="modal-title d-flex mw-100"> {{ __('Delete') }} <span v-gl-tooltip :title="environment.name" class="text-truncate mx-1 flex-fill"> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 335c668474e..fa3d217f148 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -9,7 +9,6 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link import CommitComponent from '~/vue_shared/components/commit.vue'; import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; -import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin'; import eventHub from '../event_hub'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; @@ -44,7 +43,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [environmentItemMixin, timeagoMixin], + mixins: [timeagoMixin], props: { canReadEnvironment: { @@ -65,6 +64,9 @@ export default { }, computed: { + deployIconName() { + return this.model.isDeployBoardVisible ? 'chevron-down' : 'chevron-right'; + }, /** * Verifies if `last_deployment` key exists in the current Environment. * This key is required to render most of the html - this method works has @@ -210,6 +212,10 @@ export default { })); }, + shouldRenderDeployBoard() { + return this.model.hasDeployBoard; + }, + /** * Builds the string used in the user image alt attribute. * @@ -501,6 +507,9 @@ export default { }, methods: { + toggleDeployBoard() { + eventHub.$emit('toggleDeployBoard', this.model); + }, onClickFolder() { eventHub.$emit('toggleFolder', this.model); }, diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 0cc6f3df2d7..0a5538237f9 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -1,6 +1,5 @@ <script> import { GlDeprecatedButton } from '@gitlab/ui'; -import envrionmentsAppMixin from 'ee_else_ce/environments/mixins/environments_app_mixin'; import Flash from '~/flash'; import { s__ } from '~/locale'; import emptyState from './empty_state.vue'; @@ -22,13 +21,18 @@ export default { DeleteEnvironmentModal, }, - mixins: [CIPaginationMixin, environmentsMixin, envrionmentsAppMixin], + mixins: [CIPaginationMixin, environmentsMixin], props: { endpoint: { type: String, required: true, }, + canaryDeploymentFeatureId: { + type: String, + required: false, + default: '', + }, canCreateEnvironment: { type: Boolean, required: true, @@ -41,6 +45,11 @@ export default { type: String, required: true, }, + helpCanaryDeploymentsPath: { + type: String, + required: false, + default: '', + }, helpPagePath: { type: String, required: true, @@ -50,17 +59,37 @@ export default { required: false, default: '', }, + lockPromotionSvgPath: { + type: String, + required: false, + default: '', + }, + showCanaryDeploymentCallout: { + type: Boolean, + required: false, + default: false, + }, + userCalloutsPath: { + type: String, + required: false, + default: '', + }, }, created() { eventHub.$on('toggleFolder', this.toggleFolder); + eventHub.$on('toggleDeployBoard', this.toggleDeployBoard); }, beforeDestroy() { eventHub.$off('toggleFolder'); + eventHub.$off('toggleDeployBoard'); }, methods: { + toggleDeployBoard(model) { + this.store.toggleDeployBoard(model.id); + }, toggleFolder(folder) { this.store.toggleFolder(folder); diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 89e40faa23e..380e16c7b71 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -4,7 +4,6 @@ */ import { GlLoadingIcon } from '@gitlab/ui'; import { flow, reverse, sortBy } from 'lodash/fp'; -import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin'; import { s__ } from '~/locale'; import EnvironmentItem from './environment_item.vue'; @@ -16,7 +15,6 @@ export default { CanaryDeploymentCallout: () => import('ee_component/environments/components/canary_deployment_callout.vue'), }, - mixins: [environmentTableMixin], props: { environments: { type: Array, @@ -33,6 +31,31 @@ export default { required: false, default: false, }, + canaryDeploymentFeatureId: { + type: String, + required: false, + default: '', + }, + helpCanaryDeploymentsPath: { + type: String, + required: false, + default: '', + }, + lockPromotionSvgPath: { + type: String, + required: false, + default: '', + }, + showCanaryDeploymentCallout: { + type: Boolean, + required: false, + default: false, + }, + userCalloutsPath: { + type: String, + required: false, + default: '', + }, }, computed: { sortedEnvironments() { @@ -79,9 +102,15 @@ export default { folderUrl(model) { return `${window.location.pathname}/folders/${model.folderName}`; }, + shouldRenderDeployBoard(model) { + return model.hasDeployBoard && model.isDeployBoardVisible; + }, shouldRenderFolderContent(env) { return env.isFolder && env.isOpen && env.children && env.children.length > 0; }, + shouldShowCanaryCallout(env) { + return env.showCanaryCallout && this.showCanaryDeploymentCallout; + }, sortEnvironments(environments) { /* * The sorting algorithm should sort in the following priorities: diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index d3e8fb7ff08..7448fd584c6 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -60,7 +60,7 @@ export default { footer-primary-button-variant="danger" @submit="onSubmit" > - <template slot="header"> + <template #header> <h4 class="modal-title d-flex mw-100"> Stopping <span v-gl-tooltip :title="environment.name" class="text-truncate ml-1 mr-1 flex-fill"> diff --git a/app/assets/javascripts/environments/event_hub.js b/app/assets/javascripts/environments/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/environments/event_hub.js +++ b/app/assets/javascripts/environments/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index c1bfe8d05fe..56896ac4d43 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin'; +import canaryCalloutMixin from '../mixins/canary_callout_mixin'; import environmentsFolderApp from './environments_folder_view.vue'; import { parseBoolean } from '../../lib/utils/common_utils'; import Translate from '../../vue_shared/translate'; diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index 30b02585692..e1e356a977f 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -1,5 +1,4 @@ <script> -import folderMixin from 'ee_else_ce/environments/mixins/environments_folder_view_mixin'; import environmentsMixin from '../mixins/environments_mixin'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; import StopEnvironmentModal from '../components/stop_environment_modal.vue'; @@ -11,7 +10,7 @@ export default { DeleteEnvironmentModal, }, - mixins: [environmentsMixin, CIPaginationMixin, folderMixin], + mixins: [environmentsMixin, CIPaginationMixin], props: { endpoint: { @@ -30,6 +29,31 @@ export default { type: Boolean, required: true, }, + canaryDeploymentFeatureId: { + type: String, + required: false, + default: '', + }, + showCanaryDeploymentCallout: { + type: Boolean, + required: false, + default: false, + }, + userCalloutsPath: { + type: String, + required: false, + default: '', + }, + lockPromotionSvgPath: { + type: String, + required: false, + default: '', + }, + helpCanaryDeploymentsPath: { + type: String, + required: false, + default: '', + }, }, methods: { successCallback(resp) { diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index 9a68619d4f7..4848cb0f13d 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin'; +import canaryCalloutMixin from './mixins/canary_callout_mixin'; import environmentsComponent from './components/environments_app.vue'; import { parseBoolean } from '../lib/utils/common_utils'; import Translate from '../vue_shared/translate'; diff --git a/app/assets/javascripts/environments/mixins/canary_callout_mixin.js b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js index f6d3d67b777..398576a31cb 100644 --- a/app/assets/javascripts/environments/mixins/canary_callout_mixin.js +++ b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js @@ -1,5 +1,26 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + export default { + data() { + const data = document.querySelector(this.$options.el).dataset; + + return { + canaryDeploymentFeatureId: data.environmentsDataCanaryDeploymentFeatureId, + showCanaryDeploymentCallout: parseBoolean(data.environmentsDataShowCanaryDeploymentCallout), + userCalloutsPath: data.environmentsDataUserCalloutsPath, + lockPromotionSvgPath: data.environmentsDataLockPromotionSvgPath, + helpCanaryDeploymentsPath: data.environmentsDataHelpCanaryDeploymentsPath, + }; + }, computed: { - canaryCalloutProps() {}, + canaryCalloutProps() { + return { + canaryDeploymentFeatureId: this.canaryDeploymentFeatureId, + showCanaryDeploymentCallout: this.showCanaryDeploymentCallout, + userCalloutsPath: this.userCalloutsPath, + lockPromotionSvgPath: this.lockPromotionSvgPath, + helpCanaryDeploymentsPath: this.helpCanaryDeploymentsPath, + }; + }, }, }; diff --git a/app/assets/javascripts/environments/mixins/container_mixin.js b/app/assets/javascripts/environments/mixins/container_mixin.js deleted file mode 100644 index abf7d33be91..00000000000 --- a/app/assets/javascripts/environments/mixins/container_mixin.js +++ /dev/null @@ -1,34 +0,0 @@ -export default { - props: { - canaryDeploymentFeatureId: { - type: String, - required: false, - default: null, - }, - showCanaryDeploymentCallout: { - type: Boolean, - required: false, - default: false, - }, - userCalloutsPath: { - type: String, - required: false, - default: null, - }, - lockPromotionSvgPath: { - type: String, - required: false, - default: null, - }, - helpCanaryDeploymentsPath: { - type: String, - required: false, - default: null, - }, - deployBoardsHelpPath: { - type: String, - required: false, - default: '', - }, - }, -}; diff --git a/app/assets/javascripts/environments/mixins/environment_item_mixin.js b/app/assets/javascripts/environments/mixins/environment_item_mixin.js deleted file mode 100644 index 2dfed36ec99..00000000000 --- a/app/assets/javascripts/environments/mixins/environment_item_mixin.js +++ /dev/null @@ -1,13 +0,0 @@ -export default { - computed: { - deployIconName() { - return ''; - }, - shouldRenderDeployBoard() { - return false; - }, - }, - methods: { - toggleDeployBoard() {}, - }, -}; diff --git a/app/assets/javascripts/environments/mixins/environments_app_mixin.js b/app/assets/javascripts/environments/mixins/environments_app_mixin.js deleted file mode 100644 index fc805b9235a..00000000000 --- a/app/assets/javascripts/environments/mixins/environments_app_mixin.js +++ /dev/null @@ -1,32 +0,0 @@ -export default { - props: { - canaryDeploymentFeatureId: { - type: String, - required: false, - default: '', - }, - showCanaryDeploymentCallout: { - type: Boolean, - required: false, - default: false, - }, - userCalloutsPath: { - type: String, - required: false, - default: '', - }, - lockPromotionSvgPath: { - type: String, - required: false, - default: '', - }, - helpCanaryDeploymentsPath: { - type: String, - required: false, - default: '', - }, - }, - metods: { - toggleDeployBoard() {}, - }, -}; diff --git a/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js b/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js deleted file mode 100644 index e793a7cadf2..00000000000 --- a/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js +++ /dev/null @@ -1,29 +0,0 @@ -export default { - props: { - canaryDeploymentFeatureId: { - type: String, - required: false, - default: '', - }, - showCanaryDeploymentCallout: { - type: Boolean, - required: false, - default: false, - }, - userCalloutsPath: { - type: String, - required: false, - default: '', - }, - lockPromotionSvgPath: { - type: String, - required: false, - default: '', - }, - helpCanaryDeploymentsPath: { - type: String, - required: false, - default: '', - }, - }, -}; diff --git a/app/assets/javascripts/environments/mixins/environments_table_mixin.js b/app/assets/javascripts/environments/mixins/environments_table_mixin.js deleted file mode 100644 index 208f1a7373d..00000000000 --- a/app/assets/javascripts/environments/mixins/environments_table_mixin.js +++ /dev/null @@ -1,10 +0,0 @@ -export default { - methods: { - shouldShowCanaryCallout() { - return false; - }, - shouldRenderDeployBoard() { - return false; - }, - }, -}; diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 6b7c1ff627d..1992e753255 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -58,13 +58,14 @@ export default class EnvironmentsStore { let filtered = {}; if (env.size > 1) { - filtered = Object.assign({}, env, { + filtered = { + ...env, isFolder: true, isLoadingFolderContent: oldEnvironmentState.isLoading || false, folderName: env.name, isOpen: oldEnvironmentState.isOpen || false, children: oldEnvironmentState.children || [], - }); + }; } if (env.latest) { @@ -133,6 +134,17 @@ export default class EnvironmentsStore { } /** + * Toggles deploy board visibility for the provided environment ID. + * Currently only works on EE. + * + * @param {Object} environment + * @return {Array} + */ + toggleDeployBoard() { + return this.state.environments; + } + + /** * Toggles folder open property for the given folder. * * @param {Object} folder @@ -155,7 +167,7 @@ export default class EnvironmentsStore { let updated = env; if (env.latest) { - updated = Object.assign({}, env, env.latest); + updated = { ...env, ...env.latest }; delete updated.latest; } else { updated = env; @@ -181,7 +193,7 @@ export default class EnvironmentsStore { const { environments } = this.state; const updatedEnvironments = environments.map(env => { - const updateEnv = Object.assign({}, env); + const updateEnv = { ...env }; if (env.id === environment.id) { updateEnv[prop] = newValue; } 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 3d700f4d216..45432e8ebd8 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -393,9 +393,9 @@ export default { <template #description> <div> <span>{{ __('Monitor your errors by integrating with Sentry.') }}</span> - <a href="/help/user/project/operations/error_tracking.html"> - {{ __('More information') }} - </a> + <gl-link target="_blank" href="/help/user/project/operations/error_tracking.html">{{ + __('More information') + }}</gl-link> </div> </template> </gl-empty-state> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index 8db0b1c5da0..f7f2c450be1 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { GlTooltip } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -62,7 +62,7 @@ export default { ? sprintf( __(`%{spanStart}in%{spanEnd} %{errorFn}`), { - errorFn: `<strong>${esc(this.errorFn)}</strong>`, + errorFn: `<strong>${escape(this.errorFn)}</strong>`, spanStart: `<span class="text-tertiary">`, spanEnd: `</span>`, }, diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js index 8f6f404ef8a..05554b2b566 100644 --- a/app/assets/javascripts/error_tracking/store/actions.js +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -1,4 +1,4 @@ -import service from './../services'; +import service from '../services'; import * as types from './mutation_types'; import createFlash from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index d7264e96b13..7e7a2588951 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -4,3 +4,8 @@ export const DROPDOWN_TYPE = { hint: 'hint', operator: 'operator', }; + +export const FILTER_TYPE = { + none: 'none', + any: 'any', +}; diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js index 0c8c8140ee9..1bbd33b6258 100644 --- a/app/assets/javascripts/filtered_search/dropdown_operator.js +++ b/app/assets/javascripts/filtered_search/dropdown_operator.js @@ -47,13 +47,17 @@ export default class DropdownOperator extends FilteredSearchDropdown { title: '=', help: __('is'), }, - { + ]; + + if (gon.features?.notIssuableQueries) { + dropdownData.push({ tag: 'not-equal', type: 'string', title: '!=', help: __('is not'), - }, - ]; + }); + } + this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); this.droplab.setData(this.hookId, dropdownData); super.renderContent(forceShowList); diff --git a/app/assets/javascripts/filtered_search/event_hub.js b/app/assets/javascripts/filtered_search/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/filtered_search/event_hub.js +++ b/app/assets/javascripts/filtered_search/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js index 2b6e1f25dc6..f7ce2ea01e0 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js @@ -1,6 +1,7 @@ import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; +import { FILTER_TYPE } from './constants'; const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger'; @@ -74,6 +75,9 @@ export default class FilteredSearchDropdown { renderContent(forceShowList = false) { const currentHook = this.getCurrentHook(); + + FilteredSearchDropdown.hideDropdownItemsforNotOperator(currentHook); + if (forceShowList && currentHook && currentHook.list.hidden) { currentHook.list.show(); } @@ -138,4 +142,41 @@ export default class FilteredSearchDropdown { hook.list.render(results); } } + + /** + * Hide None & Any options from the current dropdown. + * Hiding happens only for NOT operator. + */ + static hideDropdownItemsforNotOperator(currentHook) { + const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator(); + + if (lastOperator === '!=') { + const { list: dropdownEl } = currentHook.list; + + let shouldHideDivider = true; + + // Iterate over all the static dropdown values, + // then hide `None` and `Any` items. + Array.from(dropdownEl.querySelectorAll('li[data-value]')).forEach(itemEl => { + const { + dataset: { value }, + } = itemEl; + + if (value.toLowerCase() === FILTER_TYPE.none || value.toLowerCase() === FILTER_TYPE.any) { + itemEl.classList.add('hidden'); + } else { + // If we encountered any element other than None/Any, then + // we shouldn't hide the divider + shouldHideDivider = false; + } + }); + + if (shouldHideDivider) { + const divider = dropdownEl.querySelector('li.divider'); + if (divider) { + divider.classList.add('hidden'); + } + } + } + } } diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index d051b60814e..161a65c511d 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -120,7 +120,7 @@ export default class FilteredSearchDropdownManager { filter: key, }; const extraArguments = mappingKey.extraArguments || {}; - const glArguments = Object.assign({}, defaultArguments, extraArguments); + const glArguments = { ...defaultArguments, ...extraArguments }; // Passing glArguments to `new glClass(<arguments>)` mappingKey.reference = new (Function.prototype.bind.apply(glClass, [null, glArguments]))(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 724f80f8866..55a0e91b0f3 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -31,6 +31,7 @@ export default class FilteredSearchManager { isGroupDecendent = false, filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys, stateFiltersSelector = '.issues-state-filters', + placeholder = __('Search or filter results...'), }) { this.isGroup = isGroup; this.isGroupAncestor = isGroupAncestor; @@ -45,6 +46,7 @@ export default class FilteredSearchManager { this.tokensContainer = this.container.querySelector('.tokens-container'); this.filteredSearchTokenKeys = filteredSearchTokenKeys; this.stateFiltersSelector = stateFiltersSelector; + this.placeholder = placeholder; const { multipleAssignees } = this.filteredSearchInput.dataset; if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) { @@ -395,11 +397,10 @@ export default class FilteredSearchManager { handleInputPlaceholder() { const query = DropdownUtils.getSearchQuery(); - const placeholder = __('Search or filter results...'); const currentPlaceholder = this.filteredSearchInput.placeholder; - if (query.length === 0 && currentPlaceholder !== placeholder) { - this.filteredSearchInput.placeholder = placeholder; + if (query.length === 0 && currentPlaceholder !== this.placeholder) { + this.filteredSearchInput.placeholder = this.placeholder; } else if (query.length > 0 && currentPlaceholder !== '') { this.filteredSearchInput.placeholder = ''; } @@ -710,13 +711,17 @@ export default class FilteredSearchManager { } } - search(state = null) { - const paths = []; + getSearchTokens() { const searchQuery = DropdownUtils.getSearchQuery(); this.saveCurrentSearchQuery(); const tokenKeys = this.filteredSearchTokenKeys.getKeys(); - const { tokens, searchToken } = this.tokenizer.processTokens(searchQuery, tokenKeys); + return this.tokenizer.processTokens(searchQuery, tokenKeys); + } + + search(state = null) { + const paths = []; + const { tokens, searchToken } = this.getSearchTokens(); const currentState = state || getParameterByName('state') || 'opened'; paths.push(`state=${currentState}`); diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js index b3eb0475d6f..cdbc9ec84bd 100644 --- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js +++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js @@ -2,14 +2,12 @@ import { uniq } from 'lodash'; class RecentSearchesStore { constructor(initialState = {}, allowedKeys) { - this.state = Object.assign( - { - isLocalStorageAvailable: true, - recentSearches: [], - allowedKeys, - }, - initialState, - ); + this.state = { + isLocalStorageAvailable: true, + recentSearches: [], + allowedKeys, + ...initialState, + }; } addRecentSearch(newSearch) { diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index b8f4cd8a1e1..02caf0851af 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -1,4 +1,4 @@ -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { USER_TOKEN_TYPES } from 'ee_else_ce/filtered_search/constants'; import FilteredSearchContainer from '~/filtered_search/container'; import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; @@ -48,7 +48,7 @@ export default class VisualTokenValue { tokenValueContainer.dataset.originalValue = tokenValue; tokenValueElement.innerHTML = ` <img class="avatar s20" src="${user.avatar_url}" alt=""> - ${esc(user.name)} + ${escape(user.name)} `; /* eslint-enable no-param-reassign */ }) diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 4d62ec6e385..74c00d21535 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { escape } from 'lodash'; import { spriteIcon } from './lib/utils/common_utils'; const FLASH_TYPES = { @@ -39,14 +39,14 @@ const createAction = config => ` class="flash-action" ${config.href ? '' : 'role="button"'} > - ${_.escape(config.title)} + ${escape(config.title)} </a> `; const createFlashEl = (message, type) => ` <div class="flash-${type}"> <div class="flash-text"> - ${_.escape(message)} + ${escape(message)} <div class="close-icon-wrapper js-close-icon"> ${spriteIcon('close', 'close-icon')} </div> diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index 1f1776a5487..61080fb5487 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -3,7 +3,7 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import AccessorUtilities from '~/lib/utils/accessor'; import eventHub from '../event_hub'; -import store from '../store/'; +import store from '../store'; import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils'; import FrequentItemsSearchInput from './frequent_items_search_input.vue'; diff --git a/app/assets/javascripts/frequent_items/event_hub.js b/app/assets/javascripts/frequent_items/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/frequent_items/event_hub.js +++ b/app/assets/javascripts/frequent_items/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index b6deedfa5e4..f3ce30c942f 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import '@gitlab/at.js'; -import _ from 'underscore'; +import { escape, template } from 'lodash'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; @@ -11,7 +11,7 @@ function sanitize(str) { } export function membersBeforeSave(members) { - return _.map(members, member => { + return members.map(member => { const GROUP_TYPE = 'Group'; let title = ''; @@ -122,7 +122,7 @@ class GfmAutoComplete { cssClasses.push('has-warning'); } - return _.template(tpl)({ + return template(tpl)({ ...value, className: cssClasses.join(' '), }); @@ -137,7 +137,7 @@ class GfmAutoComplete { tpl += '<%- referencePrefix %>'; } } - return _.template(tpl)({ referencePrefix }); + return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix }); }, suffix: '', callbacks: { @@ -692,14 +692,14 @@ GfmAutoComplete.Emoji = { // Team Members GfmAutoComplete.Members = { templateFunction({ avatarTag, username, title, icon }) { - return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small> ${icon}</li>`; + return `<li>${avatarTag} ${username} <small>${escape(title)}</small> ${icon}</li>`; }, }; GfmAutoComplete.Labels = { templateFunction(color, title) { - return `<li><span class="dropdown-label-box" style="background: ${_.escape( + return `<li><span class="dropdown-label-box" style="background: ${escape( color, - )}"></span> ${_.escape(title)}</li>`; + )}"></span> ${escape(title)}</li>`; }, }; // Issues, MergeRequests and Snippets @@ -709,13 +709,13 @@ GfmAutoComplete.Issues = { return value.reference || '${atwho-at}${id}'; }, templateFunction({ id, title, reference }) { - return `<li><small>${reference || id}</small> ${_.escape(title)}</li>`; + return `<li><small>${reference || id}</small> ${escape(title)}</li>`; }, }; // Milestones GfmAutoComplete.Milestones = { templateFunction(title) { - return `<li>${_.escape(title)}</li>`; + return `<li>${escape(title)}</li>`; }, }; GfmAutoComplete.Loading = { diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 918276ce329..be4b4b5f87d 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,7 +1,7 @@ /* eslint-disable max-classes-per-file, one-var, consistent-return */ import $ from 'jquery'; -import _ from 'underscore'; +import { escape } from 'lodash'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import axios from './lib/utils/axios_utils'; import { visitUrl } from './lib/utils/url_utility'; @@ -145,7 +145,7 @@ class GitLabDropdownFilter { // { prop: 'foo' }, // { prop: 'baz' } // ] - if (_.isArray(data)) { + if (Array.isArray(data)) { results = fuzzaldrinPlus.filter(data, searchText, { key: this.options.keys, }); @@ -261,14 +261,14 @@ class GitLabDropdown { // If no input is passed create a default one self = this; // If selector was passed - if (_.isString(this.filterInput)) { + if (typeof this.filterInput === 'string') { this.filterInput = this.getElement(this.filterInput); } const searchFields = this.options.search ? this.options.search.fields : []; if (this.options.data) { // If we provided data // data could be an array of objects or a group of arrays - if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { + if (typeof this.options.data === 'object' && !(this.options.data instanceof Function)) { this.fullData = this.options.data; currentIndex = -1; this.parseData(this.options.data); @@ -595,13 +595,14 @@ class GitLabDropdown { return renderItem({ instance: this, - options: Object.assign({}, this.options, { + options: { + ...this.options, icon: this.icon, highlight: this.highlight, highlightText: text => this.highlightTextMatches(text, this.filterInput.val()), highlightTemplate: this.highlightTemplate.bind(this), parent, - }), + }, data, group, index, @@ -610,7 +611,7 @@ class GitLabDropdown { // eslint-disable-next-line class-methods-use-this highlightTemplate(text, template) { - return `"<b>${_.escape(text)}</b>" ${template}`; + return `"<b>${escape(text)}</b>" ${template}`; } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 1811a942beb..0b7735a7db9 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -8,7 +8,7 @@ export default class GLForm { constructor(form, enableGFM = {}) { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); - this.enableGFM = Object.assign({}, defaultAutocompleteConfig, enableGFM); + this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM }; // Disable autocomplete for keywords which do not have dataSources available const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; Object.keys(this.enableGFM).forEach(item => { @@ -29,6 +29,10 @@ export default class GLForm { if (this.autoComplete) { this.autoComplete.destroy(); } + if (this.formDropzone) { + this.formDropzone.destroy(); + } + this.form.data('glForm', null); } @@ -45,7 +49,7 @@ export default class GLForm { ); this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM); - dropzoneInput(this.form, { parallelUploads: 1 }); + this.formDropzone = dropzoneInput(this.form, { parallelUploads: 1 }); autosize(this.textarea); } // form and textarea event listeners diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index ce6591e85cf..0b401f4d732 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -111,8 +111,8 @@ export default { const filterGroupsBy = getParameterByName('filter') || null; this.isLoading = true; - // eslint-disable-next-line promise/catch-or-return - this.fetchGroups({ + + return this.fetchGroups({ page, filterGroupsBy, sortBy, @@ -126,8 +126,7 @@ export default { fetchPage(page, filterGroupsBy, sortBy, archived) { this.isLoading = true; - // eslint-disable-next-line promise/catch-or-return - this.fetchGroups({ + return this.fetchGroups({ page, filterGroupsBy, sortBy, diff --git a/app/assets/javascripts/groups/event_hub.js b/app/assets/javascripts/groups/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/groups/event_hub.js +++ b/app/assets/javascripts/groups/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/groups/new_group_child.js b/app/assets/javascripts/groups/new_group_child.js index 012177479c6..bb2aea3ea76 100644 --- a/app/assets/javascripts/groups/new_group_child.js +++ b/app/assets/javascripts/groups/new_group_child.js @@ -2,7 +2,7 @@ import { visitUrl } from '../lib/utils/url_utility'; import DropLab from '../droplab/drop_lab'; import ISetter from '../droplab/plugins/input_setter'; -const InputSetter = Object.assign({}, ISetter); +const InputSetter = { ...ISetter }; const NEW_PROJECT = 'new-project'; const NEW_SUBGROUP = 'new-subgroup'; diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 1678991b1ea..67b068f1c6b 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -74,20 +74,27 @@ function initStatusTriggers() { } } +function trackShowUserDropdownLink(trackEvent, elToTrack, el) { + const { trackLabel, trackProperty } = elToTrack.dataset; + + $(el).on('shown.bs.dropdown', () => { + Tracking.event(document.body.dataset.page, trackEvent, { + label: trackLabel, + property: trackProperty, + }); + }); +} export function initNavUserDropdownTracking() { const el = document.querySelector('.js-nav-user-dropdown'); const buyEl = document.querySelector('.js-buy-ci-minutes-link'); + const upgradeEl = document.querySelector('.js-upgrade-plan-link'); if (el && buyEl) { - const { trackLabel, trackProperty } = buyEl.dataset; - const trackEvent = 'show_buy_ci_minutes'; + trackShowUserDropdownLink('show_buy_ci_minutes', buyEl, el); + } - $(el).on('shown.bs.dropdown', () => { - Tracking.event(undefined, trackEvent, { - label: trackLabel, - property: trackProperty, - }); - }); + if (el && upgradeEl) { + trackShowUserDropdownLink('show_upgrade_link', upgradeEl, el); } } diff --git a/app/assets/javascripts/helpers/avatar_helper.js b/app/assets/javascripts/helpers/avatar_helper.js index 7891b44dd27..4f04a1b8c16 100644 --- a/app/assets/javascripts/helpers/avatar_helper.js +++ b/app/assets/javascripts/helpers/avatar_helper.js @@ -1,11 +1,14 @@ import { escape } from 'lodash'; import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; export const DEFAULT_SIZE_CLASS = 's40'; export const IDENTICON_BG_COUNT = 7; export function getIdenticonBackgroundClass(entityId) { - const type = (entityId % IDENTICON_BG_COUNT) + 1; + // If a GraphQL string id is passed in, convert it to the entity number + const id = typeof entityId === 'string' ? getIdFromGraphQLId(entityId) : entityId; + const type = (id % IDENTICON_BG_COUNT) + 1; return `bg${type}`; } diff --git a/app/assets/javascripts/helpers/event_hub_factory.js b/app/assets/javascripts/helpers/event_hub_factory.js new file mode 100644 index 00000000000..4d7f7550a94 --- /dev/null +++ b/app/assets/javascripts/helpers/event_hub_factory.js @@ -0,0 +1,20 @@ +import mitt from 'mitt'; + +export default () => { + const emitter = mitt(); + + emitter.once = (event, handler) => { + const wrappedHandler = evt => { + handler(evt); + emitter.off(event, wrappedHandler); + }; + emitter.on(event, wrappedHandler); + }; + + emitter.$on = emitter.on; + emitter.$once = emitter.once; + emitter.$off = emitter.off; + emitter.$emit = emitter.emit; + + return emitter; +}; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index 6a8ea506d1b..6c563776533 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { mapState, mapGetters, createNamespacedHelpers } from 'vuex'; import { sprintf, s__ } from '~/locale'; import consts from '../../stores/modules/commit/constants'; @@ -22,7 +22,7 @@ export default { commitToCurrentBranchText() { return sprintf( s__('IDE|Commit to %{branchName} branch'), - { branchName: `<strong class="monospace">${esc(this.currentBranchId)}</strong>` }, + { branchName: `<strong class="monospace">${escape(this.currentBranchId)}</strong>` }, false, ); }, 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 e618fb3daae..24499fb9f6d 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -24,8 +24,8 @@ export default { discardModalTitle() { return sprintf(__('Discard changes to %{path}?'), { path: this.activeFile.path }); }, - isStaged() { - return !this.activeFile.changed && this.activeFile.staged; + canDiscard() { + return this.activeFile.changed || this.activeFile.staged; }, }, methods: { @@ -53,7 +53,7 @@ export default { <changed-file-icon :file="activeFile" :is-centered="false" /> <div class="ml-auto"> <button - v-if="!isStaged" + v-if="canDiscard" ref="discardButton" type="button" class="btn btn-remove btn-inverted append-right-8" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index f6ca728defc..4cbd33e6ed6 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -1,11 +1,13 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import { n__, __ } from '~/locale'; +import { GlModal } from '@gitlab/ui'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import CommitMessageField from './message_field.vue'; import Actions from './actions.vue'; import SuccessMessage from './success_message.vue'; import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants'; +import consts from '../../stores/modules/commit/constants'; export default { components: { @@ -13,6 +15,7 @@ export default { LoadingButton, CommitMessageField, SuccessMessage, + GlModal, }, data() { return { @@ -54,7 +57,20 @@ export default { }, methods: { ...mapActions(['updateActivityBarView']), - ...mapActions('commit', ['updateCommitMessage', 'discardDraft', 'commitChanges']), + ...mapActions('commit', [ + 'updateCommitMessage', + 'discardDraft', + 'commitChanges', + 'updateCommitAction', + ]), + commit() { + return this.commitChanges().catch(() => { + this.$refs.createBranchModal.show(); + }); + }, + forceCreateNewBranch() { + return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commit()); + }, toggleIsCompact() { if (this.currentViewIsCommitView) { this.isCompact = !this.isCompact; @@ -119,13 +135,13 @@ export default { </button> <p class="text-center bold">{{ overviewText }}</p> </div> - <form v-if="!isCompact" ref="formEl" @submit.prevent.stop="commitChanges"> + <form v-if="!isCompact" ref="formEl" @submit.prevent.stop="commit"> <transition name="fade"> <success-message v-show="lastCommitMsg" /> </transition> <commit-message-field :text="commitMessage" :placeholder="preBuiltCommitMessage" @input="updateCommitMessage" - @submit="commitChanges" + @submit="commit" /> <div class="clearfix prepend-top-15"> <actions /> @@ -133,7 +149,7 @@ export default { :loading="submitCommitLoading" :label="commitButtonText" container-class="btn btn-success btn-sm float-left qa-commit-button" - @click="commitChanges" + @click="commit" /> <button v-if="!discardDraftButtonDisabled" @@ -152,6 +168,19 @@ export default { {{ __('Collapse') }} </button> </div> + <gl-modal + ref="createBranchModal" + modal-id="ide-create-branch-modal" + :ok-title="__('Create new branch')" + :title="__('Branch has changed')" + ok-variant="success" + @ok="forceCreateNewBranch" + > + {{ + __(`This branch has changed since you started editing. + Would you like to create a new branch?`) + }} + </gl-modal> </form> </transition> </div> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index a15e22d4742..e6a1a1ba73c 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -1,9 +1,8 @@ <script> -import $ from 'jquery'; import { mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; +import { GlModal } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import ListItem from './list_item.vue'; @@ -11,7 +10,7 @@ export default { components: { Icon, ListItem, - GlModal: DeprecatedModal2, + GlModal, }, directives: { tooltip, @@ -58,7 +57,7 @@ export default { methods: { ...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']), openDiscardModal() { - $('#discard-all-changes').modal('show'); + this.$refs.discardAllModal.show(); }, unstageAndDiscardAllChanges() { this.unstageAllChanges(); @@ -114,11 +113,12 @@ export default { </p> <gl-modal v-if="!stagedList" - id="discard-all-changes" - :footer-primary-button-text="__('Discard all changes')" - :header-title-text="__('Discard all changes?')" - footer-primary-button-variant="danger" - @submit="unstageAndDiscardAllChanges" + ref="discardAllModal" + ok-variant="danger" + modal-id="discard-all-changes" + :ok-title="__('Discard all changes')" + :title="__('Discard all changes?')" + @ok="unstageAndDiscardAllChanges" > {{ $options.discardModalText }} </gl-modal> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 7ebcacc530f..36c8b18e205 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -3,6 +3,7 @@ import Vue from 'vue'; import { mapActions, mapGetters, mapState } from 'vuex'; import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; +import { modalTypes } from '../constants'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; import NewModal from './new_dropdown/modal.vue'; import IdeSidebar from './ide_side_bar.vue'; @@ -12,6 +13,7 @@ import RepoEditor from './repo_editor.vue'; import RightPane from './panes/right.vue'; import ErrorMessage from './error_message.vue'; import CommitEditorHeader from './commit_sidebar/editor_header.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -26,6 +28,7 @@ export default { GlDeprecatedButton, GlLoadingIcon, }, + mixins: [glFeatureFlagsMixin()], props: { rightPaneComponent: { type: Vue.Component, @@ -52,13 +55,20 @@ export default { 'allBlobs', 'emptyRepo', 'currentTree', + 'editorTheme', ]), + themeName() { + return window.gon?.user_color_scheme; + }, }, mounted() { window.onbeforeunload = e => this.onBeforeUnload(e); + + if (this.themeName) + document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`); }, methods: { - ...mapActions(['toggleFileFinder', 'openNewEntryModal']), + ...mapActions(['toggleFileFinder']), onBeforeUnload(e = {}) { const returnValue = __('Are you sure you want to lose unsaved changes?'); @@ -72,12 +82,18 @@ export default { openFile(file) { this.$router.push(`/project${file.url}`); }, + createNewFile() { + this.$refs.newModal.open(modalTypes.blob); + }, }, }; </script> <template> - <article class="ide position-relative d-flex flex-column align-items-stretch"> + <article + class="ide position-relative d-flex flex-column align-items-stretch" + :class="{ [`theme-${themeName}`]: themeName }" + > <error-message v-if="errorMessage" :message="errorMessage" /> <div class="ide-view flex-grow d-flex"> <find-file @@ -125,7 +141,7 @@ export default { variant="success" :title="__('New file')" :aria-label="__('New file')" - @click="openNewEntryModal({ type: 'blob' })" + @click="createNewFile()" > {{ __('New file') }} </gl-deprecated-button> @@ -147,6 +163,6 @@ export default { <component :is="rightPaneComponent" v-if="currentProjectId" /> </div> <ide-status-bar /> - <new-modal /> + <new-modal ref="newModal" /> </article> </template> diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue index 901b8892e80..62dbfea2088 100644 --- a/app/assets/javascripts/ide/components/ide_review.vue +++ b/app/assets/javascripts/ide/components/ide_review.vue @@ -43,7 +43,7 @@ export default { <template> <ide-tree-list :viewer-type="viewer" header-class="ide-review-header"> - <template slot="header"> + <template #header> <div class="ide-review-button-holder"> {{ __('Review') }} <editor-mode-dropdown diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index 40cd2178e09..7cb31df85ce 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -37,7 +37,7 @@ export default { </script> <template> - <resizable-panel :collapsible="false" :initial-width="340" side="left" class="flex-column"> + <resizable-panel :initial-width="340" side="left" class="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_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index 598f3a1dac6..647f4d4be85 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -1,14 +1,17 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; +import { modalTypes } from '../constants'; import IdeTreeList from './ide_tree_list.vue'; import Upload from './new_dropdown/upload.vue'; import NewEntryButton from './new_dropdown/button.vue'; +import NewModal from './new_dropdown/modal.vue'; export default { components: { Upload, IdeTreeList, NewEntryButton, + NewModal, }, computed: { ...mapState(['currentBranchId']), @@ -26,14 +29,20 @@ export default { } }, methods: { - ...mapActions(['updateViewer', 'openNewEntryModal', 'createTempEntry', 'resetOpenFiles']), + ...mapActions(['updateViewer', 'createTempEntry', 'resetOpenFiles']), + createNewFile() { + this.$refs.newModal.open(modalTypes.blob); + }, + createNewFolder() { + this.$refs.newModal.open(modalTypes.tree); + }, }, }; </script> <template> <ide-tree-list viewer-type="editor"> - <template slot="header"> + <template #header> {{ __('Edit') }} <div class="ide-tree-actions ml-auto d-flex"> <new-entry-button @@ -41,7 +50,7 @@ export default { :show-label="false" class="d-flex border-0 p-0 mr-3 qa-new-file" icon="doc-new" - @click="openNewEntryModal({ type: 'blob' })" + @click="createNewFile()" /> <upload :show-label="false" @@ -54,9 +63,10 @@ export default { :show-label="false" class="d-flex border-0 p-0" icon="folder-new" - @click="openNewEntryModal({ type: 'tree' })" + @click="createNewFolder()" /> </div> + <new-modal ref="newModal" /> </template> </ide-tree-list> </template> diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index 504391ffdc7..975d54c7a4e 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -79,7 +79,7 @@ export default { <icon name="chevron-left" /> {{ __('View jobs') }} </button> </header> - <div class="top-bar d-flex border-left-0"> + <div class="top-bar d-flex border-left-0 mr-3"> <job-description :job="detailJob" /> <div class="controllers ml-auto"> <a @@ -97,7 +97,7 @@ export default { <scroll-button :disabled="isScrolledToBottom" direction="down" @click="scrollDown" /> </div> </div> - <pre ref="buildTrace" class="build-trace mb-0 h-100" @scroll="scrollBuildLog"> + <pre ref="buildTrace" class="build-trace mb-0 h-100 mr-3" @scroll="scrollBuildLog"> <code v-show="!detailJob.isLoading" class="bash" diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue index 195504a6861..70a92b8d3ab 100644 --- a/app/assets/javascripts/ide/components/nav_form.vue +++ b/app/assets/javascripts/ide/components/nav_form.vue @@ -25,13 +25,13 @@ export default { <div class="ide-nav-form p-0"> <tabs v-if="showMergeRequests" stop-propagation> <tab active> - <template slot="title"> + <template #title> {{ __('Branches') }} </template> <branches-search-list /> </tab> <tab> - <template slot="title"> + <template #title> {{ __('Merge Requests') }} </template> <merge-request-search-list /> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 9961c0df52e..2798ede5341 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -4,12 +4,14 @@ import icon from '~/vue_shared/components/icon.vue'; import upload from './upload.vue'; import ItemButton from './button.vue'; import { modalTypes } from '../../constants'; +import NewModal from './modal.vue'; export default { components: { icon, upload, ItemButton, + NewModal, }, props: { type: { @@ -37,9 +39,9 @@ export default { }, }, methods: { - ...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']), + ...mapActions(['createTempEntry', 'deleteEntry']), createNewItem(type) { - this.openNewEntryModal({ type, path: this.path }); + this.$refs.newModal.open(type, this.path); this.$emit('toggle', false); }, openDropdown() { @@ -109,5 +111,6 @@ export default { </li> </ul> </div> + <new-modal ref="newModal" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index bf3d736ddf3..4766a2fe6ae 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,61 +1,49 @@ <script> -import $ from 'jquery'; import { mapActions, mapState, mapGetters } from 'vuex'; import flash from '~/flash'; import { __, sprintf, s__ } from '~/locale'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; +import { GlModal } from '@gitlab/ui'; import { modalTypes } from '../../constants'; +import { trimPathComponents } from '../../utils'; export default { components: { - GlModal: DeprecatedModal2, + GlModal, }, data() { return { - name: '', + entryName: '', + modalType: modalTypes.blob, + path: '', }; }, computed: { - ...mapState(['entries', 'entryModal']), + ...mapState(['entries']), ...mapGetters('fileTemplates', ['templateTypes']), - entryName: { - get() { - const entryPath = this.entryModal.entry.path; - - if (this.entryModal.type === modalTypes.rename) { - return this.name || entryPath; - } - - return this.name || (entryPath ? `${entryPath}/` : ''); - }, - set(val) { - this.name = val.trim(); - }, - }, modalTitle() { - if (this.entryModal.type === modalTypes.tree) { + const entry = this.entries[this.path]; + + if (this.modalType === modalTypes.tree) { return __('Create new directory'); - } else if (this.entryModal.type === modalTypes.rename) { - return this.entryModal.entry.type === modalTypes.tree - ? __('Rename folder') - : __('Rename file'); + } else if (this.modalType === modalTypes.rename) { + return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file'); } return __('Create new file'); }, buttonLabel() { - if (this.entryModal.type === modalTypes.tree) { + const entry = this.entries[this.path]; + + if (this.modalType === modalTypes.tree) { return __('Create directory'); - } else if (this.entryModal.type === modalTypes.rename) { - return this.entryModal.entry.type === modalTypes.tree - ? __('Rename folder') - : __('Rename file'); + } else if (this.modalType === modalTypes.rename) { + return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file'); } return __('Create file'); }, isCreatingNewFile() { - return this.entryModal.type === 'blob'; + return this.modalType === modalTypes.blob; }, placeholder() { return this.isCreatingNewFile ? 'dir/file_name' : 'dir/'; @@ -64,7 +52,9 @@ export default { methods: { ...mapActions(['createTempEntry', 'renameEntry']), submitForm() { - if (this.entryModal.type === modalTypes.rename) { + this.entryName = trimPathComponents(this.entryName); + + if (this.modalType === modalTypes.rename) { if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) { flash( sprintf(s__('The name "%{name}" is already taken in this directory.'), { @@ -78,32 +68,32 @@ export default { ); } else { let parentPath = this.entryName.split('/'); - const entryName = parentPath.pop(); + const name = parentPath.pop(); parentPath = parentPath.join('/'); this.renameEntry({ - path: this.entryModal.entry.path, - name: entryName, + path: this.path, + name, parentPath, }); } } else { this.createTempEntry({ - name: this.name, - type: this.entryModal.type, + name: this.entryName, + type: this.modalType, }); } }, createFromTemplate(template) { this.createTempEntry({ name: template.name, - type: this.entryModal.type, + type: this.modalType, }); - $('#ide-new-entry').modal('toggle'); + this.$refs.modal.toggle(); }, focusInput() { - const name = this.entries[this.entryName] ? this.entries[this.entryName].name : null; + const name = this.entries[this.entryName]?.name; const inputValue = this.$refs.fieldName.value; this.$refs.fieldName.focus(); @@ -112,8 +102,28 @@ export default { this.$refs.fieldName.setSelectionRange(inputValue.indexOf(name), inputValue.length); } }, - closedModal() { - this.name = ''; + resetData() { + this.entryName = ''; + this.path = ''; + this.modalType = modalTypes.blob; + }, + open(type = modalTypes.blob, path = '') { + this.modalType = type; + this.path = path; + + if (this.modalType === modalTypes.rename) { + this.entryName = path; + } else { + this.entryName = path ? `${path}/` : ''; + } + + this.$refs.modal.show(); + + // wait for modal to show first + this.$nextTick(() => this.focusInput()); + }, + close() { + this.$refs.modal.hide(); }, }, }; @@ -121,22 +131,22 @@ export default { <template> <gl-modal - id="ide-new-entry" - class="qa-new-file-modal" - :header-title-text="modalTitle" - :footer-primary-button-text="buttonLabel" - footer-primary-button-variant="success" - modal-size="lg" - @submit="submitForm" - @open="focusInput" - @closed="closedModal" + ref="modal" + modal-id="ide-new-entry" + modal-class="qa-new-file-modal" + :title="modalTitle" + :ok-title="buttonLabel" + ok-variant="success" + size="lg" + @ok="submitForm" + @hide="resetData" > <div class="form-group row"> <label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label> <div class="col-sm-10"> <input ref="fieldName" - v-model="entryName" + v-model.trim="entryName" type="text" class="form-control qa-full-file-path" :placeholder="placeholder" diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue index 8adf0122fb4..91e80be7d18 100644 --- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue +++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue @@ -103,7 +103,6 @@ export default { > <resizable-panel v-show="isOpen" - :collapsible="false" :initial-width="width" :min-size="width" :class="`ide-${side}-sidebar-${currentView}`" @@ -116,7 +115,7 @@ export default { v-for="tabView in aliveTabViews" v-show="isActiveView(tabView.name)" :key="tabView.name" - class="flex-fill js-tab-view" + class="flex-fill gl-overflow-hidden js-tab-view" > <component :is="tabView.component" /> </div> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index d3e5add2e83..cf6d01b6351 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { GlLoadingIcon } from '@gitlab/ui'; import { sprintf, __ } from '../../../locale'; import Icon from '../../../vue_shared/components/icon.vue'; @@ -10,6 +10,8 @@ import Tab from '../../../vue_shared/components/tabs/tab.vue'; import EmptyState from '../../../pipelines/components/empty_state.vue'; import JobsList from '../jobs/list.vue'; +import IDEServices from '~/ide/services'; + export default { components: { Icon, @@ -35,7 +37,7 @@ export default { return sprintf( __('You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}.'), { - linkStart: `<a href="${esc(this.currentProject.web_url)}/-/ci/lint">`, + linkStart: `<a href="${escape(this.currentProject.web_url)}/-/ci/lint">`, linkEnd: '</a>', }, false, @@ -47,6 +49,7 @@ export default { }, created() { this.fetchLatestPipeline(); + IDEServices.pingUsage(this.currentProject.path_with_namespace); }, methods: { ...mapActions('pipelines', ['fetchLatestPipeline']), @@ -85,14 +88,14 @@ export default { </div> <tabs v-else class="ide-pipeline-list"> <tab :active="!pipelineFailed"> - <template slot="title"> + <template #title> {{ __('Jobs') }} <span v-if="jobsCount" class="badge badge-pill"> {{ jobsCount }} </span> </template> <jobs-list :loading="isLoadingJobs" :stages="stages" /> </tab> <tab :active="pipelineFailed"> - <template slot="title"> + <template #title> {{ __('Failed Jobs') }} <span v-if="failedJobsCount" class="badge badge-pill"> {{ failedJobsCount }} </span> </template> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 2e7e55a61c5..530fba49df2 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -1,15 +1,12 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; -import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; -import consts from '../stores/modules/commit/constants'; import { leftSidebarViews, stageKeys } from '../constants'; export default { components: { - DeprecatedModal, CommitFilesList, EmptyState, }, @@ -17,13 +14,7 @@ export default { tooltip, }, computed: { - ...mapState([ - 'changedFiles', - 'stagedFiles', - 'rightPanelCollapsed', - 'lastCommitMsg', - 'unusedSeal', - ]), + ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg', 'unusedSeal']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommittedChanges', 'activeFile']), ...mapGetters('commit', ['discardDraftButtonDisabled']), @@ -59,10 +50,6 @@ export default { }, methods: { ...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']), - ...mapActions('commit', ['commitChanges', 'updateCommitAction']), - forceCreateNewBranch() { - return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges()); - }, }, stageKeys, }; @@ -70,20 +57,6 @@ export default { <template> <div class="multi-file-commit-panel-section"> - <deprecated-modal - id="ide-create-branch-modal" - :primary-button-label="__('Create new branch')" - :title="__('Branch has changed')" - kind="success" - @submit="forceCreateNewBranch" - > - <template slot="body"> - {{ - __(`This branch has changed since you started editing. - Would you like to create a new branch?`) - }} - </template> - </deprecated-modal> <template v-if="showStageUnstageArea"> <commit-files-list :key-prefix="$options.stageKeys.staged" diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 08850679152..c72a8b2b0d0 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -13,6 +13,7 @@ import { import Editor from '../lib/editor'; import FileTemplatesBar from './file_templates/bar.vue'; import { __ } from '~/locale'; +import { extractMarkdownImagesFromEntries } from '../stores/utils'; export default { components: { @@ -26,17 +27,23 @@ export default { required: true, }, }, + data() { + return { + content: '', + images: {}, + }; + }, computed: { ...mapState('rightPane', { rightPaneIsOpen: 'isOpen', }), ...mapState([ - 'rightPanelCollapsed', 'viewer', 'panelResizing', 'currentActivityView', 'renderWhitespaceInCode', 'editorTheme', + 'entries', ]), ...mapGetters([ 'currentMergeRequest', @@ -44,6 +51,7 @@ export default { 'isEditModeActive', 'isCommitModeActive', 'isReviewModeActive', + 'currentBranch', ]), ...mapGetters('fileTemplates', ['showFileTemplatesBar']), shouldHideEditor() { @@ -87,6 +95,9 @@ export default { theme: this.editorTheme, }; }, + currentBranchCommit() { + return this.currentBranch?.commit.id; + }, }, watch: { file(newVal, oldVal) { @@ -114,9 +125,6 @@ export default { }); } }, - rightPanelCollapsed() { - this.refreshEditorDimensions(); - }, viewer() { if (!this.file.pending) { this.createEditorInstance(); @@ -136,6 +144,18 @@ export default { this.$nextTick(() => this.refreshEditorDimensions()); } }, + showContentViewer(val) { + if (!val) return; + + if (this.fileType === 'markdown') { + const { content, images } = extractMarkdownImagesFromEntries(this.file, this.entries); + this.content = content; + this.images = images; + } else { + this.content = this.file.content || this.file.raw; + this.images = {}; + } + }, }, beforeDestroy() { this.editor.dispose(); @@ -310,11 +330,13 @@ export default { ></div> <content-viewer v-if="showContentViewer" - :content="file.content || file.raw" + :content="content" + :images="images" :path="file.rawPath || file.path" :file-path="file.path" :file-size="file.size" :project-path="file.projectId" + :commit-sha="currentBranchCommit" :type="fileType" /> <diff-viewer diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue index 7277fcb7617..86a4622401c 100644 --- a/app/assets/javascripts/ide/components/resizable_panel.vue +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -1,5 +1,5 @@ <script> -import { mapActions, mapState } from 'vuex'; +import { mapActions } from 'vuex'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; export default { @@ -7,10 +7,6 @@ export default { PanelResizer, }, props: { - collapsible: { - type: Boolean, - required: true, - }, initialWidth: { type: Number, required: true, @@ -31,11 +27,6 @@ export default { }; }, computed: { - ...mapState({ - collapsed(state) { - return state[`${this.side}PanelCollapsed`]; - }, - }), panelStyle() { if (!this.collapsed) { return { @@ -47,33 +38,17 @@ export default { }, }, methods: { - ...mapActions(['setPanelCollapsedStatus', 'setResizingStatus']), - toggleFullbarCollapsed() { - if (this.collapsed && this.collapsible) { - this.setPanelCollapsedStatus({ - side: this.side, - collapsed: !this.collapsed, - }); - } - }, + ...mapActions(['setResizingStatus']), }, maxSize: window.innerWidth / 2, }; </script> <template> - <div - :class="{ - 'is-collapsed': collapsed && collapsible, - }" - :style="panelStyle" - class="multi-file-commit-panel" - @click="toggleFullbarCollapsed" - > + <div :style="panelStyle" class="multi-file-commit-panel"> <slot></slot> <panel-resizer :size.sync="width" - :enabled="!collapsed" :start-size="initialWidth" :min-size="minSize" :max-size="$options.maxSize" diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index fa2672aaece..ae8550cba76 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -78,6 +78,7 @@ export const commitItemIconMap = { export const modalTypes = { rename: 'rename', tree: 'tree', + blob: 'blob', }; export const commitActionTypes = { diff --git a/app/assets/javascripts/ide/eventhub.js b/app/assets/javascripts/ide/eventhub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/ide/eventhub.js +++ b/app/assets/javascripts/ide/eventhub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js index 9b7ed68b893..29e29d7fcd3 100644 --- a/app/assets/javascripts/ide/lib/diff/diff.js +++ b/app/assets/javascripts/ide/lib/diff/diff.js @@ -14,13 +14,12 @@ export const computeDiff = (originalContent, newContent) => { endLineNumber: lineNumber + change.count - 1, }); } else if ('added' in change || 'removed' in change) { - acc.push( - Object.assign({}, change, { - lineNumber, - modified: undefined, - endLineNumber: lineNumber + change.count - 1, - }), - ); + acc.push({ + ...change, + lineNumber, + modified: undefined, + endLineNumber: lineNumber + change.count - 1, + }); } if (!change.removed) { diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 3aff4d30d81..25224abd77c 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -7,8 +7,10 @@ import Disposable from './common/disposable'; import ModelManager from './common/model_manager'; import editorOptions, { defaultEditorOptions } from './editor_options'; import { themes } from './themes'; +import languages from './languages'; import keymap from './keymap.json'; import { clearDomElement } from '~/editor/utils'; +import { registerLanguages } from '../utils'; function setupThemes() { themes.forEach(theme => { @@ -37,6 +39,7 @@ export default class Editor { }; setupThemes(); + registerLanguages(...languages); this.debouncedUpdate = debounce(() => { this.updateDimensions(); diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js new file mode 100644 index 00000000000..0c85a1104fc --- /dev/null +++ b/app/assets/javascripts/ide/lib/languages/index.js @@ -0,0 +1,5 @@ +import vue from './vue'; + +const languages = [vue]; + +export default languages; diff --git a/app/assets/javascripts/ide/lib/languages/vue.js b/app/assets/javascripts/ide/lib/languages/vue.js new file mode 100644 index 00000000000..b9ff5c5d776 --- /dev/null +++ b/app/assets/javascripts/ide/lib/languages/vue.js @@ -0,0 +1,306 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md + *--------------------------------------------------------------------------------------------*/ + +// Based on handlebars template in https://github.com/microsoft/monaco-languages/blob/master/src/handlebars/handlebars.ts +// Look for "vuejs template attributes" in this file for Vue specific syntax. + +import { languages } from 'monaco-editor'; + +/* eslint-disable no-useless-escape */ +/* eslint-disable @gitlab/require-i18n-strings */ + +const EMPTY_ELEMENTS = [ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'menuitem', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]; + +const conf = { + wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g, + + comments: { + blockComment: ['{{!--', '--}}'], + }, + + brackets: [['<!--', '-->'], ['<', '>'], ['{{', '}}'], ['{', '}'], ['(', ')']], + + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + + surroundingPairs: [ + { open: '<', close: '>' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + + onEnterRules: [ + { + beforeText: new RegExp( + `<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`, + 'i', + ), + afterText: /^<\/(\w[\w\d]*)\s*>$/i, + action: { indentAction: languages.IndentAction.IndentOutdent }, + }, + { + beforeText: new RegExp( + `<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`, + 'i', + ), + action: { indentAction: languages.IndentAction.Indent }, + }, + ], +}; + +const language = { + defaultToken: '', + tokenPostfix: '', + // ignoreCase: true, + + // The main tokenizer for our languages + tokenizer: { + root: [ + [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.root' }], + [/<!DOCTYPE/, 'metatag.html', '@doctype'], + [/<!--/, 'comment.html', '@comment'], + [/(<)([\w]+)(\/>)/, ['delimiter.html', 'tag.html', 'delimiter.html']], + [/(<)(script)/, ['delimiter.html', { token: 'tag.html', next: '@script' }]], + [/(<)(style)/, ['delimiter.html', { token: 'tag.html', next: '@style' }]], + [/(<)([:\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]], + [/(<\/)([\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]], + [/</, 'delimiter.html'], + [/\{/, 'delimiter.html'], + [/[^<{]+/], // text + ], + + doctype: [ + [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }], + [/[^>]+/, 'metatag.content.html'], + [/>/, 'metatag.html', '@pop'], + ], + + comment: [ + [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }], + [/-->/, 'comment.html', '@pop'], + [/[^-]+/, 'comment.content.html'], + [/./, 'comment.content.html'], + ], + + otherTag: [ + [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.otherTag' }], + [/\/?>/, 'delimiter.html', '@pop'], + + // -- BEGIN vuejs template attributes + [/(v-|@|:)[\w\-\.\:\[\]]+="([^"]*)"/, 'variable'], + [/(v-|@|:)[\w\-\.\:\[\]]+='([^']*)'/, 'variable'], + + [/"([^"]*)"/, 'attribute.value'], + [/'([^']*)'/, 'attribute.value'], + + [/[\w\-\.\:\[\]]+/, 'attribute.name'], + // -- END vuejs template attributes + + [/=/, 'delimiter'], + [/[ \t\r\n]+/], // whitespace + ], + + // -- BEGIN <script> tags handling + + // After <script + script: [ + [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.script' }], + [/type/, 'attribute.name', '@scriptAfterType'], + [/"([^"]*)"/, 'attribute.value'], + [/'([^']*)'/, 'attribute.value'], + [/[\w\-]+/, 'attribute.name'], + [/=/, 'delimiter'], + [ + />/, + { + token: 'delimiter.html', + next: '@scriptEmbedded.text/javascript', + nextEmbedded: 'text/javascript', + }, + ], + [/[ \t\r\n]+/], // whitespace + [ + /(<\/)(script\s*)(>)/, + ['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }], + ], + ], + + // After <script ... type + scriptAfterType: [ + [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterType' }], + [/=/, 'delimiter', '@scriptAfterTypeEquals'], + [ + />/, + { + token: 'delimiter.html', + next: '@scriptEmbedded.text/javascript', + nextEmbedded: 'text/javascript', + }, + ], // cover invalid e.g. <script type> + [/[ \t\r\n]+/], // whitespace + [/<\/script\s*>/, { token: '@rematch', next: '@pop' }], + ], + + // After <script ... type = + scriptAfterTypeEquals: [ + [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterTypeEquals' }], + [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }], + [/'([^']*)'/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }], + [ + />/, + { + token: 'delimiter.html', + next: '@scriptEmbedded.text/javascript', + nextEmbedded: 'text/javascript', + }, + ], // cover invalid e.g. <script type=> + [/[ \t\r\n]+/], // whitespace + [/<\/script\s*>/, { token: '@rematch', next: '@pop' }], + ], + + // After <script ... type = $S2 + scriptWithCustomType: [ + [ + /\{\{/, + { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptWithCustomType.$S2' }, + ], + [/>/, { token: 'delimiter.html', next: '@scriptEmbedded.$S2', nextEmbedded: '$S2' }], + [/"([^"]*)"/, 'attribute.value'], + [/'([^']*)'/, 'attribute.value'], + [/[\w\-]+/, 'attribute.name'], + [/=/, 'delimiter'], + [/[ \t\r\n]+/], // whitespace + [/<\/script\s*>/, { token: '@rematch', next: '@pop' }], + ], + + scriptEmbedded: [ + [ + /\{\{/, + { + token: '@rematch', + switchTo: '@handlebarsInEmbeddedState.scriptEmbedded.$S2', + nextEmbedded: '@pop', + }, + ], + [/<\/script/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }], + ], + + // -- END <script> tags handling + + // -- BEGIN <style> tags handling + + // After <style + style: [ + [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.style' }], + [/type/, 'attribute.name', '@styleAfterType'], + [/"([^"]*)"/, 'attribute.value'], + [/'([^']*)'/, 'attribute.value'], + [/[\w\-]+/, 'attribute.name'], + [/=/, 'delimiter'], + [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], + [/[ \t\r\n]+/], // whitespace + [ + /(<\/)(style\s*)(>)/, + ['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }], + ], + ], + + // After <style ... type + styleAfterType: [ + [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterType' }], + [/=/, 'delimiter', '@styleAfterTypeEquals'], + [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type> + [/[ \t\r\n]+/], // whitespace + [/<\/style\s*>/, { token: '@rematch', next: '@pop' }], + ], + + // After <style ... type = + styleAfterTypeEquals: [ + [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterTypeEquals' }], + [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }], + [/'([^']*)'/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }], + [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type=> + [/[ \t\r\n]+/], // whitespace + [/<\/style\s*>/, { token: '@rematch', next: '@pop' }], + ], + + // After <style ... type = $S2 + styleWithCustomType: [ + [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleWithCustomType.$S2' }], + [/>/, { token: 'delimiter.html', next: '@styleEmbedded.$S2', nextEmbedded: '$S2' }], + [/"([^"]*)"/, 'attribute.value'], + [/'([^']*)'/, 'attribute.value'], + [/[\w\-]+/, 'attribute.name'], + [/=/, 'delimiter'], + [/[ \t\r\n]+/], // whitespace + [/<\/style\s*>/, { token: '@rematch', next: '@pop' }], + ], + + styleEmbedded: [ + [ + /\{\{/, + { + token: '@rematch', + switchTo: '@handlebarsInEmbeddedState.styleEmbedded.$S2', + nextEmbedded: '@pop', + }, + ], + [/<\/style/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }], + ], + + // -- END <style> tags handling + + handlebarsInSimpleState: [ + [/\{\{\{?/, 'delimiter.handlebars'], + [/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3' }], + { include: 'handlebarsRoot' }, + ], + + handlebarsInEmbeddedState: [ + [/\{\{\{?/, 'delimiter.handlebars'], + [/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3', nextEmbedded: '$S3' }], + { include: 'handlebarsRoot' }, + ], + + handlebarsRoot: [ + [/"[^"]*"/, 'string.handlebars'], + [/[#/][^\s}]+/, 'keyword.helper.handlebars'], + [/else\b/, 'keyword.helper.handlebars'], + [/[\s]+/], + [/[^}]/, 'variable.parameter.handlebars'], + ], + }, +}; + +export default { + id: 'vue', + extensions: ['.vue'], + aliases: ['Vue', 'vue'], + mimetypes: ['text/x-vue-template'], + conf, + language, +}; diff --git a/app/assets/javascripts/ide/lib/themes/index.js b/app/assets/javascripts/ide/lib/themes/index.js index 6ed9f6679a4..bb5be50576c 100644 --- a/app/assets/javascripts/ide/lib/themes/index.js +++ b/app/assets/javascripts/ide/lib/themes/index.js @@ -1,5 +1,9 @@ import white from './white'; import dark from './dark'; +import monokai from './monokai'; +import solarizedLight from './solarized_light'; +import solarizedDark from './solarized_dark'; +import none from './none'; export const themes = [ { @@ -10,6 +14,22 @@ export const themes = [ name: 'dark', data: dark, }, + { + name: 'solarized-light', + data: solarizedLight, + }, + { + name: 'solarized-dark', + data: solarizedDark, + }, + { + name: 'monokai', + data: monokai, + }, + { + name: 'none', + data: none, + }, ]; export const DEFAULT_THEME = 'white'; diff --git a/app/assets/javascripts/ide/lib/themes/monokai.js b/app/assets/javascripts/ide/lib/themes/monokai.js new file mode 100644 index 00000000000..d7636574754 --- /dev/null +++ b/app/assets/javascripts/ide/lib/themes/monokai.js @@ -0,0 +1,169 @@ +/* + +https://github.com/brijeshb42/monaco-themes/blob/master/themes/Tomorrow-Night.json + +The MIT License (MIT) + +Copyright (c) Brijesh Bittu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +*/ + +export default { + base: 'vs-dark', + inherit: true, + rules: [ + { + foreground: '75715e', + token: 'comment', + }, + { + foreground: 'e6db74', + token: 'string', + }, + { + foreground: 'ae81ff', + token: 'constant.numeric', + }, + { + foreground: 'ae81ff', + token: 'constant.language', + }, + { + foreground: 'ae81ff', + token: 'constant.character', + }, + { + foreground: 'ae81ff', + token: 'constant.other', + }, + { + foreground: 'f92672', + token: 'keyword', + }, + { + foreground: 'f92672', + token: 'storage', + }, + { + foreground: '66d9ef', + fontStyle: 'italic', + token: 'storage.type', + }, + { + foreground: 'a6e22e', + fontStyle: 'underline', + token: 'entity.name.class', + }, + { + foreground: 'a6e22e', + // eslint-disable-next-line @gitlab/require-i18n-strings + fontStyle: 'italic underline', + token: 'entity.other.inherited-class', + }, + { + foreground: 'a6e22e', + token: 'entity.name.function', + }, + { + foreground: 'fd971f', + fontStyle: 'italic', + token: 'variable.parameter', + }, + { + foreground: 'f92672', + token: 'entity.name.tag', + }, + { + foreground: 'a6e22e', + token: 'entity.other.attribute-name', + }, + { + foreground: '66d9ef', + token: 'support.function', + }, + { + foreground: '66d9ef', + token: 'support.constant', + }, + { + foreground: '66d9ef', + fontStyle: 'italic', + token: 'support.type', + }, + { + foreground: '66d9ef', + fontStyle: 'italic', + token: 'support.class', + }, + { + foreground: 'f8f8f0', + background: 'f92672', + token: 'invalid', + }, + { + foreground: 'f8f8f0', + background: 'ae81ff', + token: 'invalid.deprecated', + }, + { + foreground: 'cfcfc2', + token: 'meta.structure.dictionary.json string.quoted.double.json', + }, + { + foreground: '75715e', + token: 'meta.diff', + }, + { + foreground: '75715e', + token: 'meta.diff.header', + }, + { + foreground: 'f92672', + token: 'markup.deleted', + }, + { + foreground: 'a6e22e', + token: 'markup.inserted', + }, + { + foreground: 'e6db74', + token: 'markup.changed', + }, + { + foreground: 'ae81ffa0', + token: 'constant.numeric.line-number.find-in-files - match', + }, + { + foreground: 'e6db74', + token: 'entity.name.filename.find-in-files', + }, + ], + colors: { + 'editor.foreground': '#F8F8F2', + 'editor.background': '#272822', + 'editor.selectionBackground': '#49483E', + 'editor.lineHighlightBackground': '#3E3D32', + 'editorCursor.foreground': '#F8F8F0', + 'editorWhitespace.foreground': '#3B3A32', + 'editorIndentGuide.activeBackground': '#9D550FB0', + 'editor.selectionHighlightBorder': '#222218', + }, +}; diff --git a/app/assets/javascripts/ide/lib/themes/none.js b/app/assets/javascripts/ide/lib/themes/none.js new file mode 100644 index 00000000000..8e722c4ff88 --- /dev/null +++ b/app/assets/javascripts/ide/lib/themes/none.js @@ -0,0 +1,17 @@ +export default { + base: 'vs', + inherit: false, + rules: [], + colors: { + 'editor.foreground': '#2e2e2e', + 'editor.selectionBackground': '#aad6f8', + 'editor.lineHighlightBackground': '#fffeeb', + 'editorCursor.foreground': '#666666', + 'editorWhitespace.foreground': '#bbbbbb', + + 'editorLineNumber.foreground': '#cccccc', + 'diffEditor.insertedTextBackground': '#a0f5b420', + 'diffEditor.removedTextBackground': '#f9d7dc20', + 'editorIndentGuide.activeBackground': '#cccccc', + }, +}; diff --git a/app/assets/javascripts/ide/lib/themes/solarized_dark.js b/app/assets/javascripts/ide/lib/themes/solarized_dark.js new file mode 100644 index 00000000000..3c9414b9dc9 --- /dev/null +++ b/app/assets/javascripts/ide/lib/themes/solarized_dark.js @@ -0,0 +1,1110 @@ +/* + +https://github.com/brijeshb42/monaco-themes/blob/master/themes/Solarized-dark.json + +The MIT License (MIT) + +Copyright (c) Brijesh Bittu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +*/ +export default { + base: 'vs-dark', + inherit: true, + rules: [ + { + foreground: '586e75', + token: 'comment', + }, + { + foreground: '2aa198', + token: 'string', + }, + { + foreground: '586e75', + token: 'string', + }, + { + foreground: 'dc322f', + token: 'string.regexp', + }, + { + foreground: 'd33682', + token: 'constant.numeric', + }, + { + foreground: '268bd2', + token: 'variable.language', + }, + { + foreground: '268bd2', + token: 'variable.other', + }, + { + foreground: '859900', + token: 'keyword', + }, + { + foreground: '859900', + token: 'storage', + }, + { + foreground: '268bd2', + token: 'entity.name.class', + }, + { + foreground: '268bd2', + token: 'entity.name.type.class', + }, + { + foreground: '268bd2', + token: 'entity.name.function', + }, + { + foreground: '859900', + token: 'punctuation.definition.variable', + }, + { + foreground: 'dc322f', + token: 'punctuation.section.embedded.begin', + }, + { + foreground: 'dc322f', + token: 'punctuation.section.embedded.end', + }, + { + foreground: 'b58900', + token: 'constant.language', + }, + { + foreground: 'b58900', + token: 'meta.preprocessor', + }, + { + foreground: 'dc322f', + token: 'support.function.construct', + }, + { + foreground: 'dc322f', + token: 'keyword.other.new', + }, + { + foreground: 'cb4b16', + token: 'constant.character', + }, + { + foreground: 'cb4b16', + token: 'constant.other', + }, + { + foreground: '268bd2', + fontStyle: 'bold', + token: 'entity.name.tag', + }, + { + foreground: '586e75', + token: 'punctuation.definition.tag.html', + }, + { + foreground: '586e75', + token: 'punctuation.definition.tag.begin', + }, + { + foreground: '586e75', + token: 'punctuation.definition.tag.end', + }, + { + foreground: '93a1a1', + token: 'entity.other.attribute-name', + }, + { + foreground: '268bd2', + token: 'support.function', + }, + { + foreground: 'dc322f', + token: 'punctuation.separator.continuation', + }, + { + foreground: '859900', + token: 'support.type', + }, + { + foreground: '859900', + token: 'support.class', + }, + { + foreground: 'cb4b16', + token: 'support.type.exception', + }, + { + foreground: 'cb4b16', + token: 'keyword.other.special-method', + }, + { + foreground: '2aa198', + token: 'string.quoted.double', + }, + { + foreground: '2aa198', + token: 'string.quoted.single', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.begin', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.end', + }, + { + foreground: 'b58900', + token: 'entity.name.tag.css', + }, + { + foreground: 'b58900', + token: 'support.type.property-name.css', + }, + { + foreground: 'b58900', + token: 'meta.property-name.css', + }, + { + foreground: 'dc322f', + token: 'source.css', + }, + { + foreground: '586e75', + token: 'meta.selector.css', + }, + { + foreground: '6c71c4', + token: 'punctuation.section.property-list.css', + }, + { + foreground: '2aa198', + token: 'meta.property-value.css constant.numeric.css', + }, + { + foreground: '2aa198', + token: 'keyword.other.unit.css', + }, + { + foreground: '2aa198', + token: 'constant.other.color.rgb-value.css', + }, + { + foreground: '2aa198', + token: 'meta.property-value.css', + }, + { + foreground: 'dc322f', + token: 'keyword.other.important.css', + }, + { + foreground: '2aa198', + token: 'support.constant.color', + }, + { + foreground: '859900', + token: 'entity.name.tag.css', + }, + { + foreground: '586e75', + token: 'punctuation.separator.key-value.css', + }, + { + foreground: '586e75', + token: 'punctuation.terminator.rule.css', + }, + { + foreground: '268bd2', + token: 'entity.other.attribute-name.class.css', + }, + { + foreground: 'cb4b16', + token: 'entity.other.attribute-name.pseudo-element.css', + }, + { + foreground: 'cb4b16', + token: 'entity.other.attribute-name.pseudo-class.css', + }, + { + foreground: '268bd2', + token: 'entity.other.attribute-name.id.css', + }, + { + foreground: 'b58900', + token: 'meta.function.js', + }, + { + foreground: 'b58900', + token: 'entity.name.function.js', + }, + { + foreground: 'b58900', + token: 'support.function.dom.js', + }, + { + foreground: 'b58900', + token: 'text.html.basic source.js.embedded.html', + }, + { + foreground: '268bd2', + token: 'storage.type.function.js', + }, + { + foreground: '2aa198', + token: 'constant.numeric.js', + }, + { + foreground: '268bd2', + token: 'meta.brace.square.js', + }, + { + foreground: '268bd2', + token: 'storage.type.js', + }, + { + foreground: '93a1a1', + token: 'meta.brace.round', + }, + { + foreground: '93a1a1', + token: 'punctuation.definition.parameters.begin.js', + }, + { + foreground: '93a1a1', + token: 'punctuation.definition.parameters.end.js', + }, + { + foreground: '268bd2', + token: 'meta.brace.curly.js', + }, + { + foreground: '93a1a1', + fontStyle: 'italic', + token: 'entity.name.tag.doctype.html', + }, + { + foreground: '93a1a1', + fontStyle: 'italic', + token: 'meta.tag.sgml.html', + }, + { + foreground: '93a1a1', + fontStyle: 'italic', + token: 'string.quoted.double.doctype.identifiers-and-DTDs.html', + }, + { + foreground: '839496', + fontStyle: 'italic', + token: 'comment.block.html', + }, + { + fontStyle: 'italic', + token: 'entity.name.tag.script.html', + }, + { + foreground: '2aa198', + token: 'source.css.embedded.html string.quoted.double.html', + }, + { + foreground: 'cb4b16', + fontStyle: 'bold', + token: 'text.html.ruby', + }, + { + foreground: '657b83', + token: 'text.html.basic meta.tag.other.html', + }, + { + foreground: '657b83', + token: 'text.html.basic meta.tag.any.html', + }, + { + foreground: '657b83', + token: 'text.html.basic meta.tag.block.any', + }, + { + foreground: '657b83', + token: 'text.html.basic meta.tag.inline.any', + }, + { + foreground: '657b83', + token: 'text.html.basic meta.tag.structure.any.html', + }, + { + foreground: '657b83', + token: 'text.html.basic source.js.embedded.html', + }, + { + foreground: '657b83', + token: 'punctuation.separator.key-value.html', + }, + { + foreground: '657b83', + token: 'text.html.basic entity.other.attribute-name.html', + }, + { + foreground: '2aa198', + token: 'text.html.basic meta.tag.structure.any.html punctuation.definition.string.begin.html', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.begin.html', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.end.html', + }, + { + foreground: '268bd2', + fontStyle: 'bold', + token: 'entity.name.tag.block.any.html', + }, + { + fontStyle: 'italic', + token: 'source.css.embedded.html entity.name.tag.style.html', + }, + { + foreground: '839496', + fontStyle: 'italic', + token: 'source.css.embedded.html', + }, + { + foreground: '839496', + fontStyle: 'italic', + token: 'comment.block.html', + }, + { + foreground: '268bd2', + token: 'punctuation.definition.variable.ruby', + }, + { + foreground: '657b83', + token: 'meta.function.method.with-arguments.ruby', + }, + { + foreground: '2aa198', + token: 'variable.language.ruby', + }, + { + foreground: '268bd2', + token: 'entity.name.function.ruby', + }, + { + foreground: '859900', + fontStyle: 'bold', + token: 'keyword.control.ruby', + }, + { + foreground: '859900', + fontStyle: 'bold', + token: 'keyword.control.def.ruby', + }, + { + foreground: '859900', + token: 'keyword.control.class.ruby', + }, + { + foreground: '859900', + token: 'meta.class.ruby', + }, + { + foreground: 'b58900', + token: 'entity.name.type.class.ruby', + }, + { + foreground: '859900', + token: 'keyword.control.ruby', + }, + { + foreground: 'b58900', + token: 'support.class.ruby', + }, + { + foreground: '859900', + token: 'keyword.other.special-method.ruby', + }, + { + foreground: '2aa198', + token: 'constant.language.ruby', + }, + { + foreground: '2aa198', + token: 'constant.numeric.ruby', + }, + { + foreground: 'b58900', + token: 'variable.other.constant.ruby', + }, + { + foreground: '2aa198', + token: 'constant.other.symbol.ruby', + }, + { + foreground: 'dc322f', + token: 'punctuation.section.embedded.ruby', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.begin.ruby', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.end.ruby', + }, + { + foreground: 'cb4b16', + token: 'keyword.other.special-method.ruby', + }, + { + foreground: 'cb4b16', + token: 'keyword.control.import.include.php', + }, + { + foreground: '839496', + token: 'text.html.ruby meta.tag.inline.any.html', + }, + { + foreground: '2aa198', + token: 'text.html.ruby punctuation.definition.string.begin', + }, + { + foreground: '2aa198', + token: 'text.html.ruby punctuation.definition.string.end', + }, + { + foreground: '839496', + token: 'punctuation.definition.string.begin', + }, + { + foreground: '839496', + token: 'punctuation.definition.string.end', + }, + { + foreground: '839496', + token: 'support.class.php', + }, + { + foreground: 'dc322f', + token: 'keyword.operator.index-start.php', + }, + { + foreground: 'dc322f', + token: 'keyword.operator.index-end.php', + }, + { + foreground: '586e75', + token: 'meta.array.php', + }, + { + foreground: 'b58900', + token: 'meta.array.php support.function.construct.php', + }, + { + foreground: 'b58900', + token: 'meta.array.empty.php support.function.construct.php', + }, + { + foreground: 'b58900', + token: 'support.function.construct.php', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.array.begin', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.array.end', + }, + { + foreground: '2aa198', + token: 'constant.numeric.php', + }, + { + foreground: 'cb4b16', + token: 'keyword.other.new.php', + }, + { + foreground: '839496', + token: 'keyword.operator.class', + }, + { + foreground: '93a1a1', + token: 'variable.other.property.php', + }, + { + foreground: 'b58900', + token: 'storage.modifier.extends.php', + }, + { + foreground: 'b58900', + token: 'storage.type.class.php', + }, + { + foreground: 'b58900', + token: 'keyword.operator.class.php', + }, + { + foreground: '839496', + token: 'punctuation.terminator.expression.php', + }, + { + foreground: '586e75', + token: 'meta.other.inherited-class.php', + }, + { + foreground: '859900', + token: 'storage.type.php', + }, + { + foreground: '93a1a1', + token: 'entity.name.function.php', + }, + { + foreground: '859900', + token: 'support.function.construct.php', + }, + { + foreground: '839496', + token: 'entity.name.type.class.php', + }, + { + foreground: '839496', + token: 'meta.function-call.php', + }, + { + foreground: '839496', + token: 'meta.function-call.static.php', + }, + { + foreground: '839496', + token: 'meta.function-call.object.php', + }, + { + foreground: '93a1a1', + token: 'keyword.other.phpdoc', + }, + { + foreground: 'cb4b16', + token: 'source.php.embedded.block.html', + }, + { + foreground: 'cb4b16', + token: 'storage.type.function.php', + }, + { + foreground: '2aa198', + token: 'constant.numeric.c', + }, + { + foreground: 'cb4b16', + token: 'meta.preprocessor.c.include', + }, + { + foreground: 'cb4b16', + token: 'meta.preprocessor.macro.c', + }, + { + foreground: 'cb4b16', + token: 'keyword.control.import.define.c', + }, + { + foreground: 'cb4b16', + token: 'keyword.control.import.include.c', + }, + { + foreground: 'cb4b16', + token: 'entity.name.function.preprocessor.c', + }, + { + foreground: '2aa198', + token: 'meta.preprocessor.c.include string.quoted.other.lt-gt.include.c', + }, + { + foreground: '2aa198', + token: 'meta.preprocessor.c.include punctuation.definition.string.begin.c', + }, + { + foreground: '2aa198', + token: 'meta.preprocessor.c.include punctuation.definition.string.end.c', + }, + { + foreground: '586e75', + token: 'support.function.C99.c', + }, + { + foreground: '586e75', + token: 'support.function.any-method.c', + }, + { + foreground: '586e75', + token: 'entity.name.function.c', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.begin.c', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.end.c', + }, + { + foreground: 'b58900', + token: 'storage.type.c', + }, + { + foreground: 'e0eddd', + background: 'b58900', + fontStyle: 'italic', + token: 'meta.diff', + }, + { + foreground: 'e0eddd', + background: 'b58900', + fontStyle: 'italic', + token: 'meta.diff.header', + }, + { + foreground: 'dc322f', + background: 'eee8d5', + token: 'markup.deleted', + }, + { + foreground: 'cb4b16', + background: 'eee8d5', + token: 'markup.changed', + }, + { + foreground: '219186', + background: 'eee8d5', + token: 'markup.inserted', + }, + { + foreground: 'e0eddd', + background: 'b58900', + token: 'text.html.markdown meta.dummy.line-break', + }, + { + foreground: '2aa198', + token: 'text.html.markdown markup.raw.inline', + }, + { + foreground: '2aa198', + token: 'text.restructuredtext markup.raw', + }, + { + foreground: 'dc322f', + token: 'other.package.exclude', + }, + { + foreground: 'dc322f', + token: 'other.remove', + }, + { + foreground: '2aa198', + token: 'other.add', + }, + { + foreground: 'dc322f', + token: 'punctuation.section.group.tex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.arguments.begin.latex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.arguments.end.latex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.arguments.latex', + }, + { + foreground: 'b58900', + token: 'meta.group.braces.tex', + }, + { + foreground: 'b58900', + token: 'string.other.math.tex', + }, + { + foreground: 'cb4b16', + token: 'variable.parameter.function.latex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.constant.math.tex', + }, + { + foreground: '2aa198', + token: 'text.tex.latex constant.other.math.tex', + }, + { + foreground: '2aa198', + token: 'constant.other.general.math.tex', + }, + { + foreground: '2aa198', + token: 'constant.other.general.math.tex', + }, + { + foreground: '2aa198', + token: 'constant.character.math.tex', + }, + { + foreground: 'b58900', + token: 'string.other.math.tex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.begin.tex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.end.tex', + }, + { + foreground: '2aa198', + token: 'keyword.control.label.latex', + }, + { + foreground: '2aa198', + token: 'text.tex.latex constant.other.general.math.tex', + }, + { + foreground: 'dc322f', + token: 'variable.parameter.definition.label.latex', + }, + { + foreground: '859900', + token: 'support.function.be.latex', + }, + { + foreground: 'cb4b16', + token: 'support.function.section.latex', + }, + { + foreground: '2aa198', + token: 'support.function.general.tex', + }, + { + fontStyle: 'italic', + token: 'punctuation.definition.comment.tex', + }, + { + fontStyle: 'italic', + token: 'comment.line.percentage.tex', + }, + { + foreground: '2aa198', + token: 'keyword.control.ref.latex', + }, + { + foreground: '586e75', + token: 'string.quoted.double.block.python', + }, + { + foreground: '859900', + token: 'storage.type.class.python', + }, + { + foreground: '859900', + token: 'storage.type.function.python', + }, + { + foreground: '859900', + token: 'storage.modifier.global.python', + }, + { + foreground: 'cb4b16', + token: 'keyword.control.import.python', + }, + { + foreground: 'cb4b16', + token: 'keyword.control.import.from.python', + }, + { + foreground: 'b58900', + token: 'support.type.exception.python', + }, + { + foreground: '859900', + token: 'support.function.builtin.shell', + }, + { + foreground: 'cb4b16', + token: 'variable.other.normal.shell', + }, + { + foreground: '268bd2', + token: 'source.shell', + }, + { + foreground: '586e75', + token: 'meta.scope.for-in-loop.shell', + }, + { + foreground: '586e75', + token: 'variable.other.loop.shell', + }, + { + foreground: '859900', + token: 'punctuation.definition.string.end.shell', + }, + { + foreground: '859900', + token: 'punctuation.definition.string.begin.shell', + }, + { + foreground: '586e75', + token: 'meta.scope.case-block.shell', + }, + { + foreground: '586e75', + token: 'meta.scope.case-body.shell', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.logical-expression.shell', + }, + { + fontStyle: 'italic', + token: 'comment.line.number-sign.shell', + }, + { + foreground: 'cb4b16', + token: 'keyword.other.import.java', + }, + { + foreground: '586e75', + token: 'storage.modifier.import.java', + }, + { + foreground: 'b58900', + token: 'meta.class.java storage.modifier.java', + }, + { + foreground: '586e75', + token: 'source.java comment.block', + }, + { + foreground: '586e75', + token: + 'comment.block meta.documentation.tag.param.javadoc keyword.other.documentation.param.javadoc', + }, + { + foreground: 'b58900', + token: 'punctuation.definition.variable.perl', + }, + { + foreground: 'b58900', + token: 'variable.other.readwrite.global.perl', + }, + { + foreground: 'b58900', + token: 'variable.other.predefined.perl', + }, + { + foreground: 'b58900', + token: 'keyword.operator.comparison.perl', + }, + { + foreground: '859900', + token: 'support.function.perl', + }, + { + foreground: '586e75', + fontStyle: 'italic', + token: 'comment.line.number-sign.perl', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.begin.perl', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.end.perl', + }, + { + foreground: 'dc322f', + token: 'constant.character.escape.perl', + }, + { + foreground: '268bd2', + token: 'markup.heading.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.1.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.2.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.3.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.4.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.5.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.6.markdown', + }, + { + foreground: '839496', + fontStyle: 'bold', + token: 'markup.bold.markdown', + }, + { + foreground: '839496', + fontStyle: 'italic', + token: 'markup.italic.markdown', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.bold.markdown', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.italic.markdown', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.raw.markdown', + }, + { + foreground: 'b58900', + token: 'markup.list.unnumbered.markdown', + }, + { + foreground: '859900', + token: 'markup.list.numbered.markdown', + }, + { + foreground: '2aa198', + token: 'markup.raw.block.markdown', + }, + { + foreground: '2aa198', + token: 'markup.raw.inline.markdown', + }, + { + foreground: '6c71c4', + token: 'markup.quote.markdown', + }, + { + foreground: '6c71c4', + token: 'punctuation.definition.blockquote.markdown', + }, + { + foreground: 'd33682', + token: 'meta.separator.markdown', + }, + { + foreground: '586e75', + fontStyle: 'italic', + token: 'meta.image.inline.markdown', + }, + { + foreground: '586e75', + fontStyle: 'italic', + token: 'markup.underline.link.markdown', + }, + { + foreground: '93a1a1', + token: 'string.other.link.title.markdown', + }, + { + foreground: '93a1a1', + token: 'string.other.link.description.markdown', + }, + { + foreground: '586e75', + token: 'punctuation.definition.link.markdown', + }, + { + foreground: '586e75', + token: 'punctuation.definition.metadata.markdown', + }, + { + foreground: '586e75', + token: 'punctuation.definition.string.begin.markdown', + }, + { + foreground: '586e75', + token: 'punctuation.definition.string.end.markdown', + }, + { + foreground: '586e75', + token: 'punctuation.definition.constant.markdown', + }, + { + foreground: 'eee8d5', + background: 'eee8d5', + token: 'sublimelinter.notes', + }, + { + foreground: '93a1a1', + background: '93a1a1', + token: 'sublimelinter.outline.illegal', + }, + { + background: 'dc322f', + token: 'sublimelinter.underline.illegal', + }, + { + foreground: '839496', + background: '839496', + token: 'sublimelinter.outline.warning', + }, + { + background: 'b58900', + token: 'sublimelinter.underline.warning', + }, + { + foreground: '657b83', + background: '657b83', + token: 'sublimelinter.outline.violation', + }, + { + background: 'cb4b16', + token: 'sublimelinter.underline.violation', + }, + ], + colors: { + 'editor.foreground': '#839496', + 'editor.background': '#002B36', + 'editor.selectionBackground': '#073642', + 'editor.lineHighlightBackground': '#073642', + 'editorCursor.foreground': '#819090', + 'editorWhitespace.foreground': '#073642', + }, +}; diff --git a/app/assets/javascripts/ide/lib/themes/solarized_light.js b/app/assets/javascripts/ide/lib/themes/solarized_light.js new file mode 100644 index 00000000000..b7bfcf33b0f --- /dev/null +++ b/app/assets/javascripts/ide/lib/themes/solarized_light.js @@ -0,0 +1,1101 @@ +/* + +https://github.com/brijeshb42/monaco-themes/blob/master/themes/Solarized-dark.json + +The MIT License (MIT) + +Copyright (c) Brijesh Bittu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +*/ +export default { + base: 'vs', + inherit: true, + rules: [ + { + foreground: '93a1a1', + token: 'comment', + }, + { + foreground: '2aa198', + token: 'string', + }, + { + foreground: '586e75', + token: 'string', + }, + { + foreground: 'dc322f', + token: 'string.regexp', + }, + { + foreground: 'd33682', + token: 'constant.numeric', + }, + { + foreground: '268bd2', + token: 'variable.language', + }, + { + foreground: '268bd2', + token: 'variable.other', + }, + { + foreground: '859900', + token: 'keyword', + }, + { + foreground: '073642', + fontStyle: 'bold', + token: 'storage', + }, + { + foreground: '268bd2', + token: 'entity.name.class', + }, + { + foreground: '268bd2', + token: 'entity.name.type.class', + }, + { + foreground: '268bd2', + token: 'entity.name.function', + }, + { + foreground: '859900', + token: 'punctuation.definition.variable', + }, + { + foreground: 'dc322f', + token: 'punctuation.section.embedded.begin', + }, + { + foreground: 'dc322f', + token: 'punctuation.section.embedded.end', + }, + { + foreground: 'b58900', + token: 'constant.language', + }, + { + foreground: 'b58900', + token: 'meta.preprocessor', + }, + { + foreground: 'dc322f', + token: 'support.function.construct', + }, + { + foreground: 'dc322f', + token: 'keyword.other.new', + }, + { + foreground: 'cb4b16', + token: 'constant.character', + }, + { + foreground: 'cb4b16', + token: 'constant.other', + }, + { + foreground: '268bd2', + fontStyle: 'bold', + token: 'entity.name.tag', + }, + { + foreground: '93a1a1', + token: 'punctuation.definition.tag.html', + }, + { + foreground: '93a1a1', + token: 'punctuation.definition.tag.begin', + }, + { + foreground: '93a1a1', + token: 'punctuation.definition.tag.end', + }, + { + foreground: '93a1a1', + token: 'entity.other.attribute-name', + }, + { + foreground: '268bd2', + token: 'support.function', + }, + { + foreground: 'dc322f', + token: 'punctuation.separator.continuation', + }, + { + foreground: '859900', + token: 'support.type', + }, + { + foreground: '859900', + token: 'support.class', + }, + { + foreground: 'cb4b16', + token: 'support.type.exception', + }, + { + foreground: 'cb4b16', + token: 'keyword.other.special-method', + }, + { + foreground: '2aa198', + token: 'string.quoted.double', + }, + { + foreground: '2aa198', + token: 'string.quoted.single', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.begin', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.end', + }, + { + foreground: 'b58900', + token: 'entity.name.tag.css', + }, + { + foreground: 'b58900', + token: 'support.type.property-name.css', + }, + { + foreground: 'b58900', + token: 'meta.property-name.css', + }, + { + foreground: 'dc322f', + token: 'source.css', + }, + { + foreground: '586e75', + token: 'meta.selector.css', + }, + { + foreground: '6c71c4', + token: 'punctuation.section.property-list.css', + }, + { + foreground: '2aa198', + token: 'meta.property-value.css constant.numeric.css', + }, + { + foreground: '2aa198', + token: 'keyword.other.unit.css', + }, + { + foreground: '2aa198', + token: 'constant.other.color.rgb-value.css', + }, + { + foreground: '2aa198', + token: 'meta.property-value.css', + }, + { + foreground: 'dc322f', + token: 'keyword.other.important.css', + }, + { + foreground: '2aa198', + token: 'support.constant.color', + }, + { + foreground: '859900', + token: 'entity.name.tag.css', + }, + { + foreground: '586e75', + token: 'punctuation.separator.key-value.css', + }, + { + foreground: '586e75', + token: 'punctuation.terminator.rule.css', + }, + { + foreground: '268bd2', + token: 'entity.other.attribute-name.class.css', + }, + { + foreground: 'cb4b16', + token: 'entity.other.attribute-name.pseudo-element.css', + }, + { + foreground: 'cb4b16', + token: 'entity.other.attribute-name.pseudo-class.css', + }, + { + foreground: '268bd2', + token: 'entity.other.attribute-name.id.css', + }, + { + foreground: 'b58900', + token: 'meta.function.js', + }, + { + foreground: 'b58900', + token: 'entity.name.function.js', + }, + { + foreground: 'b58900', + token: 'support.function.dom.js', + }, + { + foreground: 'b58900', + token: 'text.html.basic source.js.embedded.html', + }, + { + foreground: '268bd2', + token: 'storage.type.function.js', + }, + { + foreground: '2aa198', + token: 'constant.numeric.js', + }, + { + foreground: '268bd2', + token: 'meta.brace.square.js', + }, + { + foreground: '268bd2', + token: 'storage.type.js', + }, + { + foreground: '93a1a1', + token: 'meta.brace.round', + }, + { + foreground: '93a1a1', + token: 'punctuation.definition.parameters.begin.js', + }, + { + foreground: '93a1a1', + token: 'punctuation.definition.parameters.end.js', + }, + { + foreground: '268bd2', + token: 'meta.brace.curly.js', + }, + { + foreground: '93a1a1', + fontStyle: 'italic', + token: 'entity.name.tag.doctype.html', + }, + { + foreground: '93a1a1', + fontStyle: 'italic', + token: 'meta.tag.sgml.html', + }, + { + foreground: '93a1a1', + fontStyle: 'italic', + token: 'string.quoted.double.doctype.identifiers-and-DTDs.html', + }, + { + foreground: '839496', + fontStyle: 'italic', + token: 'comment.block.html', + }, + { + fontStyle: 'italic', + token: 'entity.name.tag.script.html', + }, + { + foreground: '2aa198', + token: 'source.css.embedded.html string.quoted.double.html', + }, + { + foreground: 'cb4b16', + fontStyle: 'bold', + token: 'text.html.ruby', + }, + { + foreground: '657b83', + token: 'text.html.basic meta.tag.other.html', + }, + { + foreground: '657b83', + token: 'text.html.basic meta.tag.any.html', + }, + { + foreground: '657b83', + token: 'text.html.basic meta.tag.block.any', + }, + { + foreground: '657b83', + token: 'text.html.basic meta.tag.inline.any', + }, + { + foreground: '657b83', + token: 'text.html.basic meta.tag.structure.any.html', + }, + { + foreground: '657b83', + token: 'text.html.basic source.js.embedded.html', + }, + { + foreground: '657b83', + token: 'punctuation.separator.key-value.html', + }, + { + foreground: '657b83', + token: 'text.html.basic entity.other.attribute-name.html', + }, + { + foreground: '2aa198', + token: 'text.html.basic meta.tag.structure.any.html punctuation.definition.string.begin.html', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.begin.html', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.end.html', + }, + { + foreground: '268bd2', + fontStyle: 'bold', + token: 'entity.name.tag.block.any.html', + }, + { + fontStyle: 'italic', + token: 'source.css.embedded.html entity.name.tag.style.html', + }, + { + foreground: '839496', + fontStyle: 'italic', + token: 'source.css.embedded.html', + }, + { + foreground: '839496', + fontStyle: 'italic', + token: 'comment.block.html', + }, + { + foreground: '268bd2', + token: 'punctuation.definition.variable.ruby', + }, + { + foreground: '657b83', + token: 'meta.function.method.with-arguments.ruby', + }, + { + foreground: '2aa198', + token: 'variable.language.ruby', + }, + { + foreground: '268bd2', + token: 'entity.name.function.ruby', + }, + { + foreground: '859900', + fontStyle: 'bold', + token: 'keyword.control.ruby', + }, + { + foreground: '859900', + fontStyle: 'bold', + token: 'keyword.control.def.ruby', + }, + { + foreground: '859900', + token: 'keyword.control.class.ruby', + }, + { + foreground: '859900', + token: 'meta.class.ruby', + }, + { + foreground: 'b58900', + token: 'entity.name.type.class.ruby', + }, + { + foreground: '859900', + token: 'keyword.control.ruby', + }, + { + foreground: 'b58900', + token: 'support.class.ruby', + }, + { + foreground: '859900', + token: 'keyword.other.special-method.ruby', + }, + { + foreground: '2aa198', + token: 'constant.language.ruby', + }, + { + foreground: '2aa198', + token: 'constant.numeric.ruby', + }, + { + foreground: 'b58900', + token: 'variable.other.constant.ruby', + }, + { + foreground: '2aa198', + token: 'constant.other.symbol.ruby', + }, + { + foreground: 'dc322f', + token: 'punctuation.section.embedded.ruby', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.begin.ruby', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.end.ruby', + }, + { + foreground: 'cb4b16', + token: 'keyword.other.special-method.ruby', + }, + { + foreground: 'cb4b16', + token: 'keyword.control.import.include.php', + }, + { + foreground: '839496', + token: 'text.html.ruby meta.tag.inline.any.html', + }, + { + foreground: '2aa198', + token: 'text.html.ruby punctuation.definition.string.begin', + }, + { + foreground: '2aa198', + token: 'text.html.ruby punctuation.definition.string.end', + }, + { + foreground: '839496', + token: 'punctuation.definition.string.begin', + }, + { + foreground: '839496', + token: 'punctuation.definition.string.end', + }, + { + foreground: 'dc322f', + token: 'keyword.operator.index-start.php', + }, + { + foreground: 'dc322f', + token: 'keyword.operator.index-end.php', + }, + { + foreground: '586e75', + token: 'meta.array.php', + }, + { + foreground: 'b58900', + token: 'meta.array.php support.function.construct.php', + }, + { + foreground: 'b58900', + token: 'meta.array.empty.php support.function.construct.php', + }, + { + foreground: 'b58900', + token: 'support.function.construct.php', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.array.begin', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.array.end', + }, + { + foreground: '2aa198', + token: 'constant.numeric.php', + }, + { + foreground: 'cb4b16', + token: 'keyword.other.new.php', + }, + { + foreground: '586e75', + token: 'support.class.php', + }, + { + foreground: '586e75', + token: 'keyword.operator.class', + }, + { + foreground: '93a1a1', + token: 'variable.other.property.php', + }, + { + foreground: 'b58900', + token: 'storage.modifier.extends.php', + }, + { + foreground: 'b58900', + token: 'storage.type.class.php', + }, + { + foreground: 'b58900', + token: 'keyword.operator.class.php', + }, + { + foreground: '586e75', + token: 'meta.other.inherited-class.php', + }, + { + foreground: '859900', + token: 'storage.type.php', + }, + { + foreground: '93a1a1', + token: 'entity.name.function.php', + }, + { + foreground: '859900', + token: 'support.function.construct.php', + }, + { + foreground: '839496', + token: 'entity.name.type.class.php', + }, + { + foreground: '839496', + token: 'meta.function-call.php', + }, + { + foreground: '839496', + token: 'meta.function-call.static.php', + }, + { + foreground: '839496', + token: 'meta.function-call.object.php', + }, + { + foreground: '93a1a1', + token: 'keyword.other.phpdoc', + }, + { + foreground: 'cb4b16', + token: 'source.php.embedded.block.html', + }, + { + foreground: 'cb4b16', + token: 'storage.type.function.php', + }, + { + foreground: '2aa198', + token: 'constant.numeric.c', + }, + { + foreground: 'cb4b16', + token: 'meta.preprocessor.c.include', + }, + { + foreground: 'cb4b16', + token: 'meta.preprocessor.macro.c', + }, + { + foreground: 'cb4b16', + token: 'keyword.control.import.define.c', + }, + { + foreground: 'cb4b16', + token: 'keyword.control.import.include.c', + }, + { + foreground: 'cb4b16', + token: 'entity.name.function.preprocessor.c', + }, + { + foreground: '2aa198', + token: 'meta.preprocessor.c.include string.quoted.other.lt-gt.include.c', + }, + { + foreground: '2aa198', + token: 'meta.preprocessor.c.include punctuation.definition.string.begin.c', + }, + { + foreground: '2aa198', + token: 'meta.preprocessor.c.include punctuation.definition.string.end.c', + }, + { + foreground: '586e75', + token: 'support.function.C99.c', + }, + { + foreground: '586e75', + token: 'support.function.any-method.c', + }, + { + foreground: '586e75', + token: 'entity.name.function.c', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.begin.c', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.end.c', + }, + { + foreground: 'b58900', + token: 'storage.type.c', + }, + { + foreground: 'e0eddd', + background: 'b58900', + fontStyle: 'italic', + token: 'meta.diff', + }, + { + foreground: 'e0eddd', + background: 'b58900', + fontStyle: 'italic', + token: 'meta.diff.header', + }, + { + foreground: 'dc322f', + background: 'eee8d5', + token: 'markup.deleted', + }, + { + foreground: 'cb4b16', + background: 'eee8d5', + token: 'markup.changed', + }, + { + foreground: '219186', + background: 'eee8d5', + token: 'markup.inserted', + }, + { + foreground: 'e0eddd', + background: 'a57706', + token: 'text.html.markdown meta.dummy.line-break', + }, + { + foreground: '2aa198', + token: 'text.html.markdown markup.raw.inline', + }, + { + foreground: '2aa198', + token: 'text.restructuredtext markup.raw', + }, + { + foreground: 'dc322f', + token: 'other.package.exclude', + }, + { + foreground: 'dc322f', + token: 'other.remove', + }, + { + foreground: '2aa198', + token: 'other.add', + }, + { + foreground: 'dc322f', + token: 'punctuation.section.group.tex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.arguments.begin.latex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.arguments.end.latex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.arguments.latex', + }, + { + foreground: 'b58900', + token: 'meta.group.braces.tex', + }, + { + foreground: 'b58900', + token: 'string.other.math.tex', + }, + { + foreground: 'cb4b16', + token: 'variable.parameter.function.latex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.constant.math.tex', + }, + { + foreground: '2aa198', + token: 'text.tex.latex constant.other.math.tex', + }, + { + foreground: '2aa198', + token: 'constant.other.general.math.tex', + }, + { + foreground: '2aa198', + token: 'constant.other.general.math.tex', + }, + { + foreground: '2aa198', + token: 'constant.character.math.tex', + }, + { + foreground: 'b58900', + token: 'string.other.math.tex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.begin.tex', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.end.tex', + }, + { + foreground: '2aa198', + token: 'keyword.control.label.latex', + }, + { + foreground: '2aa198', + token: 'text.tex.latex constant.other.general.math.tex', + }, + { + foreground: 'dc322f', + token: 'variable.parameter.definition.label.latex', + }, + { + foreground: '859900', + token: 'support.function.be.latex', + }, + { + foreground: 'cb4b16', + token: 'support.function.section.latex', + }, + { + foreground: '2aa198', + token: 'support.function.general.tex', + }, + { + fontStyle: 'italic', + token: 'punctuation.definition.comment.tex', + }, + { + fontStyle: 'italic', + token: 'comment.line.percentage.tex', + }, + { + foreground: '2aa198', + token: 'keyword.control.ref.latex', + }, + { + foreground: '586e75', + token: 'string.quoted.double.block.python', + }, + { + foreground: '859900', + token: 'storage.type.class.python', + }, + { + foreground: '859900', + token: 'storage.type.function.python', + }, + { + foreground: '859900', + token: 'storage.modifier.global.python', + }, + { + foreground: 'cb4b16', + token: 'keyword.control.import.python', + }, + { + foreground: 'cb4b16', + token: 'keyword.control.import.from.python', + }, + { + foreground: 'b58900', + token: 'support.type.exception.python', + }, + { + foreground: '859900', + token: 'support.function.builtin.shell', + }, + { + foreground: 'cb4b16', + token: 'variable.other.normal.shell', + }, + { + foreground: '268bd2', + token: 'source.shell', + }, + { + foreground: '586e75', + token: 'meta.scope.for-in-loop.shell', + }, + { + foreground: '586e75', + token: 'variable.other.loop.shell', + }, + { + foreground: '859900', + token: 'punctuation.definition.string.end.shell', + }, + { + foreground: '859900', + token: 'punctuation.definition.string.begin.shell', + }, + { + foreground: '586e75', + token: 'meta.scope.case-block.shell', + }, + { + foreground: '586e75', + token: 'meta.scope.case-body.shell', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.logical-expression.shell', + }, + { + fontStyle: 'italic', + token: 'comment.line.number-sign.shell', + }, + { + foreground: 'cb4b16', + token: 'keyword.other.import.java', + }, + { + foreground: '586e75', + token: 'storage.modifier.import.java', + }, + { + foreground: 'b58900', + token: 'meta.class.java storage.modifier.java', + }, + { + foreground: '586e75', + token: 'source.java comment.block', + }, + { + foreground: '586e75', + token: + 'comment.block meta.documentation.tag.param.javadoc keyword.other.documentation.param.javadoc', + }, + { + foreground: 'b58900', + token: 'punctuation.definition.variable.perl', + }, + { + foreground: 'b58900', + token: 'variable.other.readwrite.global.perl', + }, + { + foreground: 'b58900', + token: 'variable.other.predefined.perl', + }, + { + foreground: 'b58900', + token: 'keyword.operator.comparison.perl', + }, + { + foreground: '859900', + token: 'support.function.perl', + }, + { + foreground: '586e75', + fontStyle: 'italic', + token: 'comment.line.number-sign.perl', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.begin.perl', + }, + { + foreground: '2aa198', + token: 'punctuation.definition.string.end.perl', + }, + { + foreground: 'dc322f', + token: 'constant.character.escape.perl', + }, + { + foreground: '268bd2', + token: 'markup.heading.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.1.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.2.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.3.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.4.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.5.markdown', + }, + { + foreground: '268bd2', + token: 'markup.heading.6.markdown', + }, + { + foreground: '586e75', + fontStyle: 'bold', + token: 'markup.bold.markdown', + }, + { + foreground: '586e75', + fontStyle: 'italic', + token: 'markup.italic.markdown', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.bold.markdown', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.italic.markdown', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.raw.markdown', + }, + { + foreground: 'b58900', + token: 'markup.list.unnumbered.markdown', + }, + { + foreground: '859900', + token: 'markup.list.numbered.markdown', + }, + { + foreground: '2aa198', + token: 'markup.raw.block.markdown', + }, + { + foreground: '2aa198', + token: 'markup.raw.inline.markdown', + }, + { + foreground: '6c71c4', + token: 'markup.quote.markdown', + }, + { + foreground: '6c71c4', + token: 'punctuation.definition.blockquote.markdown', + }, + { + foreground: 'd33682', + token: 'meta.separator.markdown', + }, + { + foreground: '839496', + token: 'markup.underline.link.markdown', + }, + { + foreground: '839496', + token: 'markup.underline.link.markdown', + }, + { + foreground: 'dc322f', + token: 'meta.link.inet.markdown', + }, + { + foreground: 'dc322f', + token: 'meta.link.email.lt-gt.markdown', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.begin.markdown', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.string.end.markdown', + }, + { + foreground: 'dc322f', + token: 'punctuation.definition.link.markdown', + }, + { + foreground: '6a8187', + token: 'text.plain', + }, + { + foreground: 'eee8d5', + background: 'eee8d5', + token: 'sublimelinter.notes', + }, + { + foreground: '93a1a1', + background: '93a1a1', + token: 'sublimelinter.outline.illegal', + }, + { + background: 'dc322f', + token: 'sublimelinter.underline.illegal', + }, + { + foreground: '839496', + background: '839496', + token: 'sublimelinter.outline.warning', + }, + { + background: 'b58900', + token: 'sublimelinter.underline.warning', + }, + { + foreground: '657b83', + background: '657b83', + token: 'sublimelinter.outline.violation', + }, + { + background: 'cb4b16', + token: 'sublimelinter.underline.violation', + }, + ], + colors: { + 'editor.foreground': '#586E75', + 'editor.background': '#FDF6E3', + 'editor.selectionBackground': '#EEE8D5', + 'editor.lineHighlightBackground': '#EEE8D5', + 'editorCursor.foreground': '#000000', + 'editorWhitespace.foreground': '#EAE3C9', + }, +}; diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 3adf0cf073f..1767d961259 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -88,12 +88,16 @@ export default { commit(projectId, payload) { return Api.commitMultiple(projectId, payload); }, - getFiles(projectUrl, ref) { - const url = `${projectUrl}/-/files/${ref}`; + getFiles(projectPath, ref) { + const url = `${gon.relative_url_root}/${projectPath}/-/files/${ref}`; return axios.get(url, { params: { format: 'json' } }); }, lastCommitPipelines({ getters }) { const commitSha = getters.lastCommit.id; return Api.commitPipelines(getters.currentProject.path_with_namespace, commitSha); }, + pingUsage(projectPath) { + const url = `${gon.relative_url_root}/${projectPath}/usage_ping/web_ide_pipelines_count`; + return axios.post(url); + }, }; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 04cf0ad53d5..e32b5ac7bdc 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -1,6 +1,5 @@ -import $ from 'jquery'; import Vue from 'vue'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { __, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import flash from '~/flash'; @@ -25,14 +24,6 @@ export const closeAllFiles = ({ state, dispatch }) => { state.openFiles.forEach(file => dispatch('closeFile', file)); }; -export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { - if (side === 'left') { - commit(types.SET_LEFT_PANEL_COLLAPSED, collapsed); - } else { - commit(types.SET_RIGHT_PANEL_COLLAPSED, collapsed); - } -}; - export const setResizingStatus = ({ commit }, resizing) => { commit(types.SET_RESIZING_STATUS, resizing); }; @@ -176,13 +167,6 @@ export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links); export const setErrorMessage = ({ commit }, errorMessage) => commit(types.SET_ERROR_MESSAGE, errorMessage); -export const openNewEntryModal = ({ commit }, { type, path = '' }) => { - commit(types.OPEN_NEW_ENTRY_MODAL, { type, path }); - - // open the modal manually so we don't mess around with dropdown/rows - $('#ide-new-entry').modal('show'); -}; - export const deleteEntry = ({ commit, dispatch, state }, path) => { const entry = state.entries[path]; const { prevPath, prevName, prevParentPath } = entry; @@ -296,7 +280,7 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force = sprintf( __('Branch not loaded - %{branchId}'), { - branchId: `<strong>${esc(projectId)}/${esc(branchId)}</strong>`, + branchId: `<strong>${escape(projectId)}/${escape(branchId)}</strong>`, }, false, ), diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index ae3829dc35e..6c8fb9f90aa 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -1,4 +1,4 @@ -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import flash from '~/flash'; import { __, sprintf } from '~/locale'; import service from '../../services'; @@ -73,7 +73,7 @@ export const showBranchNotFoundError = ({ dispatch }, branchId) => { text: sprintf( __("Branch %{branchName} was not found in this project's repository."), { - branchName: `<strong>${esc(branchId)}</strong>`, + branchName: `<strong>${escape(branchId)}</strong>`, }, false, ), @@ -162,7 +162,7 @@ export const openBranch = ({ dispatch }, { projectId, branchId, basePath }) => { sprintf( __('An error occurred while getting files for - %{branchId}'), { - branchId: `<strong>${esc(projectId)}/${esc(branchId)}</strong>`, + branchId: `<strong>${escape(projectId)}/${escape(branchId)}</strong>`, }, false, ), diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 7d48f0adc4c..1ca608f1287 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -59,7 +59,7 @@ export const getFiles = ({ state, commit, dispatch }, payload = {}) => commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); service - .getFiles(selectedProject.web_url, ref) + .getFiles(selectedProject.path_with_namespace, ref) .then(({ data }) => { const { entries, treeList } = decorateFiles({ data, diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 505daa8834d..592c7e15918 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -1,6 +1,6 @@ -import $ from 'jquery'; import { sprintf, __ } from '~/locale'; 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'; @@ -215,25 +215,23 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo ); }) .catch(err => { - if (err.response.status === 400) { - $('#ide-create-branch-modal').modal('show'); - } else { - dispatch( - 'setErrorMessage', - { - text: __('An error occurred while committing your changes.'), - action: () => - dispatch('commitChanges').then(() => - dispatch('setErrorMessage', null, { root: true }), - ), - actionText: __('Please try again'), - }, - { root: true }, - ); - window.dispatchEvent(new Event('resize')); - } - commit(types.UPDATE_LOADING, false); + + // don't catch bad request errors, let the view handle them + if (err.response.status === httpStatusCodes.BAD_REQUEST) throw err; + + dispatch( + 'setErrorMessage', + { + text: __('An error occurred while committing your changes.'), + action: () => + dispatch('commitChanges').then(() => dispatch('setErrorMessage', null, { root: true })), + actionText: __('Please try again'), + }, + { root: true }, + ); + + window.dispatchEvent(new Event('resize')); }); }; diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 78831bdf022..5c78bfefa04 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -2,8 +2,6 @@ export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA'; export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG'; -export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED'; -export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED'; export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS'; export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS'; export const SET_LINKS = 'SET_LINKS'; @@ -73,7 +71,6 @@ export const RESET_OPEN_FILES = 'RESET_OPEN_FILES'; export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE'; -export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL'; export const DELETE_ENTRY = 'DELETE_ENTRY'; export const RENAME_ENTRY = 'RENAME_ENTRY'; export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 5d567d9b169..12ac10df206 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -29,16 +29,6 @@ export default { }); } }, - [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) { - Object.assign(state, { - leftPanelCollapsed: collapsed, - }); - }, - [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) { - Object.assign(state, { - rightPanelCollapsed: collapsed, - }); - }, [types.SET_RESIZING_STATUS](state, resizing) { Object.assign(state, { panelResizing: resizing, @@ -192,15 +182,6 @@ export default { [types.SET_ERROR_MESSAGE](state, errorMessage) { Object.assign(state, { errorMessage }); }, - [types.OPEN_NEW_ENTRY_MODAL](state, { type, path }) { - Object.assign(state, { - entryModal: { - type, - path, - entry: { ...state.entries[path] }, - }, - }); - }, [types.DELETE_ENTRY](state, path) { const entry = state.entries[path]; const { tempFile = false } = entry; diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js index 9230f3839c1..034fdad4305 100644 --- a/app/assets/javascripts/ide/stores/mutations/project.js +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -16,9 +16,7 @@ export default { }); Object.assign(state, { - projects: Object.assign({}, state.projects, { - [projectPath]: project, - }), + projects: { ...state.projects, [projectPath]: project }, }); }, [types.TOGGLE_EMPTY_STATE](state, { projectPath, value }) { diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js index 359943b4ab7..c8f14a680c2 100644 --- a/app/assets/javascripts/ide/stores/mutations/tree.js +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -14,12 +14,13 @@ export default { }, [types.CREATE_TREE](state, { treePath }) { Object.assign(state, { - trees: Object.assign({}, state.trees, { + trees: { + ...state.trees, [treePath]: { tree: [], loading: true, }, - }), + }, }); }, [types.SET_DIRECTORY_DATA](state, { data, treePath }) { diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 0fd6a448283..0c95c22e8f8 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -15,8 +15,6 @@ export default () => ({ parentTreeUrl: '', trees: {}, projects: {}, - leftPanelCollapsed: false, - rightPanelCollapsed: false, panelResizing: false, entries: {}, viewer: viewerTypes.edit, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 4e5b01596d8..56671142bd4 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,4 +1,5 @@ import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants'; +import { relativePathToAbsolute, isAbsolute, isRootRelative } from '~/lib/utils/url_utility'; export const dataStructure = () => ({ id: '', @@ -274,3 +275,45 @@ export const pathsAreEqual = (a, b) => { // 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: + * + * ![img alt goes here](/img.png) + * ![img alt](../img 1/img.png "my image title") + * ![img alt](https://gitlab.com/assets/logo.svg "title here") + * + */ + const reMdImage = /!\[([^\]]*)\]\((.*?)(?:(?="|\))"([^"]*)")?\)/gi; + const prefix = 'gl_md_img_'; + const images = {}; + + let content = mdFile.content || mdFile.raw; + let i = 0; + + content = content.replace(reMdImage, (_, alt, path, title) => { + const imagePath = (isRootRelative(path) ? path : relativePathToAbsolute(path, mdFile.path)) + .substr(1) + .trim(); + + const imageContent = entries[imagePath]?.content || entries[imagePath]?.raw; + + if (!isAbsolute(path) && imageContent) { + const ext = path.includes('.') + ? path + .split('.') + .pop() + .trim() + : 'jpeg'; + const src = `data:image/${ext};base64,${imageContent}`; + i += 1; + const key = `{{${prefix}${i}}}`; + images[key] = { alt, src, title }; + return key; + } + return title ? `![${alt}](${path}"${title}")` : `![${alt}](${path})`; + }); + + return { content, images }; +} diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index 64ac539a4ff..1ea2b199237 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -68,3 +68,19 @@ export const createPathWithExt = p => { return `${p.substring(1, p.lastIndexOf('.') + 1 || p.length)}${ext || '.js'}`; }; + +export const trimPathComponents = path => + path + .split('/') + .map(s => s.trim()) + .join('/'); + +export function registerLanguages(def, ...defs) { + if (defs.length) defs.forEach(lang => registerLanguages(lang)); + + const languageId = def.id; + + languages.register(def); + languages.setMonarchTokensProvider(languageId, def.language); + languages.setLanguageConfiguration(languageId, def.conf); +} diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js index 7921650e8a0..229e0a62c51 100644 --- a/app/assets/javascripts/image_diff/helpers/badge_helper.js +++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js @@ -15,7 +15,7 @@ export function createImageBadge(noteId, { x, y }, classNames = []) { export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) { const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']); - buttonEl.innerText = badgeText; + buttonEl.textContent = badgeText; containerEl.appendChild(buttonEl); } @@ -32,6 +32,6 @@ export function addAvatarBadge(el, event) { // Add badge to new comment const avatarBadgeEl = el.querySelector(`#${noteId} .badge`); - avatarBadgeEl.innerText = badgeNumber; + avatarBadgeEl.textContent = badgeNumber; avatarBadgeEl.classList.remove('hidden'); } diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js index df3d90cff68..deaef686f59 100644 --- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js +++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js @@ -32,9 +32,7 @@ export function removeCommentIndicator(imageFrameEl) { commentIndicatorEl.remove(); } - return Object.assign({}, meta, { - removed: willRemove, - }); + return { ...meta, removed: willRemove }; } export function showCommentIndicator(imageFrameEl, coordinate) { diff --git a/app/assets/javascripts/image_diff/helpers/dom_helper.js b/app/assets/javascripts/image_diff/helpers/dom_helper.js index a319bcccb8f..a61e5f01f9b 100644 --- a/app/assets/javascripts/image_diff/helpers/dom_helper.js +++ b/app/assets/javascripts/image_diff/helpers/dom_helper.js @@ -4,24 +4,19 @@ export function setPositionDataAttribute(el, options) { const { x, y, width, height } = options; const { position } = el.dataset; - const positionObject = Object.assign({}, JSON.parse(position), { - x, - y, - width, - height, - }); + const positionObject = { ...JSON.parse(position), x, y, width, height }; el.setAttribute('data-position', JSON.stringify(positionObject)); } export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) { const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .badge'); - avatarBadgeEl.innerText = newBadgeNumber; + avatarBadgeEl.textContent = newBadgeNumber; } export function updateDiscussionBadgeNumber(discussionEl, newBadgeNumber) { const discussionBadgeEl = discussionEl.querySelector('.badge'); - discussionBadgeEl.innerText = newBadgeNumber; + discussionBadgeEl.textContent = newBadgeNumber; } export function toggleCollapsed(event) { diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js index 26c1b0ec7be..079f4a63f6e 100644 --- a/app/assets/javascripts/image_diff/image_diff.js +++ b/app/assets/javascripts/image_diff/image_diff.js @@ -75,9 +75,7 @@ export default class ImageDiff { if (this.renderCommentBadge) { imageDiffHelper.addImageCommentBadge(this.imageFrameEl, options); } else { - const numberBadgeOptions = Object.assign({}, options, { - badgeText: index + 1, - }); + const numberBadgeOptions = { ...options, badgeText: index + 1 }; imageDiffHelper.addImageBadge(this.imageFrameEl, numberBadgeOptions); } @@ -130,7 +128,7 @@ export default class ImageDiff { const updatedBadgeNumber = index; const discussionEl = this.el.querySelector(`#discussion_${discussionId}`); - imageBadgeEls[index].innerText = updatedBadgeNumber; + imageBadgeEls[index].textContent = updatedBadgeNumber; imageDiffHelper.updateDiscussionBadgeNumber(discussionEl, updatedBadgeNumber); imageDiffHelper.updateDiscussionAvatarBadgeNumber(discussionEl, updatedBadgeNumber); diff --git a/app/assets/javascripts/import_projects/event_hub.js b/app/assets/javascripts/import_projects/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/import_projects/event_hub.js +++ b/app/assets/javascripts/import_projects/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 1ffd5c61282..d6b519f7eac 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import { escape } from 'lodash'; import { __, sprintf } from './locale'; import axios from './lib/utils/axios_utils'; import flash from './flash'; @@ -73,9 +73,9 @@ class ImporterStatus { const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing'); job.find('.import-actions').html( sprintf( - _.escape(__('%{loadingIcon} Started')), + escape(__('%{loadingIcon} Started')), { - loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${_.escape( + loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${escape( connectingVerb, )}"></i>`, }, diff --git a/app/assets/javascripts/integrations/edit/components/active_toggle.vue b/app/assets/javascripts/integrations/edit/components/active_toggle.vue index 2b0aa2586e4..8b95b04d93c 100644 --- a/app/assets/javascripts/integrations/edit/components/active_toggle.vue +++ b/app/assets/javascripts/integrations/edit/components/active_toggle.vue @@ -12,10 +12,6 @@ export default { type: Boolean, required: true, }, - disabled: { - type: Boolean, - required: true, - }, }, data() { return { @@ -41,12 +37,7 @@ export default { <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"> - <gl-toggle - v-model="activated" - :disabled="disabled" - name="service[active]" - @change="onToggle" - /> + <gl-toggle v-model="activated" name="service[active]" @change="onToggle" /> </div> </div> </div> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue new file mode 100644 index 00000000000..fbe58c30b13 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -0,0 +1,50 @@ +<script> +import ActiveToggle from './active_toggle.vue'; +import JiraTriggerFields from './jira_trigger_fields.vue'; +import TriggerFields from './trigger_fields.vue'; + +export default { + name: 'IntegrationForm', + components: { + ActiveToggle, + JiraTriggerFields, + TriggerFields, + }, + props: { + activeToggleProps: { + type: Object, + required: true, + }, + showActive: { + type: Boolean, + required: true, + }, + triggerFieldsProps: { + type: Object, + required: true, + }, + triggerEvents: { + type: Array, + required: false, + default: () => [], + }, + type: { + type: String, + required: true, + }, + }, + computed: { + isJira() { + return this.type === 'jira'; + }, + }, +}; +</script> + +<template> + <div> + <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" /> + </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 new file mode 100644 index 00000000000..70278e401ce --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -0,0 +1,99 @@ +<script> +import { GlFormCheckbox, GlFormRadio } from '@gitlab/ui'; + +export default { + name: 'JiraTriggerFields', + components: { + GlFormCheckbox, + GlFormRadio, + }, + props: { + initialTriggerCommit: { + type: Boolean, + required: true, + }, + initialTriggerMergeRequest: { + type: Boolean, + required: true, + }, + initialEnableComments: { + type: Boolean, + required: true, + }, + initialCommentDetail: { + type: String, + required: false, + default: 'standard', + }, + }, + data() { + return { + triggerCommit: this.initialTriggerCommit, + triggerMergeRequest: this.initialTriggerMergeRequest, + enableComments: this.initialEnableComments, + commentDetail: this.initialCommentDetail, + }; + }, +}; +</script> + +<template> + <div 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"> + {{ + s__( + 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) will be created.', + ) + }} + </label> + + <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> + + <div + v-show="triggerCommit || triggerMergeRequest" + class="mt-4" + data-testid="comment-settings" + > + <label> + {{ s__('Integrations|Comment settings:') }} + </label> + <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> + + <div v-show="enableComments" class="mt-4" data-testid="comment-detail"> + <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') }} + <template #help> + {{ + s__( + 'Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs', + ) + }} + </template> + </gl-form-radio> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue new file mode 100644 index 00000000000..531490ae40c --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue @@ -0,0 +1,73 @@ +<script> +import { startCase } from 'lodash'; +import { __ } from '~/locale'; +import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; + +const typeWithPlaceholder = { + SLACK: 'slack', + MATTERMOST: 'mattermost', +}; + +const placeholderForType = { + [typeWithPlaceholder.SLACK]: __('Slack channels (e.g. general, development)'), + [typeWithPlaceholder.MATTERMOST]: __('Channel handle (e.g. town-square)'), +}; + +export default { + name: 'TriggerFields', + components: { + GlFormGroup, + GlFormCheckbox, + GlFormInput, + }, + props: { + events: { + type: Array, + required: false, + default: null, + }, + type: { + type: String, + required: true, + }, + }, + computed: { + placeholder() { + return placeholderForType[this.type]; + }, + }, + methods: { + checkboxName(name) { + return `service[${name}]`; + }, + fieldName(name) { + return `service[${name}]`; + }, + startCase, + }, +}; +</script> + +<template> + <gl-form-group + class="gl-pt-3" + :label="__('Trigger')" + label-for="trigger-fields" + data-testid="trigger-fields-group" + > + <div id="trigger-fields" class="gl-pt-3"> + <gl-form-group v-for="event in events" :key="event.title" :description="event.description"> + <input :name="checkboxName(event.name)" type="hidden" value="false" /> + <gl-form-checkbox v-model="event.value" :name="checkboxName(event.name)"> + {{ startCase(event.title) }} + </gl-form-checkbox> + <gl-form-input + v-if="event.field" + v-model="event.field.value" + :name="fieldName(event.field.name)" + :placeholder="placeholder" + /> + </gl-form-group> + </div> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/integrations/edit/event_hub.js b/app/assets/javascripts/integrations/edit/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/integrations/edit/event_hub.js +++ b/app/assets/javascripts/integrations/edit/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index a2ba581d429..2ae1342a558 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -1,28 +1,46 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; -import ActiveToggle from './components/active_toggle.vue'; +import IntegrationForm from './components/integration_form.vue'; export default el => { if (!el) { return null; } - const { showActive: showActiveStr, activated: activatedStr, disabled: disabledStr } = el.dataset; - const showActive = parseBoolean(showActiveStr); - const activated = parseBoolean(activatedStr); - const disabled = parseBoolean(disabledStr); - - if (!showActive) { - return null; + function parseBooleanInData(data) { + const result = {}; + Object.entries(data).forEach(([key, value]) => { + result[key] = parseBoolean(value); + }); + return result; } + const { type, commentDetail, triggerEvents, ...booleanAttributes } = el.dataset; + const { + showActive, + activated, + commitEvents, + mergeRequestEvents, + enableComments, + } = parseBooleanInData(booleanAttributes); + return new Vue({ el, render(createElement) { - return createElement(ActiveToggle, { + return createElement(IntegrationForm, { props: { - initialActivated: activated, - disabled, + activeToggleProps: { + initialActivated: activated, + }, + showActive, + type, + triggerFieldsProps: { + initialTriggerCommit: commitEvents, + initialTriggerMergeRequest: mergeRequestEvents, + initialEnableComments: enableComments, + initialCommentDetail: commentDetail, + }, + triggerEvents: JSON.parse(triggerEvents), }, }); }, diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index 45de287d44d..95e10cc75cc 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -1,7 +1,7 @@ /* eslint-disable consistent-return, func-names, array-callback-return */ import $ from 'jquery'; -import _ from 'underscore'; +import { intersection } from 'lodash'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; import { __ } from './locale'; @@ -111,7 +111,7 @@ export default { this.getElement('.selected-issuable:checked').each((i, el) => { labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); }); - return _.intersection.apply(this, labelIds); + return intersection.apply(this, labelIds); }, // From issuable's initial bulk selection @@ -120,7 +120,7 @@ export default { this.getElement('.selected-issuable:checked').each((i, el) => { labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); }); - return _.intersection.apply(this, labelIds); + return intersection.apply(this, labelIds); }, // From issuable's initial bulk selection @@ -144,7 +144,7 @@ export default { // Add uniqueIds to add it as argument for _.intersection labelIds.unshift(uniqueIds); // Return IDs that are present but not in all selected issueables - return _.difference(uniqueIds, _.intersection.apply(this, labelIds)); + return uniqueIds.filter(x => !intersection.apply(this, labelIds).includes(x)); }, getElement(selector) { diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index bd6e8433544..50562688c53 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this, no-new */ import $ from 'jquery'; -import { property } from 'underscore'; +import { property } from 'lodash'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import MilestoneSelect from './milestone_select'; import issueStatusSelect from './issue_status_select'; diff --git a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql new file mode 100644 index 00000000000..fe01d2c2e78 --- /dev/null +++ b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql @@ -0,0 +1,15 @@ +#import "~/graphql_shared/fragments/author.fragment.graphql" + +query getProjectIssue($iid: String!, $fullPath: ID!) { + project(fullPath: $fullPath) { + issue(iid: $iid) { + assignees { + nodes { + ...Author + id + state + } + } + } + } +} 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 new file mode 100644 index 00000000000..27a04da9541 --- /dev/null +++ b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue @@ -0,0 +1,96 @@ +<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'; + +export default { + name: 'IssuableListRoot', + components: { + GlAlert, + GlLabel, + }, + props: { + canEdit: { + type: Boolean, + required: true, + }, + isJiraConfigured: { + type: Boolean, + required: true, + }, + issuesPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + isFinishedAlertShowing: true, + isInProgressAlertShowing: true, + jiraImport: {}, + }; + }, + apollo: { + jiraImport: { + query: getIssuesListDetailsQuery, + variables() { + return { + fullPath: this.projectPath, + }; + }, + update: ({ project }) => ({ + isInProgress: isInProgress(project.jiraImportStatus), + isFinished: isFinished(project.jiraImportStatus), + label: calculateJiraImportLabel( + project.jiraImports.nodes, + project.issues.nodes.flatMap(({ labels }) => labels.nodes), + ), + }), + skip() { + return !this.isJiraConfigured || !this.canEdit; + }, + }, + }, + computed: { + labelTarget() { + return `${this.issuesPath}?label_name[]=${encodeURIComponent(this.jiraImport.label.title)}`; + }, + shouldShowFinishedAlert() { + return this.isFinishedAlertShowing && this.jiraImport.isFinished; + }, + shouldShowInProgressAlert() { + return this.isInProgressAlertShowing && this.jiraImport.isInProgress; + }, + }, + methods: { + hideFinishedAlert() { + this.isFinishedAlertShowing = false; + }, + hideInProgressAlert() { + this.isInProgressAlertShowing = false; + }, + }, +}; +</script> + +<template> + <div class="issuable-list-root"> + <gl-alert v-if="shouldShowInProgressAlert" @dismiss="hideInProgressAlert"> + {{ __('Import in progress. Refresh page to see newly added issues.') }} + </gl-alert> + <gl-alert v-if="shouldShowFinishedAlert" variant="success" @dismiss="hideFinishedAlert"> + {{ __('Issues successfully imported with the label') }} + <gl-label + :background-color="jiraImport.label.color" + scoped + size="sm" + :target="labelTarget" + :title="jiraImport.label.title" + /> + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/issuables_list/eventhub.js b/app/assets/javascripts/issuables_list/eventhub.js index d1601a7d8f3..e31806ad199 100644 --- a/app/assets/javascripts/issuables_list/eventhub.js +++ b/app/assets/javascripts/issuables_list/eventhub.js @@ -1,5 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -const issueablesEventBus = new Vue(); - -export default issueablesEventBus; +export default createEventHub(); diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js index 9fc7fa837ff..6bfb885a8af 100644 --- a/app/assets/javascripts/issuables_list/index.js +++ b/app/assets/javascripts/issuables_list/index.js @@ -1,24 +1,63 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import IssuableListRootApp from './components/issuable_list_root_app.vue'; import IssuablesListApp from './components/issuables_list_app.vue'; -export default function initIssuablesList() { - if (!gon.features || !gon.features.vueIssuablesList) { +function mountIssuableListRootApp() { + const el = document.querySelector('.js-projects-issues-root'); + + if (!el) { + return false; + } + + Vue.use(VueApollo); + + const defaultClient = createDefaultClient(); + const apolloProvider = new VueApollo({ + defaultClient, + }); + + return new Vue({ + el, + apolloProvider, + render(createComponent) { + return createComponent(IssuableListRootApp, { + props: { + canEdit: parseBoolean(el.dataset.canEdit), + isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured), + issuesPath: el.dataset.issuesPath, + projectPath: el.dataset.projectPath, + }, + }); + }, + }); +} + +function mountIssuablesListApp() { + if (!gon.features?.vueIssuablesList) { return; } document.querySelectorAll('.js-issuables-list').forEach(el => { const { canBulkEdit, ...data } = el.dataset; - const props = { - ...data, - canBulkEdit: Boolean(canBulkEdit), - }; - return new Vue({ el, render(createElement) { - return createElement(IssuablesListApp, { props }); + return createElement(IssuablesListApp, { + props: { + ...data, + canBulkEdit: Boolean(canBulkEdit), + }, + }); }, }); }); } + +export default function initIssuablesList() { + mountIssuableListRootApp(); + mountIssuablesListApp(); +} diff --git a/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql new file mode 100644 index 00000000000..b62b9b2af60 --- /dev/null +++ b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql @@ -0,0 +1,22 @@ +#import "~/jira_import/queries/jira_import.fragment.graphql" + +query($fullPath: ID!) { + project(fullPath: $fullPath) { + issues { + nodes { + labels { + nodes { + title + color + } + } + } + } + jiraImportStatus + jiraImports { + nodes { + ...JiraImport + } + } + } +} diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 9136a47d542..f0967e77faf 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -12,6 +12,8 @@ export default class Issue { constructor() { if ($('a.btn-close').length) this.initIssueBtnEventListeners(); + if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener(); + Issue.$btnNewBranch = $('#new-branch'); Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); @@ -89,7 +91,7 @@ export default class Issue { return $(document).on( 'click', - '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', + '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen, a.btn-close-anyway', e => { e.preventDefault(); e.stopImmediatePropagation(); @@ -99,19 +101,30 @@ export default class Issue { Issue.submitNoteForm($button.closest('form')); } - this.disableCloseReopenButton($button); - - const url = $button.attr('href'); - return axios - .put(url) - .then(({ data }) => { - const isClosed = $button.hasClass('btn-close'); - this.updateTopState(isClosed, data); - }) - .catch(() => flash(issueFailMessage)) - .then(() => { - this.disableCloseReopenButton($button, false); - }); + const shouldDisplayBlockedWarning = $button.hasClass('btn-issue-blocked'); + const warningBanner = $('.js-close-blocked-issue-warning'); + if (shouldDisplayBlockedWarning) { + this.toggleWarningAndCloseButton(); + } else { + this.disableCloseReopenButton($button); + + const url = $button.attr('href'); + return axios + .put(url) + .then(({ data }) => { + const isClosed = $button.is('.btn-close, .btn-close-anyway'); + this.updateTopState(isClosed, data); + if ($button.hasClass('btn-close-anyway')) { + warningBanner.addClass('hidden'); + if (this.closeReopenReportToggle) + $('.js-issuable-close-dropdown').removeClass('hidden'); + } + }) + .catch(() => flash(issueFailMessage)) + .then(() => { + this.disableCloseReopenButton($button, false); + }); + } }, ); } @@ -137,6 +150,23 @@ export default class Issue { this.reopenButtons.toggleClass('hidden', !isClosed); } + toggleWarningAndCloseButton() { + const warningBanner = $('.js-close-blocked-issue-warning'); + warningBanner.toggleClass('hidden'); + $('.btn-close').toggleClass('hidden'); + if (this.closeReopenReportToggle) { + $('.js-issuable-close-dropdown').toggleClass('hidden'); + } + } + + initIssueWarningBtnEventListener() { + return $(document).on('click', '.js-close-blocked-issue-warning button.btn-secondary', e => { + e.preventDefault(); + e.stopImmediatePropagation(); + this.toggleWarningAndCloseButton(); + }); + } + 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 b8b3a4f44fd..8cf2cda64a4 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -295,7 +295,7 @@ export default { .then(res => res.data) .then(data => this.checkForSpam(data)) .then(data => { - if (window.location.pathname !== data.web_url) { + if (!window.location.pathname.includes(data.web_url)) { visitUrl(data.web_url); } }) @@ -329,7 +329,7 @@ export default { }, deleteIssuable(payload) { - this.service + return this.service .deleteIssuable(payload) .then(res => res.data) .then(data => { @@ -340,7 +340,7 @@ export default { }) .catch(() => { createFlash( - sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }), + sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }), ); }); }, @@ -365,7 +365,12 @@ export default { :issuable-type="issuableType" /> - <recaptcha-modal v-show="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptchaModal" /> + <recaptcha-modal + v-show="showRecaptcha" + ref="recaptchaModal" + :html="recaptchaHTML" + @close="closeRecaptchaModal" + /> </div> <div v-else> <title-component diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 447d7bf21a5..35165c9b481 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -45,22 +45,24 @@ export default { :markdown-docs-path="markdownDocsPath" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" + :textarea-value="formState.description" > - <textarea - id="issue-description" - ref="textarea" - slot="textarea" - v-model="formState.description" - class="note-textarea js-gfm-input js-autosize markdown-area - qa-description-textarea" - dir="auto" - data-supports-quick-actions="false" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" - @keydown.meta.enter="updateIssuable" - @keydown.ctrl.enter="updateIssuable" - > - </textarea> + <template #textarea> + <textarea + id="issue-description" + ref="textarea" + v-model="formState.description" + class="note-textarea js-gfm-input js-autosize markdown-area + qa-description-textarea" + dir="auto" + data-supports-quick-actions="false" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files here…')" + @keydown.meta.enter="updateIssuable" + @keydown.ctrl.enter="updateIssuable" + > + </textarea> + </template> </markdown-field> </div> </template> diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/issue_show/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/issue_show/event_hub.js +++ b/app/assets/javascripts/issue_show/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); 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 b71c06e4217..d1570f52c8c 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_app.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue @@ -1,5 +1,6 @@ <script> -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +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'; @@ -13,6 +14,7 @@ export default { components: { GlAlert, GlLoadingIcon, + GlSprintf, JiraImportForm, JiraImportProgress, JiraImportSetup, @@ -30,6 +32,10 @@ export default { type: String, required: true, }, + jiraIntegrationPath: { + type: String, + required: true, + }, jiraProjects: { type: Array, required: true, @@ -47,6 +53,7 @@ export default { return { errorMessage: '', showAlert: false, + selectedProject: undefined, }; }, apollo: { @@ -59,7 +66,7 @@ export default { }, update: ({ project }) => ({ status: project.jiraImportStatus, - import: project.jiraImports.nodes[0], + imports: project.jiraImports.nodes, }), skip() { return !this.isJiraConfigured; @@ -73,6 +80,24 @@ export default { 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?.( + (acc, jiraProject) => (jiraProject.jiraProjectKey === this.selectedProject ? acc + 1 : acc), + 0, + ); + }, + importLabel() { + return this.selectedProject + ? `jira-import::${this.selectedProject}-${this.numberOfPreviousImportsForProject + 1}` + : 'jira-import::KEY-1'; + }, + hasPreviousImports() { + return this.numberOfPreviousImportsForProject > 0; + }, }, methods: { dismissAlert() { @@ -93,6 +118,13 @@ export default { return; } + const cacheData = store.readQuery({ + query: getJiraImportDetailsQuery, + variables: { + fullPath: this.projectPath, + }, + }); + store.writeQuery({ query: getJiraImportDetailsQuery, variables: { @@ -102,7 +134,10 @@ export default { project: { jiraImportStatus: IMPORT_STATE.SCHEDULED, jiraImports: { - nodes: [data.jiraImportStart.jiraImport], + nodes: [ + ...cacheData.project.jiraImports.nodes, + data.jiraImportStart.jiraImport, + ], __typename: 'JiraImportConnection', }, // eslint-disable-next-line @gitlab/require-i18n-strings @@ -115,6 +150,8 @@ export default { .then(({ data }) => { if (data.jiraImportStart.errors.length) { this.setAlertMessage(data.jiraImportStart.errors.join('. ')); + } else { + this.selectedProject = undefined; } }) .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.'))); @@ -132,19 +169,38 @@ export default { <gl-alert v-if="showAlert" variant="danger" @dismiss="dismissAlert"> {{ 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> + </gl-alert> - <jira-import-setup v-if="!isJiraConfigured" :illustration="setupIllustration" /> + <jira-import-setup + v-if="!isJiraConfigured" + :illustration="setupIllustration" + :jira-integration-path="jiraIntegrationPath" + /> <gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" /> <jira-import-progress v-else-if="isImportInProgress" :illustration="inProgressIllustration" - :import-initiator="jiraImportDetails.import.scheduledBy.name" - :import-project="jiraImportDetails.import.jiraProjectKey" - :import-time="jiraImportDetails.import.scheduledAt" + :import-initiator="mostRecentImport.scheduledBy.name" + :import-project="mostRecentImport.jiraProjectKey" + :import-time="mostRecentImport.scheduledAt" :issues-path="issuesPath" /> <jira-import-form v-else + v-model="selectedProject" + :import-label="importLabel" :issues-path="issuesPath" :jira-projects="jiraProjectsOptions" @initiateJiraImport="initiateJiraImport" diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index 0146f564260..c2fe7b29c28 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -13,6 +13,10 @@ export default { currentUserAvatarUrl: gon.current_user_avatar_url, currentUsername: gon.current_username, props: { + importLabel: { + type: String, + required: true, + }, issuesPath: { type: String, required: true, @@ -21,21 +25,25 @@ export default { type: Array, required: true, }, + value: { + type: String, + required: false, + default: undefined, + }, }, data() { return { - selectedOption: null, selectState: null, }; }, methods: { initiateJiraImport(event) { event.preventDefault(); - if (!this.selectedOption) { - this.showValidationError(); - } else { + if (this.value) { this.hideValidationError(); - this.$emit('initiateJiraImport', this.selectedOption); + this.$emit('initiateJiraImport', this.value); + } else { + this.showValidationError(); } }, hideValidationError() { @@ -62,10 +70,11 @@ export default { > <gl-form-select id="jira-project-select" - v-model="selectedOption" class="mb-2" :options="jiraProjects" :state="selectState" + :value="value" + @change="$emit('input', $event)" /> </gl-form-group> @@ -79,7 +88,7 @@ export default { id="jira-project-label" class="mb-2" background-color="#428BCA" - title="jira-import::KEY-1" + :title="importLabel" scoped /> </gl-form-group> diff --git a/app/assets/javascripts/jira_import/components/jira_import_progress.vue b/app/assets/javascripts/jira_import/components/jira_import_progress.vue index 2d610224658..78f10decd31 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_progress.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_progress.vue @@ -46,6 +46,9 @@ export default { importTime: formatDate(this.importTime), }); }, + issuesLink() { + return `${this.issuesPath}?search=${this.importProject}`; + }, }, }; </script> @@ -55,7 +58,7 @@ export default { :svg-path="illustration" :title="__('Import in progress')" :primary-button-text="__('View issues')" - :primary-button-link="issuesPath" + :primary-button-link="issuesLink" > <template #description> <p class="mb-0">{{ importInitiatorText }}</p> diff --git a/app/assets/javascripts/jira_import/components/jira_import_setup.vue b/app/assets/javascripts/jira_import/components/jira_import_setup.vue index 44773a773d5..285c5c815ac 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_setup.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_setup.vue @@ -11,6 +11,10 @@ export default { type: String, required: true, }, + jiraIntegrationPath: { + type: String, + required: true, + }, }, }; </script> @@ -21,6 +25,6 @@ export default { title="" :description="__('You will first need to set up Jira Integration to use this feature.')" :primary-button-text="__('Set up Jira Integration')" - primary-button-link="../services/jira/edit" + :primary-button-link="jiraIntegrationPath" /> </template> diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index 8bd70e4e277..b576668fe7c 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -27,6 +27,7 @@ export default function mountJiraImportApp() { inProgressIllustration: el.dataset.inProgressIllustration, 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 0eaaad580fc..aa8d03c7f17 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 @@ -3,7 +3,7 @@ query($fullPath: ID!) { project(fullPath: $fullPath) { jiraImportStatus - jiraImports(last: 1) { + jiraImports { nodes { ...JiraImport } diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils.js index 504cf19e44e..aa10dfc8099 100644 --- a/app/assets/javascripts/jira_import/utils.js +++ b/app/assets/javascripts/jira_import/utils.js @@ -1,3 +1,5 @@ +import { last } from 'lodash'; + export const IMPORT_STATE = { FAILED: 'failed', FINISHED: 'finished', @@ -8,3 +10,50 @@ export const IMPORT_STATE = { export const isInProgress = state => state === IMPORT_STATE.SCHEDULED || state === IMPORT_STATE.STARTED; + +export const isFinished = state => state === IMPORT_STATE.FINISHED; + +/** + * Calculates the label title for the most recent Jira import. + * + * @param {Object[]} jiraImports - List of Jira imports + * @param {string} jiraImports[].jiraProjectKey - Jira project key + * @returns {string} - A label title + */ +const calculateJiraImportLabelTitle = jiraImports => { + const mostRecentJiraProjectKey = last(jiraImports)?.jiraProjectKey; + const jiraProjectImportCount = jiraImports.filter( + jiraImport => jiraImport.jiraProjectKey === mostRecentJiraProjectKey, + ).length; + return `jira-import::${mostRecentJiraProjectKey}-${jiraProjectImportCount}`; +}; + +/** + * Finds the label color from a list of labels. + * + * @param {string} labelTitle - Label title + * @param {Object[]} labels - List of labels + * @param {string} labels[].title - Label title + * @param {string} labels[].color - Label color + * @returns {string} - The label color associated with the given labelTitle + */ +const calculateJiraImportLabelColor = (labelTitle, labels) => + labels.find(label => label.title === labelTitle)?.color; + +/** + * Calculates the label for the most recent Jira import. + * + * @param {Object[]} jiraImports - List of Jira imports + * @param {string} jiraImports[].jiraProjectKey - Jira project key + * @param {Object[]} labels - List of labels + * @param {string} labels[].title - Label title + * @param {string} labels[].color - Label color + * @returns {{color: string, title: string}} - A label object containing a label color and title + */ +export const calculateJiraImportLabel = (jiraImports, labels) => { + const title = calculateJiraImportLabelTitle(jiraImports); + return { + color: calculateJiraImportLabelColor(title, labels), + title, + }; +}; diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index d9168f57cc7..28cc03c88cb 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -1,5 +1,5 @@ <script> -import { escape as esc, isEmpty } from 'lodash'; +import { escape, isEmpty } from 'lodash'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { sprintf, __ } from '../../locale'; @@ -43,7 +43,7 @@ export default { '%{startLink}%{name}%{endLink}', { startLink: `<a href="${this.deploymentStatus.environment.environment_path}" class="js-environment-link">`, - name: esc(this.deploymentStatus.environment.name), + name: escape(this.deploymentStatus.environment.name), endLink: '</a>', }, false, @@ -74,8 +74,8 @@ export default { } const { name, path } = this.deploymentCluster; - const escapedName = esc(name); - const escapedPath = esc(path); + const escapedName = escape(name); + const escapedPath = escape(path); if (!escapedPath) { return escapedName; diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index f4030939f2c..0ce8dfe4442 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -220,7 +220,7 @@ export const fetchJobsForStage = ({ dispatch }, stage = {}) => { }, }) .then(({ data }) => { - const retriedJobs = data.retried.map(job => Object.assign({}, job, { retried: true })); + const retriedJobs = data.retried.map(job => ({ ...job, retried: true })); const jobs = data.latest_statuses.concat(retriedJobs); dispatch('receiveJobsForStageSuccess', jobs); @@ -236,7 +236,7 @@ export const receiveJobsForStageError = ({ commit }) => { export const triggerManualJob = ({ state }, variables) => { const parsedVariables = variables.map(variable => { - const copyVar = Object.assign({}, variable); + const copyVar = { ...variable }; delete copyVar.id; return copyVar; }); diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js index 5a61828ec6d..d76828ad19b 100644 --- a/app/assets/javascripts/jobs/store/state.js +++ b/app/assets/javascripts/jobs/store/state.js @@ -1,4 +1,4 @@ -import { isNewJobLogActive } from '../store/utils'; +import { isNewJobLogActive } from './utils'; export default () => ({ jobEndpoint: null, diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 7107c970457..65d8866fcc3 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -1,9 +1,9 @@ -/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-else-return, dot-notation, no-empty */ +/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, dot-notation, no-empty */ /* global Issuable */ /* global ListLabel */ import $ from 'jquery'; -import _ from 'underscore'; +import { isEqual, escape, sortBy, template } from 'lodash'; import { sprintf, s__, __ } from './locale'; import axios from './lib/utils/axios_utils'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; @@ -55,7 +55,6 @@ export default class LabelsSelect { }) .get(); const scopedLabels = $dropdown.data('scopedLabels'); - const scopedLabelsDocumentationLink = $dropdown.data('scopedLabelsDocumentationLink'); const { handleClick } = options; $sidebarLabelTooltip.tooltip(); @@ -76,7 +75,7 @@ export default class LabelsSelect { }) .get(); - if (_.isEqual(initialSelected, selected)) return; + if (isEqual(initialSelected, selected)) return; initialSelected = selected; const data = {}; @@ -101,10 +100,9 @@ export default class LabelsSelect { let labelCount = 0; if (data.labels.length && issueUpdateURL) { template = LabelsSelect.getLabelTemplate({ - labels: _.sortBy(data.labels, 'title'), + labels: sortBy(data.labels, 'title'), issueUpdateURL, enableScopedLabels: scopedLabels, - scopedLabelsDocumentationLink, }); labelCount = data.labels.length; @@ -188,13 +186,13 @@ export default class LabelsSelect { if (showNo) { extraData.unshift({ id: 0, - title: __('No Label'), + title: __('No label'), }); } if (showAny) { extraData.unshift({ isAny: true, - title: __('Any Label'), + title: __('Any label'), }); } if (extraData.length) { @@ -269,7 +267,7 @@ export default class LabelsSelect { } linkEl.className = selectedClass.join(' '); - linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`; + linkEl.innerHTML = `${colorEl} ${escape(label.title)}`; const listItemEl = document.createElement('li'); listItemEl.appendChild(linkEl); @@ -296,7 +294,7 @@ export default class LabelsSelect { if (selected && selected.id === 0) { this.selected = []; - return __('No Label'); + return __('No label'); } else if (isSelected) { this.selected.push(title); } else if (!isSelected && title) { @@ -311,9 +309,8 @@ export default class LabelsSelect { firstLabel: selectedLabels[0], labelCount: selectedLabels.length - 1, }); - } else { - return defaultLabel; } + return defaultLabel; }, fieldName: $dropdown.data('fieldName'), id(label) { @@ -325,9 +322,8 @@ export default class LabelsSelect { if ($dropdown.hasClass('js-filter-submit') && label.isAny == null) { return label.title; - } else { - return label.id; } + return label.id; }, hidden() { const page = $('body').attr('data-page'); @@ -436,7 +432,7 @@ export default class LabelsSelect { if (isScopedLabel(label)) { const prevIds = oldLabels.map(label => label.id); const newIds = boardsStore.detail.issue.labels.map(label => label.id); - const differentIds = _.difference(prevIds, newIds); + const differentIds = prevIds.filter(x => !newIds.includes(x)); $dropdown.data('marked', newIds); $dropdownMenu .find(differentIds.map(id => `[data-label-id="${id}"]`).join(',')) @@ -483,7 +479,7 @@ export default class LabelsSelect { '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>" class="gl-link gl-label-link has-tooltip" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>">'; const spanOpenTag = '<span class="gl-label-text" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">'; - const labelTemplate = _.template( + const labelTemplate = template( [ '<span class="gl-label">', linkOpenTag, @@ -499,15 +495,7 @@ export default class LabelsSelect { return escapeStr(label.text_color === '#FFFFFF' ? label.color : label.text_color); }; - const infoIconTemplate = _.template( - [ - '<a href="<%= scopedLabelsDocumentationLink %>" class="gl-link gl-label-icon" target="_blank" rel="noopener">', - '<i class="fa fa-question-circle"></i>', - '</a>', - ].join(''), - ); - - const scopedLabelTemplate = _.template( + const scopedLabelTemplate = template( [ '<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>;">', linkOpenTag, @@ -518,12 +506,11 @@ export default class LabelsSelect { '<%- label.title.slice(label.title.lastIndexOf("::") + 2) %>', '</span>', '</a>', - '<%= infoIconTemplate({ label, scopedLabelsDocumentationLink, escapeStr }) %>', '</span>', ].join(''), ); - const tooltipTitleTemplate = _.template( + const tooltipTitleTemplate = template( [ '<% if (isScopedLabel(label) && enableScopedLabels) { %>', "<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>", @@ -535,12 +522,12 @@ export default class LabelsSelect { ].join(''), ); - const tpl = _.template( + const tpl = template( [ - '<% _.each(labels, function(label){ %>', + '<% labels.forEach(function(label){ %>', '<% if (isScopedLabel(label) && enableScopedLabels) { %>', '<span class="d-inline-block position-relative scoped-label-wrapper">', - '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, infoIconTemplate, scopedLabelsDocumentationLink, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', + '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', '</span>', '<% } else { %>', '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>', @@ -553,11 +540,10 @@ export default class LabelsSelect { ...tplData, labelTemplate, rightLabelTextColor, - infoIconTemplate, scopedLabelTemplate, tooltipTitleTemplate, isScopedLabel, - escapeStr: _.escape, + escapeStr: escape, }); } diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 8d3b87d5cc0..b6c41ffa7ab 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -33,6 +33,7 @@ export default (resolvers = {}, config = {}) => { }; return new ApolloClient({ + typeDefs: config.typeDefs, link: ApolloLink.split( operation => operation.getContext().hasUpload || operation.getContext().isSingleRequest, createUploadLink(httpOptions), diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index f6077673ad5..6b69d2febe0 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -57,6 +57,19 @@ export const getMonthNames = abbreviated => { export const pad = (val, len = 2) => `0${val}`.slice(-len); /** + * Returns i18n weekday names array. + */ +export const getWeekdayNames = () => [ + __('Sunday'), + __('Monday'), + __('Tuesday'), + __('Wednesday'), + __('Thursday'), + __('Friday'), + __('Saturday'), +]; + +/** * Given a date object returns the day of the week in English * @param {date} date * @returns {String} diff --git a/app/assets/javascripts/lib/utils/downloader.js b/app/assets/javascripts/lib/utils/downloader.js new file mode 100644 index 00000000000..2297f5f90ce --- /dev/null +++ b/app/assets/javascripts/lib/utils/downloader.js @@ -0,0 +1,20 @@ +/** + * Helper function to trigger a download. + * + * - If the `fileName` is `_blank` it will open the file in a new tab. + * - If `fileData` is provided, it will inline the content and use data URLs to + * download the file. In this case the `url` property will be ignored. Please + * note that `fileData` needs to be Base64 encoded. + */ +export default ({ fileName, url, fileData }) => { + let href = url; + + if (fileData) { + href = `data:text/plain;base64,${fileData}`; + } + + const anchor = document.createElement('a'); + anchor.download = fileName; + anchor.href = href; + anchor.click(); +}; diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js index 16bffc5c2cf..618266f7a09 100644 --- a/app/assets/javascripts/lib/utils/keycodes.js +++ b/app/assets/javascripts/lib/utils/keycodes.js @@ -1,3 +1,6 @@ +// `e.keyCode` is deprecated, these values should be migrated +// See: https://gitlab.com/gitlab-org/gitlab/-/issues/216102 + export const BACKSPACE_KEY_CODE = 8; export const ENTER_KEY_CODE = 13; export const ESC_KEY_CODE = 27; diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js new file mode 100644 index 00000000000..8e5420e87ea --- /dev/null +++ b/app/assets/javascripts/lib/utils/keys.js @@ -0,0 +1,4 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +export const ESC_KEY = 'Escape'; +export const ESC_KEY_IE11 = 'Esc'; // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index cccf9ad311c..0dfc144c363 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-param-reassign, operator-assignment, no-else-return, consistent-return */ +/* eslint-disable func-names, no-param-reassign, operator-assignment, consistent-return */ import $ from 'jquery'; import { insertText } from '~/lib/utils/common_utils'; @@ -217,16 +217,15 @@ export function insertMarkdownText({ } if (val.indexOf(tag) === 0) { return String(val.replace(tag, '')); - } else { - return String(tag) + val; } + return String(tag) + val; }) .join('\n'); } } else if (tag.indexOf(textPlaceholder) > -1) { textToInsert = tag.replace(textPlaceholder, selected); } else { - textToInsert = String(startChar) + tag + selected + (wrap ? tag : ' '); + textToInsert = String(startChar) + tag + selected + (wrap ? tag : ''); } if (removedFirstNewLine) { diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index a495d2040d3..966e6d42b80 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -63,15 +63,22 @@ export function getParameterValues(sParam, url = window.location) { }, []); } -// @param {Object} params - url keys and value to merge -// @param {String} url +/** + * Merges a URL to a set of params replacing value for + * those already present. + * + * Also removes `null` param values from the resulting URL. + * + * @param {Object} params - url keys and value to merge + * @param {String} url + */ export function mergeUrlParams(params, url) { const re = /^([^?#]*)(\?[^#]*)?(.*)/; const merged = {}; - const urlparts = url.match(re); + const [, fullpath, query, fragment] = url.match(re); - if (urlparts[2]) { - urlparts[2] + if (query) { + query .substr(1) .split('&') .forEach(part => { @@ -84,11 +91,15 @@ export function mergeUrlParams(params, url) { Object.assign(merged, params); - const query = Object.keys(merged) + const newQuery = Object.keys(merged) + .filter(key => merged[key] !== null) .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(merged[key])}`) .join('&'); - return `${urlparts[1]}?${query}${urlparts[3]}`; + if (newQuery) { + return `${fullpath}?${newQuery}${fragment}`; + } + return `${fullpath}${fragment}`; } /** @@ -213,12 +224,45 @@ export function getBaseURL() { } /** + * Returns true if url is an absolute URL + * + * @param {String} url + */ +export function isAbsolute(url) { + return /^https?:\/\//.test(url); +} + +/** + * Returns true if url is a root-relative URL + * + * @param {String} url + */ +export function isRootRelative(url) { + return /^\//.test(url); +} + +/** * Returns true if url is an absolute or root-relative URL * * @param {String} url */ export function isAbsoluteOrRootRelative(url) { - return /^(https?:)?\//.test(url); + return isAbsolute(url) || isRootRelative(url); +} + +/** + * Converts a relative path to an absolute or a root relative path depending + * on what is passed as a basePath. + * + * @param {String} path Relative path, eg. ../img/img.png + * @param {String} basePath Absolute or root relative path, eg. /user/project or + * https://gitlab.com/user/project + */ +export function relativePathToAbsolute(path, basePath) { + const absolute = isAbsolute(basePath); + const base = absolute ? basePath : `file:///${basePath}`; + const url = new URL(path, base); + return absolute ? url.href : decodeURIComponent(url.pathname); } /** @@ -259,8 +303,10 @@ export function getWebSocketUrl(path) { export function queryToObject(query) { const removeQuestionMarkFromQuery = String(query).startsWith('?') ? query.slice(1) : query; return removeQuestionMarkFromQuery.split('&').reduce((accumulator, curr) => { - const p = curr.split('='); - accumulator[decodeURIComponent(p[0])] = decodeURIComponent(p[1]); + const [key, value] = curr.split('='); + if (value !== undefined) { + accumulator[decodeURIComponent(key)] = decodeURIComponent(value); + } return accumulator; }, {}); } diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index dd868bb9f4c..ed10c7646a8 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return, no-else-return */ +/* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return */ import $ from 'jquery'; @@ -54,6 +54,7 @@ LineHighlighter.prototype.bindEvents = function() { $fileHolder.on('click', 'a[data-line-number]', this.clickHandler); $fileHolder.on('highlight:line', this.highlightHash); + window.addEventListener('hashchange', e => this.highlightHash(e.target.location.hash)); }; LineHighlighter.prototype.highlightHash = function(newHash) { @@ -127,9 +128,8 @@ LineHighlighter.prototype.hashToRange = function(hash) { const first = parseInt(matches[1], 10); const last = matches[2] ? parseInt(matches[2], 10) : null; return [first, last]; - } else { - return [null, null]; } + return [null, null]; }; // Highlight a single line @@ -152,9 +152,8 @@ LineHighlighter.prototype.highlightRange = function(range) { } return results; - } else { - return this.highlightLine(range[0]); } + return this.highlightLine(range[0]); }; // Set the URL hash string diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js index 7ab4e725d99..b4658a159d7 100644 --- a/app/assets/javascripts/locale/sprintf.js +++ b/app/assets/javascripts/locale/sprintf.js @@ -5,7 +5,7 @@ import { escape } from 'lodash'; @param input (translated) text with parameters (e.g. '%{num_users} users use us') @param {Object} parameters object mapping parameter names to values (e.g. { num_users: 5 }) - @param {Boolean} escapeParameters whether parameter values should be escaped (see http://underscorejs.org/#escape) + @param {Boolean} escapeParameters whether parameter values should be escaped (see https://lodash.com/docs/4.17.15#escape) @returns {String} the text with parameters replaces (e.g. '5 users use us') @see https://ruby-doc.org/core-2.3.3/Kernel.html#method-i-sprintf diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 6c8f6372795..713f57a2b27 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -106,12 +106,14 @@ function deferredInitialisation() { initLogoAnimation(); initUsagePingConsent(); initUserPopovers(); - initUserTracking(); initBroadcastNotifications(); 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(); addSelectOnFocusBehaviour('.js-select-on-focus'); @@ -187,6 +189,7 @@ document.addEventListener('DOMContentLoaded', () => { if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); + initUserTracking(); initLayoutNav(); // Set the default path for all cookies to GitLab's root directory diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js index 0dabb28ea66..ef7d8cc9efe 100644 --- a/app/assets/javascripts/member_expiration_date.js +++ b/app/assets/javascripts/member_expiration_date.js @@ -29,8 +29,6 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d onSelect(dateText) { $input.val(calendar.toString(dateText)); - $input.trigger('change'); - toggleClearInput.call($input); }, firstDay: gon.first_day_of_week, @@ -49,7 +47,6 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d const calendar = input.data('pikaday'); calendar.setDate(null); - input.trigger('change'); toggleClearInput.call(input); }); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 87de58443e0..1795a0dbdf8 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -1,9 +1,9 @@ /* eslint-disable no-new, class-methods-use-this */ import $ from 'jquery'; -import Vue from 'vue'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; +import createEventHub from '~/helpers/event_hub_factory'; import axios from './lib/utils/axios_utils'; import flash from './flash'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; @@ -93,7 +93,7 @@ export default class MergeRequestTabs { this.pipelinesLoaded = false; this.commitsLoaded = false; this.fixedLayoutPref = null; - this.eventHub = new Vue(); + this.eventHub = createEventHub(); this.setUrl = setUrl !== undefined ? setUrl : true; this.setCurrentAction = this.setCurrentAction.bind(this); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index d15e4ecb537..e14212254a8 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -1,9 +1,9 @@ -/* eslint-disable one-var, no-else-return, no-self-compare, consistent-return, no-param-reassign, no-shadow */ +/* eslint-disable one-var, no-self-compare, consistent-return, no-param-reassign, no-shadow */ /* global Issuable */ /* global ListMilestone */ import $ from 'jquery'; -import _ from 'underscore'; +import { template, escape } from 'lodash'; import { __ } from '~/locale'; import '~/gl_dropdown'; import axios from './lib/utils/axios_utils'; @@ -56,11 +56,11 @@ export default class MilestoneSelect { const $loading = $block.find('.block-loading').fadeOut(); selectedMilestoneDefault = showAny ? '' : null; selectedMilestoneDefault = - showNo && defaultNo ? __('No Milestone') : selectedMilestoneDefault; + showNo && defaultNo ? __('No milestone') : selectedMilestoneDefault; selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault; if (issueUpdateURL) { - milestoneLinkTemplate = _.template( + milestoneLinkTemplate = template( '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>', ); milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`; @@ -74,14 +74,14 @@ export default class MilestoneSelect { extraOptions.push({ id: null, name: null, - title: __('Any Milestone'), + title: __('Any milestone'), }); } if (showNo) { extraOptions.push({ id: -1, - name: __('No Milestone'), - title: __('No Milestone'), + name: __('No milestone'), + title: __('No milestone'), }); } if (showUpcoming) { @@ -106,12 +106,12 @@ export default class MilestoneSelect { if (showMenuAbove) { $dropdown.data('glDropdown').positionMenuAbove(); } - $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active'); + $(`[data-milestone-id="${escape(selectedMilestone)}"] > a`).addClass('is-active'); }), renderRow: milestone => ` - <li data-milestone-id="${_.escape(milestone.name)}"> + <li data-milestone-id="${escape(milestone.name)}"> <a href='#' class='dropdown-menu-milestone-link'> - ${_.escape(milestone.title)} + ${escape(milestone.title)} </a> </li> `, @@ -123,19 +123,17 @@ export default class MilestoneSelect { toggleLabel: (selected, el) => { if (selected && 'id' in selected && $(el).hasClass('is-active')) { return selected.title; - } else { - return defaultLabel; } + return defaultLabel; }, defaultLabel, fieldName: $dropdown.data('fieldName'), - text: milestone => _.escape(milestone.title), + text: milestone => escape(milestone.title), id: milestone => { if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) { return milestone.name; - } else { - return milestone.id; } + return milestone.id; }, hidden: () => { $selectBox.hide(); @@ -148,7 +146,7 @@ export default class MilestoneSelect { selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; } $('a.is-active', $el).removeClass('is-active'); - $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active'); + $(`[data-milestone-id="${escape(selectedMilestone)}"] > a`, $el).addClass('is-active'); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: clickEvent => { @@ -244,13 +242,12 @@ export default class MilestoneSelect { ) .find('span') .text(data.milestone.title); - } else { - $value.html(milestoneLinkNoneTemplate); - return $sidebarCollapsedValue - .attr('data-original-title', __('Milestone')) - .find('span') - .text(__('None')); } + $value.html(milestoneLinkNoneTemplate); + return $sidebarCollapsedValue + .attr('data-original-title', __('Milestone')) + .find('span') + .text(__('None')); }) .catch(() => { // eslint-disable-next-line no-jquery/no-fade diff --git a/app/assets/javascripts/milestones/project_milestone_combobox.vue b/app/assets/javascripts/milestones/project_milestone_combobox.vue new file mode 100644 index 00000000000..19148d6184f --- /dev/null +++ b/app/assets/javascripts/milestones/project_milestone_combobox.vue @@ -0,0 +1,228 @@ +<script> +import { + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownHeader, + GlNewDropdownItem, + GlLoadingIcon, + GlSearchBoxByType, + GlIcon, +} from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import Api from '~/api'; +import createFlash from '~/flash'; +import { intersection, debounce } from 'lodash'; + +export default { + components: { + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownHeader, + GlNewDropdownItem, + GlLoadingIcon, + GlSearchBoxByType, + GlIcon, + }, + model: { + prop: 'preselectedMilestones', + event: 'change', + }, + props: { + projectId: { + type: String, + required: true, + }, + preselectedMilestones: { + type: Array, + default: () => [], + required: false, + }, + extraLinks: { + type: Array, + default: () => [], + required: false, + }, + }, + data() { + return { + searchQuery: '', + projectMilestones: [], + searchResults: [], + selectedMilestones: [], + requestCount: 0, + }; + }, + translations: { + milestone: __('Milestone'), + selectMilestone: __('Select milestone'), + noMilestone: __('No milestone'), + noResultsLabel: __('No matching results'), + searchMilestones: __('Search Milestones'), + }, + computed: { + selectedMilestonesLabel() { + if (this.milestoneTitles.length === 1) { + return this.milestoneTitles[0]; + } + + if (this.milestoneTitles.length > 1) { + const firstMilestoneName = this.milestoneTitles[0]; + const numberOfOtherMilestones = this.milestoneTitles.length - 1; + return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), { + firstMilestoneName, + numberOfOtherMilestones, + }); + } + + return this.$options.translations.noMilestone; + }, + milestoneTitles() { + return this.preselectedMilestones.map(milestone => milestone.title); + }, + dropdownItems() { + return this.searchResults.length ? this.searchResults : this.projectMilestones; + }, + noResults() { + return this.searchQuery.length > 2 && this.searchResults.length === 0; + }, + isLoading() { + return this.requestCount !== 0; + }, + }, + mounted() { + this.fetchMilestones(); + }, + methods: { + fetchMilestones() { + this.requestCount += 1; + + Api.projectMilestones(this.projectId) + .then(({ data }) => { + this.projectMilestones = this.getTitles(data); + this.selectedMilestones = intersection(this.projectMilestones, this.milestoneTitles); + }) + .catch(() => { + createFlash(__('An error occurred while loading milestones')); + }) + .finally(() => { + this.requestCount -= 1; + }); + }, + searchMilestones: debounce(function searchMilestones() { + this.requestCount += 1; + const options = { + search: this.searchQuery, + scope: 'milestones', + }; + + if (this.searchQuery.length < 3) { + this.requestCount -= 1; + this.searchResults = []; + return; + } + + Api.projectSearch(this.projectId, options) + .then(({ data }) => { + const searchResults = this.getTitles(data); + + this.searchResults = searchResults.length ? searchResults : []; + }) + .catch(() => { + createFlash(__('An error occurred while searching for milestones')); + }) + .finally(() => { + this.requestCount -= 1; + }); + }, 100), + toggleMilestoneSelection(clickedMilestone) { + if (!clickedMilestone) return []; + + let milestones = [...this.preselectedMilestones]; + const hasMilestone = this.milestoneTitles.includes(clickedMilestone); + + if (hasMilestone) { + milestones = milestones.filter(({ title }) => title !== clickedMilestone); + } else { + milestones.push({ title: clickedMilestone }); + } + + return milestones; + }, + onMilestoneClicked(clickedMilestone) { + const milestones = this.toggleMilestoneSelection(clickedMilestone); + this.$emit('change', milestones); + + this.selectedMilestones = intersection( + this.projectMilestones, + milestones.map(milestone => milestone.title), + ); + }, + isSelectedMilestone(milestoneTitle) { + return this.selectedMilestones.includes(milestoneTitle); + }, + getTitles(milestones) { + return milestones.filter(({ state }) => state === 'active').map(({ title }) => title); + }, + }, +}; +</script> + +<template> + <gl-new-dropdown> + <template slot="button-content"> + <span ref="buttonText" class="flex-grow-1 ml-1 text-muted">{{ + selectedMilestonesLabel + }}</span> + <gl-icon name="chevron-down" /> + </template> + + <gl-new-dropdown-header> + <span class="text-center d-block">{{ $options.translations.selectMilestone }}</span> + </gl-new-dropdown-header> + + <gl-new-dropdown-divider /> + + <gl-search-box-by-type + v-model.trim="searchQuery" + class="m-2" + :placeholder="this.$options.translations.searchMilestones" + @input="searchMilestones" + /> + + <gl-new-dropdown-item @click="onMilestoneClicked(null)"> + <span :class="{ 'pl-4': true, 'selected-item': selectedMilestones.length === 0 }"> + {{ $options.translations.noMilestone }} + </span> + </gl-new-dropdown-item> + + <gl-new-dropdown-divider /> + + <template v-if="isLoading"> + <gl-loading-icon /> + <gl-new-dropdown-divider /> + </template> + <template v-else-if="noResults"> + <div class="dropdown-item-space"> + <span ref="noResults" class="pl-4">{{ $options.translations.noResultsLabel }}</span> + </div> + <gl-new-dropdown-divider /> + </template> + <template v-else-if="dropdownItems.length"> + <gl-new-dropdown-item + v-for="item in dropdownItems" + :key="item" + role="milestone option" + @click="onMilestoneClicked(item)" + > + <span :class="{ 'pl-4': true, 'selected-item': isSelectedMilestone(item) }"> + {{ item }} + </span> + </gl-new-dropdown-item> + <gl-new-dropdown-divider /> + </template> + + <gl-new-dropdown-item v-for="(item, idx) in extraLinks" :key="idx" :href="item.url"> + <span class="pl-4">{{ item.text }}</span> + </gl-new-dropdown-item> + </gl-new-dropdown> +</template> diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index 2276a723326..986785fdfbe 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import Flash from '~/flash'; @@ -161,7 +161,7 @@ export default class SSHMirror { const $fingerprintsList = this.$hostKeysInformation.find('.js-fingerprints-list'); let fingerprints = ''; sshHostKeys.fingerprints.forEach(fingerprint => { - const escFingerprints = esc(fingerprint.fingerprint); + const escFingerprints = escape(fingerprint.fingerprint); fingerprints += `<code>${escFingerprints}</code>`; }); diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue new file mode 100644 index 00000000000..86a793c854e --- /dev/null +++ b/app/assets/javascripts/monitoring/components/alert_widget.vue @@ -0,0 +1,286 @@ +<script> +import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import createFlash from '~/flash'; +import AlertWidgetForm from './alert_widget_form.vue'; +import AlertsService from '../services/alerts_service'; +import { alertsValidator, queriesValidator } from '../validators'; +import { OPERATORS } from '../constants'; +import { values, get } from 'lodash'; + +export default { + components: { + AlertWidgetForm, + GlBadge, + GlLoadingIcon, + GlIcon, + GlTooltip, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + alertsEndpoint: { + type: String, + required: true, + }, + showLoadingState: { + type: Boolean, + required: false, + default: true, + }, + // { [alertPath]: { alert_attributes } }. Populated from subsequent API calls. + // Includes only the metrics/alerts to be managed by this widget. + alertsToManage: { + type: Object, + required: false, + default: () => ({}), + validator: alertsValidator, + }, + // [{ metric+query_attributes }]. Represents queries (and alerts) we know about + // on intial fetch. Essentially used for reference. + relevantQueries: { + type: Array, + required: true, + validator: queriesValidator, + }, + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + service: null, + errorMessage: null, + isLoading: false, + apiAction: 'create', + }; + }, + i18n: { + alertsCountMsg: s__('PrometheusAlerts|%{count} alerts applied'), + singleFiringMsg: s__('PrometheusAlerts|Firing: %{alert}'), + multipleFiringMsg: s__('PrometheusAlerts|%{firingCount} firing'), + firingAlertsTooltip: s__('PrometheusAlerts|Firing: %{alerts}'), + }, + computed: { + singleAlertSummary() { + return { + message: this.isFiring ? this.$options.i18n.singleFiringMsg : this.thresholds[0], + alert: this.thresholds[0], + }; + }, + multipleAlertsSummary() { + return { + message: this.isFiring + ? `${this.$options.i18n.alertsCountMsg}, ${this.$options.i18n.multipleFiringMsg}` + : this.$options.i18n.alertsCountMsg, + count: this.thresholds.length, + firingCount: this.firingAlerts.length, + }; + }, + shouldShowLoadingIcon() { + return this.showLoadingState && this.isLoading; + }, + thresholds() { + const alertsToManage = Object.keys(this.alertsToManage); + return alertsToManage.map(this.formatAlertSummary); + }, + hasAlerts() { + return Boolean(Object.keys(this.alertsToManage).length); + }, + hasMultipleAlerts() { + return this.thresholds.length > 1; + }, + isFiring() { + return Boolean(this.firingAlerts.length); + }, + firingAlerts() { + return values(this.alertsToManage).filter(alert => + this.passedAlertThreshold(this.getQueryData(alert), alert), + ); + }, + formattedFiringAlerts() { + return this.firingAlerts.map(alert => this.formatAlertSummary(alert.alert_path)); + }, + configuredAlert() { + return this.hasAlerts ? values(this.alertsToManage)[0].metricId : ''; + }, + }, + created() { + this.service = new AlertsService({ alertsEndpoint: this.alertsEndpoint }); + this.fetchAlertData(); + }, + methods: { + fetchAlertData() { + this.isLoading = true; + + const queriesWithAlerts = this.relevantQueries.filter(query => query.alert_path); + + return Promise.all( + queriesWithAlerts.map(query => + this.service + .readAlert(query.alert_path) + .then(alertAttributes => this.setAlert(alertAttributes, query.metricId)), + ), + ) + .then(() => { + this.isLoading = false; + }) + .catch(() => { + createFlash(s__('PrometheusAlerts|Error fetching alert')); + this.isLoading = false; + }); + }, + setAlert(alertAttributes, metricId) { + this.$emit('setAlerts', alertAttributes.alert_path, { ...alertAttributes, metricId }); + }, + removeAlert(alertPath) { + this.$emit('setAlerts', alertPath, null); + }, + formatAlertSummary(alertPath) { + const alert = this.alertsToManage[alertPath]; + const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId); + + return `${alertQuery.label} ${alert.operator} ${alert.threshold}`; + }, + passedAlertThreshold(data, alert) { + const { threshold, operator } = alert; + + switch (operator) { + case OPERATORS.greaterThan: + return data.some(value => value > threshold); + case OPERATORS.lessThan: + return data.some(value => value < threshold); + case OPERATORS.equalTo: + return data.some(value => value === threshold); + default: + return false; + } + }, + getQueryData(alert) { + const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId); + + return get(alertQuery, 'result[0].values', []).map(value => get(value, '[1]', null)); + }, + showModal() { + this.$root.$emit('bv::show::modal', this.modalId); + }, + hideModal() { + this.errorMessage = null; + this.$root.$emit('bv::hide::modal', this.modalId); + }, + handleSetApiAction(apiAction) { + this.apiAction = apiAction; + }, + handleCreate({ operator, threshold, prometheus_metric_id }) { + const newAlert = { operator, threshold, prometheus_metric_id }; + this.isLoading = true; + this.service + .createAlert(newAlert) + .then(alertAttributes => { + this.setAlert(alertAttributes, prometheus_metric_id); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error creating alert'); + this.isLoading = false; + }); + }, + handleUpdate({ alert, operator, threshold }) { + const updatedAlert = { operator, threshold }; + this.isLoading = true; + this.service + .updateAlert(alert, updatedAlert) + .then(alertAttributes => { + this.setAlert(alertAttributes, this.alertsToManage[alert].metricId); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error saving alert'); + this.isLoading = false; + }); + }, + handleDelete({ alert }) { + this.isLoading = true; + this.service + .deleteAlert(alert) + .then(() => { + this.removeAlert(alert); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error deleting alert'); + this.isLoading = false; + }); + }, + }, +}; +</script> + +<template> + <div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden"> + <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" /> + <span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{ + errorMessage + }}</span> + <span + v-else-if="hasAlerts" + ref="alertCurrentSetting" + class="alert-current-setting cursor-pointer d-flex" + @click="showModal" + > + <gl-badge + :variant="isFiring ? 'danger' : 'secondary'" + pill + 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 + :message=" + hasMultipleAlerts ? multipleAlertsSummary.message : singleAlertSummary.message + " + > + <template #alert> + {{ singleAlertSummary.alert }} + </template> + <template #count> + {{ multipleAlertsSummary.count }} + </template> + <template #firingCount> + {{ multipleAlertsSummary.firingCount }} + </template> + </gl-sprintf> + </span> + </gl-badge> + <gl-tooltip v-if="hasMultipleAlerts && isFiring" :target="() => $refs.alertCurrentSetting"> + <gl-sprintf :message="$options.i18n.firingAlertsTooltip"> + <template #alerts> + <div v-for="alert in formattedFiringAlerts" :key="alert.alert_path"> + {{ alert }} + </div> + </template> + </gl-sprintf> + </gl-tooltip> + </span> + <alert-widget-form + ref="widgetForm" + :disabled="isLoading" + :alerts-to-manage="alertsToManage" + :relevant-queries="relevantQueries" + :error-message="errorMessage" + :configured-alert="configuredAlert" + :modal-id="modalId" + @create="handleCreate" + @update="handleUpdate" + @delete="handleDelete" + @cancel="hideModal" + @setAction="handleSetApiAction" + /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue new file mode 100644 index 00000000000..74324daa1e3 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue @@ -0,0 +1,307 @@ +<script> +import { isEmpty, findKey } from 'lodash'; +import Vue from 'vue'; +import { + GlLink, + GlDeprecatedButton, + GlButtonGroup, + GlFormGroup, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlModal, + GlTooltipDirective, +} from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import Translate from '~/vue_shared/translate'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import Icon from '~/vue_shared/components/icon.vue'; +import { alertsValidator, queriesValidator } from '../validators'; +import { OPERATORS } from '../constants'; + +Vue.use(Translate); + +const SUBMIT_ACTION_TEXT = { + create: __('Add'), + update: __('Save'), + delete: __('Delete'), +}; + +const SUBMIT_BUTTON_CLASS = { + create: 'btn-success', + update: 'btn-success', + delete: 'btn-remove', +}; + +export default { + components: { + GlDeprecatedButton, + GlButtonGroup, + GlFormGroup, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlModal, + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + TrackEvent: TrackEventDirective, + }, + mixins: [glFeatureFlagsMixin()], + props: { + disabled: { + type: Boolean, + required: true, + }, + errorMessage: { + type: String, + required: false, + default: '', + }, + configuredAlert: { + type: String, + required: false, + default: '', + }, + alertsToManage: { + type: Object, + required: false, + default: () => ({}), + validator: alertsValidator, + }, + relevantQueries: { + type: Array, + required: true, + validator: queriesValidator, + }, + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + operators: OPERATORS, + operator: null, + threshold: null, + prometheusMetricId: null, + selectedAlert: {}, + alertQuery: '', + }; + }, + computed: { + isValidQuery() { + // TODO: Add query validation check (most likely via http request) + return this.alertQuery.length ? true : null; + }, + currentQuery() { + return this.relevantQueries.find(query => query.metricId === this.prometheusMetricId) || {}; + }, + formDisabled() { + // We need a prometheusMetricId to determine whether we're + // creating/updating/deleting + return this.disabled || !(this.prometheusMetricId || this.isValidQuery); + }, + supportsComputedAlerts() { + return this.glFeatures.prometheusComputedAlerts; + }, + queryDropdownLabel() { + return this.currentQuery.label || s__('PrometheusAlerts|Select query'); + }, + haveValuesChanged() { + return ( + this.operator && + this.threshold === Number(this.threshold) && + (this.operator !== this.selectedAlert.operator || + this.threshold !== this.selectedAlert.threshold) + ); + }, + submitAction() { + if (isEmpty(this.selectedAlert)) return 'create'; + if (this.haveValuesChanged) return 'update'; + return 'delete'; + }, + submitActionText() { + return SUBMIT_ACTION_TEXT[this.submitAction]; + }, + submitButtonClass() { + return SUBMIT_BUTTON_CLASS[this.submitAction]; + }, + isSubmitDisabled() { + return this.disabled || (this.submitAction === 'create' && !this.haveValuesChanged); + }, + dropdownTitle() { + return this.submitAction === 'create' + ? s__('PrometheusAlerts|Add alert') + : s__('PrometheusAlerts|Edit alert'); + }, + }, + watch: { + alertsToManage() { + this.resetAlertData(); + }, + submitAction() { + this.$emit('setAction', this.submitAction); + }, + }, + methods: { + selectQuery(queryId) { + const existingAlertPath = findKey(this.alertsToManage, alert => alert.metricId === queryId); + const existingAlert = this.alertsToManage[existingAlertPath]; + + if (existingAlert) { + this.selectedAlert = existingAlert; + this.operator = existingAlert.operator; + this.threshold = existingAlert.threshold; + } else { + this.selectedAlert = {}; + this.operator = this.operators.greaterThan; + this.threshold = null; + } + + this.prometheusMetricId = queryId; + }, + handleHidden() { + this.resetAlertData(); + this.$emit('cancel'); + }, + handleSubmit(e) { + e.preventDefault(); + this.$emit(this.submitAction, { + alert: this.selectedAlert.alert_path, + operator: this.operator, + threshold: this.threshold, + prometheus_metric_id: this.prometheusMetricId, + }); + }, + handleShown() { + if (this.configuredAlert) { + this.selectQuery(this.configuredAlert); + } else if (this.relevantQueries.length === 1) { + this.selectQuery(this.relevantQueries[0].metricId); + } + }, + resetAlertData() { + this.operator = null; + this.threshold = null; + this.prometheusMetricId = null; + this.selectedAlert = {}; + }, + getAlertFormActionTrackingOption() { + const label = `${this.submitAction}_alert`; + return { + category: document.body.dataset.page, + action: 'click_button', + label, + }; + }, + }, + alertQueryText: { + label: __('Query'), + validFeedback: __('Query is valid'), + invalidFeedback: __('Invalid query'), + descriptionTooltip: __( + 'Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula.', + ), + }, +}; +</script> + +<template> + <gl-modal + ref="alertModal" + :title="dropdownTitle" + :modal-id="modalId" + :ok-variant="submitAction === 'delete' ? 'danger' : 'success'" + :ok-disabled="formDisabled" + @ok="handleSubmit" + @hidden="handleHidden" + @shown="handleShown" + > + <div v-if="errorMessage" class="alert-modal-message danger_message">{{ errorMessage }}</div> + <div class="alert-form"> + <gl-form-group + v-if="supportsComputedAlerts" + :label="$options.alertQueryText.label" + label-for="alert-query-input" + :valid-feedback="$options.alertQueryText.validFeedback" + :invalid-feedback="$options.alertQueryText.invalidFeedback" + :state="isValidQuery" + > + <gl-form-input id="alert-query-input" v-model.trim="alertQuery" :state="isValidQuery" /> + <template #description> + <div class="d-flex align-items-center"> + {{ __('Single or combined queries') }} + <icon + v-gl-tooltip="$options.alertQueryText.descriptionTooltip" + name="question" + class="prepend-left-4" + /> + </div> + </template> + </gl-form-group> + <gl-form-group v-else label-for="alert-query-dropdown" :label="$options.alertQueryText.label"> + <gl-dropdown + id="alert-query-dropdown" + :text="queryDropdownLabel" + toggle-class="dropdown-menu-toggle qa-alert-query-dropdown" + > + <gl-dropdown-item + v-for="query in relevantQueries" + :key="query.metricId" + data-qa-selector="alert_query_option" + @click="selectQuery(query.metricId)" + > + {{ query.label }} + </gl-dropdown-item> + </gl-dropdown> + </gl-form-group> + <gl-button-group class="mb-2" :label="s__('PrometheusAlerts|Operator')"> + <gl-deprecated-button + :class="{ active: operator === operators.greaterThan }" + :disabled="formDisabled" + type="button" + @click="operator = operators.greaterThan" + > + {{ operators.greaterThan }} + </gl-deprecated-button> + <gl-deprecated-button + :class="{ active: operator === operators.equalTo }" + :disabled="formDisabled" + type="button" + @click="operator = operators.equalTo" + > + {{ operators.equalTo }} + </gl-deprecated-button> + <gl-deprecated-button + :class="{ active: operator === operators.lessThan }" + :disabled="formDisabled" + type="button" + @click="operator = operators.lessThan" + > + {{ operators.lessThan }} + </gl-deprecated-button> + </gl-button-group> + <gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold"> + <gl-form-input + id="alerts-threshold" + v-model.number="threshold" + :disabled="formDisabled" + type="number" + data-qa-selector="alert_threshold_field" + /> + </gl-form-group> + </div> + <template #modal-ok> + <gl-link + v-track-event="getAlertFormActionTrackingOption()" + class="text-reset text-decoration-none" + > + {{ submitActionText }} + </gl-link> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue index 447f8845506..34da5885c97 100644 --- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -3,7 +3,7 @@ import { flattenDeep, isNumber } from 'lodash'; import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { roundOffFloat } from '~/lib/utils/common_utils'; import { hexToRgb } from '~/lib/utils/color_utils'; -import { areaOpacityValues, symbolSizes, colorValues } from '../../constants'; +import { areaOpacityValues, symbolSizes, colorValues, panelTypes } from '../../constants'; import { graphDataValidatorForAnomalyValues } from '../../utils'; import MonitorTimeSeriesChart from './time_series.vue'; @@ -91,7 +91,7 @@ export default { ]); return { ...this.graphData, - type: 'line-chart', + type: panelTypes.LINE_CHART, metrics: [metricQuery], }; }, @@ -209,7 +209,7 @@ export default { :series-config="metricSeriesConfig" > <slot></slot> - <template v-slot:tooltipContent="slotProps"> + <template #tooltip-content="slotProps"> <div v-for="(content, seriesIndex) in slotProps.tooltip.content" :key="seriesIndex" diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue index 0a0165a113e..55a25ee09fd 100644 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -63,7 +63,7 @@ export default { }; </script> <template> - <div v-gl-resize-observer-directive="onResize" class="col-12 col-lg-6"> + <div v-gl-resize-observer-directive="onResize"> <gl-heatmap ref="heatmapChart" v-bind="$attrs" diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index bf40e8f448e..8f37a12af75 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -6,7 +6,7 @@ import dateFormat from 'dateformat'; import { s__, __ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; -import { chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants'; +import { panelTypes, chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants'; import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options'; import { annotationsYAxis, generateAnnotationsSeries } from './annotations'; import { makeDataSeries } from '~/helpers/monitor_helper'; @@ -64,10 +64,10 @@ export default { required: false, default: '', }, - singleEmbed: { - type: Boolean, + height: { + type: Number, required: false, - default: false, + default: chartHeight, }, thresholds: { type: Array, @@ -100,7 +100,6 @@ export default { sha: '', }, width: 0, - height: chartHeight, svgs: {}, primaryColor: null, throttledDatazoom: null, @@ -211,8 +210,8 @@ export default { }, glChartComponent() { const chartTypes = { - 'area-chart': GlAreaChart, - 'line-chart': GlLineChart, + [panelTypes.AREA_CHART]: GlAreaChart, + [panelTypes.LINE_CHART]: GlLineChart, }; return chartTypes[this.graphData.type] || GlAreaChart; }, diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 4d60b02d0df..2018c706b11 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,8 +1,10 @@ <script> -import { debounce, pickBy } from 'lodash'; +import { debounce } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import VueDraggable from 'vuedraggable'; import { + GlIcon, + GlButton, GlDeprecatedButton, GlDropdown, GlDropdownItem, @@ -14,10 +16,10 @@ import { GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import DashboardPanel from './dashboard_panel.vue'; import { s__ } from '~/locale'; import createFlash from '~/flash'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +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 invalidUrl from '~/lib/utils/invalid_url'; @@ -28,17 +30,27 @@ 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 TrackEventDirective from '~/vue_shared/directives/track_event'; -import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils'; +import { + getAddMetricTrackingOptions, + timeRangeToUrl, + timeRangeFromUrl, + panelToUrl, + expandedPanelPayloadFromUrl, + convertVariablesForURL, +} from '../utils'; import { metricStates } from '../constants'; import { defaultTimeRange, timeRanges } from '~/vue_shared/constants'; export default { components: { VueDraggable, - PanelType, + DashboardPanel, Icon, + GlIcon, + GlButton, GlDeprecatedButton, GlDropdown, GlLoadingIcon, @@ -54,13 +66,14 @@ export default { EmptyState, GroupEmptyState, DashboardsDropdown, + + VariablesSection, }, directives: { GlModal: GlModalDirective, GlTooltip: GlTooltipDirective, TrackEvent: TrackEventDirective, }, - mixins: [glFeatureFlagsMixin()], props: { externalDashboardUrl: { type: String, @@ -197,7 +210,6 @@ export default { }, data() { return { - state: 'gettingStarted', formIsValid: null, selectedTimeRange: timeRangeFromUrl() || defaultTimeRange, hasValidDates: true, @@ -212,16 +224,16 @@ export default { 'showEmptyState', 'useDashboardEndpoint', 'allDashboards', - 'additionalPanelTypesEnabled', 'environmentsLoading', + 'expandedPanel', + 'promVariables', + 'isUpdatingStarredValue', + ]), + ...mapGetters('monitoringDashboard', [ + 'selectedDashboard', + 'getMetricStates', + 'filteredEnvironments', ]), - ...mapGetters('monitoringDashboard', ['getMetricStates', 'filteredEnvironments']), - firstDashboard() { - return this.allDashboards.length > 0 ? this.allDashboards[0] : {}; - }, - selectedDashboard() { - return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard; - }, showRearrangePanelsBtn() { return !this.showEmptyState && this.rearrangePanelsAvailable; }, @@ -229,20 +241,44 @@ export default { return ( this.customMetricsAvailable && !this.showEmptyState && - this.firstDashboard === this.selectedDashboard - ); - }, - hasHeaderButtons() { - return ( - this.addingMetricsAvailable || - this.showRearrangePanelsBtn || - this.selectedDashboard.can_edit || - this.externalDashboardUrl.length + // 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; }, + shouldShowVariablesSection() { + return Object.keys(this.promVariables).length > 0; + }, + }, + watch: { + dashboard(newDashboard) { + try { + const expandedPanel = expandedPanelPayloadFromUrl(newDashboard); + if (expandedPanel) { + this.setExpandedPanel(expandedPanel); + } + } catch { + createFlash( + s__( + 'Metrics|Link contains invalid chart information, please verify the link to see the expanded panel.', + ), + ); + } + }, + expandedPanel: { + handler({ group, panel }) { + const dashboardPath = this.currentDashboard || this.selectedDashboard?.path; + updateHistory({ + url: panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), group, panel), + title: document.title, + }); + }, + deep: true, + }, }, created() { this.setInitialState({ @@ -255,6 +291,10 @@ export default { logsPath: this.logsPath, currentEnvironmentName: this.currentEnvironmentName, }); + window.addEventListener('keyup', this.onKeyup); + }, + destroyed() { + window.removeEventListener('keyup', this.onKeyup); }, mounted() { if (!this.hasMetrics) { @@ -273,6 +313,9 @@ export default { 'setInitialState', 'setPanelGroupMetrics', 'filterEnvironments', + 'setExpandedPanel', + 'clearExpandedPanel', + 'toggleStarredValue', ]), updatePanels(key, panels) { this.setPanelGroupMetrics({ @@ -299,11 +342,9 @@ export default { // As a fallback, switch to default time range instead this.selectedTimeRange = defaultTimeRange; }, - - generateLink(group, title, yLabel) { - const dashboard = this.currentDashboard || this.firstDashboard.path; - const params = pickBy({ dashboard, group, title, y_label: yLabel }, value => value != null); - return mergeUrlParams(params, window.location.href); + generatePanelUrl(groupKey, panel) { + const dashboardPath = this.currentDashboard || this.selectedDashboard?.path; + return panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), groupKey, panel); }, hideAddMetricModal() { this.$refs.addMetricModal.hide(); @@ -366,11 +407,28 @@ export default { }); this.selectedTimeRange = { start, end }; }, + onExpandPanel(group, panel) { + this.setExpandedPanel({ group, panel }); + }, + onGoBack() { + this.clearExpandedPanel(); + }, + onKeyup(event) { + const { key } = event; + if (key === ESC_KEY || key === ESC_KEY_IE11) { + this.clearExpandedPanel(); + } + }, }, addMetric: { title: s__('Metrics|Add metric'), modalId: 'add-metric', }, + i18n: { + goBackLabel: s__('Metrics|Go back (Esc)'), + starDashboard: s__('Metrics|Star dashboard'), + unstarDashboard: s__('Metrics|Unstar dashboard'), + }, }; </script> @@ -388,7 +446,6 @@ export default { class="flex-grow-1" toggle-class="dropdown-menu-toggle" :default-branch="defaultBranch" - :selected-dashboard="selectedDashboard" @selectDashboard="selectDashboard($event)" /> </div> @@ -443,7 +500,7 @@ export default { <date-time-picker ref="dateTimePicker" class="flex-grow-1 show-last-dropdown" - data-qa-selector="show_last_dropdown" + data-qa-selector="range_picker_dropdown" :value="selectedTimeRange" :options="timeRanges" @input="onDateTimePickerInput" @@ -467,6 +524,32 @@ export default { <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" @@ -516,7 +599,10 @@ export default { </gl-modal> </div> - <div v-if="selectedDashboard.can_edit" class="mb-2 mr-2 d-flex d-sm-block"> + <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" @@ -539,61 +625,92 @@ export default { </div> </div> </div> - + <variables-section v-if="shouldShowVariablesSection && !showEmptyState" /> <div v-if="!showEmptyState"> - <graph-group - v-for="(groupData, index) in dashboard.panelGroups" - :key="`${groupData.group}.${groupData.priority}`" - :name="groupData.group" - :show-panels="showPanels" - :collapse-group="collapseGroup(groupData.key)" + <dashboard-panel + v-show="expandedPanel.panel" + ref="expandedPanel" + :settings-path="settingsPath" + :clipboard-text="generatePanelUrl(expandedPanel.group, expandedPanel.panel)" + :graph-data="expandedPanel.panel" + :alerts-endpoint="alertsEndpoint" + :height="600" + :prometheus-alerts-available="prometheusAlertsAvailable" + @timerangezoom="onTimeRangeZoom" > - <vue-draggable - v-if="!groupSingleEmptyState(groupData.key)" - :value="groupData.panels" - group="metrics-dashboard" - :component-data="{ attrs: { class: 'row mx-0 w-100' } }" - :disabled="!isRearrangingPanels" - @input="updatePanels(groupData.key, $event)" + <template #topLeft> + <gl-button + ref="goBackBtn" + v-gl-tooltip + class="mr-3 my-3" + :title="$options.i18n.goBackLabel" + @click="onGoBack" + > + <gl-icon + name="arrow-left" + :aria-label="$options.i18n.goBackLabel" + class="text-secondary" + /> + </gl-button> + </template> + </dashboard-panel> + + <div v-show="!expandedPanel.panel"> + <graph-group + v-for="groupData in dashboard.panelGroups" + :key="`${groupData.group}.${groupData.priority}`" + :name="groupData.group" + :show-panels="showPanels" + :collapse-group="collapseGroup(groupData.key)" > - <div - v-for="(graphData, graphIndex) in groupData.panels" - :key="`panel-type-${graphIndex}`" - class="col-12 col-lg-6 px-2 mb-2 draggable" - :class="{ 'draggable-enabled': isRearrangingPanels }" + <vue-draggable + v-if="!groupSingleEmptyState(groupData.key)" + :value="groupData.panels" + group="metrics-dashboard" + :component-data="{ attrs: { class: 'row mx-0 w-100' } }" + :disabled="!isRearrangingPanels" + @input="updatePanels(groupData.key, $event)" > - <div class="position-relative draggable-panel js-draggable-panel"> - <div - v-if="isRearrangingPanels" - class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" - @click="removePanel(groupData.key, groupData.panels, graphIndex)" - > - <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"> - <icon name="close" /> - </a> - </div> + <div + v-for="(graphData, graphIndex) in groupData.panels" + :key="`dashboard-panel-${graphIndex}`" + class="col-12 col-lg-6 px-2 mb-2 draggable" + :class="{ 'draggable-enabled': isRearrangingPanels }" + > + <div class="position-relative draggable-panel js-draggable-panel"> + <div + v-if="isRearrangingPanels" + class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" + @click="removePanel(groupData.key, groupData.panels, graphIndex)" + > + <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"> + <icon name="close" /> + </a> + </div> - <panel-type - :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" - :graph-data="graphData" - :alerts-endpoint="alertsEndpoint" - :prometheus-alerts-available="prometheusAlertsAvailable" - :index="`${index}-${graphIndex}`" - @timerangezoom="onTimeRangeZoom" - /> + <dashboard-panel + :settings-path="settingsPath" + :clipboard-text="generatePanelUrl(groupData.group, graphData)" + :graph-data="graphData" + :alerts-endpoint="alertsEndpoint" + :prometheus-alerts-available="prometheusAlertsAvailable" + @timerangezoom="onTimeRangeZoom" + @expand="onExpandPanel(groupData.group, graphData)" + /> + </div> </div> + </vue-draggable> + <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6"> + <group-empty-state + ref="empty-group" + :documentation-path="documentationPath" + :settings-path="settingsPath" + :selected-state="groupSingleEmptyState(groupData.key)" + :svg-path="emptyNoDataSmallSvgPath" + /> </div> - </vue-draggable> - <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6"> - <group-empty-state - ref="empty-group" - :documentation-path="documentationPath" - :settings-path="settingsPath" - :selected-state="groupSingleEmptyState(groupData.key)" - :svg-path="emptyNoDataSmallSvgPath" - /> - </div> - </graph-group> + </graph-group> + </div> </div> <empty-state v-else diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 2beae0d9540..48825fda5c8 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -14,6 +14,9 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { __, n__ } from '~/locale'; +import { panelTypes } from '../constants'; + +import MonitorEmptyChart from './charts/empty_chart.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; @@ -21,22 +24,20 @@ import MonitorHeatmapChart from './charts/heatmap.vue'; import MonitorColumnChart from './charts/column.vue'; import MonitorBarChart from './charts/bar.vue'; import MonitorStackedColumnChart from './charts/stacked_column.vue'; -import MonitorEmptyChart from './charts/empty_chart.vue'; + import TrackEventDirective from '~/vue_shared/directives/track_event'; +import AlertWidget from './alert_widget.vue'; import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; const events = { timeRangeZoom: 'timerangezoom', + expand: 'expand', }; export default { components: { - MonitorSingleStatChart, - MonitorColumnChart, - MonitorBarChart, - MonitorHeatmapChart, - MonitorStackedColumnChart, MonitorEmptyChart, + AlertWidget, GlIcon, GlLoadingIcon, GlTooltip, @@ -58,28 +59,41 @@ export default { }, graphData: { type: Object, - required: true, - }, - index: { - type: String, required: false, - default: '', + default: null, }, groupId: { type: String, required: false, - default: 'panel-type-chart', + default: 'dashboard-panel', }, namespace: { type: String, required: false, default: 'monitoringDashboard', }, + alertsEndpoint: { + type: String, + required: false, + default: null, + }, + prometheusAlertsAvailable: { + type: Boolean, + required: false, + default: false, + }, + settingsPath: { + type: String, + required: false, + default: null, + }, }, data() { return { showTitleTooltip: false, zoomedTimeRange: null, + allAlerts: {}, + expandBtnAvailable: Boolean(this.$listeners[events.expand]), }; }, computed: { @@ -101,23 +115,18 @@ export default { timeRange(state) { return state[this.namespace].timeRange; }, + metricsSavedToDb(state, getters) { + return getters[`${this.namespace}/metricsSavedToDb`]; + }, }), title() { - return this.graphData.title || ''; - }, - alertWidgetAvailable() { - // This method is extended by ee functionality - return false; + return this.graphData?.title || ''; }, graphDataHasResult() { - return ( - this.graphData.metrics && - this.graphData.metrics[0].result && - this.graphData.metrics[0].result.length > 0 - ); + return this.graphData?.metrics?.[0]?.result?.length > 0; }, graphDataIsLoading() { - const { metrics = [] } = this.graphData; + const metrics = this.graphData?.metrics || []; return metrics.some(({ loading }) => loading); }, logsPathWithTimeRange() { @@ -129,7 +138,7 @@ export default { return null; }, csvText() { - const chartData = this.graphData.metrics[0].result[0].values; + const chartData = this.graphData?.metrics[0].result[0].values || []; const yLabel = this.graphData.y_label; const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/require-i18n-strings return chartData.reduce((csv, data) => { @@ -141,27 +150,77 @@ export default { const data = new Blob([this.csvText], { type: 'text/plain' }); return window.URL.createObjectURL(data); }, - timeChartComponent() { - if (this.isPanelType('anomaly-chart')) { + + /** + * A chart is "basic" if it doesn't support + * the same features as the TimeSeries based components + * such as "annotations". + * + * @returns Vue Component wrapping a basic visualization + */ + basicChartComponent() { + if (this.isPanelType(panelTypes.SINGLE_STAT)) { + return MonitorSingleStatChart; + } + if (this.isPanelType(panelTypes.HEATMAP)) { + return MonitorHeatmapChart; + } + if (this.isPanelType(panelTypes.BAR)) { + return MonitorBarChart; + } + if (this.isPanelType(panelTypes.COLUMN)) { + return MonitorColumnChart; + } + if (this.isPanelType(panelTypes.STACKED_COLUMN)) { + return MonitorStackedColumnChart; + } + if (this.isPanelType(panelTypes.ANOMALY_CHART)) { + return MonitorAnomalyChart; + } + return null; + }, + + /** + * In monitoring, Time Series charts typically support + * a larger feature set like "annotations", "deployment + * data", alert "thresholds" and "datazoom". + * + * This is intentional as Time Series are more frequently + * used. + * + * @returns Vue Component wrapping a time series visualization, + * Area Charts are rendered by default. + */ + timeSeriesChartComponent() { + if (this.isPanelType(panelTypes.ANOMALY_CHART)) { return MonitorAnomalyChart; } return MonitorTimeSeriesChart; }, isContextualMenuShown() { - return ( - this.graphDataHasResult && - !this.isPanelType('single-stat') && - !this.isPanelType('heatmap') && - !this.isPanelType('column') && - !this.isPanelType('stacked-column') - ); + return Boolean(this.graphDataHasResult && !this.basicChartComponent); }, editCustomMetricLink() { + if (this.graphData.metrics.length > 1) { + return this.settingsPath; + } return this.graphData?.metrics[0].edit_path; }, editCustomMetricLinkText() { return n__('Metrics|Edit metric', 'Metrics|Edit metrics', this.graphData.metrics.length); }, + hasMetricsInDb() { + const { metrics = [] } = this.graphData; + return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId)); + }, + alertWidgetAvailable() { + return ( + this.prometheusAlertsAvailable && + this.alertsEndpoint && + this.graphData && + this.hasMetricsInDb + ); + }, }, mounted() { this.refreshTitleTooltip(); @@ -176,7 +235,7 @@ export default { return Object.values(this.getGraphAlerts(queries)); }, isPanelType(type) { - return this.graphData.type && this.graphData.type === type; + return this.graphData?.type === type; }, showToast() { this.$toast.show(__('Link copied')); @@ -197,15 +256,27 @@ export default { this.zoomedTimeRange = { start, end }; this.$emit(events.timeRangeZoom, { start, end }); }, + onExpand() { + this.$emit(events.expand); + }, + setAlerts(alertPath, alertAttributes) { + if (alertAttributes) { + this.$set(this.allAlerts, alertPath, alertAttributes); + } else { + this.$delete(this.allAlerts, alertPath); + } + }, }, + panelTypes, }; </script> <template> <div v-gl-resize-observer="onResize" class="prometheus-graph"> <div class="d-flex align-items-center mr-3"> + <slot name="topLeft"></slot> <h5 ref="graphTitle" - class="prometheus-graph-title gl-font-size-large font-weight-bold text-truncate append-right-8" + class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate append-right-8" > {{ title }} </h5> @@ -215,7 +286,7 @@ export default { <alert-widget v-if="isContextualMenuShown && alertWidgetAvailable" class="mx-1" - :modal-id="`alert-modal-${index}`" + :modal-id="`alert-modal-${graphData.id}`" :alerts-endpoint="alertsEndpoint" :relevant-queries="graphData.metrics" :alerts-to-manage="getGraphAlerts(graphData.metrics)" @@ -243,6 +314,14 @@ export default { <gl-icon name="ellipsis_v" class="text-secondary" /> </template> <gl-dropdown-item + v-if="expandBtnAvailable" + ref="expandBtn" + :href="clipboardText" + @click.prevent="onExpand" + > + {{ s__('Metrics|Expand panel') }} + </gl-dropdown-item> + <gl-dropdown-item v-if="editCustomMetricLink" ref="editMetricLink" :href="editCustomMetricLink" @@ -271,13 +350,14 @@ export default { ref="copyChartLink" v-track-event="generateLinkToChartOptions(clipboardText)" :data-clipboard-text="clipboardText" + data-qa-selector="generate_chart_link_menu_item" @click="showToast(clipboardText)" > {{ __('Copy link to chart') }} </gl-dropdown-item> <gl-dropdown-item v-if="alertWidgetAvailable" - v-gl-modal="`alert-modal-${index}`" + v-gl-modal="`alert-modal-${graphData.id}`" data-qa-selector="alert_widget_menu_item" > {{ __('Alerts') }} @@ -287,38 +367,27 @@ export default { </div> </div> - <monitor-single-stat-chart - v-if="isPanelType('single-stat') && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-heatmap-chart - v-else-if="isPanelType('heatmap') && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-bar-chart - v-else-if="isPanelType('bar') && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-column-chart - v-else-if="isPanelType('column') && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-stacked-column-chart - v-else-if="isPanelType('stacked-column') && graphDataHasResult" + <monitor-empty-chart v-if="!graphDataHasResult" /> + <component + :is="basicChartComponent" + v-else-if="basicChartComponent" :graph-data="graphData" + v-bind="$attrs" + v-on="$listeners" /> <component - :is="timeChartComponent" - v-else-if="graphDataHasResult" - ref="timeChart" + :is="timeSeriesChartComponent" + v-else + ref="timeSeriesChart" :graph-data="graphData" :deployment-data="deploymentData" :annotations="annotations" :project-path="projectPath" :thresholds="getGraphAlertValues(graphData.metrics)" :group-id="groupId" + v-bind="$attrs" + v-on="$listeners" @datazoom="onDatazoom" /> - <monitor-empty-chart v-else v-bind="$attrs" v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 8f3e0a6ec75..8b86890715f 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -1,7 +1,8 @@ <script> -import { mapState, mapActions } from 'vuex'; +import { mapState, mapActions, mapGetters } from 'vuex'; import { GlAlert, + GlIcon, GlDropdown, GlDropdownItem, GlDropdownHeader, @@ -21,6 +22,7 @@ const events = { export default { components: { GlAlert, + GlIcon, GlDropdown, GlDropdownItem, GlDropdownHeader, @@ -34,11 +36,6 @@ export default { GlModal: GlModalDirective, }, props: { - selectedDashboard: { - type: Object, - required: false, - default: () => ({}), - }, defaultBranch: { type: String, required: true, @@ -54,26 +51,41 @@ export default { }, computed: { ...mapState('monitoringDashboard', ['allDashboards']), + ...mapGetters('monitoringDashboard', ['selectedDashboard']), isSystemDashboard() { - return this.selectedDashboard.system_dashboard; + return this.selectedDashboard?.system_dashboard; }, selectedDashboardText() { - return this.selectedDashboard.display_name; + return this.selectedDashboard?.display_name; + }, + selectedDashboardPath() { + return this.selectedDashboard?.path; }, + filteredDashboards() { - return this.allDashboards.filter(({ display_name }) => + return this.allDashboards.filter(({ display_name = '' }) => display_name.toLowerCase().includes(this.searchTerm.toLowerCase()), ); }, shouldShowNoMsgContainer() { return this.filteredDashboards.length === 0; }, + starredDashboards() { + return this.filteredDashboards.filter(({ starred }) => starred); + }, + nonStarredDashboards() { + return this.filteredDashboards.filter(({ starred }) => !starred); + }, + okButtonText() { return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate'); }, }, methods: { ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), + dashboardDisplayName(dashboard) { + return dashboard.display_name || dashboard.path || ''; + }, selectDashboard(dashboard) { this.$emit(events.selectDashboard, dashboard); }, @@ -127,15 +139,34 @@ export default { v-model="searchTerm" class="m-2" /> + <div class="flex-fill overflow-auto"> <gl-dropdown-item - v-for="dashboard in filteredDashboards" + v-for="dashboard in starredDashboards" + :key="dashboard.path" + :active="dashboard.path === selectedDashboardPath" + active-class="is-active" + @click="selectDashboard(dashboard)" + > + <div class="d-flex"> + {{ dashboardDisplayName(dashboard) }} + <gl-icon class="text-muted ml-auto" name="star" /> + </div> + </gl-dropdown-item> + + <gl-dropdown-divider + v-if="starredDashboards.length && nonStarredDashboards.length" + ref="starredListDivider" + /> + + <gl-dropdown-item + v-for="dashboard in nonStarredDashboards" :key="dashboard.path" - :active="dashboard.path === selectedDashboard.path" + :active="dashboard.path === selectedDashboardPath" active-class="is-active" @click="selectDashboard(dashboard)" > - {{ dashboard.display_name || dashboard.path }} + {{ dashboardDisplayName(dashboard) }} </gl-dropdown-item> </div> diff --git a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue index 3f8b0f76997..1557a49137e 100644 --- a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue +++ b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapActions } from 'vuex'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { defaultTimeRange } from '~/vue_shared/constants'; import { timeRangeFromUrl, removeTimeRangeParams } from '../../utils'; @@ -10,7 +10,7 @@ let sidebarMutationObserver; export default { components: { - PanelType, + DashboardPanel, }, props: { containerClass: { @@ -113,9 +113,9 @@ export default { </script> <template> <div class="metrics-embed p-0 d-flex flex-wrap" :class="embedClass"> - <panel-type + <dashboard-panel v-for="(graphData, graphIndex) in charts" - :key="`panel-type-${graphIndex}`" + :key="`dashboard-panel-${graphIndex}`" :class="panelClass" :graph-data="graphData" :group-id="dashboardUrl" diff --git a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue b/app/assets/javascripts/monitoring/components/variables/custom_variable.vue new file mode 100644 index 00000000000..0ac7c0b80df --- /dev/null +++ b/app/assets/javascripts/monitoring/components/variables/custom_variable.vue @@ -0,0 +1,50 @@ +<script> +import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; + +export default { + components: { + GlFormGroup, + GlDropdown, + GlDropdownItem, + }, + props: { + name: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + value: { + type: String, + required: false, + default: '', + }, + options: { + type: Array, + required: true, + }, + }, + computed: { + defaultText() { + const selectedOpt = this.options.find(opt => opt.value === this.value); + return selectedOpt?.text || this.value; + }, + }, + methods: { + onUpdate(value) { + this.$emit('onUpdate', this.name, value); + }, + }, +}; +</script> +<template> + <gl-form-group :label="label"> + <gl-dropdown toggle-class="dropdown-menu-toggle" :text="defaultText"> + <gl-dropdown-item v-for="(opt, key) in options" :key="key" @click="onUpdate(opt.value)">{{ + opt.text + }}</gl-dropdown-item> + </gl-dropdown> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/monitoring/components/variables/text_variable.vue b/app/assets/javascripts/monitoring/components/variables/text_variable.vue new file mode 100644 index 00000000000..ce0d19760e2 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/variables/text_variable.vue @@ -0,0 +1,39 @@ +<script> +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; + +export default { + components: { + GlFormGroup, + GlFormInput, + }, + props: { + name: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + value: { + type: String, + required: true, + }, + }, + methods: { + onUpdate(event) { + this.$emit('onUpdate', this.name, event.target.value); + }, + }, +}; +</script> +<template> + <gl-form-group :label="label"> + <gl-form-input + :value="value" + :name="name" + @keyup.native.enter="onUpdate" + @blur.native="onUpdate" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue new file mode 100644 index 00000000000..e054c9d8e26 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/variables_section.vue @@ -0,0 +1,56 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import CustomVariable from './variables/custom_variable.vue'; +import TextVariable from './variables/text_variable.vue'; +import { setPromCustomVariablesFromUrl } from '../utils'; + +export default { + components: { + CustomVariable, + TextVariable, + }, + computed: { + ...mapState('monitoringDashboard', ['promVariables']), + }, + methods: { + ...mapActions('monitoringDashboard', ['fetchDashboardData', 'updateVariableValues']), + refreshDashboard(variable, value) { + if (this.promVariables[variable].value !== value) { + const changedVariable = { key: variable, value }; + // update the Vuex store + this.updateVariableValues(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(); + } + }, + variableComponent(type) { + const types = { + text: TextVariable, + custom: CustomVariable, + }; + return types[type] || TextVariable; + }, + }, +}; +</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"> + <component + :is="variableComponent(variable.type)" + class="mb-0 flex-grow-1" + :label="variable.label" + :value="variable.value" + :name="key" + :options="variable.options" + @onUpdate="refreshDashboard" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 0b393f19789..0c2eafeed54 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -48,6 +48,55 @@ export const metricStates = { UNKNOWN_ERROR: 'UNKNOWN_ERROR', }; +/** + * Supported panel types in dashboards, values of `panel.type`. + * + * Values should not be changed as they correspond to + * values in users the `.yml` dashboard definition. + */ +export const panelTypes = { + /** + * Area Chart + * + * Time Series chart with an area + */ + AREA_CHART: 'area-chart', + /** + * Line Chart + * + * Time Series chart with a line + */ + LINE_CHART: 'line-chart', + /** + * Anomaly Chart + * + * Time Series chart with 3 metrics + */ + ANOMALY_CHART: 'anomaly-chart', + /** + * Single Stat + * + * Single data point visualization + */ + SINGLE_STAT: 'single-stat', + /** + * Heatmap + */ + HEATMAP: 'heatmap', + /** + * Bar chart + */ + BAR: 'bar', + /** + * Column chart + */ + COLUMN: 'column', + /** + * Stacked column chart + */ + STACKED_COLUMN: 'stacked-column', +}; + export const sidebarAnimationDuration = 300; // milliseconds. export const chartHeight = 300; @@ -143,3 +192,38 @@ export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z'; * https://gitlab.com/gitlab-org/gitlab/-/issues/214671 */ export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; + +export const OPERATORS = { + greaterThan: '>', + equalTo: '==', + lessThan: '<', +}; + +/** + * Dashboard yml files support custom user-defined variables that + * are rendered as input elements in the monitoring dashboard. + * These values can be edited by the user and are passed on to the + * the backend and eventually to Prometheus API proxy. + * + * As of 13.0, the supported types are: + * simple custom -> dropdown elements + * advanced custom -> dropdown elements + * text -> text input elements + * + * Custom variables have a simple and a advanced variant. + */ +export const VARIABLE_TYPES = { + custom: 'custom', + text: 'text', +}; + +/** + * The names of templating variables defined in the dashboard yml + * file are prefixed with a constant so that it doesn't collide with + * other URL params that the monitoring dashboard relies on for + * features like panel fullscreen etc. + * + * The prefix is added before it is appended to the URL and removed + * before passing the data to the backend. + */ +export const VARIABLE_PREFIX = 'var-'; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index d296f5b7a66..2bbf9ef9d78 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; -import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; +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'; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js new file mode 100644 index 00000000000..afe5ee0938d --- /dev/null +++ b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js @@ -0,0 +1,13 @@ +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/services/alerts_service.js b/app/assets/javascripts/monitoring/services/alerts_service.js new file mode 100644 index 00000000000..4b7337972fe --- /dev/null +++ b/app/assets/javascripts/monitoring/services/alerts_service.js @@ -0,0 +1,32 @@ +import axios from '~/lib/utils/axios_utils'; + +export default class AlertsService { + constructor({ alertsEndpoint }) { + this.alertsEndpoint = alertsEndpoint; + } + + getAlerts() { + return axios.get(this.alertsEndpoint).then(resp => resp.data); + } + + createAlert({ prometheus_metric_id, operator, threshold }) { + return axios + .post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold }) + .then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + readAlert(alertPath) { + return axios.get(alertPath).then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + updateAlert(alertPath, { operator, threshold }) { + return axios.put(alertPath, { operator, threshold }).then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + deleteAlert(alertPath) { + return axios.delete(alertPath).then(resp => resp.data); + } +} diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index f04f775761c..b057afa2264 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -3,6 +3,8 @@ 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, @@ -13,11 +15,7 @@ import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; import statusCodes from '../../lib/utils/http_status'; -import { - backOff, - convertObjectPropsToCamelCase, - isFeatureFlagEnabled, -} from '../../lib/utils/common_utils'; +import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; import { @@ -80,6 +78,10 @@ export const setTimeRange = ({ commit }, timeRange) => { commit(types.SET_TIME_RANGE, timeRange); }; +export const setVariables = ({ commit }, variables) => { + commit(types.SET_VARIABLES, variables); +}; + export const filterEnvironments = ({ commit, dispatch }, searchTerm) => { commit(types.SET_ENVIRONMENTS_FILTER, searchTerm); dispatch('fetchEnvironmentsData'); @@ -89,19 +91,30 @@ export const setShowErrorBanner = ({ commit }, enabled) => { commit(types.SET_SHOW_ERROR_BANNER, enabled); }; +export const setExpandedPanel = ({ commit }, { group, panel }) => { + commit(types.SET_EXPANDED_PANEL, { group, panel }); +}; + +export const clearExpandedPanel = ({ commit }) => { + commit(types.SET_EXPANDED_PANEL, { + group: null, + panel: null, + }); +}; + // All Data +/** + * Fetch all dashboard data. + * + * @param {Object} store + * @returns A promise that resolves when the dashboard + * skeleton has been loaded. + */ export const fetchData = ({ dispatch }) => { dispatch('fetchEnvironmentsData'); dispatch('fetchDashboard'); - /** - * Annotations data is not yet fetched. This will be - * ready after the BE piece is implemented. - * https://gitlab.com/gitlab-org/gitlab/-/issues/211330 - */ - if (isFeatureFlagEnabled('metricsDashboardAnnotations')) { - dispatch('fetchAnnotations'); - } + dispatch('fetchAnnotations'); }; // Metrics dashboard @@ -148,6 +161,7 @@ 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'); @@ -200,12 +214,19 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { * * @param {metric} metric */ -export const fetchPrometheusMetric = ({ commit }, { metric, defaultQueryParams }) => { +export const fetchPrometheusMetric = ( + { commit, state, getters }, + { metric, defaultQueryParams }, +) => { const queryParams = { ...defaultQueryParams }; if (metric.step) { queryParams.step = metric.step; } + if (Object.keys(state.promVariables).length > 0) { + queryParams.variables = getters.getCustomVariablesArray; + } + commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId }); return getPrometheusMetricResult(metric.prometheusEndpointPath, queryParams) @@ -327,6 +348,35 @@ export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_AN // Dashboard manipulation +export const toggleStarredValue = ({ commit, state, getters }) => { + const { selectedDashboard } = getters; + + if (state.isUpdatingStarredValue) { + // Prevent repeating requests for the same change + return; + } + if (!selectedDashboard) { + return; + } + + const method = selectedDashboard.starred ? 'DELETE' : 'POST'; + const url = selectedDashboard.user_starred_path; + const newStarredValue = !selectedDashboard.starred; + + commit(types.REQUEST_DASHBOARD_STARRING); + + axios({ + url, + method, + }) + .then(() => { + commit(types.RECEIVE_DASHBOARD_STARRING_SUCCESS, newStarredValue); + }) + .catch(() => { + commit(types.RECEIVE_DASHBOARD_STARRING_FAILURE); + }); +}; + /** * Set a new array of metrics to a panel group * @param {*} data An object containing @@ -364,5 +414,11 @@ export const duplicateSystemDashboard = ({ state }, payload) => { }); }; +// Variables manipulation + +export const updateVariableValues = ({ commit }, updatedVariable) => { + commit(types.UPDATE_VARIABLE_VALUES, updatedVariable); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index a6d80c5063e..ae3ff5596e1 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -1,9 +1,25 @@ +import { flatMap } from 'lodash'; import { NOT_IN_DB_PREFIX } from '../constants'; const metricsIdsInPanel = panel => panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); /** + * Returns a reference to the currently selected dashboard + * from the list of dashboards. + * + * @param {Object} state + */ +export const selectedDashboard = state => { + const { allDashboards } = state; + return ( + allDashboards.find(d => d.path === state.currentDashboard) || + allDashboards.find(d => d.default) || + null + ); +}; + +/** * Get all state for metric in the dashboard or a group. The * states are not repeated so the dashboard or group can show * a global state. @@ -96,5 +112,17 @@ export const filteredEnvironments = state => env.name.toLowerCase().includes((state.environmentsSearchTerm || '').trim().toLowerCase()), ); +/** + * Maps an variables object to an array along with stripping + * the variable prefix. + * + * @param {Object} variables - Custom variables provided by the user + * @returns {Array} The custom variables array to be send to the API + * in the format of [variable1, variable1_value] + */ + +export const getCustomVariablesArray = state => + flatMap(state.promVariables, (variable, key) => [key, variable.value]); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 27a9a67edaa..d60334609fd 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -1,7 +1,13 @@ -// Dashboard "skeleton", groups, panels and metrics +// Dashboard "skeleton", groups, panels, metrics, query variables export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD'; export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS'; export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE'; +export const SET_VARIABLES = 'SET_VARIABLES'; +export const UPDATE_VARIABLE_VALUES = 'UPDATE_VARIABLE_VALUES'; + +export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING'; +export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS'; +export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE'; // Annotations export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS'; @@ -31,5 +37,5 @@ export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE' export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; - export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER'; +export const SET_EXPANDED_PANEL = 'SET_EXPANDED_PANEL'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index aa31b6642d7..f41cf3fc477 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,5 +1,7 @@ -import pick from 'lodash/pick'; +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'; @@ -71,6 +73,23 @@ export default { state.showEmptyState = true; }, + [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); + + 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 }); + }, + [types.RECEIVE_DASHBOARD_STARRING_FAILURE](state) { + state.isUpdatingStarredValue = false; + }, + /** * Deployments and environments */ @@ -134,6 +153,8 @@ export default { metric.loading = false; metric.result = null; }, + + // Parameters and other information [types.SET_INITIAL_STATE](state, initialState = {}) { Object.assign(state, pick(initialState, initialStateKeys)); }, @@ -163,4 +184,17 @@ export default { [types.SET_ENVIRONMENTS_FILTER](state, searchTerm) { state.environmentsSearchTerm = searchTerm; }, + [types.SET_EXPANDED_PANEL](state, { group, panel }) { + state.expandedPanel.group = group; + state.expandedPanel.panel = panel; + }, + [types.SET_VARIABLES](state, variables) { + state.promVariables = variables; + }, + [types.UPDATE_VARIABLE_VALUES](state, updatedVariable) { + Object.assign(state.promVariables[updatedVariable.key], { + ...state.promVariables[updatedVariable.key], + value: updatedVariable.value, + }); + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index e60510e747b..9ae1da93e5f 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -14,10 +14,27 @@ export default () => ({ emptyState: 'gettingStarted', showEmptyState: true, showErrorBanner: true, + isUpdatingStarredValue: false, dashboard: { panelGroups: [], }, + /** + * Panel that is currently "zoomed" in as + * a single panel in view. + */ + expandedPanel: { + /** + * {?String} Panel's group name. + */ + group: null, + /** + * {?Object} Panel content from `dashboard` + * null when no panel is expanded. + */ + panel: null, + }, allDashboards: [], + promVariables: {}, // Other project data annotations: [], diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 9f06d18c46f..a47e5f598f5 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -144,6 +144,7 @@ const mapYAxisToViewModel = ({ * @returns {Object} */ const mapPanelToViewModel = ({ + id = null, title = '', type, x_axis = {}, @@ -162,6 +163,7 @@ const mapPanelToViewModel = ({ const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line babel/camelcase return { + id, title, type, xLabel: xAxis.name, diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js new file mode 100644 index 00000000000..bfb469da19e --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js @@ -0,0 +1,167 @@ +import { isString } from 'lodash'; +import { VARIABLE_TYPES } from '../constants'; + +/** + * This file exclusively deals with parsing user-defined variables + * in dashboard yml file. + * + * As of 13.0, simple text, advanced text, simple custom and + * advanced custom variables are supported. + * + * In the future iterations, text and query variables will be + * supported + * + */ + +/** + * Simple text variable is a string value only. + * This method parses such variables to a standard format. + * + * @param {String|Object} simpleTextVar + * @returns {Object} + */ +const textSimpleVariableParser = simpleTextVar => ({ + type: VARIABLE_TYPES.text, + label: null, + value: simpleTextVar, +}); + +/** + * Advanced text variable is an object. + * This method parses such variables to a standard format. + * + * @param {Object} advTextVar + * @returns {Object} + */ +const textAdvancedVariableParser = advTextVar => ({ + type: VARIABLE_TYPES.text, + label: advTextVar.label, + value: advTextVar.options.default_value, +}); + +/** + * Normalize simple and advanced custom variable options to a standard + * format + * @param {Object} custom variable option + * @returns {Object} normalized custom variable options + */ +const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({ + default: defaultOpt, + text, + value, +}); + +/** + * Custom advanced variables are rendered as dropdown elements in the dashboard + * header. This method parses advanced custom variables. + * + * The default value is the option with default set to true or the first option + * if none of the options have default prop true. + * + * @param {Object} advVariable advance custom variable + * @returns {Object} + */ +const customAdvancedVariableParser = advVariable => { + const options = (advVariable?.options?.values ?? []).map(normalizeCustomVariableOptions); + const defaultOpt = options.find(opt => opt.default === true) || options[0]; + return { + type: VARIABLE_TYPES.custom, + label: advVariable.label, + value: defaultOpt?.value, + options, + }; +}; + +/** + * Simple custom variables have an array of values. + * This method parses such variables options to a standard format. + * + * @param {String} opt option from simple custom variable + * @returns {Object} + */ +const parseSimpleCustomOptions = opt => ({ text: opt, value: opt }); + +/** + * Custom simple variables are rendered as dropdown elements in the dashboard + * header. This method parses simple custom variables. + * + * Simple custom variables do not have labels so its set to null here. + * + * The default value is set to the first option as the user cannot + * set a default value for this format + * + * @param {Array} customVariable array of options + * @returns {Object} + */ +const customSimpleVariableParser = simpleVar => { + const options = (simpleVar || []).map(parseSimpleCustomOptions); + return { + type: VARIABLE_TYPES.custom, + value: options[0].value, + label: null, + options: options.map(normalizeCustomVariableOptions), + }; +}; + +/** + * Utility method to determine if a custom variable is + * simple or not. If its not simple, it is advanced. + * + * @param {Array|Object} customVar Array if simple, object if advanced + * @returns {Boolean} true if simple, false if advanced + */ +const isSimpleCustomVariable = customVar => Array.isArray(customVar); + +/** + * This method returns a parser based on the type of the variable. + * Currently, the supported variables are simple custom and + * advanced custom only. In the future, this method will support + * text and query variables. + * + * @param {Array|Object} variable + * @return {Function} parser method + */ +const getVariableParser = variable => { + if (isSimpleCustomVariable(variable)) { + return customSimpleVariableParser; + } else if (variable.type === VARIABLE_TYPES.custom) { + return customAdvancedVariableParser; + } else if (variable.type === VARIABLE_TYPES.text) { + return textAdvancedVariableParser; + } else if (isString(variable)) { + return textSimpleVariableParser; + } + return () => null; +}; + +/** + * This method parses the templating property in the dashboard yml file. + * The templating property has variables that are rendered as input elements + * for the user to edit. The values from input elements are relayed to + * backend and eventually Prometheus API. + * + * This method currently is not used anywhere. Once the issue + * https://gitlab.com/gitlab-org/gitlab/-/issues/214536 is completed, + * this method will have been used by the monitoring dashboard. + * + * @param {Object} templating templating variables from the dashboard yml file + * @returns {Object} a map of processed templating variables + */ +export const parseTemplatingVariables = ({ variables = {} } = {}) => + Object.entries(variables).reduce((acc, [key, variable]) => { + // get the parser + const parser = getVariableParser(variable); + // parse the variable + const parsedVar = parser(variable); + // for simple custom variable label is null and it should be + // replace with key instead + if (parsedVar) { + acc[key] = { + ...parsedVar, + label: parsedVar.label || key, + }; + } + return acc; + }, {}); + +export default {}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 7c6cd19eb7b..1f028ffbcad 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -1,9 +1,23 @@ -import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; +import { pickBy, mapKeys } from 'lodash'; +import { + queryToObject, + mergeUrlParams, + removeParams, + updateHistory, +} from '~/lib/utils/url_utility'; import { timeRangeParamNames, timeRangeFromParams, timeRangeToParams, } from '~/lib/utils/datetime_range'; +import { VARIABLE_PREFIX } from './constants'; + +/** + * List of non time range url parameters + * This will be removed once we add support for free text variables + * via the dashboard yaml files in https://gitlab.com/gitlab-org/gitlab/-/issues/215689 + */ +export const dashboardParams = ['dashboard', 'group', 'title', 'y_label', 'embedded']; /** * This method is used to validate if the graph data format for a chart component @@ -28,7 +42,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => { ); }; -/* eslint-disable @gitlab/require-i18n-strings */ /** * Checks that element that triggered event is located on cluster health check dashboard * @param {HTMLElement} element to check against @@ -36,6 +49,7 @@ export const graphDataValidatorForValues = (isValues, graphData) => { */ const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show'); +/* eslint-disable @gitlab/require-i18n-strings */ /** * Tracks snowplow event when user generates link to metric chart * @param {String} chart link that will be sent as a property for the event @@ -71,6 +85,7 @@ export const downloadCSVOptions = title => { return { category, action, label: 'Chart title', property: title }; }; +/* eslint-enable @gitlab/require-i18n-strings */ /** * Generate options for snowplow to track adding a new metric via the dashboard @@ -113,6 +128,78 @@ export const timeRangeFromUrl = (search = window.location.search) => { }; /** + * Variable labels are used as names for the dropdowns and also + * as URL params. Prefixing the name reduces the risk of + * collision with other URL params + * + * @param {String} label label for the template variable + * @returns {String} + */ +export const addPrefixToLabel = label => `${VARIABLE_PREFIX}${label}`; + +/** + * Before the templating variables are passed to the backend the + * prefix needs to be removed. + * + * This method removes the prefix at the beginning of the string. + * + * @param {String} label label to remove prefix from + * @returns {String} + */ +export const removePrefixFromLabel = label => + (label || '').replace(new RegExp(`^${VARIABLE_PREFIX}`), ''); + +/** + * Convert parsed template variables to an object + * with just keys and values. Prepare the promVariables + * to be added to the URL. Keys of the object will + * have a prefix so that these params can be + * differentiated from other URL params. + * + * @param {Object} variables + * @returns {Object} + */ +export const convertVariablesForURL = variables => + Object.keys(variables || {}).reduce((acc, key) => { + acc[addPrefixToLabel(key)] = variables[key]?.value; + return acc; + }, {}); + +/** + * User-defined variables from the URL are extracted. The variables + * begin with a constant prefix so that it doesn't collide with + * other URL params. + * + * @param {String} New URL + * @returns {Object} The custom variables defined by the user in the URL + */ + +export const getPromCustomVariablesFromUrl = (search = window.location.search) => { + const params = queryToObject(search); + // pick the params with variable prefix + const paramsWithVars = pickBy(params, (val, key) => key.startsWith(VARIABLE_PREFIX)); + // remove the prefix before storing in the Vuex store + return mapKeys(paramsWithVars, (val, key) => removePrefixFromLabel(key)); +}; + +/** + * Update the URL with promVariables. This usually get triggered when + * the user interacts with the dynamic input elements in the monitoring + * dashboard header. + * + * @param {Object} promVariables user defined variables + */ +export const setPromCustomVariablesFromUrl = promVariables => { + // prep the variables to append to URL + const parsedVariables = convertVariablesForURL(promVariables); + // update the URL + updateHistory({ + url: mergeUrlParams(parsedVariables, window.location.href), + title: document.title, + }); +}; + +/** * Returns a URL with no time range based on the current URL. * * @param {String} New URL @@ -133,6 +220,81 @@ export const timeRangeToUrl = (timeRange, url = window.location.href) => { }; /** + * Locates a panel (and its corresponding group) given a (URL) search query. Returns + * it as payload for the store to set the right expandaded panel. + * + * Params used to locate a panel are: + * - group: Group identifier + * - title: Panel title + * - y_label: Panel y_label + * + * @param {Object} dashboard - Dashboard reference from the Vuex store + * @param {String} search - URL location search query + * @returns {Object} payload - Payload for expanded panel to be displayed + * @returns {String} payload.group - Group where panel is located + * @returns {Object} payload.panel - Dashboard panel (graphData) reference + * @throws Will throw an error if Panel cannot be located. + */ +export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.search) => { + const params = queryToObject(search); + + // Search for the panel if any of the search params is identified + if (params.group || params.title || params.y_label) { + const panelGroup = dashboard.panelGroups.find(({ group }) => params.group === group); + const panel = panelGroup.panels.find( + // eslint-disable-next-line babel/camelcase + ({ y_label, title }) => y_label === params.y_label && title === params.title, + ); + + if (!panel) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Panel could no found by URL parameters.'); + } + return { group: panelGroup.group, panel }; + } + return null; +}; + +/** + * Convert panel information to a URL for the user to + * bookmark or share highlighting a specific panel. + * + * If no group/panel is set, the dashboard URL is returned. + * + * @param {?String} dashboard - Dashboard path, used as identifier for a dashboard + * @param {?Object} promVariables - Custom variables that came from the URL + * @param {?String} group - Group Identifier + * @param {?Object} panel - Panel object from the dashboard + * @param {?String} url - Base URL including current search params + * @returns Dashboard URL which expands a panel (chart) + */ +export const panelToUrl = ( + dashboard = null, + promVariables, + group, + panel, + url = window.location.href, +) => { + const params = { + dashboard, + ...promVariables, + }; + + if (group && panel) { + params.group = group; + params.title = panel.title; + params.y_label = panel.y_label; + } else { + // Remove existing parameters if any + params.group = null; + params.title = null; + params.y_label = null; + } + + return mergeUrlParams(params, url); +}; + +/** * Get the metric value from first data point. * Currently only used for bar charts * @@ -191,4 +353,39 @@ export const barChartsDataParser = (data = []) => {}, ); +/** + * Custom variables are defined in the dashboard yml file + * and their values can be passed through the URL. + * + * On component load, this method merges variables data + * from the yml file with URL data to store in the Vuex store. + * Not all params coming from the URL need to be stored. Only + * the ones that have a corresponding variable defined in the + * yml file. + * + * This ensures that there is always a single source of truth + * for variables + * + * This method can be improved further. See the below issue + * https://gitlab.com/gitlab-org/gitlab/-/issues/217713 + * + * @param {Object} varsFromYML template variables from yml file + * @returns {Object} + */ +export const mergeURLVariables = (varsFromYML = {}) => { + const varsFromURL = getPromCustomVariablesFromUrl(); + const variables = {}; + Object.keys(varsFromYML).forEach(key => { + if (Object.prototype.hasOwnProperty.call(varsFromURL, key)) { + variables[key] = { + ...varsFromYML[key], + value: varsFromURL[key], + }; + } else { + variables[key] = varsFromYML[key]; + } + }); + return variables; +}; + export default {}; diff --git a/app/assets/javascripts/monitoring/validators.js b/app/assets/javascripts/monitoring/validators.js new file mode 100644 index 00000000000..cd426f1a221 --- /dev/null +++ b/app/assets/javascripts/monitoring/validators.js @@ -0,0 +1,44 @@ +// Prop validator for alert information, expecting an object like the example below. +// +// { +// '/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37': { +// alert_path: "/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37", +// metricId: '1', +// operator: ">", +// query: "rate(http_requests_total[5m])[30m:1m]", +// threshold: 0.002, +// title: "Core Usage (Total)", +// } +// } +export function alertsValidator(value) { + return Object.keys(value).every(key => { + const alert = value[key]; + return ( + alert.alert_path && + key === alert.alert_path && + alert.metricId && + typeof alert.metricId === 'string' && + alert.operator && + typeof alert.threshold === 'number' + ); + }); +} + +// Prop validator for query information, expecting an array like the example below. +// +// [ +// { +// metricId: '16', +// label: 'Total Cores' +// }, +// { +// metricId: '17', +// label: 'Sub-total Cores' +// } +// ] +export function queriesValidator(value) { + return value.every( + query => + query.metricId && typeof query.metricId === 'string' && typeof query.label === 'string', + ); +} diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index 8671f0fd783..b96a111cf13 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -1,5 +1,3 @@ -/* eslint-disable no-else-return */ - import $ from 'jquery'; import '~/gl_dropdown'; import Api from './api'; @@ -23,9 +21,8 @@ export default class NamespaceSelect { toggleLabel(selected) { if (selected.id == null) { return selected.text; - } else { - return `${selected.kind}: ${selected.full_path}`; } + return `${selected.kind}: ${selected.full_path}`; }, data(term, dataCallback) { return Api.namespaces(term, namespaces => { @@ -43,9 +40,8 @@ export default class NamespaceSelect { text(namespace) { if (namespace.id == null) { return namespace.text; - } else { - return `${namespace.kind}: ${namespace.full_path}`; } + return `${namespace.kind}: ${namespace.full_path}`; }, renderRow: this.renderRow, clicked(options) { diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index be3ea4e680c..9d064894433 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, no-return-assign, no-else-return, @gitlab/require-i18n-strings */ +/* eslint-disable func-names, consistent-return, no-return-assign, @gitlab/require-i18n-strings */ import $ from 'jquery'; import RefSelectDropdown from './ref_select_dropdown'; @@ -76,9 +76,8 @@ export default class NewBranchForm { const matched = this.name.val().match(restriction.pattern); if (matched) { return errors.concat(formatter(matched.reduce(unique, []), restriction)); - } else { - return errors; } + return errors; }; const errors = this.restrictions.reduce(validator, []); if (errors.length > 0) { diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index dab27cf8269..fcb09ea90db 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -36,9 +36,9 @@ const katexRegexString = `( .replace(/\s/g, '') .trim(); -renderer.paragraph = t => { +function renderKatex(t) { let text = t; - let inline = false; + let numInline = 0; // number of successfull converted math formulas if (typeof katex !== 'undefined') { const katexString = text @@ -50,24 +50,40 @@ renderer.paragraph = t => { const numberOfMatches = katexString.match(regex); if (numberOfMatches && numberOfMatches.length !== 0) { + let matches = regex.exec(katexString); if (matchLocation > 0) { - let matches = regex.exec(katexString); - inline = true; + numInline += 1; while (matches !== null) { - const renderedKatex = katex.renderToString(matches[0].replace(/\$/g, '')); - text = `${text.replace(matches[0], ` ${renderedKatex}`)}`; + try { + const renderedKatex = katex.renderToString( + matches[0].replace(/\$/g, '').replace(/'/g, "'"), + ); // get the tick ' back again from HTMLified string + text = `${text.replace(matches[0], ` ${renderedKatex}`)}`; + } catch { + numInline -= 1; + } matches = regex.exec(katexString); } } else { - const matches = regex.exec(katexString); - text = katex.renderToString(matches[2]); + try { + text = katex.renderToString(matches[2].replace(/'/g, "'")); + } catch (error) { + numInline -= 1; + } } } } - + return [text, numInline > 0]; +} +renderer.paragraph = t => { + const [text, inline] = renderKatex(t); return `<p class="${inline ? 'inline-katex' : ''}">${text}</p>`; }; +renderer.listitem = t => { + const [text, inline] = renderKatex(t); + return `<li class="${inline ? 'inline-katex' : ''}">${text}</li>`; +}; marked.setOptions({ renderer, diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index 61626f7aaf5..f2d3796cccf 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -63,6 +63,9 @@ export default { }, rawCode(output) { if (output.text) { + if (typeof output.text === 'string') { + return output.text; + } return output.text.join(''); } diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 9e2231922b7..6e695de447d 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-properties, babel/camelcase, no-unused-expressions, default-case, -consistent-return, no-alert, no-param-reassign, no-else-return, +consistent-return, no-alert, no-param-reassign, no-shadow, no-useless-escape, class-methods-use-this */ @@ -256,7 +256,7 @@ export default class Notes { discussionNoteForm = $textarea.closest('.js-discussion-note-form'); if (discussionNoteForm.length) { if ($textarea.val() !== '') { - if (!window.confirm(__('Are you sure you want to cancel creating this comment?'))) { + if (!window.confirm(__('Your comment will be discarded.'))) { return; } } @@ -268,7 +268,7 @@ export default class Notes { originalText = $textarea.closest('form').data('originalNote'); newText = $textarea.val(); if (originalText !== newText) { - if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) { + if (!window.confirm(__('Are you sure you want to discard this comment?'))) { return; } } @@ -964,11 +964,11 @@ export default class Notes { form .prepend( - `<div class="avatar-note-form-holder"><div class="content"><a href="${escape( + `<a href="${escape( gon.current_username, )}" class="user-avatar-link d-none d-sm-block"><img class="avatar s40" src="${encodeURI( - gon.current_user_avatar_url, - )}" alt="${escape(gon.current_user_fullname)}" /></a></div></div>`, + gon.current_user_avatar_url || gon.default_avatar_url, + )}" alt="${escape(gon.current_user_fullname)}" /></a>`, ) .append('</div>') .find('.js-close-discussion-note-form') @@ -1123,10 +1123,9 @@ export default class Notes { if (row.is('.js-temp-notes-holder')) { // remove temporary row for diff lines return row.remove(); - } else { - // only remove the form - return form.remove(); } + // only remove the form + return form.remove(); } cancelDiscussionForm(e) { @@ -1397,7 +1396,7 @@ export default class Notes { } /** - * Check if note does not exists on page + * Check if note does not exist on page */ static isNewNote(noteEntity, noteIds) { return $.inArray(noteEntity.id, noteIds) === -1; diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 9a809b71a58..a070cf8866a 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -3,6 +3,7 @@ import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import { isEmpty } from 'lodash'; import Autosize from 'autosize'; +import { GlAlert, GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; @@ -34,6 +35,10 @@ export default { userAvatarLink, loadingButton, TimelineEntryItem, + GlAlert, + GlIntersperse, + GlLink, + GlSprintf, }, mixins: [issuableStateMixin], props: { @@ -57,8 +62,9 @@ export default { 'getNoteableData', 'getNotesData', 'openState', + 'getBlockedByIssues', ]), - ...mapState(['isToggleStateButtonLoading']), + ...mapState(['isToggleStateButtonLoading', 'isToggleBlockedIssueWarning']), noteableDisplayName() { return splitCamelCase(this.noteableType).toLowerCase(); }, @@ -159,6 +165,7 @@ export default { 'reopenIssue', 'toggleIssueLocalState', 'toggleStateButtonLoading', + 'toggleBlockedIssueWarning', ]), setIsSubmitButtonDisabled(note, isSubmitting) { if (!isEmpty(note) && !isSubmitting) { @@ -220,22 +227,17 @@ export default { this.isSubmitting = false; }, toggleIssueState() { + if ( + this.noteableType.toLowerCase() === constants.ISSUE_NOTEABLE_TYPE && + this.isOpen && + this.getBlockedByIssues && + this.getBlockedByIssues.length > 0 + ) { + this.toggleBlockedIssueWarning(true); + return; + } if (this.isOpen) { - this.closeIssue() - .then(() => { - this.enableButton(); - refreshUserMergeRequestCounts(); - }) - .catch(() => { - this.enableButton(); - this.toggleStateButtonLoading(false); - Flash( - sprintf( - __('Something went wrong while closing the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, - ), - ); - }); + this.forceCloseIssue(); } else { this.reopenIssue() .then(() => { @@ -258,6 +260,23 @@ export default { }); } }, + forceCloseIssue() { + this.closeIssue() + .then(() => { + this.enableButton(); + refreshUserMergeRequestCounts(); + }) + .catch(() => { + this.enableButton(); + this.toggleStateButtonLoading(false); + Flash( + sprintf( + __('Something went wrong while closing the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, + ), + ); + }); + }, discard(shouldClear = true) { // `blur` is needed to clear slash commands autocomplete cache if event fired. // `focus` is needed to remain cursor in the textarea. @@ -361,6 +380,36 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" > </textarea> </markdown-field> + <gl-alert + v-if="isToggleBlockedIssueWarning" + class="prepend-top-16" + :title="__('Are you sure you want to close this blocked issue?')" + :primary-button-text="__('Yes, close issue')" + :secondary-button-text="__('Cancel')" + variant="warning" + :dismissible="false" + @primaryAction="forceCloseIssue" + @secondaryAction="toggleBlockedIssueWarning(false) && enableButton()" + > + <p> + <gl-sprintf + :message=" + __('This issue is currently blocked by the following issues: %{issues}.') + " + > + <template #issues> + <gl-intersperse> + <gl-link + v-for="blockingIssue in getBlockedByIssues" + :key="blockingIssue.web_url" + :href="blockingIssue.web_url" + >#{{ blockingIssue.iid }}</gl-link + > + </gl-intersperse> + </template> + </gl-sprintf> + </p> + </gl-alert> <div class="note-form-actions"> <div class="float-left btn-group @@ -427,7 +476,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" </div> <loading-button - v-if="canToggleIssueState" + v-if="canToggleIssueState && !isToggleBlockedIssueWarning" :loading="isToggleStateButtonLoading" :container-class="[ actionButtonClassNames, diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index 07952f9edd9..4a1a1086329 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -29,9 +29,6 @@ export default { resolveAllDiscussionsIssuePath() { return this.getNoteableData.create_issue_to_resolve_discussions_path; }, - resolvedDiscussionsCount() { - return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount; - }, toggeableDiscussions() { return this.discussions.filter(discussion => !discussion.individual_note); }, @@ -60,15 +57,15 @@ export default { <div class="full-width-mobile d-flex d-sm-flex"> <div class="line-resolve-all"> <span - :class="{ 'is-active': allResolved }" - class="line-resolve-btn is-disabled" - type="button" + :class="{ 'line-resolve-btn is-active': allResolved, 'line-resolve-text': !allResolved }" > - <icon :name="allResolved ? 'check-circle-filled' : 'check-circle'" /> - </span> - <span class="line-resolve-text"> - {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }} - {{ n__('thread resolved', 'threads resolved', resolvableDiscussionsCount) }} + <template v-if="allResolved"> + <icon name="check-circle-filled" /> + {{ __('All threads resolved') }} + </template> + <template v-else> + {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} + </template> </span> </div> <div diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index b024884bea0..21d0bffdf1c 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -328,7 +328,8 @@ export default { <button class="btn note-edit-cancel js-close-discussion-note-form" type="button" - @click="cancelHandler()" + data-testid="cancelBatchCommentsEnabled" + @click="cancelHandler(true)" > {{ __('Cancel') }} </button> @@ -353,7 +354,8 @@ export default { <button class="btn btn-cancel note-edit-cancel js-close-discussion-note-form" type="button" - @click="cancelHandler()" + data-testid="cancel" + @click="cancelHandler(true)" > {{ __('Cancel') }} </button> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index f82b3554cac..81812ee2279 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -1,12 +1,17 @@ <script> import { mapActions } from 'vuex'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import GitlabTeamMemberBadge from '~/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'; export default { components: { timeAgoTooltip, - GitlabTeamMemberBadge, + GitlabTeamMemberBadge: () => + import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'), + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { author: { @@ -44,6 +49,18 @@ export default { required: false, default: true, }, + isConfidential: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isUsernameLinkHovered: false, + emojiTitle: '', + authorStatusHasTooltip: false, + }; }, computed: { toggleChevronClass() { @@ -55,10 +72,29 @@ export default { hasAuthor() { return this.author && Object.keys(this.author).length; }, - showGitlabTeamMemberBadge() { - return this.author?.is_gitlab_employee; + authorLinkClasses() { + return { + hover: this.isUsernameLinkHovered, + 'text-underline': this.isUsernameLinkHovered, + 'author-name-link': true, + 'js-user-link': true, + }; + }, + authorStatus() { + return this.author.status_tooltip_html; + }, + emojiElement() { + return this.$refs?.authorStatus?.querySelector('gl-emoji'); }, }, + mounted() { + this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : ''; + + const authorStatusTitle = this.$refs?.authorStatus + ?.querySelector('.user-status-emoji') + ?.getAttribute('title'); + this.authorStatusHasTooltip = authorStatusTitle && authorStatusTitle !== ''; + }, methods: { ...mapActions(['setTargetNoteHash']), handleToggle() { @@ -69,6 +105,20 @@ export default { this.setTargetNoteHash(this.noteTimestampLink); } }, + removeEmojiTitle() { + this.emojiElement.removeAttribute('title'); + }, + addEmojiTitle() { + this.emojiElement.setAttribute('title', this.emojiTitle); + }, + handleUsernameMouseEnter() { + this.$refs.authorNameLink.dispatchEvent(new Event('mouseenter')); + this.isUsernameLinkHovered = true; + }, + handleUsernameMouseLeave() { + this.$refs.authorNameLink.dispatchEvent(new Event('mouseleave')); + this.isUsernameLinkHovered = false; + }, }, }; </script> @@ -87,18 +137,34 @@ export default { </div> <template v-if="hasAuthor"> <a - v-once + ref="authorNameLink" :href="author.path" - class="js-user-link" + :class="authorLinkClasses" :data-user-id="author.id" :data-username="author.username" > <slot name="note-header-info"></slot> <span class="note-header-author-name bold">{{ author.name }}</span> - <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> - <span class="note-headline-light">@{{ author.username }}</span> </a> - <gitlab-team-member-badge v-if="showGitlabTeamMemberBadge" /> + <span + v-if="authorStatus" + ref="authorStatus" + v-on=" + authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {} + " + v-html="authorStatus" + ></span> + <span class="text-nowrap author-username"> + <a + ref="authorUsernameLink" + class="author-username-link" + :href="author.path" + @mouseenter="handleUsernameMouseEnter" + @mouseleave="handleUsernameMouseLeave" + ><span class="note-headline-light">@{{ author.username }}</span> + </a> + <gitlab-team-member-badge v-if="author && author.is_gitlab_employee" /> + </span> </template> <span v-else>{{ __('A deleted user') }}</span> <span class="note-headline-light note-headline-meta"> @@ -118,6 +184,15 @@ export default { </a> <time-ago-tooltip v-else ref="noteTimestamp" :time="createdAt" tooltip-placement="bottom" /> </template> + <gl-icon + v-if="isConfidential" + v-gl-tooltip:tooltipcontainer.bottom + data-testid="confidentialIndicator" + name="eye-slash" + :size="14" + :title="s__('Notes|Private comments are accessible by internal staff only')" + class="gl-ml-1 gl-text-gray-800 align-middle" + /> <slot name="extra-controls"></slot> <i v-if="showSpinner" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index dea782683f2..37675e20b3d 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -255,10 +255,16 @@ export default { </div> <div class="timeline-content"> <div class="note-header"> - <note-header v-once :author="author" :created-at="note.created_at" :note-id="note.id"> + <note-header + v-once + :author="author" + :created-at="note.created_at" + :note-id="note.id" + :is-confidential="note.confidential" + > <slot slot="note-header-info" name="note-header-info"></slot> <span v-if="commit" v-html="actionText"></span> - <span v-else class="d-none d-sm-inline">·</span> + <span v-else-if="note.created_at" class="d-none d-sm-inline">·</span> </note-header> <note-actions :author-id="author.id" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index c1dd56aedf2..faa6006945d 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -230,10 +230,11 @@ export default { const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') }; if (doesHashExistInUrl(constants.NOTE_UNDERSCORE)) { - return Object.assign({}, defaultConfig, { + return { + ...defaultConfig, filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE, persistFilter: false, - }); + }; } return defaultConfig; }, diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 8f9e2359e0d..ba814649078 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -2,11 +2,9 @@ import Vue from 'vue'; import notesApp from './components/notes_app.vue'; import initDiscussionFilters from './discussion_filters'; import initSortDiscussions from './sort_discussions'; -import createStore from './stores'; +import { store } from './stores'; document.addEventListener('DOMContentLoaded', () => { - const store = createStore(); - // eslint-disable-next-line no-new new Vue({ el: '#js-vue-notes', diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 08c7efd69a6..c9026352d18 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,6 +1,6 @@ import { mapGetters, mapActions, mapState } from 'vuex'; import { scrollToElement } from '~/lib/utils/common_utils'; -import eventHub from '../../notes/event_hub'; +import eventHub from '../event_hub'; /** * @param {string} selector diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 1b80b59621a..0999d0aa7ac 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -185,12 +185,27 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, }); }; +export const toggleBlockedIssueWarning = ({ commit }, value) => { + commit(types.TOGGLE_BLOCKED_ISSUE_WARNING, value); + // Hides Close issue button at the top of issue page + const closeDropdown = document.querySelector('.js-issuable-close-dropdown'); + if (closeDropdown) { + closeDropdown.classList.toggle('d-none'); + } else { + const closeButton = document.querySelector( + '.detail-page-header-actions .btn-close.btn-grouped', + ); + closeButton.classList.toggle('d-md-block'); + } +}; + export const closeIssue = ({ commit, dispatch, state }) => { dispatch('toggleStateButtonLoading', true); return axios.put(state.notesData.closePath).then(({ data }) => { commit(types.CLOSE_ISSUE); dispatch('emitStateChangedEvent', data); dispatch('toggleStateButtonLoading', false); + dispatch('toggleBlockedIssueWarning', false); }); }; @@ -233,7 +248,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { const hasQuickActions = utils.hasQuickActions(placeholderText); const replyId = noteData.data.in_reply_to_discussion_id; let methodToDispatch; - const postData = Object.assign({}, noteData); + const postData = { ...noteData }; if (postData.isDraft === true) { methodToDispatch = replyId ? 'batchComments/addDraftToDiscussion' diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index eb877083bca..85997b44bcc 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -35,6 +35,8 @@ export const getNoteableData = state => state.noteableData; export const getNoteableDataByProp = state => prop => state.noteableData[prop]; +export const getBlockedByIssues = state => state.noteableData.blocked_by_issues; + export const userCanReply = state => Boolean(state.noteableData.current_user.can_create_note); export const openState = state => state.noteableData.state; diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js index d41b02b4a4b..c4895f58656 100644 --- a/app/assets/javascripts/notes/stores/index.js +++ b/app/assets/javascripts/notes/stores/index.js @@ -4,4 +4,8 @@ import notesModule from './modules'; Vue.use(Vuex); -export default () => new Vuex.Store(notesModule()); +// NOTE: Giving the option to either use a singleton or new instance of notes. +const notesStore = () => new Vuex.Store(notesModule()); + +export default notesStore; +export const store = notesStore(); diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 81844ad6e98..25f0f546103 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -14,6 +14,7 @@ export default () => ({ // View layer isToggleStateButtonLoading: false, + isToggleBlockedIssueWarning: false, isNotesFetched: false, isLoading: true, isLoadingDescriptionVersion: false, @@ -24,6 +25,7 @@ export default () => ({ }, userData: {}, noteableData: { + confidential: false, // TODO: Move data like this to Issue Store, should not be apart of notes. current_user: {}, preview_note_path: 'path/to/preview', }, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 5b7225bb3d2..2f7b2788d8a 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -33,6 +33,7 @@ export const SET_DISCUSSIONS_SORT = 'SET_DISCUSSIONS_SORT'; export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const REOPEN_ISSUE = 'REOPEN_ISSUE'; export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING'; +export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING'; // Description version export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index dab09d1d05c..f06874991f0 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -249,6 +249,10 @@ export default { Object.assign(state, { isToggleStateButtonLoading: value }); }, + [types.TOGGLE_BLOCKED_ISSUE_WARNING](state, value) { + Object.assign(state, { isToggleBlockedIssueWarning: value }); + }, + [types.SET_NOTES_FETCHED_STATE](state, value) { Object.assign(state, { isNotesFetched: value }); }, diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js index 5ec9688a6e4..8183e81fb02 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js @@ -1,3 +1,3 @@ -import initUserInternalRegexPlaceholder from '../../application_settings/account_and_limits'; +import initUserInternalRegexPlaceholder from '../account_and_limits'; document.addEventListener('DOMContentLoaded', initUserInternalRegexPlaceholder()); diff --git a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js index 78a5c4c27be..ae2209b0292 100644 --- a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js +++ b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js @@ -1,3 +1,3 @@ -import DueDateSelectors from '~/due_date_select'; +import initExpiresAtField from '~/access_tokens'; -document.addEventListener('DOMContentLoaded', () => new DueDateSelectors()); +document.addEventListener('DOMContentLoaded', initExpiresAtField); 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 a99fde54981..b22fbf6b833 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 @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import { s__, sprintf } from '~/locale'; @@ -34,7 +34,7 @@ export default { return sprintf( s__('AdminProjects|Delete Project %{projectName}?'), { - projectName: `'${esc(this.projectName)}'`, + projectName: `'${escape(this.projectName)}'`, }, false, ); @@ -46,7 +46,7 @@ export default { and all related resources including issues, merge requests, etc.. Once you confirm and press %{strong_start}Delete project%{strong_end}, it cannot be undone or recovered.`), { - projectName: `<strong>${esc(this.projectName)}</strong>`, + projectName: `<strong>${escape(this.projectName)}</strong>`, strong_start: '<strong>', strong_end: '</strong>', }, @@ -57,7 +57,7 @@ export default { return sprintf( s__('AdminUsers|To confirm, type %{projectName}'), { - projectName: `<code>${esc(this.projectName)}</code>`, + projectName: `<code>${escape(this.projectName)}</code>`, }, false, ); diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue index 5b7c8141084..71df677c7fd 100644 --- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue +++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { GlModal, GlDeprecatedButton, GlFormInput } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; @@ -56,7 +56,7 @@ export default { return sprintf( this.content, { - username: `<strong>${esc(this.username)}</strong>`, + username: `<strong>${escape(this.username)}</strong>`, strong_start: '<strong>', strong_end: '</strong>', }, @@ -67,7 +67,7 @@ export default { return sprintf( s__('AdminUsers|To confirm, type %{username}'), { - username: `<code>${esc(this.username)}</code>`, + username: `<code>${escape(this.username)}</code>`, }, false, ); @@ -121,7 +121,7 @@ export default { /> </form> </template> - <template slot="modal-footer"> + <template #modal-footer> <gl-deprecated-button variant="secondary" @click="onCancel">{{ s__('Cancel') }}</gl-deprecated-button> diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue index 061044eba84..58dba41277d 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue @@ -123,7 +123,7 @@ Once deleted, it cannot be undone or recovered.`), kind="danger" @submit="onSubmit" > - <template slot="body" slot-scope="props"> + <template #body="props"> <p v-html="props.text"></p> </template> </deprecated-modal> diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue index 26adf4cbbe0..e18732d0fd5 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue @@ -68,7 +68,7 @@ export default { footer-primary-button-variant="warning" @submit="onSubmit" > - <template slot="title"> + <template #title> {{ title }} </template> <div> diff --git a/app/assets/javascripts/pages/milestones/shared/event_hub.js b/app/assets/javascripts/pages/milestones/shared/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/pages/milestones/shared/event_hub.js +++ b/app/assets/javascripts/pages/milestones/shared/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js index 78a5c4c27be..ae2209b0292 100644 --- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js +++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js @@ -1,3 +1,3 @@ -import DueDateSelectors from '~/due_date_select'; +import initExpiresAtField from '~/access_tokens'; -document.addEventListener('DOMContentLoaded', () => new DueDateSelectors()); +document.addEventListener('DOMContentLoaded', initExpiresAtField); diff --git a/app/assets/javascripts/pages/projects/alert_management/details/index.js b/app/assets/javascripts/pages/projects/alert_management/details/index.js new file mode 100644 index 00000000000..0124795e1af --- /dev/null +++ b/app/assets/javascripts/pages/projects/alert_management/details/index.js @@ -0,0 +1,5 @@ +import AlertDetails from '~/alert_management/details'; + +document.addEventListener('DOMContentLoaded', () => { + AlertDetails('#js-alert_details'); +}); diff --git a/app/assets/javascripts/pages/projects/alert_management/index/index.js b/app/assets/javascripts/pages/projects/alert_management/index/index.js new file mode 100644 index 00000000000..1e98bcfd2eb --- /dev/null +++ b/app/assets/javascripts/pages/projects/alert_management/index/index.js @@ -0,0 +1,5 @@ +import AlertManagementList from '~/alert_management/list'; + +document.addEventListener('DOMContentLoaded', () => { + AlertManagementList(); +}); diff --git a/app/assets/javascripts/pages/projects/blob/new/index.js b/app/assets/javascripts/pages/projects/blob/new/index.js index 720cb249052..189053f3ed7 100644 --- a/app/assets/javascripts/pages/projects/blob/new/index.js +++ b/app/assets/javascripts/pages/projects/blob/new/index.js @@ -1,12 +1,3 @@ import initBlobBundle from '~/blob_edit/blob_bundle'; -import initPopover from '~/blob/suggest_gitlab_ci_yml'; -document.addEventListener('DOMContentLoaded', () => { - initBlobBundle(); - - const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml'); - - if (suggestEl) { - initPopover(suggestEl); - } -}); +document.addEventListener('DOMContentLoaded', initBlobBundle); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 557aea0c5de..e5e4670a5d7 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -32,9 +32,10 @@ document.addEventListener('DOMContentLoaded', () => { GpgBadges.fetch(); - if (gon.features?.codeNavigation) { - const el = document.getElementById('js-code-navigation'); - const { codeNavigationPath, blobPath, definitionPathPrefix } = el.dataset; + const codeNavEl = document.getElementById('js-code-navigation'); + + if (gon.features?.codeNavigation && codeNavEl) { + const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset; // eslint-disable-next-line promise/catch-or-return import('~/code_navigation').then(m => diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js index 0d69a689316..31ec4e29ad2 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 'ee_else_ce/monitoring/monitoring_bundle'; +import monitoringBundle from '~/monitoring/monitoring_bundle_with_alerts'; document.addEventListener('DOMContentLoaded', monitoringBundle); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index bf54ca972b2..e8e0cda2139 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -7,6 +7,7 @@ import UsersSelect from '~/users_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; +import initIssuablesList from '~/issuables_list'; import initManualOrdering from '~/manual_ordering'; document.addEventListener('DOMContentLoaded', () => { @@ -16,9 +17,11 @@ document.addEventListener('DOMContentLoaded', () => { page: FILTERED_SEARCH.ISSUES, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); - new IssuableIndex(ISSUABLE_INDEX.ISSUE); + new IssuableIndex(ISSUABLE_INDEX.ISSUE); new ShortcutsNavigation(); new UsersSelect(); + initManualOrdering(); + initIssuablesList(); }); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 75df80a0f6c..46c9b2fe0af 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -12,6 +12,16 @@ export default function() { initIssueableApp(); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); + + // .js-design-management is currently EE-only. + // This will be moved to CE as part of https://gitlab.com/gitlab-org/gitlab/-/issues/212566#frontend + // at which point this conditional can be removed. + if (document.querySelector('.js-design-management')) { + import(/* webpackChunkName: 'design_management' */ '~/design_management') + .then(module => module.default()) + .catch(() => {}); + } + new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new 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 12e16b79d37..3b26047455d 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 @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; @@ -49,7 +49,7 @@ export default { const label = `<span class="label color-label" style="background-color: ${this.labelColor}; color: ${this.labelTextColor};" - >${esc(this.labelTitle)}</span>`; + >${escape(this.labelTitle)}</span>`; return sprintf( s__('Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>'), diff --git a/app/assets/javascripts/pages/projects/labels/event_hub.js b/app/assets/javascripts/pages/projects/labels/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/pages/projects/labels/event_hub.js +++ b/app/assets/javascripts/pages/projects/labels/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 3a0d9c17228..4efabcb7df3 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -1,5 +1,13 @@ <script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import { getWeekdayNames } from '~/lib/utils/datetime_utility'; + export default { + components: { + GlSprintf, + GlLink, + }, props: { initialCronInterval: { type: String, @@ -9,25 +17,51 @@ export default { }, data() { return { + isEditingCustom: false, + randomHour: this.generateRandomHour(), + randomWeekDayIndex: this.generateRandomWeekDayIndex(), + randomDay: this.generateRandomDay(), inputNameAttribute: 'schedule[cron]', cronInterval: this.initialCronInterval, - cronIntervalPresets: { - everyDay: '0 4 * * *', - everyWeek: '0 4 * * 0', - everyMonth: '0 4 1 * *', - }, cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron', - customInputEnabled: false, }; }, computed: { + cronIntervalPresets() { + return { + everyDay: `0 ${this.randomHour} * * *`, + everyWeek: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`, + everyMonth: `0 ${this.randomHour} ${this.randomDay} * *`, + }; + }, intervalIsPreset() { return Object.values(this.cronIntervalPresets).includes(this.cronInterval); }, - // The text input is editable when there's a custom interval, or when it's - // a preset interval and the user clicks the 'custom' radio button - isEditable() { - return Boolean(this.customInputEnabled || !this.intervalIsPreset); + formattedTime() { + if (this.randomHour > 12) { + return `${this.randomHour - 12}:00pm`; + } else if (this.randomHour === 12) { + return `12:00pm`; + } + return `${this.randomHour}:00am`; + }, + weekday() { + return getWeekdayNames()[this.randomWeekDayIndex]; + }, + everyDayText() { + return sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }); + }, + everyWeekText() { + return sprintf(s__('Every week (%{weekday} at %{time})'), { + weekday: this.weekday, + time: this.formattedTime, + }); + }, + everyMonthText() { + return sprintf(s__('Every month (Day %{day} at %{time})'), { + day: this.randomDay, + time: this.formattedTime, + }); }, }, watch: { @@ -39,14 +73,31 @@ export default { }); }, }, - created() { - if (this.intervalIsPreset) { - this.enableCustomInput = false; + // If at the mounting stage the default is still an empty string, we + // know we are not editing an existing field so we update it so + // that the default is the first radio option + mounted() { + if (this.cronInterval === '') { + this.cronInterval = this.cronIntervalPresets.everyDay; } }, methods: { + setCustomInput(e) { + if (!this.isEditingCustom) { + this.isEditingCustom = true; + this.$refs.customInput.click(); + // Because we need to manually trigger the click on the radio btn, + // it will add a space to update the v-model. If the user is typing + // and the space is added, it will feel very unituitive so we reset + // the value to the original + this.cronInterval = e.target.value; + } + if (this.intervalIsPreset) { + this.isEditingCustom = false; + } + }, toggleCustomInput(shouldEnable) { - this.customInputEnabled = shouldEnable; + this.isEditingCustom = shouldEnable; if (shouldEnable) { // We need to change the value so other radios don't remain selected @@ -54,6 +105,15 @@ export default { this.cronInterval = `${this.cronInterval} `; } }, + generateRandomHour() { + return Math.floor(Math.random() * 23); + }, + generateRandomWeekDayIndex() { + return Math.floor(Math.random() * 6); + }, + generateRandomDay() { + return Math.floor(Math.random() * 28); + }, }, }; </script> @@ -62,24 +122,6 @@ export default { <div class="interval-pattern-form-group"> <div class="cron-preset-radio-input"> <input - id="custom" - :name="inputNameAttribute" - :value="cronInterval" - :checked="isEditable" - class="label-bold" - type="radio" - @click="toggleCustomInput(true)" - /> - - <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label> - - <span class="cron-syntax-link-wrap"> - (<a :href="cronSyntaxUrl" target="_blank"> {{ __('Cron syntax') }} </a>) - </span> - </div> - - <div class="cron-preset-radio-input"> - <input id="every-day" v-model="cronInterval" :name="inputNameAttribute" @@ -89,7 +131,9 @@ export default { @click="toggleCustomInput(false)" /> - <label class="label-bold" for="every-day"> {{ __('Every day (at 4:00am)') }} </label> + <label class="label-bold" for="every-day"> + {{ everyDayText }} + </label> </div> <div class="cron-preset-radio-input"> @@ -104,7 +148,7 @@ export default { /> <label class="label-bold" for="every-week"> - {{ __('Every week (Sundays at 4:00am)') }} + {{ everyWeekText }} </label> </div> @@ -120,20 +164,43 @@ export default { /> <label class="label-bold" for="every-month"> - {{ __('Every month (on the 1st at 4:00am)') }} + {{ everyMonthText }} </label> </div> + <div class="cron-preset-radio-input"> + <input + id="custom" + ref="customInput" + v-model="cronInterval" + :name="inputNameAttribute" + :value="cronInterval" + class="label-bold" + type="radio" + @click="toggleCustomInput(true)" + /> + + <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label> + + <gl-sprintf :message="__('(%{linkStart}Cron syntax%{linkEnd})')"> + <template #link="{content}"> + <gl-link :href="cronSyntaxUrl" target="_blank" class="gl-font-sm"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + <div class="cron-interval-input-wrapper"> <input id="schedule_cron" v-model="cronInterval" :placeholder="__('Define a custom pattern with cron syntax')" :name="inputNameAttribute" - :disabled="!isEditable" class="form-control inline cron-interval-input" type="text" required="true" + @input="setCustomInput" /> </div> </div> diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue index 22512a6f12a..da96e6f36b4 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue @@ -2,7 +2,8 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; import Translate from '../../../../../vue_shared/translate'; -import illustrationSvg from '../icons/intro_illustration.svg'; +// Full path is needed for Jest to be able to correctly mock this file +import illustrationSvg from '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg'; import { parseBoolean } from '~/lib/utils/common_utils'; Vue.use(Translate); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js index dc6df27f1c7..497e2c9c0ae 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js @@ -11,9 +11,7 @@ Vue.use(Translate); function initIntervalPatternInput() { const intervalPatternMount = document.getElementById('interval-pattern-input'); - const initialCronInterval = intervalPatternMount - ? intervalPatternMount.dataset.initialInterval - : ''; + const initialCronInterval = intervalPatternMount?.dataset?.initialInterval; return new Vue({ el: intervalPatternMount, diff --git a/app/assets/javascripts/pages/projects/pipelines/dag/index.js b/app/assets/javascripts/pages/projects/pipelines/dag/index.js new file mode 100644 index 00000000000..d19c22ba556 --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipelines/dag/index.js @@ -0,0 +1,2 @@ +// /dag is an alias for show +import '../show/index'; diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js index 4b4a274794d..bbad3238ec4 100644 --- a/app/assets/javascripts/pages/projects/pipelines/index/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js @@ -50,6 +50,7 @@ document.addEventListener( hasGitlabCi: parseBoolean(this.dataset.hasGitlabCi), ciLintPath: this.dataset.ciLintPath, resetCachePath: this.dataset.resetCachePath, + projectId: this.dataset.projectId, }, }); }, diff --git a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js new file mode 100644 index 00000000000..ae2209b0292 --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js @@ -0,0 +1,3 @@ +import initExpiresAtField from '~/access_tokens'; + +document.addEventListener('DOMContentLoaded', initExpiresAtField); 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 6efddec1172..ab32fe18972 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 @@ -1,9 +1,8 @@ <script> -import { GlSprintf, GlLink } from '@gitlab/ui'; +import { GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; import { s__ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import projectFeatureSetting from './project_feature_setting.vue'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import projectSettingRow from './project_setting_row.vue'; @@ -12,6 +11,7 @@ import { visibilityLevelDescriptions, featureAccessLevelMembers, featureAccessLevelEveryone, + featureAccessLevel, } from '../constants'; import { toggleHiddenClassBySelector } from '../external'; @@ -24,8 +24,9 @@ export default { projectSettingRow, GlSprintf, GlLink, + GlFormCheckbox, }, - mixins: [settingsMixin, glFeatureFlagsMixin()], + mixins: [settingsMixin], props: { currentSettings: { @@ -127,7 +128,7 @@ export default { wikiAccessLevel: 20, snippetsAccessLevel: 20, pagesAccessLevel: 20, - metricsAccessLevel: visibilityOptions.PRIVATE, + metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS, containerRegistryEnabled: true, lfsEnabled: true, requestAccessEnabled: true, @@ -174,6 +175,10 @@ export default { return options; }, + metricsOptionsDropdownEnabled() { + return this.featureAccessLevelOptions.length < 2; + }, + repositoryEnabled() { return this.repositoryAccessLevel > 0; }, @@ -195,10 +200,6 @@ export default { 'ProjectSettings|View and edit files in this project. Non-project members will only have read access', ); }, - - metricsDashboardVisibilitySwitchingAvailable() { - return this.glFeatures.metricsDashboardVisibilitySwitchingAvailable; - }, }, watch: { @@ -211,6 +212,7 @@ export default { 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) { // When from Internal->Private narrow access for only members this.pagesAccessLevel = 10; @@ -225,6 +227,7 @@ export default { 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; this.highlightChanges(); } }, @@ -473,7 +476,6 @@ export default { /> </project-setting-row> <project-setting-row - v-if="metricsDashboardVisibilitySwitchingAvailable" ref="metrics-visibility-settings" :label="__('Metrics Dashboard')" :help-text=" @@ -485,17 +487,18 @@ export default { <div class="project-feature-controls"> <div class="select-wrapper"> <select - v-model="metricsAccessLevel" + v-model="metricsDashboardAccessLevel" + :disabled="metricsOptionsDropdownEnabled" name="project[project_feature_attributes][metrics_dashboard_access_level]" - class="form-control select-control" + class="form-control project-repo-select select-control" > <option - :value="visibilityOptions.PRIVATE" - :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)" + :value="featureAccessLevelMembers[0]" + :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)" >{{ featureAccessLevelMembers[1] }}</option > <option - :value="visibilityOptions.PUBLIC" + :value="featureAccessLevelEveryone[0]" :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)" >{{ featureAccessLevelEveryone[1] }}</option > @@ -517,5 +520,23 @@ export default { ) }}</span> </project-setting-row> + <project-setting-row class="mb-3"> + <input + :value="showDefaultAwardEmojis" + type="hidden" + name="project[project_setting_attributes][show_default_award_emojis]" + /> + <gl-form-checkbox + v-model="showDefaultAwardEmojis" + name="project[project_setting_attributes][show_default_award_emojis]" + > + {{ s__('ProjectSettings|Show default award emojis') }} + <template #help>{{ + s__( + 'ProjectSettings|When enabled, issues, merge requests, and snippets will always show thumbs-up and thumbs-down award emoji buttons.', + ) + }}</template> + </gl-form-checkbox> + </project-setting-row> </div> </template> diff --git a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue index 6af346ace67..580cca49b5e 100644 --- a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue +++ b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { GlModal, GlModalDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; @@ -38,7 +38,7 @@ export default { return sprintf( s__('WikiPageConfirmDelete|Delete page %{pageTitle}?'), { - pageTitle: esc(this.pageTitle), + pageTitle: escape(this.pageTitle), }, false, ); @@ -46,6 +46,7 @@ export default { }, methods: { onSubmit() { + window.onbeforeunload = null; this.$refs.form.submit(); }, }, diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js index 93afdc54ce1..ed67219383b 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/projects/wikis/wikis.js @@ -44,6 +44,19 @@ export default class Wikis { linkExample.innerHTML = MARKDOWN_LINK_TEXT[e.target.value]; }); } + + const wikiTextarea = document.querySelector('form.wiki-form #wiki_content'); + const wikiForm = document.querySelector('form.wiki-form'); + + if (wikiTextarea) { + wikiTextarea.addEventListener('input', () => { + window.onbeforeunload = () => ''; + }); + + wikiForm.addEventListener('submit', () => { + window.onbeforeunload = null; + }); + } } handleWikiTitleChange(e) { diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js index 7fdf4ee0bf3..e54e32199f0 100644 --- a/app/assets/javascripts/pages/search/init_filtered_search.js +++ b/app/assets/javascripts/pages/search/init_filtered_search.js @@ -1,4 +1,4 @@ -import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; +import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; export default ({ page, diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 24ae900b445..e1a0e2df0e0 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -116,7 +116,9 @@ export default { </template> </table> - <div slot="footer"></div> + <template #footer> + <div></div> + </template> </gl-modal> {{ title }} <request-warning :html-id="htmlId" :warnings="warnings" /> diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index 4598626718c..b3068c46bcb 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -18,6 +18,11 @@ export default class PersistentUserCallout { init() { const closeButton = this.container.querySelector('.js-close'); + + if (!closeButton) { + return; + } + closeButton.addEventListener('click', event => this.dismiss(event)); if (this.deferLinks) { diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index ebd7a17040a..15c220a554d 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -69,7 +69,9 @@ export default { > <ci-icon :status="group.status" /> - <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom"> + <span + class="ci-status-text text-truncate mw-70p gl-pl-1-deprecated-no-really-do-not-use-me d-inline-block align-bottom" + > {{ group.name }} </span> diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue index 7125790ac3d..74a261f35d7 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -27,7 +27,9 @@ export default { <template> <span class="ci-job-name-component mw-100"> <ci-icon :status="status" /> - <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom"> + <span + class="ci-status-text text-truncate mw-70p gl-pl-1-deprecated-no-really-do-not-use-me d-inline-block align-bottom" + > {{ name }} </span> </span> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index 3d3dabbdf22..bed0ed51d5f 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -1,5 +1,5 @@ <script> -import { isEmpty, escape as esc } from 'lodash'; +import { isEmpty, escape } from 'lodash'; import stageColumnMixin from '../../mixins/stage_column_mixin'; import JobItem from './job_item.vue'; import JobGroupDropdown from './job_group_dropdown.vue'; @@ -44,7 +44,7 @@ export default { }, methods: { groupId(group) { - return `ci-badge-${esc(group.name)}`; + return `ci-badge-${escape(group.name)}`; }, pipelineActionRequestComplete() { this.$emit('refreshPipelineGraph'); diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index d4f23697e09..fc93635bdb5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -9,14 +9,18 @@ import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue'; 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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { TablePagination, NavigationTabs, NavigationControls, + PipelinesFilteredSearch, }, - mixins: [pipelinesMixin, CIPaginationMixin], + mixins: [pipelinesMixin, CIPaginationMixin, glFeatureFlagsMixin()], props: { store: { type: Object, @@ -78,6 +82,10 @@ export default { required: false, default: null, }, + projectId: { + type: String, + required: true, + }, }, data() { return { @@ -209,6 +217,9 @@ export default { }, ]; }, + canFilterPipelines() { + return this.glFeatures.filterPipelinesSearch; + }, }, created() { this.service = new PipelinesService(this.endpoint); @@ -238,6 +249,30 @@ export default { createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.')); }); }, + resetRequestData() { + this.requestData = { page: this.page, scope: this.scope }; + }, + filterPipelines(filters) { + this.resetRequestData(); + + 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) { + this.requestData[filter.type] = filter.value.data; + } + + if (!filter.type) { + createFlash(RAW_TEXT_WARNING, 'warning'); + } + }); + + if (filters.length === 0) { + this.resetRequestData(); + } + + this.updateContent(this.requestData); + }, }, }; </script> @@ -267,6 +302,13 @@ export default { /> </div> + <pipelines-filtered-search + v-if="canFilterPipelines" + :pipelines="state.pipelines" + :project-id="projectId" + @filterPipelines="filterPipelines" + /> + <div class="content-list pipelines"> <gl-loading-icon v-if="stateToRender === $options.stateMap.loading" diff --git a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue new file mode 100644 index 00000000000..8f9c3eb70a2 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue @@ -0,0 +1,91 @@ +<script> +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'; + +export default { + components: { + GlFilteredSearch, + }, + props: { + pipelines: { + type: Array, + required: true, + }, + projectId: { + type: String, + required: true, + }, + }, + data() { + return { + projectUsers: null, + projectBranches: null, + }; + }, + computed: { + tokens() { + return [ + { + type: 'username', + 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', + icon: 'branch', + title: s__('Pipeline|Branch name'), + unique: true, + token: PipelineBranchNameToken, + operators: [{ value: '=', description: __('is'), default: 'true' }], + branches: this.projectBranches, + projectId: this.projectId, + }, + ]; + }, + }, + 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; + }); + }, + methods: { + onSubmit(filters) { + this.$emit('filterPipelines', filters); + }, + }, +}; +</script> + +<template> + <div class="row-content-block"> + <gl-filtered-search + :placeholder="__('Filter pipelines')" + :available-tokens="tokens" + @submit="onSubmit" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index e25f8ab4790..981914dd046 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -99,9 +99,10 @@ export default { // 3. If GitLab user does not have avatar, they might have a Gravatar } else if (this.pipeline.commit.author_gravatar_url) { - commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, { + commitAuthorInformation = { + ...this.pipeline.commit.author, avatar_url: this.pipeline.commit.author_gravatar_url, - }); + }; } // 4. If committer is not a GitLab User, they can have a Gravatar } else { diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index 7426936515a..569920a4f31 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -137,7 +137,7 @@ export default { }, isDropdownOpen() { - return this.$el.classList.contains('open'); + return this.$el.classList.contains('show'); }, pipelineActionRequestComplete() { diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 388b300b39d..06ab45adf80 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -21,7 +21,8 @@ export default { return this.selectedSuite.total_count > 0; }, showTests() { - return this.testReports.total_count > 0; + const { test_suites: testSuites = [] } = this.testReports; + return testSuites.length > 0; }, }, methods: { 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 9739ef76867..80a1c83f171 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -29,7 +29,14 @@ export default { successPercentage() { // Returns a full number when the decimals equal .00. // Otherwise returns a float to two decimal points - return Number(((this.report.success_count / this.report.total_count) * 100 || 0).toFixed(2)); + // Do not include skipped tests as part of the total when doing success calculations. + + const totalCompletedCount = this.report.total_count - this.report.skipped_count; + + if (totalCompletedCount > 0) { + return Number(((this.report.success_count / totalCompletedCount) * 100 || 0).toFixed(2)); + } + return 0; }, formattedDuration() { return formatTime(secondsToMilliseconds(this.report.total_time)); diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue index 6effd6e949d..4dfb67dd8e8 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -1,14 +1,19 @@ <script> import { mapGetters } from 'vuex'; import { s__ } from '~/locale'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import store from '~/pipelines/stores/test_reports'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { name: 'TestsSummaryTable', components: { + GlIcon, SmartVirtualList, }, + directives: { + GlTooltip: GlTooltipDirective, + }, store, props: { heading: { @@ -75,7 +80,10 @@ export default { v-for="(testSuite, index) in getTestSuites" :key="index" role="row" - class="gl-responsive-table-row gl-responsive-table-row-clickable test-reports-summary-row rounded cursor-pointer js-suite-row" + class="gl-responsive-table-row test-reports-summary-row rounded js-suite-row" + :class="{ + 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error, + }" @click="tableRowClick(testSuite)" > <div class="table-section section-25"> @@ -84,6 +92,14 @@ export default { </div> <div class="table-mobile-content underline cgray pl-3"> {{ testSuite.name }} + <gl-icon + v-if="testSuite.suite_error" + ref="suiteErrorIcon" + v-gl-tooltip + name="error" + :title="testSuite.suite_error" + class="vertical-align-middle" + /> </div> </div> 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 new file mode 100644 index 00000000000..a7a3f986255 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue @@ -0,0 +1,70 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import Api from '~/api'; +import { FETCH_BRANCH_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 { + branches: this.config.branches, + loading: true, + }; + }, + methods: { + fetchBranchBySearchTerm(searchTerm) { + Api.branches(this.config.projectId, searchTerm) + .then(res => { + this.branches = res.data.map(branch => branch.name); + this.loading = false; + }) + .catch(err => { + createFlash(FETCH_BRANCH_ERROR_MESSAGE); + this.loading = false; + throw err; + }); + }, + searchBranches: debounce(function debounceSearch({ data }) { + this.fetchBranchBySearchTerm(data); + }, FILTER_PIPELINES_SEARCH_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchBranches" + > + <template #suggestions> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="(branch, index) in branches" + :key="index" + :value="branch" + > + {{ branch }} + </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 new file mode 100644 index 00000000000..83e3558e1a1 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue @@ -0,0 +1,114 @@ +<script> +import { + GlFilteredSearchToken, + GlAvatar, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import Api from '~/api'; +import createFlash from '~/flash'; +import { debounce } from 'lodash'; +import { + ANY_TRIGGER_AUTHOR, + FETCH_AUTHOR_ERROR_MESSAGE, + FILTER_PIPELINES_SEARCH_DELAY, +} from '../../constants'; + +export default { + anyTriggerAuthor: ANY_TRIGGER_AUTHOR, + components: { + GlFilteredSearchToken, + GlAvatar, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + users: this.config.triggerAuthors, + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data.toLowerCase(); + }, + activeUser() { + return this.users.find(user => { + return user.username.toLowerCase() === this.currentValue; + }); + }, + }, + methods: { + fetchAuthorBySearchTerm(searchTerm) { + Api.projectUsers(this.config.projectId, searchTerm) + .then(res => { + this.users = res; + this.loading = false; + }) + .catch(err => { + createFlash(FETCH_AUTHOR_ERROR_MESSAGE); + this.loading = false; + throw err; + }); + }, + searchAuthors: debounce(function debounceSearch({ data }) { + this.fetchAuthorBySearchTerm(data); + }, FILTER_PIPELINES_SEARCH_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="activeUser" + :size="16" + :src="activeUser.avatar_url" + shape="circle" + class="gl-mr-2" + /> + <span>{{ activeUser ? activeUser.name : inputValue }}</span> + </template> + <template #suggestions> + <gl-filtered-search-suggestion :value="$options.anyTriggerAuthor">{{ + $options.anyTriggerAuthor + }}</gl-filtered-search-suggestion> + <gl-dropdown-divider /> + + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="user in users" + :key="user.username" + :value="user.username" + > + <div class="d-flex"> + <gl-avatar :size="32" :src="user.avatar_url" /> + <div> + <div>{{ user.name }}</div> + <div>@{{ user.username }}</div> + </div> + </div> + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index c9655d18a04..d694430830b 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -1,9 +1,19 @@ +import { s__, __ } from '~/locale'; + export const CANCEL_REQUEST = 'CANCEL_REQUEST'; 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 TestStatus = { FAILED: 'failed', SKIPPED: 'skipped', SUCCESS: 'success', }; + +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 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 d76425c96b7..01295874e56 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -14,13 +14,7 @@ import axios from '~/lib/utils/axios_utils'; Vue.use(Translate); -export default () => { - const { dataset } = document.querySelector('.js-pipeline-details-vue'); - - const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); - - mediator.fetchPipeline(); - +const createPipelinesDetailApp = mediator => { // eslint-disable-next-line no-new new Vue({ el: '#js-pipeline-graph-vue', @@ -50,7 +44,9 @@ export default () => { }); }, }); +}; +const createPipelineHeaderApp = mediator => { // eslint-disable-next-line no-new new Vue({ el: '#js-pipeline-header-vue', @@ -94,7 +90,9 @@ export default () => { }); }, }); +}; +const createPipelinesTabs = dataset => { const tabsElement = document.querySelector('.pipelines-tabs'); const testReportsEnabled = window.gon && window.gon.features && window.gon.features.junitPipelineView; @@ -119,27 +117,40 @@ export default () => { tabsElement.addEventListener('click', tabClickHandler); } + } +}; - // eslint-disable-next-line no-new - new Vue({ - el: '#js-pipeline-tests-detail', - components: { - TestReports, - }, - render(createElement) { - return createElement('test-reports'); - }, - }); +const createTestDetails = detailsEndpoint => { + // eslint-disable-next-line no-new + new Vue({ + el: '#js-pipeline-tests-detail', + components: { + TestReports, + }, + render(createElement) { + return createElement('test-reports'); + }, + }); - axios - .get(dataset.testReportsCountEndpoint) - .then(({ data }) => { - if (!data.total_count) { - return; - } + axios + .get(detailsEndpoint) + .then(({ data }) => { + if (!data.total_count) { + return; + } - document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count; - }) - .catch(() => {}); - } + document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count; + }) + .catch(() => {}); +}; + +export default () => { + const { dataset } = document.querySelector('.js-pipeline-details-vue'); + const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); + mediator.fetchPipeline(); + + createPipelinesDetailApp(mediator); + createPipelineHeaderApp(mediator); + createPipelinesTabs(dataset); + createTestDetails(dataset.testReportsCountEndpoint); }; diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index 3c755db23dc..ae94d7a7ca0 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -19,13 +19,23 @@ export default class PipelinesService { } getPipelines(data = {}) { - const { scope, page } = data; + const { scope, page, username, ref } = data; const { CancelToken } = axios; + const queryParams = { scope, page }; + + if (username) { + queryParams.username = username; + } + + if (ref) { + queryParams.ref = ref; + } + this.cancelationSource = CancelToken.source(); return axios.get(this.endpoint, { - params: { scope, page }, + params: queryParams, cancelToken: this.cancelationSource.token, }); } diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js index 1ef73760e02..c6f65277c8d 100644 --- a/app/assets/javascripts/pipelines/stores/pipeline_store.js +++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js @@ -15,7 +15,7 @@ export default class PipelineStore { * @param {Object} pipeline */ storePipeline(pipeline = {}) { - const pipelineCopy = Object.assign({}, pipeline); + const pipelineCopy = { ...pipeline }; if (pipelineCopy.triggered_by) { pipelineCopy.triggered_by = [pipelineCopy.triggered_by]; diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue index 85c5c073a74..aeb69fb1c05 100644 --- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue +++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue @@ -85,7 +85,7 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), kind="danger" @submit="onSubmit" > - <template slot="body" slot-scope="props"> + <template #body="props"> <p v-html="props.text"></p> <form ref="form" :action="actionUrl" method="post"> diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index fa09e063552..feb83e07607 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; @@ -43,10 +43,10 @@ You are going to change the username %{currentUsernameBold} to %{newUsernameBold Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group. Please update your Git repository remotes as soon as possible.`), { - currentUsernameBold: `<strong>${esc(this.username)}</strong>`, - newUsernameBold: `<strong>${esc(this.newUsername)}</strong>`, - currentUsername: esc(this.username), - newUsername: esc(this.newUsername), + currentUsernameBold: `<strong>${escape(this.username)}</strong>`, + newUsernameBold: `<strong>${escape(this.newUsername)}</strong>`, + currentUsername: escape(this.username), + newUsername: escape(this.newUsername), }, false, ); diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 15c7c09366c..2b2c365dd54 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-else-return */ +/* eslint-disable func-names */ import $ from 'jquery'; import Api from './api'; @@ -74,18 +74,17 @@ const projectSelect = () => { }, projectsCallback, ); - } else { - return Api.projects( - query.term, - { - order_by: this.orderBy, - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - membership: !this.allProjects, - }, - projectsCallback, - ); } + return Api.projects( + query.term, + { + order_by: this.orderBy, + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + membership: !this.allProjects, + }, + projectsCallback, + ); }, id(project) { if (simpleFilter) return project.id; diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue index 78f9389b80c..eb514b5c070 100644 --- a/app/assets/javascripts/projects/commits/components/author_select.vue +++ b/app/assets/javascripts/projects/commits/components/author_select.vue @@ -110,6 +110,7 @@ export default { <gl-new-dropdown :text="dropdownText" :disabled="hasSearchParam" + toggle-class="gl-py-3" class="gl-dropdown w-100 mt-2 mt-sm-0" > <gl-new-dropdown-header> diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index daeae071d6a..a3a53c2f975 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/browser'; import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; @@ -26,6 +27,9 @@ export default { }, }) .then(({ data }) => dispatch('receiveAuthorsSuccess', data)) - .catch(() => dispatch('receiveAuthorsError')); + .catch(error => { + Sentry.captureException(error); + dispatch('receiveAuthorsError'); + }); }, }; diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue index 4dc1c512689..cdf03a5013f 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -113,6 +113,9 @@ export default { </script> <template> <div> + <div class="mb-3"> + <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3> + </div> <h4 class="my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4> <div class="row"> <div class="col-md-6"> diff --git a/app/assets/javascripts/registry/explorer/components/image_list.vue b/app/assets/javascripts/registry/explorer/components/image_list.vue new file mode 100644 index 00000000000..bc209b12738 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/image_list.vue @@ -0,0 +1,124 @@ +<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/constants.js b/app/assets/javascripts/registry/explorer/constants.js index d4b9d25b212..7cbe657bfc0 100644 --- a/app/assets/javascripts/registry/explorer/constants.js +++ b/app/assets/javascripts/registry/explorer/constants.js @@ -37,16 +37,31 @@ 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 deleting the tag.', + '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_TAG_SUCCESS_MESSAGE = s__('ContainerRegistry|Tag deleted successfully'); export const DELETE_TAGS_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while deleting the tags.', + '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 DELETE_TAGS_SUCCESS_MESSAGE = s__('ContainerRegistry|Tags deleted successfully'); export const DEFAULT_PAGE = 1; export const DEFAULT_PAGE_SIZE = 10; @@ -65,6 +80,27 @@ 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__( diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js index 9269aa074f8..2bba3ee4ff9 100644 --- a/app/assets/javascripts/registry/explorer/index.js +++ b/app/assets/javascripts/registry/explorer/index.js @@ -19,7 +19,7 @@ export default () => { const { endpoint } = el.dataset; const store = createStore(); - const router = createRouter(endpoint, store); + const router = createRouter(endpoint); store.dispatch('setInitialState', el.dataset); const attachMainComponent = () => diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 6afd4d1107a..cc2dc531dc8 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -9,12 +9,14 @@ import { GlPagination, GlModal, GlSprintf, + GlAlert, + GlLink, GlEmptyState, GlResizeObserverDirective, GlSkeletonLoader, } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import { n__, s__ } from '~/locale'; +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'; @@ -35,6 +37,14 @@ import { 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'; export default { @@ -49,6 +59,8 @@ export default { GlSkeletonLoader, GlSprintf, GlEmptyState, + GlAlert, + GlLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -60,6 +72,19 @@ export default { 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, + }, data() { return { selectedItems: [], @@ -67,6 +92,7 @@ export default { selectAllChecked: false, modalDescription: null, isDesktop: true, + deleteAlertType: false, }; }, computed: { @@ -78,9 +104,15 @@ export default { }, 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` }, + { + 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 }, @@ -110,20 +142,43 @@ 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: s__(`ContainerRegistry|You are about to remove %{item} tags. Are you sure?`), + message: REMOVE_TAGS_CONFIRMATION_TEXT, item: this.itemsToBeDeleted.length, }; } else { const { path } = this.tags[itemIndex]; this.modalDescription = { - message: s__(`ContainerRegistry|You are about to remove %{item}. Are you sure?`), + message: REMOVE_TAG_CONFIRMATION_TEXT, item: path, }; } @@ -179,19 +234,17 @@ export default { this.track('click_button'); this.$refs.deleteModal.show(); }, - handleSingleDelete(itemToDelete) { + handleSingleDelete(index) { + const itemToDelete = this.tags[index]; this.itemsToBeDeleted = []; + this.selectedItems = this.selectedItems.filter(i => i !== index); return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id }) - .then(() => - this.$toast.show(DELETE_TAG_SUCCESS_MESSAGE, { - type: 'success', - }), - ) - .catch(() => - this.$toast.show(DELETE_TAG_ERROR_MESSAGE, { - type: 'error', - }), - ); + .then(() => { + this.deleteAlertType = 'success_tag'; + }) + .catch(() => { + this.deleteAlertType = 'danger_tag'; + }); }, handleMultipleDelete() { const { itemsToBeDeleted } = this; @@ -202,24 +255,19 @@ export default { ids: itemsToBeDeleted.map(x => this.tags[x].name), params: this.$route.params.id, }) - .then(() => - this.$toast.show(DELETE_TAGS_SUCCESS_MESSAGE, { - type: 'success', - }), - ) - .catch(() => - this.$toast.show(DELETE_TAGS_ERROR_MESSAGE, { - type: 'error', - }), - ); + .then(() => { + this.deleteAlertType = 'success_tags'; + }) + .catch(() => { + this.deleteAlertType = 'danger_tags'; + }); }, onDeletionConfirmed() { this.track('confirm_delete'); if (this.isMultiDelete) { this.handleMultipleDelete(); } else { - const index = this.itemsToBeDeleted[0]; - this.handleSingleDelete(this.tags[index]); + this.handleSingleDelete(this.itemsToBeDeleted[0]); } }, handleResize() { @@ -231,9 +279,24 @@ 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" + 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="s__('ContainerRegistry|%{imageName} tags')"> + <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> <template #imageName> {{ imageName }} </template> @@ -256,8 +319,8 @@ export default { :disabled="!selectedItems || selectedItems.length === 0" class="float-right" variant="danger" - :title="s__('ContainerRegistry|Remove selected tags')" - :aria-label="s__('ContainerRegistry|Remove selected tags')" + :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" + :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" @click="deleteMultipleItems()" > <gl-icon name="remove" /> @@ -272,17 +335,24 @@ export default { @change="updateSelectedItems(index)" /> </template> - <template #cell(name)="{item}"> - <span ref="rowName"> - {{ 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" - /> + <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"> @@ -299,15 +369,15 @@ export default { </span> </template> <template #cell(created_at)="{value}"> - <span ref="rowTime"> + <span ref="rowTime" v-gl-tooltip :title="tooltipTitle(value)"> {{ timeFormatted(value) }} </span> </template> <template #cell(actions)="{index, item}"> <gl-deprecated-button ref="singleDeleteButton" - :title="s__('ContainerRegistry|Remove tag')" - :aria-label="s__('ContainerRegistry|Remove tag')" + :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" @@ -337,15 +407,9 @@ export default { </template> <gl-empty-state v-else - :title="s__('ContainerRegistry|This image has no active tags')" + :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE" :svg-path="config.noContainersImage" - :description=" - 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.`, - ) - " + :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE" class="mx-auto my-0" /> </template> diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/registry/explorer/pages/index.vue index 95d83c82987..709a163d56d 100644 --- a/app/assets/javascripts/registry/explorer/pages/index.vue +++ b/app/assets/javascripts/registry/explorer/pages/index.vue @@ -1,46 +1,9 @@ <script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { mapState, mapActions, mapGetters } from 'vuex'; -import { s__ } from '~/locale'; - -export default { - components: { - GlAlert, - GlSprintf, - GlLink, - }, - i18n: { - garbageCollectionTipText: s__( - 'ContainerRegistry|This Registry contains deleted image tag data. Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.', - ), - }, - computed: { - ...mapState(['config']), - ...mapGetters(['showGarbageCollection']), - }, - methods: { - ...mapActions(['setShowGarbageCollectionTip']), - }, -}; +export default {}; </script> <template> <div> - <gl-alert - v-if="showGarbageCollection" - variant="tip" - class="my-2" - @dismiss="setShowGarbageCollectionTip(false)" - > - <gl-sprintf :message="$options.i18n.garbageCollectionTipText"> - <template #docLink="{content}"> - <gl-link :href="config.garbageCollectionHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </gl-alert> - <transition name="slide"> <router-view ref="router-view" /> </transition> diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 8923c305b2d..4efa6f08d84 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -2,53 +2,52 @@ import { mapState, mapActions } from 'vuex'; import { GlEmptyState, - GlPagination, GlTooltipDirective, - GlDeprecatedButton, - GlIcon, GlModal, GlSprintf, GlLink, GlAlert, GlSkeletonLoader, + GlSearchBoxByClick, } from '@gitlab/ui'; import Tracking from '~/tracking'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + 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 { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, CONTAINER_REGISTRY_TITLE, CONNECTION_ERROR_TITLE, CONNECTION_ERROR_MESSAGE, LIST_INTRO_TEXT, - LIST_DELETE_BUTTON_DISABLED, - REMOVE_REPOSITORY_LABEL, REMOVE_REPOSITORY_MODAL_TEXT, - ROW_SCHEDULED_FOR_DELETION, + REMOVE_REPOSITORY_LABEL, + SEARCH_PLACEHOLDER_TEXT, + IMAGE_REPOSITORY_LIST_LABEL, + EMPTY_RESULT_TITLE, + EMPTY_RESULT_MESSAGE, } from '../constants'; export default { name: 'RegistryListApp', components: { GlEmptyState, - GlPagination, ProjectEmptyState, GroupEmptyState, ProjectPolicyAlert, - ClipboardButton, QuickstartDropdown, - GlDeprecatedButton, - GlIcon, + ImageList, GlModal, GlSprintf, GlLink, GlAlert, GlSkeletonLoader, + GlSearchBoxByClick, }, directives: { GlTooltip: GlTooltipDirective, @@ -60,20 +59,23 @@ export default { height: 40, }, i18n: { - containerRegistryTitle: CONTAINER_REGISTRY_TITLE, - connectionErrorTitle: CONNECTION_ERROR_TITLE, - connectionErrorMessage: CONNECTION_ERROR_MESSAGE, - introText: LIST_INTRO_TEXT, - deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED, - removeRepositoryLabel: REMOVE_REPOSITORY_LABEL, - removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT, - rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION, - asyncDeleteErrorMessage: ASYNC_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, }, data() { return { itemToDelete: {}, deleteAlertType: null, + search: null, + isEmpty: false, }; }, computed: { @@ -83,14 +85,6 @@ export default { label: 'registry_repository_delete', }; }, - currentPage: { - get() { - return this.pagination.page; - }, - set(page) { - this.requestImagesList({ page }); - }, - }, showQuickStartDropdown() { return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); }, @@ -103,8 +97,19 @@ export default { : DELETE_IMAGE_ERROR_MESSAGE; }, }, + mounted() { + this.loadImageList(this.$route.name); + }, methods: { ...mapActions(['requestImagesList', 'requestDeleteImage']), + loadImageList(fromName) { + if (!fromName || !this.images?.length) { + return this.requestImagesList().then(() => { + this.isEmpty = this.images.length === 0; + }); + } + return Promise.resolve(); + }, deleteImage(item) { this.track('click_button'); this.itemToDelete = item; @@ -120,10 +125,6 @@ export default { this.deleteAlertType = 'danger'; }); }, - encodeListItem(item) { - const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id }); - return window.btoa(params); - }, dismissDeleteAlert() { this.deleteAlertType = null; this.itemToDelete = {}; @@ -152,12 +153,12 @@ export default { <gl-empty-state v-if="config.characterError" - :title="$options.i18n.connectionErrorTitle" + :title="$options.i18n.CONNECTION_ERROR_TITLE" :svg-path="config.containersErrorImage" > <template #description> <p> - <gl-sprintf :message="$options.i18n.connectionErrorMessage"> + <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE"> <template #docLink="{content}"> <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank"> {{ content }} @@ -171,11 +172,11 @@ export default { <template v-else> <div> <div class="d-flex justify-content-between align-items-center"> - <h4>{{ $options.i18n.containerRegistryTitle }}</h4> + <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.introText"> + <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT"> <template #docLink="{content}"> <gl-link :href="config.helpPagePath" target="_blank"> {{ content }} @@ -199,73 +200,40 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <div v-if="images.length" ref="imagesList" class="d-flex flex-column"> - <div - v-for="(listItem, index) in images" - :key="index" - ref="rowItem" - v-gl-tooltip="{ - placement: 'left', - disabled: !listItem.deleting, - title: $options.i18n.rowScheduledForDeletion, - }" - > - <div - class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom" - :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }" - > - <div class="d-felx align-items-center"> - <router-link - ref="detailsLink" - :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" - > - {{ listItem.path }} - </router-link> - <clipboard-button - v-if="listItem.location" - ref="clipboardButton" - :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.asyncDeleteErrorMessage" - 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.deleteButtonDisabled" - > - <gl-deprecated-button - ref="deleteImageButton" - v-gl-tooltip - :disabled="!listItem.destroy_path || listItem.deleting" - :title="$options.i18n.removeRepositoryLabel" - :aria-label="$options.i18n.removeRepositoryLabel" - class="btn-inverted" - variant="danger" - @click="deleteImage(listItem)" - > - <gl-icon name="remove" /> - </gl-deprecated-button> - </div> + <template v-if="!isEmpty"> + <div class="gl-display-flex gl-p-1" data-testid="listHeader"> + <div class="gl-flex-fill-1"> + <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5> + </div> + <div> + <gl-search-box-by-click + v-model="search" + :placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT" + @submit="requestImagesList({ name: $event })" + /> </div> </div> - <gl-pagination - v-model="currentPage" - :per-page="pagination.perPage" - :total-items="pagination.total" - align="center" - class="w-100 mt-2" + + <image-list + v-if="images.length" + :images="images" + :pagination="pagination" + @pageChange="requestImagesList({ pagination: { page: $event }, name: search })" + @delete="deleteImage" /> - </div> + <gl-empty-state + v-else + :svg-path="config.noContainersImage" + data-testid="emptySearch" + :title="$options.i18n.EMPTY_RESULT_TITLE" + class="container-message" + > + <template #description> + {{ $options.i18n.EMPTY_RESULT_MESSAGE }} + </template> + </gl-empty-state> + </template> <template v-else> <project-empty-state v-if="!config.isGroupPage" /> <group-empty-state v-else /> @@ -279,9 +247,9 @@ export default { @ok="handleDeleteImage" @cancel="track('cancel_delete')" > - <template #modal-title>{{ $options.i18n.removeRepositoryLabel }}</template> + <template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template> <p> - <gl-sprintf :message="$options.i18n.removeRepositoryModalText"> + <gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT"> <template #title> <b>{{ itemToDelete.path }}</b> </template> diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js index 28df3177df4..478eaca1a68 100644 --- a/app/assets/javascripts/registry/explorer/router.js +++ b/app/assets/javascripts/registry/explorer/router.js @@ -7,7 +7,7 @@ import { decodeAndParse } from './utils'; Vue.use(VueRouter); -export default function createRouter(base, store) { +export default function createRouter(base) { const router = new VueRouter({ base, mode: 'history', @@ -20,12 +20,6 @@ export default function createRouter(base, store) { nameGenerator: () => s__('ContainerRegistry|Container Registry'), root: true, }, - beforeEnter: (to, from, next) => { - if (!from.name || !store.state.images?.length) { - store.dispatch('requestImagesList'); - } - next(); - }, }, { name: 'details', @@ -34,10 +28,6 @@ export default function createRouter(base, store) { meta: { nameGenerator: route => decodeAndParse(route.params.id).name, }, - beforeEnter: (to, from, next) => { - store.dispatch('requestTagsList', { params: to.params.id }); - next(); - }, }, ], }); diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js index b4f66dbbcd6..7f80bc21d6e 100644 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -23,12 +23,15 @@ export const receiveTagsListSuccess = ({ commit }, { data, headers }) => { commit(types.SET_TAGS_PAGINATION, headers); }; -export const requestImagesList = ({ commit, dispatch, state }, pagination = {}) => { +export const requestImagesList = ( + { commit, dispatch, state }, + { pagination = {}, name = null } = {}, +) => { commit(types.SET_MAIN_LOADING, true); const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination; return axios - .get(state.config.endpoint, { params: { page, per_page: perPage } }) + .get(state.config.endpoint, { params: { page, per_page: perPage, name } }) .then(({ data, headers }) => { dispatch('receiveImagesListSuccess', { data, headers }); }) @@ -66,7 +69,7 @@ export const requestDeleteTag = ({ commit, dispatch, state }, { tag, params }) = dispatch('setShowGarbageCollectionTip', true); return dispatch('requestTagsList', { pagination: state.tagsPagination, params }); }) - .catch(() => { + .finally(() => { commit(types.SET_MAIN_LOADING, false); }); }; @@ -83,7 +86,7 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) dispatch('setShowGarbageCollectionTip', true); return dispatch('requestTagsList', { pagination: state.tagsPagination, params }); }) - .catch(() => { + .finally(() => { commit(types.SET_MAIN_LOADING, false); }); }; diff --git a/app/assets/javascripts/registry/explorer/stores/index.js b/app/assets/javascripts/registry/explorer/stores/index.js index b3ff2e6e002..153032e37d3 100644 --- a/app/assets/javascripts/registry/explorer/stores/index.js +++ b/app/assets/javascripts/registry/explorer/stores/index.js @@ -15,4 +15,5 @@ export const createStore = () => mutations, }); +// Deprecated and to be removed export default createStore(); diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js index 6ae1dbb72c4..a318aa2a694 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; import Translate from '~/vue_shared/translate'; -import store from './store/'; +import store from './store'; import RegistrySettingsApp from './components/registry_settings_app.vue'; Vue.use(GlToast); diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js index ef4b4f0ba02..ac1a931d8e0 100644 --- a/app/assets/javascripts/registry/settings/store/getters.js +++ b/app/assets/javascripts/registry/settings/store/getters.js @@ -16,6 +16,7 @@ export const getSettings = (state, getters) => ({ older_than: getters.getOlderThan, keep_n: getters.getKeepN, name_regex: state.settings.name_regex, + name_regex_keep: state.settings.name_regex_keep, }); export const getIsEdited = state => !isEqual(state.original, state.settings); diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js index bb7071b020b..3ba13419b98 100644 --- a/app/assets/javascripts/registry/settings/store/mutations.js +++ b/app/assets/javascripts/registry/settings/store/mutations.js @@ -21,7 +21,7 @@ export default { state.original = Object.freeze(settings); }, [types.RESET_SETTINGS](state) { - state.settings = Object.assign({}, state.original); + state.settings = { ...state.original }; }, [types.TOGGLE_LOADING](state) { state.isLoading = !state.isLoading; diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue index 3e212f09e35..04a547db07e 100644 --- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue +++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue @@ -1,8 +1,23 @@ <script> import { uniqueId } from 'lodash'; import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlSprintf } from '@gitlab/ui'; -import { s__, __ } from '~/locale'; -import { NAME_REGEX_LENGTH } from '../constants'; +import { + NAME_REGEX_LENGTH, + ENABLED_TEXT, + DISABLED_TEXT, + TEXT_AREA_INVALID_FEEDBACK, + EXPIRATION_INTERVAL_LABEL, + EXPIRATION_SCHEDULE_LABEL, + KEEP_N_LABEL, + NAME_REGEX_LABEL, + NAME_REGEX_PLACEHOLDER, + NAME_REGEX_DESCRIPTION, + NAME_REGEX_KEEP_LABEL, + NAME_REGEX_KEEP_PLACEHOLDER, + NAME_REGEX_KEEP_DESCRIPTION, + ENABLE_TOGGLE_LABEL, + ENABLE_TOGGLE_DESCRIPTION, +} from '../constants'; import { mapComputedToEvent } from '../utils'; export default { @@ -40,42 +55,73 @@ export default { default: 'right', }, }, - nameRegexPlaceholder: '.*', + i18n: { + textAreaInvalidFeedback: TEXT_AREA_INVALID_FEEDBACK, + enableToggleLabel: ENABLE_TOGGLE_LABEL, + enableToggleDescription: ENABLE_TOGGLE_DESCRIPTION, + }, selectList: [ { name: 'expiration-policy-interval', - label: s__('ContainerRegistry|Expiration interval:'), + label: EXPIRATION_INTERVAL_LABEL, model: 'older_than', optionKey: 'olderThan', }, { name: 'expiration-policy-schedule', - label: s__('ContainerRegistry|Expiration schedule:'), + label: EXPIRATION_SCHEDULE_LABEL, model: 'cadence', optionKey: 'cadence', }, { name: 'expiration-policy-latest', - label: s__('ContainerRegistry|Number of tags to retain:'), + label: KEEP_N_LABEL, model: 'keep_n', optionKey: 'keepN', }, ], + textAreaList: [ + { + name: 'expiration-policy-name-matching', + label: NAME_REGEX_LABEL, + model: 'name_regex', + placeholder: NAME_REGEX_PLACEHOLDER, + stateVariable: 'nameRegexState', + description: NAME_REGEX_DESCRIPTION, + }, + { + name: 'expiration-policy-keep-name', + label: NAME_REGEX_KEEP_LABEL, + model: 'name_regex_keep', + placeholder: NAME_REGEX_KEEP_PLACEHOLDER, + stateVariable: 'nameKeepRegexState', + description: NAME_REGEX_KEEP_DESCRIPTION, + }, + ], data() { return { uniqueId: uniqueId(), }; }, computed: { - ...mapComputedToEvent(['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex'], 'value'), + ...mapComputedToEvent( + ['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex', 'name_regex_keep'], + 'value', + ), policyEnabledText() { - return this.enabled ? __('enabled') : __('disabled'); + return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; }, - nameRegexState() { - return this.name_regex ? this.name_regex.length <= NAME_REGEX_LENGTH : null; + textAreaState() { + return { + nameRegexState: this.validateNameRegex(this.name_regex), + nameKeepRegexState: this.validateNameRegex(this.name_regex_keep), + }; }, fieldsValidity() { - return this.nameRegexState !== false; + return ( + this.textAreaState.nameRegexState !== false && + this.textAreaState.nameKeepRegexState !== false + ); }, isFormElementDisabled() { return !this.enabled || this.isLoading; @@ -94,6 +140,9 @@ export default { }, }, methods: { + validateNameRegex(value) { + return value ? value.length <= NAME_REGEX_LENGTH : null; + }, idGenerator(id) { return `${id}_${this.uniqueId}`; }, @@ -111,7 +160,7 @@ export default { :label-cols="labelCols" :label-align="labelAlign" :label-for="idGenerator('expiration-policy-toggle')" - :label="s__('ContainerRegistry|Expiration policy:')" + :label="$options.i18n.enableToggleLabel" > <div class="d-flex align-items-start"> <gl-toggle @@ -120,9 +169,7 @@ export default { :disabled="isLoading" /> <span class="mb-2 ml-1 lh-2"> - <gl-sprintf - :message="s__('ContainerRegistry|Docker tag expiration policy is %{toggleStatus}')" - > + <gl-sprintf :message="$options.i18n.enableToggleDescription"> <template #toggleStatus> <strong>{{ policyEnabledText }}</strong> </template> @@ -157,35 +204,34 @@ export default { </gl-form-group> <gl-form-group - :id="idGenerator('expiration-policy-name-matching-group')" + v-for="textarea in $options.textAreaList" + :id="idGenerator(`${textarea.name}-group`)" + :key="textarea.name" :label-cols="labelCols" :label-align="labelAlign" - :label-for="idGenerator('expiration-policy-name-matching')" - :label=" - s__('ContainerRegistry|Docker tags with names matching this regex pattern will expire:') - " - :state="nameRegexState" - :invalid-feedback=" - s__('ContainerRegistry|The value of this input should be less than 255 characters') - " + :label-for="idGenerator(textarea.name)" + :state="textAreaState[textarea.stateVariable]" + :invalid-feedback="$options.i18n.textAreaInvalidFeedback" > + <template #label> + <gl-sprintf :message="textarea.label"> + <template #italic="{content}"> + <i>{{ content }}</i> + </template> + </gl-sprintf> + </template> <gl-form-textarea - :id="idGenerator('expiration-policy-name-matching')" - v-model="name_regex" - :placeholder="$options.nameRegexPlaceholder" - :state="nameRegexState" + :id="idGenerator(textarea.name)" + :value="value[textarea.model]" + :placeholder="textarea.placeholder" + :state="textAreaState[textarea.stateVariable]" :disabled="isFormElementDisabled" trim + @input="updateModel($event, textarea.model)" /> <template #description> <span ref="regex-description"> - <gl-sprintf - :message=" - s__( - 'ContainerRegistry|Wildcards such as %{codeStart}.*-stable%{codeEnd} or %{codeStart}production/.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', - ) - " - > + <gl-sprintf :message="textarea.description"> <template #code="{content}"> <code>{{ content }}</code> </template> diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js index c0dac466b29..4689d01b1c8 100644 --- a/app/assets/javascripts/registry/shared/constants.js +++ b/app/assets/javascripts/registry/shared/constants.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; export const FETCH_SETTINGS_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while fetching the expiration policy.', @@ -13,3 +13,33 @@ export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__( ); export const NAME_REGEX_LENGTH = 255; + +export const ENABLED_TEXT = __('enabled'); +export const DISABLED_TEXT = __('disabled'); + +export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Expiration policy:'); +export const ENABLE_TOGGLE_DESCRIPTION = s__( + 'ContainerRegistry|Docker tag expiration policy is %{toggleStatus}', +); + +export const TEXT_AREA_INVALID_FEEDBACK = s__( + 'ContainerRegistry|The value of this input should be less than 255 characters', +); + +export const EXPIRATION_INTERVAL_LABEL = s__('ContainerRegistry|Expiration interval:'); +export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Expiration schedule:'); +export const KEEP_N_LABEL = s__('ContainerRegistry|Number of tags to retain:'); +export const NAME_REGEX_LABEL = s__( + 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}', +); +export const NAME_REGEX_PLACEHOLDER = '.*'; +export const NAME_REGEX_DESCRIPTION = s__( + 'ContainerRegistry|Regular expressions such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', +); +export const NAME_REGEX_KEEP_LABEL = s__( + 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}be preserved:%{italicEnd}', +); +export const NAME_REGEX_KEEP_PLACEHOLDER = ''; +export const NAME_REGEX_KEEP_DESCRIPTION = s__( + 'ContainerRegistry|Regular expressions such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', +); diff --git a/app/assets/javascripts/releases/components/app_edit.vue b/app/assets/javascripts/releases/components/app_edit.vue index 8d68ff02116..01dd0638023 100644 --- a/app/assets/javascripts/releases/components/app_edit.vue +++ b/app/assets/javascripts/releases/components/app_edit.vue @@ -1,7 +1,7 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { __, sprintf } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; @@ -9,6 +9,7 @@ import { BACK_URL_PARAM } from '~/releases/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; import AssetLinksForm from './asset_links_form.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; export default { name: 'ReleaseEditApp', @@ -18,6 +19,7 @@ export default { GlButton, MarkdownField, AssetLinksForm, + MilestoneCombobox, }, directives: { autofocusonshow, @@ -32,6 +34,10 @@ export default { 'markdownPreviewPath', 'releasesPagePath', 'updateReleaseApiDocsPath', + 'release', + 'newMilestonePath', + 'manageMilestonesPath', + 'projectId', ]), ...mapGetters('detail', ['isValid']), showForm() { @@ -58,7 +64,7 @@ export default { 'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}', ), { - linkStart: `<a href="${esc( + linkStart: `<a href="${escape( this.updateReleaseApiDocsPath, )}" target="_blank" rel="noopener noreferrer">`, linkEnd: '</a>', @@ -82,6 +88,14 @@ export default { this.updateReleaseNotes(notes); }, }, + releaseMilestones: { + get() { + return this.$store.state.detail.release.milestones; + }, + set(milestones) { + this.updateReleaseMilestones(milestones); + }, + }, cancelPath() { return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath; }, @@ -91,6 +105,18 @@ export default { isSaveChangesDisabled() { return this.isUpdatingRelease || !this.isValid; }, + milestoneComboboxExtraLinks() { + return [ + { + text: __('Create new'), + url: this.newMilestonePath, + }, + { + text: __('Manage milestones'), + url: this.manageMilestonesPath, + }, + ]; + }, }, created() { this.fetchRelease(); @@ -101,6 +127,7 @@ export default { 'updateRelease', 'updateReleaseTitle', 'updateReleaseNotes', + 'updateReleaseMilestones', ]), }, }; @@ -137,6 +164,16 @@ export default { class="form-control" /> </gl-form-group> + <gl-form-group class="w-50"> + <label>{{ __('Milestones') }}</label> + <div class="d-flex flex-column col-md-6 col-sm-10 pl-0"> + <milestone-combobox + v-model="releaseMilestones" + :project-id="projectId" + :extra-links="milestoneComboboxExtraLinks" + /> + </div> + </gl-form-group> <gl-form-group> <label for="release-notes">{{ __('Release notes') }}</label> <div class="bordered-box pr-3 pl-3"> @@ -147,19 +184,19 @@ export default { :add-spacing-classes="false" class="prepend-top-10 append-bottom-10" > - <textarea - id="release-notes" - slot="textarea" - v-model="releaseNotes" - class="note-textarea js-gfm-input js-autosize markdown-area" - dir="auto" - data-supports-quick-actions="false" - :aria-label="__('Release notes')" - :placeholder="__('Write your release notes or drag your files here…')" - @keydown.meta.enter="updateRelease()" - @keydown.ctrl.enter="updateRelease()" - > - </textarea> + <template #textarea> + <textarea + id="release-notes" + v-model="releaseNotes" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-supports-quick-actions="false" + :aria-label="__('Release notes')" + :placeholder="__('Write your release notes or drag your files here…')" + @keydown.meta.enter="updateRelease()" + @keydown.ctrl.enter="updateRelease()" + ></textarea> + </template> </markdown-field> </div> </gl-form-group> @@ -174,12 +211,9 @@ export default { type="submit" :aria-label="__('Save changes')" :disabled="isSaveChangesDisabled" + >{{ __('Save changes') }}</gl-button > - {{ __('Save changes') }} - </gl-button> - <gl-button :href="cancelPath" class="js-cancel-button"> - {{ __('Cancel') }} - </gl-button> + <gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button> </div> </form> </div> diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index 215a376fc76..67085ecca2b 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapActions } from 'vuex'; -import { GlSkeletonLoading, GlEmptyState, GlLink } from '@gitlab/ui'; +import { GlSkeletonLoading, GlEmptyState, GlLink, GlButton } from '@gitlab/ui'; import { getParameterByName, historyPushState, @@ -18,6 +18,7 @@ export default { ReleaseBlock, TablePagination, GlLink, + GlButton, }, props: { projectId: { @@ -69,14 +70,16 @@ export default { </script> <template> <div class="flex flex-column mt-2"> - <gl-link + <gl-button v-if="newReleasePath" :href="newReleasePath" :aria-describedby="shouldRenderEmptyState && 'releases-description'" - class="btn btn-success align-self-end mb-2 js-new-release-btn" + category="primary" + variant="success" + class="align-self-end mb-2 js-new-release-btn" > {{ __('New release') }} - </gl-link> + </gl-button> <gl-skeleton-loading v-if="isLoading" class="js-loading" /> diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index 4bdc88f01dd..0698ca5e31f 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -162,7 +162,7 @@ export default { :state="isNameValid(link)" @change="onLinkTitleInput(link.id, $event)" /> - <template v-slot:invalid-feedback> + <template #invalid-feedback> <span v-if="hasEmptyName(link)" class="invalid-feedback d-inline"> {{ __('Link title is required') }} </span> diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue index 59c1b3eb48e..acae6fda533 100644 --- a/app/assets/javascripts/releases/components/evidence_block.vue +++ b/app/assets/javascripts/releases/components/evidence_block.vue @@ -76,11 +76,13 @@ export default { </gl-link> <expand-button> - <template slot="short"> + <template #short> <span class="js-short monospace">{{ shortSha(index) }}</span> </template> - <template slot="expanded"> - <span class="js-expanded monospace gl-pl-1">{{ sha(index) }}</span> + <template #expanded> + <span class="js-expanded monospace gl-pl-1-deprecated-no-really-do-not-use-me">{{ + sha(index) + }}</span> </template> </expand-button> <clipboard-button diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue index a95fbc0b373..26154272d39 100644 --- a/app/assets/javascripts/releases/components/release_block_footer.vue +++ b/app/assets/javascripts/releases/components/release_block_footer.vue @@ -57,6 +57,11 @@ export default { ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) : null; }, + createdTime() { + const now = new Date(); + const isFuture = now < new Date(this.releasedAt); + return isFuture ? __('Will be created') : __('Created'); + }, }, }; </script> @@ -86,7 +91,7 @@ export default { v-if="releasedAt || author" class="float-left d-flex align-items-center js-author-date-info" > - <span class="text-secondary">{{ __('Created') }} </span> + <span class="text-secondary">{{ createdTime }} </span> <template v-if="releasedAt"> <span v-gl-tooltip.bottom diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue index 6f7e1dcfe2f..ed49841757a 100644 --- a/app/assets/javascripts/releases/components/release_block_header.vue +++ b/app/assets/javascripts/releases/components/release_block_header.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; import { setUrlParams } from '~/lib/utils/url_utility'; @@ -10,6 +10,7 @@ export default { GlLink, GlBadge, Icon, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -41,7 +42,7 @@ export default { <template> <div class="card-header d-flex align-items-center bg-white pr-0"> - <h2 class="card-title my-2 mr-auto gl-font-size-20"> + <h2 class="card-title my-2 mr-auto gl-font-size-20-deprecated-no-really-do-not-use-me"> <gl-link v-if="selfLink" :href="selfLink" class="font-size-inherit"> {{ release.name }} </gl-link> @@ -50,14 +51,16 @@ export default { __('Upcoming Release') }}</gl-badge> </h2> - <gl-link + <gl-button v-if="editLink" v-gl-tooltip - class="btn btn-default append-right-10 js-edit-button ml-2" + category="primary" + variant="default" + class="append-right-10 js-edit-button ml-2 pb-2" :title="__('Edit this release')" :href="editLink" > <icon name="pencil" /> - </gl-link> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/releases/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue index 052e4088a5f..40133941011 100644 --- a/app/assets/javascripts/releases/components/release_block_metadata.vue +++ b/app/assets/javascripts/releases/components/release_block_metadata.vue @@ -38,9 +38,12 @@ export default { return Boolean(this.author); }, releasedTimeAgo() { - return sprintf(__('released %{time}'), { - time: this.timeFormatted(this.release.releasedAt), - }); + const now = new Date(); + const isFuture = now < new Date(this.release.releasedAt); + const time = this.timeFormatted(this.release.releasedAt); + return isFuture + ? sprintf(__('will be released %{time}'), { time }) + : sprintf(__('released %{time}'), { time }); }, shouldRenderMilestones() { return Boolean(this.release.milestones?.length); @@ -74,7 +77,11 @@ export default { <div class="append-right-4"> • - <span v-gl-tooltip.bottom :title="tooltipTitle(release.releasedAt)"> + <span + v-gl-tooltip.bottom + class="js-release-date-info" + :title="tooltipTitle(release.releasedAt)" + > {{ releasedTimeAgo }} </span> </div> 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 01ad0cbf732..d9fbd2884b7 100644 --- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue +++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue @@ -3,7 +3,7 @@ import { GlProgressBar, GlLink, GlBadge, - GlDeprecatedButton, + GlButton, GlTooltipDirective, GlSprintf, } from '@gitlab/ui'; @@ -17,7 +17,7 @@ export default { GlProgressBar, GlLink, GlBadge, - GlDeprecatedButton, + GlButton, GlSprintf, }, directives: { @@ -134,13 +134,9 @@ export default { <span :key="'bullet-' + milestone.id" class="append-right-4">•</span> </template> <template v-if="shouldRenderShowMoreLink(index)"> - <gl-deprecated-button - :key="'more-button-' + milestone.id" - variant="link" - @click="toggleShowAll" - > + <gl-button :key="'more-button-' + milestone.id" variant="link" @click="toggleShowAll"> {{ moreText }} - </gl-deprecated-button> + </gl-button> </template> </template> </div> diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js index 7b84c18242c..3bc427dfa16 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/actions.js +++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js @@ -18,7 +18,12 @@ export const fetchRelease = ({ dispatch, state }) => { return api .release(state.projectId, state.tagName) - .then(({ data: release }) => { + .then(({ data }) => { + const release = { + ...data, + milestones: data.milestones || [], + }; + dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true })); }) .catch(error => { @@ -28,6 +33,8 @@ export const fetchRelease = ({ dispatch, state }) => { export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title); export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes); +export const updateReleaseMilestones = ({ commit }, milestones) => + commit(types.UPDATE_RELEASE_MILESTONES, milestones); export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE); export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => { @@ -45,12 +52,14 @@ export const updateRelease = ({ dispatch, state, getters }) => { dispatch('requestUpdateRelease'); const { release } = state; + const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : []; return ( api .updateRelease(state.projectId, state.tagName, { name: release.name, description: release.description, + milestones, }) /** 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 04944b76e42..1d6356990ce 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js @@ -4,6 +4,7 @@ export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR'; export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; +export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES'; export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE'; export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS'; diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js index 3d97e3a75c2..5c29b402cba 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js @@ -28,6 +28,10 @@ export default { state.release.description = notes; }, + [types.UPDATE_RELEASE_MILESTONES](state, milestones) { + state.release.milestones = milestones; + }, + [types.REQUEST_UPDATE_RELEASE](state) { state.isUpdatingRelease = true; }, diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js index b513e1bed79..6d0d102c719 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/state.js +++ b/app/assets/javascripts/releases/stores/modules/detail/state.js @@ -6,6 +6,8 @@ export default ({ markdownPreviewPath, updateReleaseApiDocsPath, releaseAssetsDocsPath, + manageMilestonesPath, + newMilestonePath, }) => ({ projectId, tagName, @@ -14,6 +16,8 @@ export default ({ markdownPreviewPath, updateReleaseApiDocsPath, releaseAssetsDocsPath, + manageMilestonesPath, + newMilestonePath, /** The Release object */ release: null, diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue index 6aae9195be1..653dcced98b 100644 --- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue +++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue @@ -26,18 +26,11 @@ export default { * The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation. * Here we simply split the string on `.` and get the code in the 5th position */ - if (this.issue.code === undefined) { - return null; - } - - return this.issue.code.split('.')[4] || null; + return this.issue.code?.split('.')[4]; }, learnMoreUrl() { - if (this.parsedTECHSCode === null) { - return 'https://www.w3.org/TR/WCAG20-TECHS/Overview.html'; - } - - return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode}.html`; + // eslint-disable-next-line @gitlab/require-i18n-strings + return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode || 'Overview'}.html`; }, }, }; @@ -52,10 +45,19 @@ export default { > {{ s__('AccessibilityReport|New') }} </div> - {{ issue.name }} - <gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{ - s__('AccessibilityReport|Learn More') - }}</gl-link> + <div> + {{ + sprintf( + s__( + 'AccessibilityReport|The accessibility scanning found an error of the following type: %{code}', + ), + { code: issue.code }, + ) + }} + <gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{ + s__('AccessibilityReport|Learn More') + }}</gl-link> + </div> {{ sprintf(s__('AccessibilityReport|Message: %{message}'), { message: issue.message }) }} </div> </div> diff --git a/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue new file mode 100644 index 00000000000..6f8ddd01df8 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue @@ -0,0 +1,64 @@ +<script> +import { mapActions, mapGetters } from 'vuex'; +import { componentNames } from '~/reports/components/issue_body'; +import ReportSection from '~/reports/components/report_section.vue'; +import IssuesList from '~/reports/components/issues_list.vue'; +import createStore from './store'; + +export default { + name: 'GroupedAccessibilityReportsApp', + store: createStore(), + components: { + ReportSection, + IssuesList, + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + componentNames, + computed: { + ...mapGetters([ + 'summaryStatus', + 'groupedSummaryText', + 'shouldRenderIssuesList', + 'unresolvedIssues', + 'resolvedIssues', + 'newIssues', + ]), + }, + created() { + this.setEndpoint(this.endpoint); + + this.fetchReport(); + }, + methods: { + ...mapActions(['fetchReport', 'setEndpoint']), + }, +}; +</script> +<template> + <report-section + :status="summaryStatus" + :success-text="groupedSummaryText" + :loading-text="groupedSummaryText" + :error-text="groupedSummaryText" + :has-issues="shouldRenderIssuesList" + class="mr-widget-section grouped-security-reports mr-report" + > + <template #body> + <div class="mr-widget-grouped-section report-block"> + <issues-list + v-if="shouldRenderIssuesList" + :unresolved-issues="unresolvedIssues" + :new-issues="newIssues" + :resolved-issues="resolvedIssues" + :component="$options.componentNames.AccessibilityIssueBody" + class="report-block-group-list" + /> + </div> + </template> + </report-section> +</template> diff --git a/app/assets/javascripts/reports/accessibility_report/store/actions.js b/app/assets/javascripts/reports/accessibility_report/store/actions.js new file mode 100644 index 00000000000..446cfd79984 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/actions.js @@ -0,0 +1,79 @@ +import Visibility from 'visibilityjs'; +import Poll from '~/lib/utils/poll'; +import httpStatusCodes from '~/lib/utils/http_status'; +import axios from '~/lib/utils/axios_utils'; +import * as types from './mutation_types'; + +let eTagPoll; + +export const clearEtagPoll = () => { + eTagPoll = null; +}; + +export const stopPolling = () => { + if (eTagPoll) eTagPoll.stop(); +}; + +export const restartPolling = () => { + if (eTagPoll) eTagPoll.restart(); +}; + +export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint); + +/** + * We need to poll the report endpoint while they are being parsed in the Backend. + * This can take up to one minute. + * + * Poll.js will handle etag response. + * While http status code is 204, it means it's parsing, and we'll keep polling + * When http status code is 200, it means parsing is done, we can show the results & stop polling + * When http status code is 500, it means parsing went wrong and we stop polling + */ +export const fetchReport = ({ state, dispatch, commit }) => { + commit(types.REQUEST_REPORT); + + eTagPoll = new Poll({ + resource: { + getReport(endpoint) { + return axios.get(endpoint); + }, + }, + data: state.endpoint, + method: 'getReport', + successCallback: ({ status, data }) => dispatch('receiveReportSuccess', { status, data }), + errorCallback: () => dispatch('receiveReportError'), + }); + + if (!Visibility.hidden()) { + eTagPoll.makeRequest(); + } else { + axios + .get(state.endpoint) + .then(({ status, data }) => dispatch('receiveReportSuccess', { status, data })) + .catch(() => dispatch('receiveReportError')); + } + + Visibility.change(() => { + if (!Visibility.hidden() && state.isLoading) { + dispatch('restartPolling'); + } else { + dispatch('stopPolling'); + } + }); +}; + +export const receiveReportSuccess = ({ commit, dispatch }, { status, data }) => { + if (status === httpStatusCodes.OK) { + commit(types.RECEIVE_REPORT_SUCCESS, data); + // Stop polling since we have the information already parsed and it won't be changing + dispatch('stopPolling'); + } +}; + +export const receiveReportError = ({ commit, dispatch }) => { + commit(types.RECEIVE_REPORT_ERROR); + dispatch('stopPolling'); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/reports/accessibility_report/store/getters.js b/app/assets/javascripts/reports/accessibility_report/store/getters.js new file mode 100644 index 00000000000..9aff427e644 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/getters.js @@ -0,0 +1,48 @@ +import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../../constants'; +import { s__, n__ } from '~/locale'; + +export const groupedSummaryText = state => { + if (state.isLoading) { + return s__('Reports|Accessibility scanning results are being parsed'); + } + + if (state.hasError) { + return s__('Reports|Accessibility scanning failed loading results'); + } + + const numberOfResults = state.report?.summary?.errored || 0; + if (numberOfResults === 0) { + return s__('Reports|Accessibility scanning detected no issues for the source branch only'); + } + + return n__( + 'Reports|Accessibility scanning detected %d issue for the source branch only', + 'Reports|Accessibility scanning detected %d issues for the source branch only', + numberOfResults, + ); +}; + +export const summaryStatus = state => { + if (state.isLoading) { + return LOADING; + } + + if (state.hasError || state.status === STATUS_FAILED) { + return ERROR; + } + + return SUCCESS; +}; + +export const shouldRenderIssuesList = state => + Object.values(state.report).some(x => Array.isArray(x) && x.length > 0); + +// We could just map state, but we're going to iterate in the future +// to add notes and warnings to these issue lists, so I'm going to +// keep these as getters +export const unresolvedIssues = state => state.report.existing_errors; +export const resolvedIssues = state => state.report.resolved_errors; +export const newIssues = state => state.report.new_errors; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/static_site_editor/store/index.js b/app/assets/javascripts/reports/accessibility_report/store/index.js index 43256979ddd..047964260ad 100644 --- a/app/assets/javascripts/static_site_editor/store/index.js +++ b/app/assets/javascripts/reports/accessibility_report/store/index.js @@ -1,19 +1,16 @@ -import Vuex from 'vuex'; import Vue from 'vue'; -import createState from './state'; -import * as getters from './getters'; +import Vuex from 'vuex'; import * as actions from './actions'; +import * as getters from './getters'; import mutations from './mutations'; +import state from './state'; Vue.use(Vuex); -const createStore = ({ initialState } = {}) => { - return new Vuex.Store({ - state: createState(initialState), - getters, +export default initialState => + new Vuex.Store({ actions, + getters, mutations, + state: state(initialState), }); -}; - -export default createStore; diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js new file mode 100644 index 00000000000..22e2330e1ea --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js @@ -0,0 +1,5 @@ +export const SET_ENDPOINT = 'SET_ENDPOINT'; + +export const REQUEST_REPORT = 'REQUEST_REPORT'; +export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS'; +export const RECEIVE_REPORT_ERROR = 'RECEIVE_REPORT_ERROR'; diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutations.js b/app/assets/javascripts/reports/accessibility_report/store/mutations.js new file mode 100644 index 00000000000..20d3e5be9a3 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/mutations.js @@ -0,0 +1,20 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_ENDPOINT](state, endpoint) { + state.endpoint = endpoint; + }, + [types.REQUEST_REPORT](state) { + state.isLoading = true; + }, + [types.RECEIVE_REPORT_SUCCESS](state, report) { + state.hasError = false; + state.isLoading = false; + state.report = report; + }, + [types.RECEIVE_REPORT_ERROR](state) { + state.isLoading = false; + state.hasError = true; + state.report = {}; + }, +}; diff --git a/app/assets/javascripts/reports/accessibility_report/store/state.js b/app/assets/javascripts/reports/accessibility_report/store/state.js new file mode 100644 index 00000000000..2a4cefea5e6 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/state.js @@ -0,0 +1,28 @@ +export default (initialState = {}) => ({ + endpoint: initialState.endpoint || '', + + isLoading: initialState.isLoading || false, + hasError: initialState.hasError || false, + + /** + * Report will have the following format: + * { + * status: {String}, + * summary: { + * total: {Number}, + * resolved: {Number}, + * errored: {Number}, + * }, + * existing_errors: {Array.<Object>}, + * existing_notes: {Array.<Object>}, + * existing_warnings: {Array.<Object>}, + * new_errors: {Array.<Object>}, + * new_notes: {Array.<Object>}, + * new_warnings: {Array.<Object>}, + * resolved_errors: {Array.<Object>}, + * resolved_notes: {Array.<Object>}, + * resolved_warnings: {Array.<Object>}, + * } + */ + report: initialState.report || {}, +}); diff --git a/app/assets/javascripts/reports/components/grouped_issues_list.vue b/app/assets/javascripts/reports/components/grouped_issues_list.vue new file mode 100644 index 00000000000..97587636644 --- /dev/null +++ b/app/assets/javascripts/reports/components/grouped_issues_list.vue @@ -0,0 +1,93 @@ +<script> +import { s__ } from '~/locale'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; +import ReportItem from '~/reports/components/report_item.vue'; + +export default { + components: { + ReportItem, + SmartVirtualList, + }, + props: { + component: { + type: String, + required: false, + default: '', + }, + resolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + unresolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + resolvedHeading: { + type: String, + required: false, + default: s__('ciReport|Fixed'), + }, + unresolvedHeading: { + type: String, + required: false, + default: s__('ciReport|New'), + }, + }, + groups: ['unresolved', 'resolved'], + typicalReportItemHeight: 32, + maxShownReportItems: 20, + computed: { + groups() { + return this.$options.groups + .map(group => ({ + name: group, + issues: this[`${group}Issues`], + heading: this[`${group}Heading`], + })) + .filter(({ issues }) => issues.length > 0); + }, + listLength() { + // every group has a header which is rendered as a list item + const groupsCount = this.groups.length; + const issuesCount = this.groups.reduce( + (totalIssues, { issues }) => totalIssues + issues.length, + 0, + ); + + return groupsCount + issuesCount; + }, + }, +}; +</script> + +<template> + <smart-virtual-list + :length="listLength" + :remain="$options.maxShownReportItems" + :size="$options.typicalReportItemHeight" + class="report-block-container" + wtag="ul" + wclass="report-block-list" + > + <template v-for="(group, groupIndex) in groups"> + <h2 + :key="group.name" + :data-testid="`${group.name}Heading`" + :class="[groupIndex > 0 ? 'mt-2' : 'mt-0']" + class="h5 mb-1" + > + {{ group.heading }} + </h2> + <report-item + v-for="(issue, issueIndex) in group.issues" + :key="`${group.name}-${issue.name}-${group.name}-${issueIndex}`" + :issue="issue" + :show-report-section-status-icon="false" + :component="component" + status="none" + /> + </template> + </smart-virtual-list> +</template> diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index 88d174f96ed..0f7a0e60dc0 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { s__ } from '~/locale'; +import { sprintf, s__ } from '~/locale'; import { componentNames } from './issue_body'; import ReportSection from './report_section.vue'; import SummaryRow from './summary_row.vue'; @@ -52,8 +52,17 @@ export default { methods: { ...mapActions(['setEndpoint', 'fetchReports']), reportText(report) { - const summary = report.summary || {}; - return reportTextBuilder(report.name, summary); + const { name, summary } = report || {}; + + if (report.status === 'error') { + return sprintf(s__('Reports|An error occurred while loading %{name} results'), { name }); + } + + if (!report.name) { + return s__('Reports|An error occured while loading report'); + } + + return reportTextBuilder(name, summary); }, getReportIcon(report) { return statusIcon(report.status); diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue index 62a9338b864..d79e3ddd798 100644 --- a/app/assets/javascripts/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/reports/components/issue_status_icon.vue @@ -8,7 +8,6 @@ export default { Icon, }, props: { - // failed || success status: { type: String, required: true, @@ -27,7 +26,7 @@ export default { return 'status_success_borderless'; } - return 'status_created_borderless'; + return 'dash'; }, isStatusFailed() { return this.status === STATUS_FAILED; diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 20b0c52dbda..68956fc6d2b 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -167,7 +167,7 @@ export default { <div class="media"> <status-icon :status="statusIconName" :size="24" class="align-self-center" /> <div class="media-body d-flex flex-align-self-center align-items-center"> - <div class="js-code-text code-text"> + <div data-testid="report-section-code-text" class="js-code-text code-text"> <div> {{ headerText }} <slot :name="slotName"></slot> diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index 1845b51e6b2..b3905cbfcfb 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -22,3 +22,6 @@ export const status = { ERROR: 'ERROR', SUCCESS: 'SUCCESS', }; + +export const ACCESSIBILITY_ISSUE_ERROR = 'error'; +export const ACCESSIBILITY_ISSUE_WARNING = 'warning'; diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js index 68f6de3a7ee..35ab72bf694 100644 --- a/app/assets/javascripts/reports/store/mutations.js +++ b/app/assets/javascripts/reports/store/mutations.js @@ -8,8 +8,7 @@ export default { state.isLoading = true; }, [types.RECEIVE_REPORTS_SUCCESS](state, response) { - // Make sure to clean previous state in case it was an error - state.hasError = false; + state.hasError = response.suites.some(suite => suite.status === 'error'); state.isLoading = false; diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 886e9d76cca..45c343c3f7f 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -242,7 +242,7 @@ export default { </li> <li v-if="renderAddToTreeDropdown" class="breadcrumb-item"> <gl-dropdown toggle-class="add-to-tree qa-add-to-tree ml-1"> - <template slot="button-content"> + <template #button-content> <span class="sr-only">{{ __('Add to tree') }}</span> <icon name="plus" :size="16" class="float-left" /> <icon name="chevron-down" :size="16" class="float-left" /> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index a13f8ac65cf..010fc9a5d1a 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -121,9 +121,8 @@ export default { :href="commit.webUrl" :class="{ 'font-italic': !commit.message }" class="commit-row-message item-title" - > - {{ commit.title }} - </gl-link> + v-html="commit.titleHtml" + /> <gl-deprecated-button v-if="commit.description" :class="{ open: showDescription }" diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index f741a6df5d9..34424121390 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -167,9 +167,8 @@ export default { :href="commit.commitPath" :title="commit.message" class="str-truncated-100 tree-commit-link" - > - {{ commit.message }} - </gl-link> + v-html="commit.titleHtml" + /> <gl-skeleton-loading v-else :lines="1" class="h-auto" /> </td> <td class="tree-time-ago text-right cursor-default"> diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql index 9bb13c475c7..be6897b9a16 100644 --- a/app/assets/javascripts/repository/queries/commit.fragment.graphql +++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql @@ -1,6 +1,7 @@ fragment TreeEntryCommit on LogTreeCommit { sha message + titleHtml committedDate commitPath fileName diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql index a22cadf0e8d..f54f09fd647 100644 --- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql @@ -5,6 +5,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { lastCommit { sha title + titleHtml description message webUrl diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index 49e024ca4ff..c5646c32850 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -7,17 +7,28 @@ import TreePage from './pages/tree.vue'; Vue.use(VueRouter); export default function createRouter(base, baseRef) { + const treePathRoute = { + component: TreePage, + props: route => ({ + path: route.params.path?.replace(/^\//, '') || '/', + }), + }; + return new VueRouter({ mode: 'history', base: joinPaths(gon.relative_url_root || '', base), routes: [ { - path: `(/-)?/tree/${baseRef}/:path*`, + name: 'treePathDecoded', + // Sometimes the ref needs decoding depending on how the backend sends it to us + path: `(/-)?/tree/${decodeURI(baseRef)}/:path*`, + ...treePathRoute, + }, + { name: 'treePath', - component: TreePage, - props: route => ({ - path: route.params.path?.replace(/^\//, '') || '/', - }), + // Support without decoding as well just in case the ref doesn't need to be decoded + path: `(/-)?/tree/${baseRef}/:path*`, + ...treePathRoute, }, { path: '/', diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js index 3973798605d..90ac01c5874 100644 --- a/app/assets/javascripts/repository/utils/commit.js +++ b/app/assets/javascripts/repository/utils/commit.js @@ -3,6 +3,7 @@ export function normalizeData(data, path, extra = () => {}) { return data.map(d => ({ sha: d.commit.id, message: d.commit.message, + titleHtml: d.commit_title_html, committedDate: d.commit.committed_date, commitPath: d.commit_path, fileName: d.file_name, diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 550ec3cb0d1..0bb33de0234 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -1,7 +1,6 @@ /* eslint-disable func-names, consistent-return, no-param-reassign */ import $ from 'jquery'; -import _ from 'underscore'; import Cookies from 'js-cookie'; import flash from './flash'; import axios from './lib/utils/axios_utils'; @@ -142,7 +141,7 @@ Sidebar.prototype.sidebarCollapseClicked = function(e) { }; Sidebar.prototype.openDropdown = function(blockOrName) { - const $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; + const $block = typeof blockOrName === 'string' ? this.getBlock(blockOrName) : blockOrName; if (!this.isOpen()) { this.setCollapseAfterUpdate($block); this.toggleSidebar('open'); diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 3eaa34c8a93..d8eb981c106 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,7 +1,7 @@ /* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; -import { escape, throttle } from 'underscore'; +import { escape, throttle } from 'lodash'; import { s__, __ } from '~/locale'; import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; import axios from './lib/utils/axios_utils'; @@ -407,7 +407,7 @@ export class SearchAutocomplete { disableAutocomplete() { if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) { this.searchInput.addClass('js-autocomplete-disabled'); - this.dropdown.dropdown('toggle'); + this.dropdownToggle.dropdown('toggle'); this.restoreMenu(); } } diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue index 272c0bd5614..29a61cfbbfe 100644 --- a/app/assets/javascripts/serverless/components/area.vue +++ b/app/assets/javascripts/serverless/components/area.vue @@ -138,8 +138,8 @@ export default { :width="width" :include-legend-avg-max="false" > - <template slot="tooltipTitle">{{ tooltipPopoverTitle }}</template> - <template slot="tooltipContent">{{ tooltipPopoverContent }}</template> + <template #tooltipTitle>{{ tooltipPopoverTitle }}</template> + <template #tooltipContent>{{ tooltipPopoverContent }}</template> </gl-area-chart> </div> </template> diff --git a/app/assets/javascripts/serverless/event_hub.js b/app/assets/javascripts/serverless/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/serverless/event_hub.js +++ b/app/assets/javascripts/serverless/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/set_status_modal/event_hub.js b/app/assets/javascripts/set_status_modal/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/set_status_modal/event_hub.js +++ b/app/assets/javascripts/set_status_modal/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index f16b16a6837..3baf4bf0742 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -1,6 +1,6 @@ <script> -import CollapsedAssigneeList from '../assignees/collapsed_assignee_list.vue'; -import UncollapsedAssigneeList from '../assignees/uncollapsed_assignee_list.vue'; +import CollapsedAssigneeList from './collapsed_assignee_list.vue'; +import UncollapsedAssigneeList from './uncollapsed_assignee_list.vue'; export default { // name: 'Assignees' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue new file mode 100644 index 00000000000..bf0c52b2341 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -0,0 +1,75 @@ +<script> +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql'; +import actionCable from '~/actioncable_consumer'; + +export default { + subscription: null, + name: 'AssigneesRealtime', + props: { + mediator: { + type: Object, + required: true, + }, + issuableIid: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + apollo: { + project: { + query, + variables() { + return { + iid: this.issuableIid, + fullPath: this.projectPath, + }; + }, + result(data) { + this.handleFetchResult(data); + }, + }, + }, + mounted() { + this.initActionCablePolling(); + }, + beforeDestroy() { + this.$options.subscription.unsubscribe(); + }, + methods: { + received(data) { + if (data.event === 'updated') { + this.$apollo.queries.project.refetch(); + } + }, + initActionCablePolling() { + this.$options.subscription = actionCable.subscriptions.create( + { + channel: 'IssuesChannel', + project_path: this.projectPath, + iid: this.issuableIid, + }, + { received: this.received }, + ); + }, + handleFetchResult({ data }) { + const { nodes } = data.project.issue.assignees; + + const assignees = nodes.map(n => ({ + ...n, + avatar_url: n.avatarUrl, + id: getIdFromGraphQLId(n.id), + })); + + this.mediator.store.setAssigneesFromRealtime(assignees); + }, + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index ce592720531..0906d5abec3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -3,8 +3,10 @@ import Flash from '~/flash'; import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import AssigneeTitle from './assignee_title.vue'; import Assignees from './assignees.vue'; +import AssigneesRealtime from './assignees_realtime.vue'; import { __ } from '~/locale'; export default { @@ -12,7 +14,9 @@ export default { components: { AssigneeTitle, Assignees, + AssigneesRealtime, }, + mixins: [glFeatureFlagsMixin()], props: { mediator: { type: Object, @@ -32,6 +36,14 @@ export default { required: false, default: 'issue', }, + issuableIid: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, }, data() { return { @@ -39,6 +51,12 @@ export default { loading: false, }; }, + computed: { + shouldEnableRealtime() { + // Note: Realtime is only available on issues right now, future support for MR wil be built later. + return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue'; + }, + }, created() { this.removeAssignee = this.store.removeAssignee.bind(this.store); this.addAssignee = this.store.addAssignee.bind(this.store); @@ -84,6 +102,12 @@ export default { <template> <div> + <assignees-realtime + v-if="shouldEnableRealtime" + :issuable-iid="issuableIid" + :project-path="projectPath" + :mediator="mediator" + /> <assignee-title :number-of-assignees="store.assignees.length" :loading="loading || store.isFetching.assignees" diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index 3d112bba668..fed9e5886c0 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -66,7 +66,7 @@ export default { <template> <assignee-avatar-link v-if="hasOneUser" - v-slot="{ user }" + #default="{ user }" tooltip-placement="left" :tooltip-has-name="false" :user="firstUser" diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 5b3c3642290..550a1be1e64 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -1,15 +1,16 @@ <script> +import { mapState } from 'vuex'; import { __ } from '~/locale'; import Flash from '~/flash'; import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '~/sidebar/event_hub'; -import editForm from './edit_form.vue'; +import EditForm from './edit_form.vue'; import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor'; export default { components: { - editForm, + EditForm, Icon, }, directives: { @@ -17,10 +18,6 @@ export default { }, mixins: [recaptchaModalImplementor], props: { - isConfidential: { - required: true, - type: Boolean, - }, isEditable: { required: true, type: Boolean, @@ -36,11 +33,12 @@ export default { }; }, computed: { + ...mapState({ confidential: ({ noteableData }) => noteableData.confidential }), confidentialityIcon() { - return this.isConfidential ? 'eye-slash' : 'eye'; + return this.confidential ? 'eye-slash' : 'eye'; }, tooltipLabel() { - return this.isConfidential ? __('Confidential') : __('Not confidential'); + return this.confidential ? __('Confidential') : __('Not confidential'); }, }, created() { @@ -95,17 +93,16 @@ export default { data-track-label="right_sidebar" data-track-property="confidentiality" @click.prevent="toggleForm" + >{{ __('Edit') }}</a > - {{ __('Edit') }} - </a> </div> <div class="value sidebar-item-value hide-collapsed"> - <editForm + <edit-form v-if="edit" - :is-confidential="isConfidential" + :is-confidential="confidential" :update-confidential-attribute="updateConfidentialAttribute" /> - <div v-if="!isConfidential" class="no-value sidebar-item-value"> + <div v-if="!confidential" class="no-value sidebar-item-value"> <icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" /> {{ __('Not confidential') }} </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index 5d0e39e8195..e106afea9f5 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -40,7 +40,12 @@ export default { <button type="button" class="btn btn-default append-right-10" @click="closeForm"> {{ __('Cancel') }} </button> - <button type="button" class="btn btn-close" @click.prevent="submitForm"> + <button + type="button" + class="btn btn-close" + data-testid="confidential-toggle" + @click.prevent="submitForm" + > {{ toggleButtonText }} </button> </div> diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index db2e51c3aca..d2904f4157c 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -95,22 +95,18 @@ export default { @click="onClickCollapsedIcon" > <i class="fa fa-users" aria-hidden="true"> </i> - <gl-loading-icon v-if="loading" class="js-participants-collapsed-loading-icon" /> - <span v-else class="js-participants-collapsed-count"> {{ participantCount }} </span> + <gl-loading-icon v-if="loading" /> + <span v-else data-testid="collapsed-count"> {{ participantCount }} </span> </div> <div v-if="showParticipantLabel" class="title hide-collapsed"> - <gl-loading-icon - v-if="loading" - :inline="true" - class="js-participants-expanded-loading-icon" - /> + <gl-loading-icon v-if="loading" :inline="true" /> {{ participantLabel }} </div> <div class="participants-list hide-collapsed"> <div v-for="participant in visibleParticipants" :key="participant.id" - class="participants-author js-participants-author" + class="participants-author" > <a :href="participant.web_url" class="author-link"> <user-avatar-image @@ -125,11 +121,7 @@ export default { </div> </div> <div v-if="hasMoreParticipants" class="participants-more hide-collapsed"> - <button - type="button" - class="btn-transparent btn-link js-toggle-participants-button" - @click="toggleMoreParticipants" - > + <button type="button" class="btn-transparent btn-link" @click="toggleMoreParticipants"> {{ toggleLabel }} </button> </div> diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js index 2a61f7b5c05..0fb9cf22653 100644 --- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js +++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import '~/gl_dropdown'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { __ } from '~/locale'; function isValidProjectId(id) { @@ -49,7 +49,7 @@ class SidebarMoveIssue { renderRow: project => ` <li> <a href="#" class="js-move-issue-dropdown-item"> - ${esc(project.name_with_namespace)} + ${escape(project.name_with_namespace)} </a> </li> `, diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 6f8214b18ee..e371091fc53 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; @@ -8,17 +9,29 @@ import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; import sidebarParticipants from './components/participants/sidebar_participants.vue'; import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; import Translate from '../vue_shared/translate'; +import createDefaultClient from '~/lib/graphql'; +import { store } from '~/notes/stores'; Vue.use(Translate); +Vue.use(VueApollo); + +function getSidebarOptions() { + return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); +} function mountAssigneesComponent(mediator) { const el = document.getElementById('js-vue-sidebar-assignees'); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); if (!el) return; + const { iid, fullPath } = getSidebarOptions(); // eslint-disable-next-line no-new new Vue({ el, + apolloProvider, components: { SidebarAssignees, }, @@ -26,6 +39,8 @@ function mountAssigneesComponent(mediator) { createElement('sidebar-assignees', { props: { mediator, + issuableIid: String(iid), + projectPath: fullPath, field: el.dataset.field, signedIn: el.hasAttribute('data-signed-in'), issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request', @@ -45,8 +60,8 @@ function mountConfidentialComponent(mediator) { const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar); new ConfidentialComp({ + store, propsData: { - isConfidential: initialData.is_confidential, isEditable: initialData.is_editable, service: mediator.service, }, @@ -144,6 +159,4 @@ export function mountSidebar(mediator) { mountTimeTrackingComponent(); } -export function getSidebarOptions() { - return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); -} +export { getSidebarOptions }; diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 66f7f9e3c66..095f93b72a9 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -89,6 +89,10 @@ export default class SidebarStore { this.assignees = []; } + setAssigneesFromRealtime(data) { + this.assignees = data; + } + setAutocompleteProjects(projects) { this.autocompleteProjects = projects; } diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js index a3ed8d9c632..76a1f6d1458 100644 --- a/app/assets/javascripts/snippet/snippet_bundle.js +++ b/app/assets/javascripts/snippet/snippet_bundle.js @@ -3,18 +3,6 @@ import setupCollapsibleInputs from './collapsible_input'; let editor; -const initAce = () => { - const editorEl = document.getElementById('editor'); - const form = document.querySelector('.snippet-form-holder form'); - const content = document.querySelector('.snippet-file-content'); - - editor = initEditorLite({ el: editorEl }); - - form.addEventListener('submit', () => { - content.value = editor.getValue(); - }); -}; - const initMonaco = () => { const editorEl = document.getElementById('editor'); const contentEl = document.querySelector('.snippet-file-content'); @@ -36,15 +24,7 @@ const initMonaco = () => { }); }; -export const initEditor = () => { - if (window?.gon?.features?.monacoSnippets) { - initMonaco(); - } else { - initAce(); - } - setupCollapsibleInputs(); -}; - export default () => { - initEditor(); + initMonaco(); + setupCollapsibleInputs(); }; diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index 2185b1d67e4..e8d6c005435 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -81,7 +81,10 @@ export default { return this.isUpdating ? __('Saving') : __('Save changes'); }, cancelButtonHref() { - return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`; + if (this.newSnippet) { + return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`; + } + return this.snippet.webUrl; }, titleFieldId() { return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`; @@ -173,7 +176,13 @@ export default { class="loading-animation prepend-top-20 append-bottom-20" /> <template v-else> - <title-field :id="titleFieldId" v-model="snippet.title" required :autofocus="true" /> + <title-field + :id="titleFieldId" + v-model="snippet.title" + data-qa-selector="snippet_title" + required + :autofocus="true" + /> <snippet-description-edit :id="descriptionFieldId" v-model="snippet.description" @@ -198,12 +207,15 @@ export default { category="primary" variant="success" :disabled="updatePrevented" + data-qa-selector="submit_button" @click="handleFormSubmit" >{{ saveButtonLabel }}</gl-button > </template> <template #append> - <gl-button :href="cancelButtonHref">{{ __('Cancel') }}</gl-button> + <gl-button data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{ + __('Cancel') + }}</gl-button> </template> </form-footer-actions> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index 44b4607e5a9..dd03902417d 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -33,7 +33,11 @@ export default { <div class="form-group file-editor"> <label>{{ s__('Snippets|File') }}</label> <div class="file-holder snippet"> - <blob-header-edit :value="fileName" @input="$emit('name-change', $event)" /> + <blob-header-edit + :value="fileName" + data-qa-selector="snippet_file_name" + @input="$emit('name-change', $event)" + /> <gl-loading-icon v-if="isLoading" :label="__('Loading snippet')" diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index 02a0fc7686d..6b218b21e56 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -7,7 +7,12 @@ import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; import GetBlobContent from '../queries/snippet.blob.content.query.graphql'; -import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants'; +import { + SIMPLE_BLOB_VIEWER, + RICH_BLOB_VIEWER, + BLOB_RENDER_EVENT_LOAD, + BLOB_RENDER_EVENT_SHOW_SOURCE, +} from '~/blob/components/constants'; export default { components: { @@ -27,6 +32,16 @@ export default { }, update: data => data.snippets.edges[0].node.blob.richData || data.snippets.edges[0].node.blob.plainData, + result() { + if (this.activeViewerType === RICH_BLOB_VIEWER) { + this.blob.richViewer.renderError = null; + } else { + this.blob.simpleViewer.renderError = null; + } + }, + skip() { + return this.viewer.renderError; + }, }, }, props: { @@ -62,9 +77,15 @@ export default { }, methods: { switchViewer(newViewer) { - this.activeViewerType = newViewer; + this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER; + }, + forceQuery() { + this.$apollo.queries.blobContent.skip = false; + this.$apollo.queries.blobContent.refetch(); }, }, + BLOB_RENDER_EVENT_LOAD, + BLOB_RENDER_EVENT_SHOW_SOURCE, }; </script> <template> @@ -75,12 +96,21 @@ export default { <template #actions> <clone-dropdown-button v-if="canBeCloned" + class="mr-2" :ssh-link="snippet.sshUrlToRepo" :http-link="snippet.httpUrlToRepo" + data-qa-selector="clone_button" /> </template> </blob-header> - <blob-content :loading="isContentLoading" :content="blobContent" :active-viewer="viewer" /> + <blob-content + :loading="isContentLoading" + :content="blobContent" + :active-viewer="viewer" + :blob="blob" + @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery" + @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer" + /> </article> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue index 6f3a86be8d7..0fe539a5de7 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue @@ -51,9 +51,9 @@ export default { > <textarea slot="textarea" - class="note-textarea js-gfm-input js-autosize markdown-area - qa-description-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')" diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue new file mode 100644 index 00000000000..72afcc30be6 --- /dev/null +++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue @@ -0,0 +1,21 @@ +<script> +import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; + +export default { + components: { + MarkdownFieldView, + }, + props: { + description: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> +<template> + <markdown-field-view class="snippet-description" data-qa-selector="snippet_description_field"> + <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 30a23b51bc4..c0967e9093c 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -10,6 +10,7 @@ import { GlDropdown, GlDropdownItem, GlButton, + GlTooltipDirective, } from '@gitlab/ui'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -30,6 +31,9 @@ export default { TimeAgoTooltip, GlButton, }, + directives: { + GlTooltip: GlTooltipDirective, + }, apollo: { canCreateSnippet: { query() { @@ -43,7 +47,7 @@ export default { update(data) { return this.snippet.project ? data.project.userPermissions.createSnippet - : data.currentUser.userPermissions.createSnippet; + : data.currentUser?.userPermissions.createSnippet; }, }, }, @@ -67,6 +71,10 @@ export default { condition: this.snippet.userPermissions.updateSnippet, text: __('Edit'), href: this.editLink, + disabled: this.snippet.blob.binary, + title: this.snippet.blob.binary + ? __('Snippets with non-text files can only be edited via Git.') + : undefined, }, { condition: this.snippet.userPermissions.adminSnippet, @@ -119,7 +127,7 @@ export default { }, methods: { redirectToSnippets() { - window.location.pathname = 'dashboard/snippets'; + window.location.pathname = `${this.snippet.project?.fullPath || 'dashboard'}/snippets`; }, closeDeleteModal() { this.$refs.deleteModal.hide(); @@ -186,18 +194,26 @@ export default { <div class="detail-page-header-actions"> <div class="d-none d-sm-flex"> <template v-for="(action, index) in personalSnippetActions"> - <gl-button + <div v-if="action.condition" :key="index" - :disabled="action.disabled" - :variant="action.variant" - :category="action.category" - :class="action.cssClass" - :href="action.href" - @click="action.click ? action.click() : undefined" + v-gl-tooltip + :title="action.title" + class="d-inline-block" > - {{ action.text }} - </gl-button> + <gl-button + :disabled="action.disabled" + :variant="action.variant" + :category="action.category" + :class="action.cssClass" + :href="action.href" + data-qa-selector="snippet_action_button" + :data-qa-action="action.text" + @click="action.click ? action.click() : undefined" + > + {{ action.text }} + </gl-button> + </div> </template> </div> <div class="d-block d-sm-none dropdown"> @@ -205,6 +221,8 @@ export default { <gl-dropdown-item v-for="(action, index) in personalSnippetActions" :key="index" + :disabled="action.disabled" + :title="action.title" :href="action.href" @click="action.click ? action.click() : undefined" >{{ action.text }}</gl-dropdown-item diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue index 1fc0423a06c..5267c3748ca 100644 --- a/app/assets/javascripts/snippets/components/snippet_title.vue +++ b/app/assets/javascripts/snippets/components/snippet_title.vue @@ -1,11 +1,14 @@ <script> -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { GlSprintf } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import SnippetDescription from './snippet_description_view.vue'; + export default { components: { TimeAgoTooltip, GlSprintf, + SnippetDescription, }, props: { snippet: { @@ -20,9 +23,8 @@ export default { <h2 class="snippet-title prepend-top-0 mb-3" data-qa-selector="snippet_title"> {{ snippet.title }} </h2> - <div v-if="snippet.description" class="description" data-qa-selector="snippet_description"> - <div class="md js-snippet-description" v-html="snippet.descriptionHtml"></div> - </div> + + <snippet-description v-if="snippet.description" :description="snippet.descriptionHtml" /> <small v-if="snippet.updatedAt !== snippet.createdAt" class="edited-text"> <gl-sprintf :message="__('Edited %{timeago}')"> diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql index d793d0b6bb4..e7765dfd8ba 100644 --- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql +++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql @@ -17,6 +17,8 @@ fragment SnippetBase on Snippet { path rawPath size + externalStorage + renderedAsText simpleViewer { ...BlobViewer } diff --git a/app/assets/javascripts/static_site_editor/components/app.vue b/app/assets/javascripts/static_site_editor/components/app.vue new file mode 100644 index 00000000000..98240aef810 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/app.vue @@ -0,0 +1,3 @@ +<template> + <router-view /> +</template> 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 921d93669c5..dff21d919a9 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -1,18 +1,61 @@ <script> -import { GlFormTextarea } from '@gitlab/ui'; +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'; export default { components: { - GlFormTextarea, + RichContentEditor, + PublishToolbar, + EditHeader, }, props: { - value: { + title: { type: String, required: true, }, + content: { + type: String, + required: true, + }, + savingChanges: { + type: Boolean, + required: true, + }, + returnUrl: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + editableContent: this.content, + saveable: false, + }; + }, + computed: { + modified() { + return this.content !== this.editableContent; + }, + }, + methods: { + onSubmit() { + this.$emit('submit', { content: this.editableContent }); + }, }, }; </script> <template> - <gl-form-textarea :value="value" v-on="$listeners" /> + <div class="d-flex flex-grow-1 flex-column"> + <edit-header class="py-2" :title="title" /> + <rich-content-editor v-model="editableContent" class="mb-9" /> + <publish-toolbar + class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full" + :return-url="returnUrl" + :saveable="modified" + :saving-changes="savingChanges" + @submit="onSubmit" + /> + </div> </template> diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue index 274d2f71749..6cd2a4dd700 100644 --- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue +++ b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue @@ -1,10 +1,9 @@ <script> -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; export default { components: { GlButton, - GlLoadingIcon, }, props: { returnUrl: { @@ -26,14 +25,18 @@ export default { }; </script> <template> - <div class="d-flex bg-light border-top justify-content-between align-items-center py-3 px-4"> - <gl-loading-icon :class="{ invisible: !savingChanges }" size="md" /> + <div class="d-flex bg-light border-top justify-content-end align-items-center py-3 px-4"> <div> <gl-button v-if="returnUrl" ref="returnUrlLink" :href="returnUrl">{{ s__('StaticSiteEditor|Return to site') }}</gl-button> - <gl-button variant="success" :disabled="!saveable || savingChanges" @click="$emit('submit')"> - {{ __('Submit Changes') }} + <gl-button + variant="success" + :disabled="!saveable" + :loading="savingChanges" + @click="$emit('submit')" + > + <span>{{ __('Submit Changes') }}</span> </gl-button> </div> </div> diff --git a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue index 41cb901720c..dd907570114 100644 --- a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue +++ b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue @@ -28,7 +28,8 @@ export default { }, returnUrl: { type: String, - required: true, + required: false, + default: '', }, }, }; @@ -46,7 +47,7 @@ export default { }} </p> <div class="d-flex justify-content-end"> - <gl-button ref="returnToSiteButton" :href="returnUrl">{{ + <gl-button v-if="returnUrl" ref="returnToSiteButton" :href="returnUrl">{{ s__('StaticSiteEditor|Return to site') }}</gl-button> <gl-button ref="mergeRequestButton" class="ml-2" :href="mergeRequest.url" variant="success"> @@ -60,7 +61,7 @@ export default { <ul> <li> {{ s__('StaticSiteEditor|You created a new branch:') }} - <span ref="branchLink">{{ branch.label }}</span> + <gl-link ref="branchLink" :href="branch.url">{{ branch.label }}</gl-link> </li> <li> {{ s__('StaticSiteEditor|You created a merge request:') }} diff --git a/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue b/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue new file mode 100644 index 00000000000..1b6179883aa --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue @@ -0,0 +1,19 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> +<template> + <gl-skeleton-loader :width="500" :height="102"> + <rect width="500" height="16" rx="4" /> + <rect y="20" width="375" height="16" rx="4" /> + <rect x="380" y="20" width="120" height="16" rx="4" /> + <rect y="40" width="250" height="16" rx="4" /> + <rect x="255" y="40" width="150" height="16" rx="4" /> + <rect x="410" y="40" width="90" height="16" rx="4" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue deleted file mode 100644 index 82917319fc3..00000000000 --- a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue +++ /dev/null @@ -1,95 +0,0 @@ -<script> -import { mapState, mapGetters, mapActions } from 'vuex'; -import { GlSkeletonLoader } from '@gitlab/ui'; - -import EditArea from './edit_area.vue'; -import EditHeader from './edit_header.vue'; -import SavedChangesMessage from './saved_changes_message.vue'; -import Toolbar from './publish_toolbar.vue'; -import InvalidContentMessage from './invalid_content_message.vue'; -import SubmitChangesError from './submit_changes_error.vue'; - -export default { - components: { - EditArea, - EditHeader, - InvalidContentMessage, - GlSkeletonLoader, - SavedChangesMessage, - Toolbar, - SubmitChangesError, - }, - computed: { - ...mapState([ - 'content', - 'isLoadingContent', - 'isSavingChanges', - 'isContentLoaded', - 'isSupportedContent', - 'returnUrl', - 'title', - 'submitChangesError', - 'savedContentMeta', - ]), - ...mapGetters(['contentChanged']), - }, - mounted() { - if (this.isSupportedContent) { - this.loadContent(); - } - }, - methods: { - ...mapActions(['loadContent', 'setContent', 'submitChanges', 'dismissSubmitChangesError']), - }, -}; -</script> -<template> - <div class="d-flex justify-content-center h-100 pt-2"> - <!-- Success view --> - <saved-changes-message - v-if="savedContentMeta" - :branch="savedContentMeta.branch" - :commit="savedContentMeta.commit" - :merge-request="savedContentMeta.mergeRequest" - :return-url="returnUrl" - /> - - <!-- Main view --> - <template v-else-if="isSupportedContent"> - <div v-if="isLoadingContent" class="w-50 h-50"> - <gl-skeleton-loader :width="500" :height="102"> - <rect width="500" height="16" rx="4" /> - <rect y="20" width="375" height="16" rx="4" /> - <rect x="380" y="20" width="120" height="16" rx="4" /> - <rect y="40" width="250" height="16" rx="4" /> - <rect x="255" y="40" width="150" height="16" rx="4" /> - <rect x="410" y="40" width="90" height="16" rx="4" /> - </gl-skeleton-loader> - </div> - <div v-if="isContentLoaded" class="d-flex flex-grow-1 flex-column"> - <submit-changes-error - v-if="submitChangesError" - class="w-75 align-self-center" - :error="submitChangesError" - @retry="submitChanges" - @dismiss="dismissSubmitChangesError" - /> - <edit-header class="w-75 align-self-center py-2" :title="title" /> - <edit-area - class="w-75 h-100 shadow-none align-self-center" - :value="content" - @input="setContent" - /> - <toolbar - :return-url="returnUrl" - :saveable="contentChanged" - :saving-changes="isSavingChanges" - @submit="submitChanges" - /> - </div> - </template> - - <!-- Error view --> - <invalid-content-message v-else class="w-75" /> - </div> -</template> diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index d7ce2a93a56..4794cf5eead 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; export const BRANCH_SUFFIX_COUNT = 8; export const DEFAULT_TARGET_BRANCH = 'master'; @@ -10,5 +10,10 @@ export const SUBMIT_CHANGES_COMMIT_ERROR = s__( export const SUBMIT_CHANGES_MERGE_REQUEST_ERROR = s__( 'StaticSiteEditor|Could not create merge request.', ); +export const LOAD_CONTENT_ERROR = __( + 'An error ocurred while loading your content. Please try again.', +); export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor'); + +export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit'; diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js new file mode 100644 index 00000000000..0a5d8c07ad9 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/index.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import typeDefs from './typedefs.graphql'; +import fileResolver from './resolvers/file'; +import submitContentChangesResolver from './resolvers/submit_content_changes'; + +Vue.use(VueApollo); + +const createApolloProvider = appData => { + const defaultClient = createDefaultClient( + { + Project: { + file: fileResolver, + }, + Mutation: { + submitContentChanges: submitContentChangesResolver, + }, + }, + { + typeDefs, + }, + ); + + defaultClient.cache.writeData({ + data: { + appData: { + __typename: 'AppData', + ...appData, + }, + }, + }); + + return new VueApollo({ + defaultClient, + }); +}; + +export default createApolloProvider; diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql new file mode 100644 index 00000000000..2840d419966 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql @@ -0,0 +1,7 @@ +mutation submitContentChanges($input: SubmitContentChangesInput) { + submitContentChanges(input: $input) @client { + branch + commit + mergeRequest + } +} diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql new file mode 100644 index 00000000000..fdbf4459aee --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql @@ -0,0 +1,9 @@ +query appData { + appData @client { + isSupportedContent + project + sourcePath + username, + returnUrl + } +} diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql new file mode 100644 index 00000000000..c29b6f93b81 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql @@ -0,0 +1,3 @@ +query savedContentMeta { + savedContentMeta @client +} diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql new file mode 100644 index 00000000000..e36d244ae57 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql @@ -0,0 +1,9 @@ +query sourceContent($project: ID!, $sourcePath: String!) { + project(fullPath: $project) { + fullPath, + file(path: $sourcePath) @client { + title + content + } + } +} diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js new file mode 100644 index 00000000000..16f176581cb --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js @@ -0,0 +1,11 @@ +import loadSourceContent from '../../services/load_source_content'; + +const fileResolver = ({ fullPath: projectId }, { path: sourcePath }) => { + return loadSourceContent({ projectId, sourcePath }).then(sourceContent => ({ + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'File', + ...sourceContent, + })); +}; + +export default fileResolver; diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js new file mode 100644 index 00000000000..6c4e3a4d973 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js @@ -0,0 +1,24 @@ +import submitContentChanges from '../../services/submit_content_changes'; +import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql'; + +const submitContentChangesResolver = ( + _, + { input: { project: projectId, username, sourcePath, content } }, + { cache }, +) => { + return submitContentChanges({ projectId, username, sourcePath, content }).then( + savedContentMeta => { + cache.writeQuery({ + query: savedContentMetaQuery, + data: { + savedContentMeta: { + __typename: 'SavedContentMeta', + ...savedContentMeta, + }, + }, + }); + }, + ); +}; + +export default submitContentChangesResolver; diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql new file mode 100644 index 00000000000..59da2e27144 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql @@ -0,0 +1,43 @@ +type File { + title: String + content: String! +} + +type SavedContentField { + label: String! + url: String! +} + +type SavedContentMeta { + mergeRequest: SavedContentField! + commit: SavedContentField! + branch: SavedContentField! +} + +type AppData { + isSupportedContent: Boolean! + project: String! + returnUrl: String + sourcePath: String! + username: String! +} + +type SubmitContentChangesInput { + project: String! + sourcePath: String! + content: String! + username: String! +} + +extend type Project { + file(path: ID!): File +} + +extend type Query { + appData: AppData! + savedContentMeta: SavedContentMeta +} + +extend type Mutation { + submitContentChanges(input: SubmitContentChangesInput!): SavedContentMeta +} diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js index 15d668fd431..12aa301e02f 100644 --- a/app/assets/javascripts/static_site_editor/index.js +++ b/app/assets/javascripts/static_site_editor/index.js @@ -1,29 +1,32 @@ import Vue from 'vue'; -import StaticSiteEditor from './components/static_site_editor.vue'; -import createStore from './store'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import App from './components/app.vue'; +import createRouter from './router'; +import createApolloProvider from './graphql'; const initStaticSiteEditor = el => { - const { projectId, path: sourcePath, returnUrl } = el.dataset; - const isSupportedContent = 'isSupportedContent' in el.dataset; + const { isSupportedContent, path: sourcePath, baseUrl, namespace, project } = el.dataset; + const { current_username: username } = window.gon; + const returnUrl = el.dataset.returnUrl || null; - const store = createStore({ - initialState: { - isSupportedContent, - projectId, - returnUrl, - sourcePath, - username: window.gon.current_username, - }, + const router = createRouter(baseUrl); + const apolloProvider = createApolloProvider({ + isSupportedContent: parseBoolean(isSupportedContent), + project: `${namespace}/${project}`, + returnUrl, + sourcePath, + username, }); return new Vue({ el, - store, + router, + apolloProvider, components: { - StaticSiteEditor, + App, }, render(createElement) { - return createElement('static-site-editor', StaticSiteEditor); + return createElement('app'); }, }); }; diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue new file mode 100644 index 00000000000..f65b648acd6 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -0,0 +1,120 @@ +<script> +import SkeletonLoader from '../components/skeleton_loader.vue'; +import EditArea from '../components/edit_area.vue'; +import InvalidContentMessage from '../components/invalid_content_message.vue'; +import SubmitChangesError from '../components/submit_changes_error.vue'; +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 { SUCCESS_ROUTE } from '../router/constants'; + +export default { + components: { + SkeletonLoader, + EditArea, + InvalidContentMessage, + SubmitChangesError, + }, + apollo: { + appData: { + query: appDataQuery, + }, + sourceContent: { + query: sourceContentQuery, + update: ({ + project: { + file: { title, content }, + }, + }) => { + return { title, content }; + }, + variables() { + return { + project: this.appData.project, + sourcePath: this.appData.sourcePath, + }; + }, + skip() { + return !this.appData.isSupportedContent; + }, + error() { + createFlash(LOAD_CONTENT_ERROR); + }, + }, + }, + data() { + return { + content: null, + submitChangesError: null, + isSavingChanges: false, + }; + }, + computed: { + isLoadingContent() { + return this.$apollo.queries.sourceContent.loading; + }, + isContentLoaded() { + return Boolean(this.sourceContent); + }, + }, + methods: { + onDismissError() { + this.submitChangesError = null; + }, + onSubmit({ content }) { + this.content = content; + this.submitChanges(); + }, + submitChanges() { + this.isSavingChanges = true; + + this.$apollo + .mutate({ + mutation: submitContentChangesMutation, + variables: { + input: { + project: this.appData.project, + username: this.appData.username, + sourcePath: this.appData.sourcePath, + content: this.content, + }, + }, + }) + .then(() => { + this.$router.push(SUCCESS_ROUTE); + }) + .catch(e => { + this.submitChangesError = e.message; + }) + .finally(() => { + this.isSavingChanges = false; + }); + }, + }, +}; +</script> +<template> + <div class="container d-flex gl-flex-direction-column pt-2 h-100"> + <template v-if="appData.isSupportedContent"> + <skeleton-loader v-if="isLoadingContent" class="w-75 gl-align-self-center gl-mt-5" /> + <submit-changes-error + v-if="submitChangesError" + :error="submitChangesError" + @retry="submitChanges" + @dismiss="onDismissError" + /> + <edit-area + v-if="isContentLoaded" + :title="sourceContent.title" + :content="sourceContent.content" + :saving-changes="isSavingChanges" + :return-url="appData.returnUrl" + @submit="onSubmit" + /> + </template> + + <invalid-content-message v-else class="w-75" /> + </div> +</template> diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue new file mode 100644 index 00000000000..123683b2833 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/pages/success.vue @@ -0,0 +1,35 @@ +<script> +import savedContentMetaQuery from '../graphql/queries/saved_content_meta.query.graphql'; +import appDataQuery from '../graphql/queries/app_data.query.graphql'; +import SavedChangesMessage from '../components/saved_changes_message.vue'; +import { HOME_ROUTE } from '../router/constants'; + +export default { + components: { + SavedChangesMessage, + }, + apollo: { + savedContentMeta: { + query: savedContentMetaQuery, + }, + appData: { + query: appDataQuery, + }, + }, + created() { + if (!this.savedContentMeta) { + this.$router.push(HOME_ROUTE); + } + }, +}; +</script> +<template> + <div v-if="savedContentMeta" class="container"> + <saved-changes-message + :branch="savedContentMeta.branch" + :commit="savedContentMeta.commit" + :merge-request="savedContentMeta.mergeRequest" + :return-url="appData.returnUrl" + /> + </div> +</template> diff --git a/app/assets/javascripts/static_site_editor/router/constants.js b/app/assets/javascripts/static_site_editor/router/constants.js new file mode 100644 index 00000000000..fd715f918ce --- /dev/null +++ b/app/assets/javascripts/static_site_editor/router/constants.js @@ -0,0 +1,2 @@ +export const HOME_ROUTE = { name: 'home' }; +export const SUCCESS_ROUTE = { name: 'success' }; diff --git a/app/assets/javascripts/static_site_editor/router/index.js b/app/assets/javascripts/static_site_editor/router/index.js new file mode 100644 index 00000000000..12692612bbc --- /dev/null +++ b/app/assets/javascripts/static_site_editor/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/static_site_editor/router/routes.js b/app/assets/javascripts/static_site_editor/router/routes.js new file mode 100644 index 00000000000..6fb9dbe0182 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/router/routes.js @@ -0,0 +1,21 @@ +import Home from '../pages/home.vue'; +import Success from '../pages/success.vue'; + +import { HOME_ROUTE, SUCCESS_ROUTE } from './constants'; + +export default [ + { + ...HOME_ROUTE, + path: '/', + component: Home, + }, + { + ...SUCCESS_ROUTE, + path: '/success', + component: Success, + }, + { + path: '*', + redirect: HOME_ROUTE, + }, +]; 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 ff591e4b245..49135d2141b 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 @@ -1,4 +1,5 @@ import Api from '~/api'; +import Tracking from '~/tracking'; import { s__, sprintf } from '~/locale'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import generateBranchName from '~/static_site_editor/services/generate_branch_name'; @@ -8,6 +9,7 @@ import { SUBMIT_CHANGES_BRANCH_ERROR, SUBMIT_CHANGES_COMMIT_ERROR, SUBMIT_CHANGES_MERGE_REQUEST_ERROR, + TRACKING_ACTION_CREATE_COMMIT, } from '../constants'; const createBranch = (projectId, branch) => @@ -18,8 +20,10 @@ const createBranch = (projectId, branch) => throw new Error(SUBMIT_CHANGES_BRANCH_ERROR); }); -const commitContent = (projectId, message, branch, sourcePath, content) => - Api.commitMultiple( +const commitContent = (projectId, message, branch, sourcePath, content) => { + Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT); + + return Api.commitMultiple( projectId, convertObjectPropsToSnakeCase({ branch, @@ -35,6 +39,7 @@ const commitContent = (projectId, message, branch, sourcePath, content) => ).catch(() => { throw new Error(SUBMIT_CHANGES_COMMIT_ERROR); }); +}; const createMergeRequest = (projectId, title, sourceBranch, targetBranch = DEFAULT_TARGET_BRANCH) => Api.createProjectMergeRequest( @@ -56,8 +61,8 @@ const submitContentChanges = ({ username, projectId, sourcePath, content }) => { const meta = {}; return createBranch(projectId, branch) - .then(() => { - Object.assign(meta, { branch: { label: branch } }); + .then(({ data: { web_url: url } }) => { + Object.assign(meta, { branch: { label: branch, url } }); return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content); }) @@ -67,7 +72,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content }) => { return createMergeRequest(projectId, mergeRequestTitle, branch); }) .then(({ data: { iid: label, web_url: url } }) => { - Object.assign(meta, { mergeRequest: { label, url } }); + Object.assign(meta, { mergeRequest: { label: label.toString(), url } }); return meta; }); diff --git a/app/assets/javascripts/static_site_editor/store/actions.js b/app/assets/javascripts/static_site_editor/store/actions.js deleted file mode 100644 index 9f5e9e8c589..00000000000 --- a/app/assets/javascripts/static_site_editor/store/actions.js +++ /dev/null @@ -1,37 +0,0 @@ -import createFlash from '~/flash'; -import { __ } from '~/locale'; - -import * as mutationTypes from './mutation_types'; -import loadSourceContent from '~/static_site_editor/services/load_source_content'; -import submitContentChanges from '~/static_site_editor/services/submit_content_changes'; - -export const loadContent = ({ commit, state: { sourcePath, projectId } }) => { - commit(mutationTypes.LOAD_CONTENT); - - return loadSourceContent({ sourcePath, projectId }) - .then(data => commit(mutationTypes.RECEIVE_CONTENT_SUCCESS, data)) - .catch(() => { - commit(mutationTypes.RECEIVE_CONTENT_ERROR); - createFlash(__('An error ocurred while loading your content. Please try again.')); - }); -}; - -export const setContent = ({ commit }, content) => { - commit(mutationTypes.SET_CONTENT, content); -}; - -export const submitChanges = ({ state: { projectId, content, sourcePath, username }, commit }) => { - commit(mutationTypes.SUBMIT_CHANGES); - - return submitContentChanges({ content, projectId, sourcePath, username }) - .then(data => commit(mutationTypes.SUBMIT_CHANGES_SUCCESS, data)) - .catch(error => { - commit(mutationTypes.SUBMIT_CHANGES_ERROR, error.message); - }); -}; - -export const dismissSubmitChangesError = ({ commit }) => { - commit(mutationTypes.DISMISS_SUBMIT_CHANGES_ERROR); -}; - -export default () => {}; diff --git a/app/assets/javascripts/static_site_editor/store/getters.js b/app/assets/javascripts/static_site_editor/store/getters.js deleted file mode 100644 index ebc68f8e9e6..00000000000 --- a/app/assets/javascripts/static_site_editor/store/getters.js +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/prefer-default-export -export const contentChanged = ({ originalContent, content }) => originalContent !== content; diff --git a/app/assets/javascripts/static_site_editor/store/mutation_types.js b/app/assets/javascripts/static_site_editor/store/mutation_types.js deleted file mode 100644 index 9cf356aecc5..00000000000 --- a/app/assets/javascripts/static_site_editor/store/mutation_types.js +++ /dev/null @@ -1,8 +0,0 @@ -export const LOAD_CONTENT = 'loadContent'; -export const RECEIVE_CONTENT_SUCCESS = 'receiveContentSuccess'; -export const RECEIVE_CONTENT_ERROR = 'receiveContentError'; -export const SET_CONTENT = 'setContent'; -export const SUBMIT_CHANGES = 'submitChanges'; -export const SUBMIT_CHANGES_SUCCESS = 'submitChangesSuccess'; -export const SUBMIT_CHANGES_ERROR = 'submitChangesError'; -export const DISMISS_SUBMIT_CHANGES_ERROR = 'dismissSubmitChangesError'; diff --git a/app/assets/javascripts/static_site_editor/store/mutations.js b/app/assets/javascripts/static_site_editor/store/mutations.js deleted file mode 100644 index 72fe71f1c9b..00000000000 --- a/app/assets/javascripts/static_site_editor/store/mutations.js +++ /dev/null @@ -1,36 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.LOAD_CONTENT](state) { - state.isLoadingContent = true; - }, - [types.RECEIVE_CONTENT_SUCCESS](state, { title, content }) { - state.isLoadingContent = false; - state.isContentLoaded = true; - state.title = title; - state.content = content; - state.originalContent = content; - }, - [types.RECEIVE_CONTENT_ERROR](state) { - state.isLoadingContent = false; - }, - [types.SET_CONTENT](state, content) { - state.content = content; - }, - [types.SUBMIT_CHANGES](state) { - state.isSavingChanges = true; - state.submitChangesError = ''; - }, - [types.SUBMIT_CHANGES_SUCCESS](state, meta) { - state.savedContentMeta = meta; - state.isSavingChanges = false; - state.originalContent = state.content; - }, - [types.SUBMIT_CHANGES_ERROR](state, error) { - state.submitChangesError = error; - state.isSavingChanges = false; - }, - [types.DISMISS_SUBMIT_CHANGES_ERROR](state) { - state.submitChangesError = ''; - }, -}; diff --git a/app/assets/javascripts/static_site_editor/store/state.js b/app/assets/javascripts/static_site_editor/store/state.js deleted file mode 100644 index 8c524b4ffe9..00000000000 --- a/app/assets/javascripts/static_site_editor/store/state.js +++ /dev/null @@ -1,23 +0,0 @@ -const createState = (initialState = {}) => ({ - username: null, - projectId: null, - returnUrl: null, - sourcePath: null, - - isLoadingContent: false, - isSavingChanges: false, - isSupportedContent: false, - - isContentLoaded: false, - - originalContent: '', - content: '', - title: '', - - submitChangesError: '', - savedContentMeta: null, - - ...initialState, -}); - -export default createState; diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js index 37f3dd4b496..474b5132bc6 100644 --- a/app/assets/javascripts/syntax_highlight.js +++ b/app/assets/javascripts/syntax_highlight.js @@ -1,4 +1,4 @@ -/* eslint-disable consistent-return, no-else-return */ +/* eslint-disable consistent-return */ import $ from 'jquery'; @@ -16,11 +16,10 @@ export default function syntaxHighlight(el) { if ($(el).hasClass('js-syntax-highlight')) { // Given the element itself, apply highlighting return $(el).addClass(gon.user_color_scheme); - } else { - // Given a parent element, recurse to any of its applicable children - const $children = $(el).find('.js-syntax-highlight'); - if ($children.length) { - return syntaxHighlight($children); - } + } + // Given a parent element, recurse to any of its applicable children + const $children = $(el).find('.js-syntax-highlight'); + if ($children.length) { + return syntaxHighlight($children); } } diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js index f4e546e4d4e..cf9064aba57 100644 --- a/app/assets/javascripts/terminal/terminal.js +++ b/app/assets/javascripts/terminal/terminal.js @@ -13,14 +13,11 @@ Terminal.applyAddon(webLinks); export default class GLTerminal { constructor(element, options = {}) { - this.options = Object.assign( - {}, - { - cursorBlink: true, - screenKeys: true, - }, - options, - ); + this.options = { + cursorBlink: true, + screenKeys: true, + ...options, + }; this.container = element; this.onDispose = []; diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index 09fe952e5f0..10510595570 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { omitBy, isUndefined } from 'lodash'; const DEFAULT_SNOWPLOW_OPTIONS = { namespace: 'gl', @@ -14,11 +14,8 @@ const DEFAULT_SNOWPLOW_OPTIONS = { linkClickTracking: false, }; -const eventHandler = (e, func, opts = {}) => { - const el = e.target.closest('[data-track-event]'); - const action = el && el.dataset.trackEvent; - if (!action) return; - +const createEventPayload = (el, { suffix = '' } = {}) => { + const action = el.dataset.trackEvent + (suffix || ''); let value = el.dataset.trackValue || el.value || undefined; if (el.type === 'checkbox' && !el.checked) value = false; @@ -29,7 +26,19 @@ const eventHandler = (e, func, opts = {}) => { context: el.dataset.trackContext, }; - func(opts.category, action + (opts.suffix || ''), _.omit(data, _.isUndefined)); + return { + action, + data: omitBy(data, isUndefined), + }; +}; + +const eventHandler = (e, func, opts = {}) => { + const el = e.target.closest('[data-track-event]'); + + if (!el) return; + + const { action, data } = createEventPayload(el, opts); + func(opts.category, action, data); }; const eventHandlers = (category, func) => { @@ -62,17 +71,30 @@ export default class Tracking { return window.snowplow('trackStructEvent', category, action, label, property, value, contexts); } - static bindDocument(category = document.body.dataset.page, documentOverride = null) { - const el = documentOverride || document; - if (!this.enabled() || el.trackingBound) return []; + static bindDocument(category = document.body.dataset.page, parent = document) { + if (!this.enabled() || parent.trackingBound) return []; - el.trackingBound = true; + // eslint-disable-next-line no-param-reassign + parent.trackingBound = true; const handlers = eventHandlers(category, (...args) => this.event(...args)); - handlers.forEach(event => el.addEventListener(event.name, event.func)); + handlers.forEach(event => parent.addEventListener(event.name, event.func)); return handlers; } + static trackLoadEvents(category = document.body.dataset.page, parent = document) { + if (!this.enabled()) return []; + + const loadEvents = parent.querySelectorAll('[data-track-event="render"]'); + + loadEvents.forEach(element => { + const { action, data } = createEventPayload(element); + this.event(category, action, data); + }); + + return loadEvents; + } + static mixin(opts = {}) { return { computed: { @@ -111,6 +133,7 @@ export function initUserTracking() { if (opts.linkClickTracking) window.snowplow('enableLinkClickTracking'); Tracking.bindDocument(); + Tracking.trackLoadEvents(); document.dispatchEvent(new Event('SnowplowInitialized')); } diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js index 59276ee79d8..947246b2fbb 100644 --- a/app/assets/javascripts/tree.js +++ b/app/assets/javascripts/tree.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, one-var, no-else-return, class-methods-use-this */ +/* eslint-disable func-names, consistent-return, one-var, class-methods-use-this */ import $ from 'jquery'; import { visitUrl } from './lib/utils/url_utility'; @@ -15,9 +15,8 @@ export default class TreeView { if (e.metaKey || e.which === 2) { e.preventDefault(); return window.open(path, '_blank'); - } else { - return visitUrl(path); } + return visitUrl(path); } }); // Show the "Loading commit data" for only the first element diff --git a/app/assets/javascripts/users_select/constants.js b/app/assets/javascripts/users_select/constants.js new file mode 100644 index 00000000000..64df1e1748c --- /dev/null +++ b/app/assets/javascripts/users_select/constants.js @@ -0,0 +1,18 @@ +export const AJAX_USERS_SELECT_OPTIONS_MAP = { + projectId: 'projectId', + groupId: 'groupId', + showCurrentUser: 'currentUser', + authorId: 'authorId', + skipUsers: 'skipUsers', +}; + +export const AJAX_USERS_SELECT_PARAMS_MAP = { + project_id: 'projectId', + group_id: 'groupId', + skip_ldap: 'skipLdap', + todo_filter: 'todoFilter', + todo_state_filter: 'todoStateFilter', + current_user: 'showCurrentUser', + author_id: 'authorId', + skip_users: 'skipUsers', +}; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select/index.js index 6821df57b5a..2dbe5a8171e 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select/index.js @@ -1,13 +1,18 @@ -/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, babel/camelcase, no-param-reassign */ +/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-self-compare, no-unused-expressions, yoda, prefer-spread, babel/camelcase, no-param-reassign */ /* global Issuable */ /* global emitSidebarEvent */ import $ from 'jquery'; -import _ from 'underscore'; -import axios from './lib/utils/axios_utils'; -import { s__, __, sprintf } from './locale'; -import ModalStore from './boards/stores/modal_store'; -import { parseBoolean } from './lib/utils/common_utils'; +import { escape, template, uniqBy } from 'lodash'; +import axios from '../lib/utils/axios_utils'; +import { s__, __, sprintf } from '../locale'; +import ModalStore from '../boards/stores/modal_store'; +import { parseBoolean } from '../lib/utils/common_utils'; +import { + AJAX_USERS_SELECT_OPTIONS_MAP, + AJAX_USERS_SELECT_PARAMS_MAP, +} from 'ee_else_ce/users_select/constants'; +import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils'; // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; @@ -81,7 +86,7 @@ function UsersSelect(currentUser, els, options = {}) { const userName = currentUserInfo.name; const userId = currentUserInfo.id || currentUser.id; - const inputHtmlString = _.template(` + const inputHtmlString = template(` <input type="hidden" name="<%- fieldName %>" data-meta="<%- userName %>" value="<%- userId %>" /> @@ -148,12 +153,11 @@ function UsersSelect(currentUser, els, options = {}) { name: selectedUser.name, length: otherSelected.length, }); - } else { - return sprintf(s__('UsersSelect|%{name} + %{length} more'), { - name: firstUser.name, - length: selectedUsers.length - 1, - }); } + return sprintf(s__('UsersSelect|%{name} + %{length} more'), { + name: firstUser.name, + length: selectedUsers.length - 1, + }); }; $('.assign-to-me-link').on('click', e => { @@ -205,7 +209,7 @@ function UsersSelect(currentUser, els, options = {}) { username: data.assignee.username, avatar: data.assignee.avatar_url, }; - tooltipTitle = _.escape(user.name); + tooltipTitle = escape(user.name); } else { user = { name: s__('UsersSelect|Unassigned'), @@ -219,10 +223,10 @@ function UsersSelect(currentUser, els, options = {}) { return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); }); }; - collapsedAssigneeTemplate = _.template( + collapsedAssigneeTemplate = template( '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>', ); - assigneeTemplate = _.template( + assigneeTemplate = template( `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { openingTag: '<a href="#" class="js-assign-yourself">', @@ -248,7 +252,7 @@ function UsersSelect(currentUser, els, options = {}) { // Potential duplicate entries when dealing with issue board // because issue board is also managed by vue - const selectedUsers = _.uniq(selectedInputs, false, a => a.value) + const selectedUsers = uniqBy(selectedInputs, a => a.value) .filter(input => { const userId = parseInt(input.value, 10); const inUsersArray = users.find(u => u.id === userId); @@ -375,13 +379,11 @@ function UsersSelect(currentUser, els, options = {}) { $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); if (selected.text) { return selected.text; - } else { - return selected.name; } - } else { - $dropdown.find('.dropdown-toggle-text').addClass('is-default'); - return defaultLabel; + return selected.name; } + $dropdown.find('.dropdown-toggle-text').addClass('is-default'); + return defaultLabel; }, defaultLabel, hidden() { @@ -543,7 +545,7 @@ function UsersSelect(currentUser, els, options = {}) { let img = ''; if (user.beforeDivider != null) { - `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape( + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${escape( user.name, )}</a></li>`; } else { @@ -558,13 +560,8 @@ function UsersSelect(currentUser, els, options = {}) { import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { $('.ajax-users-select').each((i, select) => { - const options = {}; + const options = getAjaxUsersSelectOptions($(select), AJAX_USERS_SELECT_OPTIONS_MAP); options.skipLdap = $(select).hasClass('skip_ldap'); - options.projectId = $(select).data('projectId'); - options.groupId = $(select).data('groupId'); - options.showCurrentUser = $(select).data('currentUser'); - options.authorId = $(select).data('authorId'); - options.skipUsers = $(select).data('skipUsers'); const showNullUser = $(select).data('nullUser'); const showAnyUser = $(select).data('anyUser'); const showEmailUser = $(select).data('emailUser'); @@ -672,10 +669,10 @@ UsersSelect.prototype.formatResult = function(user) { </div> <div class='user-info'> <div class='user-name dropdown-menu-user-full-name'> - ${_.escape(user.name)} + ${escape(user.name)} </div> <div class='user-username dropdown-menu-user-username text-secondary'> - ${!user.invite ? `@${_.escape(user.username)}` : ''} + ${!user.invite ? `@${escape(user.username)}` : ''} </div> </div> </div> @@ -683,7 +680,7 @@ UsersSelect.prototype.formatResult = function(user) { }; UsersSelect.prototype.formatSelection = function(user) { - return _.escape(user.name); + return escape(user.name); }; UsersSelect.prototype.user = function(user_id, callback) { @@ -705,14 +702,7 @@ UsersSelect.prototype.users = function(query, options, callback) { const params = { search: query, active: true, - project_id: options.projectId || null, - group_id: options.groupId || null, - skip_ldap: options.skipLdap || null, - todo_filter: options.todoFilter || null, - todo_state_filter: options.todoStateFilter || null, - current_user: options.showCurrentUser || null, - author_id: options.authorId || null, - skip_users: options.skipUsers || null, + ...getAjaxUsersSelectParams(options, AJAX_USERS_SELECT_PARAMS_MAP), }; if (options.issuableType === 'merge_request') { @@ -746,7 +736,7 @@ UsersSelect.prototype.renderRow = function(issuableType, user, selected, usernam ${this.renderRowAvatar(issuableType, user, img)} <span class="d-flex flex-column overflow-hidden"> <strong class="dropdown-menu-user-full-name"> - ${_.escape(user.name)} + ${escape(user.name)} </strong> ${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''} </span> diff --git a/app/assets/javascripts/users_select/utils.js b/app/assets/javascripts/users_select/utils.js new file mode 100644 index 00000000000..b46fd15fb77 --- /dev/null +++ b/app/assets/javascripts/users_select/utils.js @@ -0,0 +1,27 @@ +/** + * Get options from data attributes on passed `$select`. + * @param {jQuery} $select + * @param {Object} optionsMap e.g. { optionKeyName: 'dataAttributeName' } + */ +export const getAjaxUsersSelectOptions = ($select, optionsMap) => { + return Object.keys(optionsMap).reduce((accumulator, optionKey) => { + const dataKey = optionsMap[optionKey]; + accumulator[optionKey] = $select.data(dataKey); + + return accumulator; + }, {}); +}; + +/** + * Get query parameters used for users request from passed `options` parameter + * @param {Object} options e.g. { currentUserId: 1, fooBar: 'baz' } + * @param {Object} paramsMap e.g. { user_id: 'currentUserId', foo_bar: 'fooBar' } + */ +export const getAjaxUsersSelectParams = (options, paramsMap) => { + return Object.keys(paramsMap).reduce((accumulator, paramKey) => { + const optionKey = paramsMap[paramKey]; + accumulator[paramKey] = options[optionKey] || null; + + return accumulator; + }, {}); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue index 33db9b87b17..2f922b990d9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue @@ -75,7 +75,7 @@ export default { :href="deployment.url" target="_blank" rel="noopener noreferrer nofollow" - class="js-deploy-meta gl-font-size-12" + class="js-deploy-meta gl-font-sm" > {{ deployment.name }} </gl-link> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue index ba8da46d207..294871ca5c2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -51,7 +51,7 @@ export default { <div class="mr-widget-extension d-flex align-items-center pl-3"> <div v-if="hasError" class="ci-widget media"> <div class="media-body"> - <span class="gl-font-size-small mr-widget-margin-left gl-line-height-24 js-error-state">{{ + <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state">{{ title }}</span> </div> 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 c38272ab239..2433ba879aa 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 @@ -1,5 +1,5 @@ <script> -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { n__, s__, sprintf } from '~/locale'; import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility'; import Icon from '~/vue_shared/components/icon.vue'; @@ -35,7 +35,7 @@ export default { 'mrWidget|The source branch is %{commitsBehindLinkStart}%{commitsBehind}%{commitsBehindLinkEnd} the target branch', ), { - commitsBehindLinkStart: `<a href="${esc(this.mr.targetBranchPath)}">`, + commitsBehindLinkStart: `<a href="${escape(this.mr.targetBranchPath)}">`, commitsBehind: n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount), commitsBehindLinkEnd: '</a>', }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 42db1935123..6df53311ef0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -42,6 +42,10 @@ export default { type: String, required: false, }, + pipelineMustSucceed: { + type: Boolean, + required: false, + }, sourceBranchLink: { type: String, required: false, @@ -60,7 +64,10 @@ export default { return this.pipeline && Object.keys(this.pipeline).length > 0; }, hasCIError() { - return this.hasCi && !this.ciStatus; + return (this.hasCi && !this.ciStatus) || this.hasPipelineMustSucceedConflict; + }, + hasPipelineMustSucceedConflict() { + return !this.hasCi && this.pipelineMustSucceed; }, status() { return this.pipeline.details && this.pipeline.details.status @@ -76,9 +83,13 @@ export default { return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0; }, errorText() { + if (this.hasPipelineMustSucceedConflict) { + return s__('Pipeline|No pipeline has been run for this commit.'); + } + return sprintf( s__( - 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation.%{linkEnd}', + 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.', ), { linkStart: `<a href="${this.troubleshootingDocsPath}">`, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index d81e99d3c09..8fba0e2981f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -79,11 +79,12 @@ export default { :pipeline-coverage-delta="mr.pipelineCoverageDelta" :ci-status="mr.ciStatus" :has-ci="mr.hasCI" + :pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds" :source-branch="branch" :source-branch-link="branchLink" :troubleshooting-docs-path="mr.troubleshootingDocsPath" /> - <template v-slot:footer> + <template #footer> <div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts"> <artifacts-app :endpoint="mr.exposedArtifactsPath" /> </div> 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 edf90085a5b..8313b8afb1b 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 @@ -5,7 +5,6 @@ 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'; -import Visibility from 'visibilityjs'; export default { name: 'MRWidgetTerraformPlan', @@ -68,7 +67,11 @@ export default { method: 'fetchPlans', successCallback: ({ data }) => { this.plans = data; - this.loading = false; + + if (Object.keys(this.plan).length) { + this.loading = false; + poll.stop(); + } }, errorCallback: () => { this.plans = {}; @@ -77,17 +80,7 @@ export default { }, }); - if (!Visibility.hidden()) { - poll.makeRequest(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - poll.restart(); - } else { - poll.stop(); - } - }); + poll.makeRequest(); }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue index dcf02a29f52..e4f4032776b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -1,6 +1,6 @@ <script> import { GlDeprecatedButton } from '@gitlab/ui'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { __, n__, sprintf, s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; @@ -60,7 +60,7 @@ export default { { commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`, mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`, - targetBranch: `<span class="label-branch">${esc(this.targetBranch)}</span>`, + targetBranch: `<span class="label-branch">${escape(this.targetBranch)}</span>`, }, false, ); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index a368e29d086..92848e86e76 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -2,7 +2,7 @@ import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge'; import Flash from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; -import MrWidgetAuthor from '../../components/mr_widget_author.vue'; +import MrWidgetAuthor from '../mr_widget_author.vue'; import eventHub from '../../event_hub'; import { AUTO_MERGE_STRATEGIES } from '../../constants'; import { __ } from '~/locale'; @@ -52,7 +52,6 @@ export default { .then(res => res.data) .then(data => { eventHub.$emit('UpdateWidgetData', data); - eventHub.$emit('MRWidgetUpdateRequested'); }) .catch(() => { this.isCancellingAutoMerge = false; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index a5c75369fa1..302a30dab54 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -1,5 +1,5 @@ <script> -import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue'; +import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; import statusIcon from '../mr_widget_status_icon.vue'; export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 139cbe17e35..d421b744fa1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { s__, sprintf } from '~/locale'; import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover'; import StatusIcon from '../mr_widget_status_icon.vue'; @@ -50,7 +50,7 @@ export default { content: sprintf( s__('mrWidget|%{link_start}Learn more about resolving conflicts%{link_end}'), { - link_start: `<a href="${esc( + link_start: `<a href="${escape( this.mr.conflictsDocsPath, )}" target="_blank" rel="noopener noreferrer">`, link_end: '</a>', diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 7279aaf0809..1a6e186a371 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -5,7 +5,7 @@ import Flash from '~/flash'; import tooltip from '~/vue_shared/directives/tooltip'; import { s__, __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue'; +import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue index 01a195049ba..f6bfb178437 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue @@ -1,5 +1,4 @@ <script> -import { s__, sprintf } from '~/locale'; import { GlPopover, GlDeprecatedButton } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import Cookies from 'js-cookie'; @@ -15,18 +14,6 @@ export default { dismissTrackValue: 20, showTrackValue: 10, trackEvent: 'click_button', - popoverContent: sprintf( - '%{messageText1}%{lineBreak}%{messageText2}%{lineBreak}%{messageText3}%{lineBreak}%{messageText4}%{lineBreak}%{messageText5}', - { - messageText1: s__('mrWidget|Detect issues before deployment with a CI pipeline'), - messageText2: s__('mrWidget|that continuously tests your code. We created'), - messageText3: s__("mrWidget|a quick guide that'll show you how to create"), - messageText4: s__('mrWidget|one. Make your code more secure and more'), - messageText5: s__('mrWidget|robust in just a minute.'), - lineBreak: '<br/>', - }, - false, - ), components: { GlPopover, GlDeprecatedButton, @@ -110,7 +97,13 @@ export default { <div class="svg-content svg-150 pt-1"> <img :src="pipelineSvgPath" /> </div> - <p v-html="$options.popoverContent"></p> + <p> + {{ + s__( + 'mrWidget|Detect issues before deployment with a CI pipeline that continuously tests your code. We created a quick guide that will show you how to create one. Make your code more secure and more robust in just a minute.', + ) + }} + </p> <gl-deprecated-button ref="ok" category="primary" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 360a75c3946..82be5eeb5ff 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -1,6 +1,6 @@ <script> import { isEmpty } from 'lodash'; -import { GlIcon, GlDeprecatedButton } from '@gitlab/ui'; +import { GlIcon, GlDeprecatedButton, GlSprintf, GlLink } from '@gitlab/ui'; import successSvg from 'icons/_icon_status_success.svg'; import warningSvg from 'icons/_icon_status_warning.svg'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; @@ -26,6 +26,8 @@ export default { CommitEdit, CommitMessageDropdown, GlIcon, + GlSprintf, + GlLink, GlDeprecatedButton, MergeImmediatelyConfirmationDialog: () => import( @@ -56,7 +58,7 @@ export default { status() { const { pipeline, isPipelineFailed, hasCI, ciStatus } = this.mr; - if (hasCI && !ciStatus) { + if ((hasCI && !ciStatus) || this.hasPipelineMustSucceedConflict) { return 'failed'; } else if (this.isAutoMergeAvailable) { return 'pending'; @@ -97,6 +99,9 @@ export default { return __('Merge'); }, + hasPipelineMustSucceedConflict() { + return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds; + }, isRemoveSourceBranchButtonDisabled() { return this.isMergeButtonDisabled; }, @@ -343,9 +348,19 @@ export default { /> </template> <template v-else> - <span class="bold js-resolve-mr-widget-items-message"> - {{ mergeDisabledText }} - </span> + <div class="bold js-resolve-mr-widget-items-message"> + <gl-sprintf + v-if="hasPipelineMustSucceedConflict" + :message="pipelineMustSucceedConflictText" + > + <template #link="{ content }"> + <gl-link :href="mr.pipelineMustSucceedDocsPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + <gl-sprintf v-else :message="mergeDisabledText" /> + </div> </template> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index 98f682c2e8a..5305894873f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -51,7 +51,7 @@ export default { rel="noopener noreferrer nofollow" data-container="body" > - <icon name="question-o" /> + <icon name="question" /> </a> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/event_hub.js b/app/assets/javascripts/vue_merge_request_widget/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/vue_merge_request_widget/event_hub.js +++ b/app/assets/javascripts/vue_merge_request_widget/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 32a2b7b83f4..39fa5e465b8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -1,6 +1,9 @@ import { __ } from '~/locale'; export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.'); +export const PIPELINE_MUST_SUCCEED_CONFLICT_TEXT = __( + 'Pipelines must succeed for merge requests to be eligible to merge. Please enable pipelines for this project to continue. For more information, see the %{linkStart}documentation.%{linkEnd}', +); export default { computed: { @@ -16,6 +19,9 @@ export default { mergeDisabledText() { return MERGE_DISABLED_TEXT; }, + pipelineMustSucceedConflictText() { + return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT; + }, autoMergeText() { // MWPS is currently the only auto merge strategy available in CE return __('Merge when pipeline succeeds'); diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 05f73c4cdaf..265ff81f39f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -39,6 +39,7 @@ import SourceBranchRemovalStatus from './components/source_branch_removal_status import TerraformPlan from './components/mr_widget_terraform_plan.vue'; import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; import { setFaviconOverlay } from '../lib/utils/common_utils'; +import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; export default { el: '#js-vue-mr-widget', @@ -76,6 +77,7 @@ export default { SourceBranchRemovalStatus, GroupedTestReportsApp, TerraformPlan, + GroupedAccessibilityReportsApp, }, props: { mrData: { @@ -100,8 +102,11 @@ export default { shouldRenderMergeHelp() { return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1; }, + hasPipelineMustSucceedConflict() { + return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds; + }, shouldRenderPipelines() { - return this.mr.hasCI; + return this.mr.hasCI || this.hasPipelineMustSucceedConflict; }, shouldSuggestPipelines() { return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath; @@ -138,6 +143,9 @@ export default { mergeError, }); }, + shouldShowAccessibilityReport() { + return this.mr.accessibilityReportPath; + }, }, watch: { state(newVal, oldVal) { @@ -380,6 +388,11 @@ export default { <terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" /> + <grouped-accessibility-reports-app + v-if="shouldShowAccessibilityReport" + :endpoint="mr.accessibilityReportPath" + /> + <div class="mr-widget-section"> <component :is="componentName" :mr="mr" :service="service" /> @@ -415,7 +428,9 @@ export default { <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" /> </div> </div> - <div v-if="shouldRenderMergeHelp" class="mr-widget-footer"><mr-widget-merge-help /></div> + <div v-if="shouldRenderMergeHelp" class="mr-widget-footer"> + <mr-widget-merge-help /> + </div> </div> <mr-widget-pipeline-container v-if="shouldRenderMergedPipeline" diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index a298331c1fc..a2ee0bc3ca1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -21,7 +21,7 @@ export default function deviseState(data) { return stateKey.unresolvedDiscussions; } else if (this.isPipelineBlocked) { return stateKey.pipelineBlocked; - } else if (this.isSHAMismatch) { + } else if (this.canMerge && this.isSHAMismatch) { return stateKey.shaMismatch; } else if (this.autoMergeEnabled) { return this.mergeError ? stateKey.autoMergeFailed : stateKey.autoMergeEnabled; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 9f001dda540..d61e122d612 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -103,6 +103,7 @@ export default class MergeRequestStore { this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; this.terraformReportsPath = data.terraform_reports_path; this.testResultsPath = data.test_reports_path; + this.accessibilityReportPath = data.accessibility_report_path; this.exposedArtifactsPath = data.exposed_artifacts_path; this.cancelAutoMergePath = data.cancel_auto_merge_path; this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path); @@ -123,15 +124,13 @@ export default class MergeRequestStore { const currentUser = data.current_user; - if (currentUser) { - this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path; - this.revertInForkPath = currentUser.revert_in_fork_path; + this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path; + this.revertInForkPath = currentUser.revert_in_fork_path; - this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false; - this.canCreateIssue = currentUser.can_create_issue || false; - this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; - this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false; - } + this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false; + this.canCreateIssue = currentUser.can_create_issue || false; + this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; + this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false; this.setState(data); } @@ -162,6 +161,7 @@ export default class MergeRequestStore { // Paths are set on the first load of the page and not auto-refreshed this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path; this.troubleshootingDocsPath = data.troubleshooting_docs_path; + this.pipelineMustSucceedDocsPath = data.pipeline_must_succeed_docs_path; this.mergeRequestBasicPath = data.merge_request_basic_path; this.mergeRequestWidgetPath = data.merge_request_widget_path; this.mergeRequestCachedWidgetPath = data.merge_request_cached_widget_path; diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index 848295cc984..c0a42e08dee 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -34,10 +34,21 @@ export default { required: false, default: '', }, + defaultAwards: { + type: Array, + required: false, + default: () => [], + }, }, computed: { + groupedDefaultAwards() { + return this.defaultAwards.reduce((obj, key) => Object.assign(obj, { [key]: [] }), {}); + }, groupedAwards() { - const { thumbsup, thumbsdown, ...rest } = groupBy(this.awards, x => x.name); + const { thumbsup, thumbsdown, ...rest } = { + ...this.groupedDefaultAwards, + ...groupBy(this.awards, x => x.name), + }; return [ ...(thumbsup ? [this.createAwardList('thumbsup', thumbsup)] : []), @@ -73,6 +84,10 @@ export default { }; }, getAwardListTitle(awardsList) { + if (!awardsList.length) { + return ''; + } + const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; let awardList = awardsList; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue index afbfb1e0ee2..52ce05f0d99 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -1,8 +1,12 @@ <script> +import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; import ViewerMixin from './mixins'; import { handleBlobRichViewer } from '~/blob/viewer'; export default { + components: { + MarkdownFieldView, + }, mixins: [ViewerMixin], mounted() { handleBlobRichViewer(this.$refs.content, this.type); @@ -10,5 +14,5 @@ export default { }; </script> <template> - <div ref="content" v-html="content"></div> + <markdown-field-view ref="content" v-html="content" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue index e64c7132117..1eb05780206 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -44,7 +44,8 @@ export default { </script> <template> <div - class="file-content code js-syntax-highlight qa-file-content" + class="file-content code js-syntax-highlight" + data-qa-selector="file_content" :class="$options.userColorScheme" > <div class="line-numbers"> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 162cfc02959..890dbe86c0d 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -1,5 +1,5 @@ <script> -import Icon from '../../vue_shared/components/icon.vue'; +import Icon from './icon.vue'; /** * Renders CI icon based on API response shared between all places where it is used. diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index d38dd258ce6..0234b6bf848 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -67,6 +67,7 @@ export default { <template> <gl-deprecated-button v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }" + v-gl-tooltip.hover.blur :class="cssClass" :title="title" :data-clipboard-text="clipboardText" diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue index 7826c179889..ac95c88225e 100644 --- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue @@ -4,7 +4,6 @@ import { GlNewDropdownHeader, GlFormInputGroup, GlButton, - GlIcon, GlTooltipDirective, } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; @@ -16,7 +15,6 @@ export default { GlNewDropdownHeader, GlFormInputGroup, GlButton, - GlIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -59,9 +57,10 @@ export default { v-gl-tooltip.hover :title="$options.copyURLTooltip" :data-clipboard-text="sshLink" - > - <gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" /> - </gl-button> + data-qa-selector="copy_ssh_url_button" + icon="copy-to-clipboard" + class="d-inline-flex" + /> </template> </gl-form-input-group> </div> @@ -77,9 +76,10 @@ export default { v-gl-tooltip.hover :title="$options.copyURLTooltip" :data-clipboard-text="httpLink" - > - <gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" /> - </gl-button> + data-qa-selector="copy_http_url_button" + icon="copy-to-clipboard" + class="d-inline-flex" + /> </template> </gl-form-input-group> </div> diff --git a/app/assets/javascripts/vue_shared/components/code_block.vue b/app/assets/javascripts/vue_shared/components/code_block.vue index 3cca7a86bef..1928bf6dac5 100644 --- a/app/assets/javascripts/vue_shared/components/code_block.vue +++ b/app/assets/javascripts/vue_shared/components/code_block.vue @@ -6,11 +6,26 @@ export default { type: String, required: true, }, + maxHeight: { + type: String, + required: false, + default: 'initial', + }, + }, + computed: { + styleObject() { + const { maxHeight } = this; + const isScrollable = maxHeight !== 'initial'; + const scrollableStyles = { + maxHeight, + overflowY: 'auto', + }; + + return isScrollable ? scrollableStyles : null; + }, }, }; </script> <template> - <pre class="code-block rounded"> - <code class="d-block">{{ code }}</code> - </pre> + <pre class="code-block rounded" :style="styleObject"><code class="d-block">{{ code }}</code></pre> </template> diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index 356f733fb8c..23bea6c28b4 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -4,7 +4,7 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import UserAvatarLink from './user_avatar/user_avatar_link.vue'; -import Icon from '../../vue_shared/components/icon.vue'; +import Icon from './icon.vue'; export default { directives: { diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue index 2f5e5f35064..fe488ab6cfa 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue @@ -24,6 +24,11 @@ export default { required: false, default: '', }, + commitSha: { + type: String, + required: false, + default: '', + }, projectPath: { type: String, required: false, @@ -34,6 +39,11 @@ export default { required: false, default: '', }, + images: { + type: Object, + required: false, + default: () => ({}), + }, }, computed: { viewer() { @@ -62,6 +72,8 @@ export default { :file-size="fileSize" :project-path="projectPath" :content="content" + :images="images" + :commit-sha="commitSha" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js index da0b45110e2..b7fa73bc197 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js +++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js @@ -26,8 +26,8 @@ const fileExtensionViewers = { export function viewerInformationForPath(path) { if (!path) return null; const name = path.split('/').pop(); - const viewerName = - fileNameViewers[name] || fileExtensionViewers[name ? name.split('.').pop() : ''] || ''; + const extension = name.includes('.') && name.split('.').pop(); + const viewerName = fileNameViewers[name] || fileExtensionViewers[extension]; return viewers[viewerName]; } diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index eb3e489fb8c..1344c766e0e 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -1,8 +1,11 @@ <script> import $ from 'jquery'; +import '~/behaviors/markdown/render_gfm'; + import { GlSkeletonLoading } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; +import { forEach, escape } from 'lodash'; const { CancelToken } = axios; let axiosSource; @@ -16,6 +19,11 @@ export default { type: String, required: true, }, + commitSha: { + type: String, + required: false, + default: '', + }, filePath: { type: String, required: false, @@ -25,6 +33,11 @@ export default { type: String, required: true, }, + images: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { @@ -55,6 +68,9 @@ export default { text: this.content, path: this.filePath, }; + if (this.commitSha) { + postBody.ref = this.commitSha; + } const postOptions = { cancelToken: axiosSource.token, }; @@ -66,11 +82,19 @@ export default { postOptions, ) .then(({ data }) => { - this.previewContent = data.body; + let previewContent = data.body; + forEach(this.images, ({ src, title = '', alt }, key) => { + previewContent = previewContent.replace( + key, + `<img src="${escape(src)}" title="${escape(title)}" alt="${escape(alt)}">`, + ); + }); + + this.previewContent = previewContent; this.isLoading = false; this.$nextTick(() => { - $(this.$refs['markdown-preview']).renderGFM(); + $(this.$refs.markdownPreview).renderGFM(); }); }) .catch(() => { @@ -84,7 +108,7 @@ export default { </script> <template> - <div ref="markdown-preview" class="md-previewer"> + <div ref="markdownPreview" class="md-previewer"> <gl-skeleton-loading v-if="isLoading" /> <div v-else class="md" v-html="previewContent"></div> </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 ffc616d7309..07748482204 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 @@ -169,15 +169,15 @@ export default { menu-class="date-time-picker-menu" toggle-class="date-time-picker-toggle text-truncate" > - <div class="d-flex justify-content-between gl-p-2"> + <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-for="custom-from-time" - label-class="gl-pb-1" - class="custom-time-range-form-group col-md-7 gl-pl-1 gl-pr-0 m-0" + 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" > - <div class="gl-pt-2"> + <div class="gl-pt-2-deprecated-no-really-do-not-use-me"> <date-time-picker-input id="custom-time-from" v-model="startInput" @@ -198,14 +198,18 @@ export default { </gl-deprecated-button> </gl-form-group> </gl-form-group> - <gl-form-group label-for="group-id-dropdown" class="col-md-5 gl-pl-1 gl-pr-1 m-0"> + <gl-form-group + label-for="group-id-dropdown" + class="col-md-5 gl-pl-1-deprecated-no-really-do-not-use-me gl-pr-1-deprecated-no-really-do-not-use-me m-0" + > <template #label> - <span class="gl-pl-5">{{ __('Quick range') }}</span> + <span class="gl-pl-5-deprecated-no-really-do-not-use-me">{{ __('Quick range') }}</span> </template> <gl-dropdown-item v-for="(option, index) in options" :key="index" + data-qa-selector="quick_range_item" :active="isOptionActive(option)" active-class="active" @click="setQuickRange(option)" 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 73511879ff2..018e3a84c39 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue @@ -1,8 +1,8 @@ <script> import fuzzaldrinPlus from 'fuzzaldrin-plus'; import Icon from '~/vue_shared/components/icon.vue'; -import FileIcon from '../../../vue_shared/components/file_icon.vue'; -import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue'; +import FileIcon from '../file_icon.vue'; +import ChangedFileIcon from '../changed_file_icon.vue'; const MAX_PATH_LENGTH = 60; diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js index 2f6640232dd..9ecae87c1a9 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js @@ -493,6 +493,7 @@ const fileNameIcons = { '.npmignore': 'npm', '.npmrc': 'npm', '.yarnrc': 'yarn', + '.yarnrc.yml': 'yarn', 'yarn.lock': 'yarn', '.yarnclean': 'yarn', '.yarn-integrity': 'yarn', @@ -575,6 +576,7 @@ const fileNameIcons = { '.prettierrc.json': 'prettier', '.prettierrc.yaml': 'prettier', '.prettierrc.yml': 'prettier', + '.prettierignore': 'prettier', 'nodemon.json': 'nodemon', '.sonarrc': 'sonar', browserslist: 'browserlist', diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 0a5cc7b693c..0cc96309a92 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -148,19 +148,6 @@ export default { cursor: pointer; } -.file-row:hover, -.file-row:focus { - background: #f2f2f2; -} - -.file-row:active { - background: #dfdfdf; -} - -.file-row.is-active { - background: #f2f2f2; -} - .file-row-name-container { display: flex; width: 100%; diff --git a/app/assets/javascripts/vue_shared/components/form/title.vue b/app/assets/javascripts/vue_shared/components/form/title.vue index fad69dc1e24..5d6633fa6d7 100644 --- a/app/assets/javascripts/vue_shared/components/form/title.vue +++ b/app/assets/javascripts/vue_shared/components/form/title.vue @@ -6,6 +6,7 @@ export default { GlFormInput, GlFormGroup, }, + inheritAttrs: false, }; </script> <template> diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue index bbf293664a6..508f43afe61 100644 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue @@ -34,7 +34,7 @@ function createMenuItemTemplate({ original }) { return `${avatarTag} ${original.username} - <small class="small font-weight-normal gl-color-inherit">${name}${count}</small> + <small class="small font-weight-normal gl-reset-color">${name}${count}</small> ${icon}`; } diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue index 9dd61c8eada..87a995464fa 100644 --- a/app/assets/javascripts/vue_shared/components/identicon.vue +++ b/app/assets/javascripts/vue_shared/components/identicon.vue @@ -4,7 +4,7 @@ import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar export default { props: { entityId: { - type: Number, + type: [Number, String], required: true, }, entityName: { diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue index 89a8595fc79..cb3cd18e5a7 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue @@ -1,11 +1,11 @@ <script> import { GlLink } from '@gitlab/ui'; -import { escape as esc } from 'lodash'; +import { escape } from 'lodash'; import { __, sprintf } from '~/locale'; -import icon from '../../../vue_shared/components/icon.vue'; +import icon from '../icon.vue'; function buildDocsLinkStart(path) { - return `<a href="${esc(path)}" target="_blank" rel="noopener noreferrer">`; + return `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`; } export default { 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 5d7e9557aff..4f1b1c758b2 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 @@ -1,9 +1,9 @@ <script> import '~/commons/bootstrap'; -import { GlTooltip, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlTooltip, GlTooltipDirective } from '@gitlab/ui'; import { sprintf } from '~/locale'; -import IssueMilestone from '../../components/issue/issue_milestone.vue'; -import IssueAssignees from '../../components/issue/issue_assignees.vue'; +import IssueMilestone from './issue_milestone.vue'; +import IssueAssignees from './issue_assignees.vue'; import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; import CiIcon from '../ci_icon.vue'; @@ -13,6 +13,7 @@ export default { IssueMilestone, IssueAssignees, CiIcon, + GlIcon, GlTooltip, }, directives: { @@ -44,6 +45,9 @@ export default { visibility: 'hidden', }; }, + iconClasses() { + return `${this.iconClass} ic-${this.iconName}`; + }, }, }; </script> @@ -54,30 +58,29 @@ export default { 'issuable-info-container': !canReorder, 'card-body': canReorder, }" - class="item-body d-flex align-items-center p-2 p-lg-3 py-xl-2 px-xl-3" + class="item-body d-flex align-items-center py-2 px-3" > <div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap"> <!-- Title area: Status icon (XL) and title --> - <div class="item-title d-flex align-items-center mb-xl-0"> - <span ref="iconElementXL"> - <icon + <div class="item-title d-flex align-items-xl-center mb-xl-0"> + <div ref="iconElementXL"> + <gl-icon v-if="hasState" ref="iconElementXL" - :class="iconClass" + class="mr-2 d-block" + :class="iconClasses" :name="iconName" - :size="16" :title="stateTitle" :aria-label="state" /> - </span> + </div> <gl-tooltip :target="() => $refs.iconElementXL"> <span v-html="stateTitle"></span> </gl-tooltip> - <icon + <gl-icon v-if="confidential" v-gl-tooltip name="eye-slash" - :size="16" :title="__('Confidential')" class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0" :aria-label="__('Confidential')" @@ -97,17 +100,6 @@ export default { <div class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2" > - <span ref="iconElement"> - <icon - v-if="hasState" - :class="iconClass" - :name="iconName" - :title="stateTitle" - :aria-label="state" - data-html="true" - class="d-xl-none" - /> - </span> <gl-tooltip :target="() => this.$refs.iconElement"> <span v-html="stateTitle"></span> </gl-tooltip> @@ -159,7 +151,7 @@ export default { v-gl-tooltip :disabled="removeDisabled" type="button" - class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button mr-xl-0 align-self-xl-center" + class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button" data-qa-selector="remove_related_issue_button" :title="__('Remove')" :aria-label="__('Remove')" diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 26e878d56a0..8007ccb91d5 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,7 +1,7 @@ <script> import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; -import { unescape as unesc } from 'lodash'; +import { unescape } from 'lodash'; import { __, sprintf } from '~/locale'; import { stripHtml } from '~/lib/utils/text_utility'; import Flash from '../../../flash'; @@ -115,7 +115,7 @@ export default { return text; } - return unesc(stripHtml(richText).replace(/\n/g, '')); + return unescape(stripHtml(richText).replace(/\n/g, '')); } return ''; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue new file mode 100644 index 00000000000..d77123371f2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue @@ -0,0 +1,19 @@ +<script> +import $ from 'jquery'; +import '~/behaviors/markdown/render_gfm'; + +export default { + mounted() { + this.renderGFM(); + }, + methods: { + renderGFM() { + $(this.$el).renderGFM(); + }, + }, +}; +</script> + +<template> + <div><slot></slot></div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js index a4e004c3341..e193883b6e9 100644 --- a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js +++ b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js @@ -1,9 +1,9 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; // see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml export const callbackName = 'recaptchaDialogCallback'; -export const eventHub = new Vue(); +export const eventHub = createEventHub(); const throwDuplicateCallbackError = () => { throw new Error(`${callbackName} is already defined!`); 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 new file mode 100644 index 00000000000..457f1806452 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js @@ -0,0 +1,37 @@ +import { __ } from '~/locale'; +import { generateToolbarItem } from './toolbar_service'; + +/* eslint-disable @gitlab/require-i18n-strings */ +const TOOLBAR_ITEM_CONFIGS = [ + { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') }, + { icon: 'bold', command: 'Bold', tooltip: __('Add bold text') }, + { icon: 'italic', command: 'Italic', tooltip: __('Add italic text') }, + { icon: 'strikethrough', command: 'Strike', tooltip: __('Add strikethrough text') }, + { 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') }, + { icon: 'list-task', command: 'Task', tooltip: __('Add a task list') }, + { icon: 'list-indent', command: 'Indent', tooltip: __('Indent') }, + { icon: 'list-outdent', command: 'Outdent', tooltip: __('Outdent') }, + { isDivider: true }, + { icon: 'dash', command: 'HR', tooltip: __('Add a line') }, + { icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') }, + { isDivider: true }, + { icon: 'code', command: 'Code', tooltip: __('Insert inline code') }, +]; + +export const EDITOR_OPTIONS = { + toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)), +}; + +export const EDITOR_TYPES = { + wysiwyg: 'wysiwyg', +}; + +export const EDITOR_HEIGHT = '100%'; + +export const EDITOR_PREVIEW_STYLE = 'horizontal'; 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 new file mode 100644 index 00000000000..ba3696c8ad1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -0,0 +1,65 @@ +<script> +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'; + +export default { + components: { + ToastEditor: () => + import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then( + toast => toast.Editor, + ), + }, + props: { + value: { + type: String, + required: true, + }, + options: { + type: Object, + required: false, + default: () => EDITOR_OPTIONS, + }, + initialEditType: { + type: String, + required: false, + default: EDITOR_TYPES.wysiwyg, + }, + height: { + type: String, + required: false, + default: EDITOR_HEIGHT, + }, + previewStyle: { + type: String, + required: false, + default: EDITOR_PREVIEW_STYLE, + }, + }, + computed: { + editorOptions() { + return { ...EDITOR_OPTIONS, ...this.options }; + }, + }, + methods: { + onContentChanged() { + this.$emit('input', this.getMarkdown()); + }, + getMarkdown() { + return this.$refs.editor.invoke('getMarkdown'); + }, + }, +}; +</script> +<template> + <toast-editor + ref="editor" + :initial-value="value" + :options="editorOptions" + :preview-style="previewStyle" + :initial-edit-type="initialEditType" + :height="height" + @change="onContentChanged" + /> +</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 new file mode 100644 index 00000000000..58aaeef45f2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue @@ -0,0 +1,20 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + props: { + icon: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <button class="p-0 gl-display-flex toolbar-button"> + <gl-icon class="gl-mx-auto" :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 new file mode 100644 index 00000000000..fff90f3e3fb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js @@ -0,0 +1,32 @@ +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/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index 44cc11a6aaa..5eef439aa90 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -80,11 +80,6 @@ export default { required: false, default: false, }, - scopedLabelsDocumentationLink: { - type: String, - required: false, - default: '#', - }, }, computed: { hiddenInputName() { @@ -136,7 +131,6 @@ export default { <dropdown-value :labels="context.labels" :label-filter-base-path="labelFilterBasePath" - :scoped-labels-documentation-link="scopedLabelsDocumentationLink" :enable-scoped-labels="enableScopedLabels" > <slot></slot> @@ -157,7 +151,6 @@ export default { :namespace="namespace" :labels="context.labels" :show-extra-options="!showCreate" - :scoped-labels-documentation-link="scopedLabelsDocumentationLink" :enable-scoped-labels="enableScopedLabels" /> <div diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue index c3bc61d0053..30f7e6a5980 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue @@ -36,11 +36,6 @@ export default { required: false, default: false, }, - scopedLabelsDocumentationLink: { - type: String, - required: false, - default: '#', - }, }, computed: { dropdownToggleText() { @@ -72,7 +67,6 @@ export default { :data-namespace-path="namespace" :data-show-any="showExtraOptions" :data-scoped-labels="enableScopedLabels" - :data-scoped-labels-documentation-link="scopedLabelsDocumentationLink" type="button" class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal" data-toggle="dropdown" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue index fe43f77b1ee..71d7069dd57 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue @@ -20,11 +20,6 @@ export default { required: false, default: false, }, - scopedLabelsDocumentationLink: { - type: String, - required: false, - default: '#', - }, }, computed: { isEmpty() { @@ -64,7 +59,6 @@ export default { :title="label.title" :description="label.description" :scoped="showScopedLabels(label)" - :scoped-labels-documentation-link="scopedLabelsDocumentationLink" /> </template> </div> 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 new file mode 100644 index 00000000000..ab652c9356a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js @@ -0,0 +1,5 @@ +// eslint-disable-next-line import/prefer-default-export +export const DropdownVariant = { + Sidebar: 'sidebar', + Standalone: 'standalone', +}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue index 55fa1e4ef9c..f45c14f8344 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue @@ -1,21 +1,35 @@ <script> -import { mapGetters } from 'vuex'; -import { GlDeprecatedButton, GlIcon } from '@gitlab/ui'; +import { mapActions, mapGetters } from 'vuex'; +import { GlButton, GlIcon } from '@gitlab/ui'; export default { components: { - GlDeprecatedButton, + GlButton, GlIcon, }, computed: { - ...mapGetters(['dropdownButtonText']), + ...mapGetters(['dropdownButtonText', 'isDropdownVariantStandalone']), + }, + methods: { + ...mapActions(['toggleDropdownContents']), + handleButtonClick(e) { + if (this.isDropdownVariantStandalone) { + this.toggleDropdownContents(); + e.stopPropagation(); + } + }, }, }; </script> <template> - <gl-deprecated-button class="labels-select-dropdown-button w-100 text-left"> - <span class="dropdown-toggle-text">{{ dropdownButtonText }}</span> + <gl-button + class="labels-select-dropdown-button js-dropdown-button w-100 text-left" + @click="handleButtonClick" + > + <span class="dropdown-toggle-text flex-fill"> + {{ dropdownButtonText }} + </span> <gl-icon name="chevron-down" class="pull-right" /> - </gl-deprecated-button> + </gl-button> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue index 6bb77f6b6f3..ba8d8391952 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue @@ -1,18 +1,10 @@ <script> import { mapState, mapActions } from 'vuex'; -import { - GlTooltipDirective, - GlDeprecatedButton, - GlIcon, - GlFormInput, - GlLink, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; export default { components: { - GlDeprecatedButton, - GlIcon, + GlButton, GlFormInput, GlLink, GlLoadingIcon, @@ -60,25 +52,23 @@ export default { <template> <div class="labels-select-contents-create js-labels-create"> <div class="dropdown-title d-flex align-items-center pt-0 pb-2"> - <gl-deprecated-button + <gl-button :aria-label="__('Go back')" variant="link" - size="sm" + size="small" class="js-btn-back dropdown-header-button p-0" + icon="arrow-left" @click="toggleDropdownContentsCreateView" - > - <gl-icon name="arrow-left" /> - </gl-deprecated-button> + /> <span class="flex-grow-1">{{ labelsCreateTitle }}</span> - <gl-deprecated-button + <gl-button :aria-label="__('Close')" variant="link" - size="sm" + size="small" class="dropdown-header-button p-0" + icon="close" @click="toggleDropdownContents" - > - <gl-icon name="close" /> - </gl-deprecated-button> + /> </div> <div class="dropdown-input"> <gl-form-input @@ -107,21 +97,19 @@ export default { </div> </div> <div class="dropdown-actions clearfix pt-2 px-2"> - <gl-deprecated-button + <gl-button :disabled="disableCreate" - variant="primary" + category="primary" + variant="success" class="pull-left d-flex align-items-center" @click="handleCreateClick" > <gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" /> {{ __('Create') }} - </gl-deprecated-button> - <gl-deprecated-button - class="pull-right js-btn-cancel-create" - @click="toggleDropdownContentsCreateView" - > + </gl-button> + <gl-button class="pull-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView"> {{ __('Cancel') }} - </gl-deprecated-button> + </gl-button> </div> </div> </template> 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 a8e48bfe1a1..1ef2e8b3bed 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 @@ -1,16 +1,18 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { GlLoadingIcon, GlDeprecatedButton, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +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 LabelItem from './label_item.vue'; + export default { components: { GlLoadingIcon, - GlDeprecatedButton, - GlIcon, + GlButton, GlSearchBoxByType, GlLink, + LabelItem, }, data() { return { @@ -20,6 +22,8 @@ export default { }, computed: { ...mapState([ + 'allowLabelCreate', + 'allowMultiselect', 'labelsManagePath', 'labels', 'labelsFetchInProgress', @@ -27,7 +31,7 @@ export default { 'footerCreateLabelTitle', 'footerManageLabelTitle', ]), - ...mapGetters(['selectedLabelsList']), + ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar']), visibleLabels() { if (this.searchKey) { return this.labels.filter(label => @@ -56,12 +60,8 @@ export default { 'toggleDropdownContentsCreateView', 'fetchLabels', 'updateSelectedLabels', + 'toggleDropdownContents', ]), - getDropdownLabelBoxStyle(label) { - return { - backgroundColor: label.color, - }; - }, isLabelSelected(label) { return this.selectedLabelsList.includes(label.id); }, @@ -111,6 +111,7 @@ export default { }, handleLabelClick(label) { this.updateSelectedLabels([label]); + if (!this.allowMultiselect) this.toggleDropdownContents(); }, }, }; @@ -123,54 +124,47 @@ export default { class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100" size="md" /> - <div class="dropdown-title d-flex align-items-center pt-0 pb-2"> + <div v-if="isDropdownVariantSidebar" class="dropdown-title d-flex align-items-center pt-0 pb-2"> <span class="flex-grow-1">{{ labelsListTitle }}</span> - <gl-deprecated-button + <gl-button :aria-label="__('Close')" variant="link" - size="sm" + size="small" class="dropdown-header-button p-0" + icon="close" @click="toggleDropdownContents" - > - <gl-icon name="close" /> - </gl-deprecated-button> + /> </div> - <div class="dropdown-input"> + <div class="dropdown-input" @click.stop="() => {}"> <gl-search-box-by-type v-model="searchKey" :autofocus="true" /> </div> - <div v-if="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content"> + <div v-show="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content"> <ul class="list-unstyled mb-0"> <li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left"> - <gl-link - class="d-flex align-items-baseline text-break-word label-item" - :class="{ 'is-focused': index === currentHighlightItem }" - @click="handleLabelClick(label)" - > - <gl-icon v-show="label.set" name="mobile-issue-close" class="mr-2 align-self-center" /> - <span v-show="!label.set" class="mr-3 pr-2"></span> - <span class="dropdown-label-box" :style="getDropdownLabelBoxStyle(label)"></span> - <span>{{ label.title }}</span> - </gl-link> + <label-item + :label="label" + :highlight="index === currentHighlightItem" + @clickLabel="handleLabelClick(label)" + /> </li> - <li v-if="!visibleLabels.length" class="p-2 text-center"> + <li v-show="!visibleLabels.length" class="p-2 text-center"> {{ __('No matching results') }} </li> </ul> </div> - <div class="dropdown-footer"> + <div v-if="isDropdownVariantSidebar" class="dropdown-footer"> <ul class="list-unstyled"> - <li> - <gl-deprecated-button - variant="link" + <li v-if="allowLabelCreate"> + <gl-link class="d-flex w-100 flex-row text-break-word label-item" @click="toggleDropdownContentsCreateView" - >{{ footerCreateLabelTitle }}</gl-deprecated-button + >{{ footerCreateLabelTitle }}</gl-link > </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/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue index 695af775750..12ad2acf308 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue @@ -9,12 +9,7 @@ export default { GlLabel, }, computed: { - ...mapState([ - 'selectedLabels', - 'allowScopedLabels', - 'labelsFilterBasePath', - 'scopedLabelsDocumentationPath', - ]), + ...mapState(['selectedLabels', 'allowScopedLabels', 'labelsFilterBasePath']), }, methods: { labelFilterUrl(label) { @@ -45,7 +40,6 @@ export default { :background-color="label.color" :target="labelFilterUrl(label)" :scoped="scopedLabel(label)" - :scoped-labels-documentation-link="scopedLabelsDocumentationPath" tooltip-placement="top" /> </template> 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 new file mode 100644 index 00000000000..c95221d71b5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue @@ -0,0 +1,52 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + label: { + type: Object, + required: true, + }, + highlight: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isSet: this.label.set, + }; + }, + computed: { + labelBoxStyle() { + return { + backgroundColor: this.label.color, + }; + }, + }, + methods: { + handleClick() { + this.isSet = !this.isSet; + this.$emit('clickLabel', this.label); + }, + }, +}; +</script> + +<template> + <gl-link + class="d-flex align-items-baseline text-break-word label-item" + :class="{ 'is-focused': highlight }" + @click="handleClick" + > + <gl-icon v-show="isSet" name="mobile-issue-close" class="mr-2 align-self-center" /> + <span v-show="!isSet" data-testid="no-icon" class="mr-3 pr-2"></span> + <span class="dropdown-label-box" data-testid="label-color-box" :style="labelBoxStyle"></span> + <span>{{ label.title }}</span> + </gl-link> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 78102caacf5..f38b66fdfdf 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -1,7 +1,7 @@ <script> import $ from 'jquery'; import Vue from 'vue'; -import Vuex, { mapState, mapActions } from 'vuex'; +import Vuex, { mapState, mapActions, mapGetters } from 'vuex'; import { __ } from '~/locale'; import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; @@ -13,6 +13,8 @@ import DropdownValue from './dropdown_value.vue'; import DropdownButton from './dropdown_button.vue'; import DropdownContents from './dropdown_contents.vue'; +import { DropdownVariant } from './constants'; + Vue.use(Vuex); export default { @@ -33,14 +35,19 @@ export default { type: Boolean, required: true, }, + allowMultiselect: { + type: Boolean, + required: false, + default: false, + }, allowScopedLabels: { type: Boolean, required: true, }, - dropdownOnly: { - type: Boolean, + variant: { + type: String, required: false, - default: false, + default: DropdownVariant.Sidebar, }, selectedLabels: { type: Array, @@ -67,11 +74,6 @@ export default { required: false, default: '', }, - scopedLabelsDocumentationPath: { - type: String, - required: false, - default: '', - }, labelsListTitle: { type: String, required: false, @@ -95,6 +97,10 @@ export default { }, computed: { ...mapState(['showDropdownButton', 'showDropdownContents']), + ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone']), + dropdownButtonVisible() { + return this.isDropdownVariantSidebar ? this.showDropdownButton : true; + }, }, watch: { selectedLabels(selectedLabels) { @@ -105,15 +111,15 @@ export default { }, mounted() { this.setInitialState({ - dropdownOnly: this.dropdownOnly, + variant: this.variant, allowLabelEdit: this.allowLabelEdit, allowLabelCreate: this.allowLabelCreate, + allowMultiselect: this.allowMultiselect, allowScopedLabels: this.allowScopedLabels, selectedLabels: this.selectedLabels, labelsFetchPath: this.labelsFetchPath, labelsManagePath: this.labelsManagePath, labelsFilterBasePath: this.labelsFilterBasePath, - scopedLabelsDocumentationPath: this.scopedLabelsDocumentationPath, labelsListTitle: this.labelsListTitle, labelsCreateTitle: this.labelsCreateTitle, footerCreateLabelTitle: this.footerCreateLabelTitle, @@ -154,13 +160,24 @@ export default { // as the dropdown wrapper is not using `GlDropdown` as // it will also require us to use `BDropdownForm` // which is yet to be implemented in GitLab UI. + const hasExceptionClass = [ + 'js-dropdown-button', + 'js-btn-cancel-create', + 'js-sidebar-dropdown-toggle', + ].some( + className => + target?.classList.contains(className) || + target?.parentElement.classList.contains(className), + ); + + const hadExceptionParent = ['.js-btn-back', '.js-labels-list'].some( + className => $(target).parents(className).length, + ); + if ( - this.showDropdownButton && this.showDropdownContents && - !$(target).parents('.js-btn-back').length && - !$(target).parents('.js-labels-list').length && - !target?.classList.contains('js-btn-cancel-create') && - !target?.classList.contains('js-sidebar-dropdown-toggle') && + !hadExceptionParent && + !hasExceptionClass && !this.$refs.dropdownButtonCollapsed?.$el.contains(target) && !this.$refs.dropdownContents?.$el.contains(target) ) { @@ -181,10 +198,12 @@ export default { </script> <template> - <div class="labels-select-wrapper position-relative"> - <div v-if="!dropdownOnly"> + <div + class="labels-select-wrapper position-relative" + :class="{ 'is-standalone': isDropdownVariantStandalone }" + > + <template v-if="isDropdownVariantSidebar"> <dropdown-value-collapsed - v-if="allowLabelCreate" ref="dropdownButtonCollapsed" :labels="selectedLabels" @onValueClick="handleCollapsedValueClick" @@ -196,8 +215,18 @@ export default { <dropdown-value v-show="!showDropdownButton"> <slot></slot> </dropdown-value> - <dropdown-button v-show="showDropdownButton" /> - <dropdown-contents v-if="showDropdownButton && showDropdownContents" ref="dropdownContents" /> - </div> + <dropdown-button v-show="dropdownButtonVisible" /> + <dropdown-contents + v-if="dropdownButtonVisible && showDropdownContents" + ref="dropdownContents" + /> + </template> + <template v-if="isDropdownVariantStandalone"> + <dropdown-button v-show="dropdownButtonVisible" /> + <dropdown-contents + v-if="dropdownButtonVisible && showDropdownContents" + ref="dropdownContents" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js index c08a8a8ea58..c39222959a9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js @@ -1,4 +1,5 @@ import { __, s__, sprintf } from '~/locale'; +import { DropdownVariant } from '../constants'; /** * Returns string representing current labels @@ -6,8 +7,11 @@ import { __, s__, sprintf } from '~/locale'; * * @param {object} state */ -export const dropdownButtonText = state => { - const selectedLabels = state.labels.filter(label => label.set); +export const dropdownButtonText = (state, getters) => { + const selectedLabels = getters.isDropdownVariantSidebar + ? state.labels.filter(label => label.set) + : state.selectedLabels; + if (!selectedLabels.length) { return __('Label'); } else if (selectedLabels.length > 1) { @@ -26,5 +30,19 @@ export const dropdownButtonText = state => { */ export const selectedLabelsList = state => state.selectedLabels.map(label => label.id); +/** + * Returns boolean representing whether dropdown variant + * is `sidebar` + * @param {object} state + */ +export const isDropdownVariantSidebar = state => state.variant === DropdownVariant.Sidebar; + +/** + * Returns boolean representing whether dropdown variant + * is `standalone` + * @param {object} state + */ +export const isDropdownVariantStandalone = state => state.variant === DropdownVariant.Standalone; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index 32a78507e88..54f8c78b4e1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -1,4 +1,5 @@ import * as types from './mutation_types'; +import { DropdownVariant } from '../constants'; export default { [types.SET_INITIAL_STATE](state, props) { @@ -10,7 +11,7 @@ export default { }, [types.TOGGLE_DROPDOWN_CONTENTS](state) { - if (!state.dropdownOnly) { + if (state.variant === DropdownVariant.Sidebar) { state.showDropdownButton = !state.showDropdownButton; } state.showDropdownContents = !state.showDropdownContents; @@ -57,20 +58,13 @@ export default { }, [types.UPDATE_SELECTED_LABELS](state, { labels }) { - // Iterate over all the labels and update - // `set` prop value to represent their current state. - const labelIds = labels.map(label => label.id); - state.labels = state.labels.reduce((allLabels, label) => { - if (labelIds.includes(label.id)) { - allLabels.push({ - ...label, - touched: true, - set: !label.set, - }); - } else { - allLabels.push(label); - } - return allLabels; - }, []); + // Find the label to update from all the labels + // and change `set` prop value to represent their current state. + const labelId = labels.pop()?.id; + const candidateLabel = state.labels.find(label => labelId === label.id); + if (candidateLabel) { + candidateLabel.touched = true; + candidateLabel.set = !candidateLabel.set; + } }, }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js index ceabc696693..6a6c0b4c0ee 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js @@ -11,13 +11,13 @@ export default () => ({ namespace: '', labelsFetchPath: '', labelsFilterBasePath: '', - scopedLabelsDocumentationPath: '#', // UI Flags + variant: '', allowLabelCreate: false, allowLabelEdit: false, allowScopedLabels: false, - dropdownOnly: false, + allowMultiselect: false, showDropdownButton: false, showDropdownContents: false, showDropdownContentsCreateView: false, diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue b/app/assets/javascripts/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue deleted file mode 100644 index 527cbd458e2..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue +++ /dev/null @@ -1,27 +0,0 @@ -<script> -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; - -const GITLAB_TEAM_MEMBER_LABEL = __('GitLab Team Member'); - -export default { - name: 'GitlabTeamMemberBadge', - directives: { - GlTooltip: GlTooltipDirective, - }, - components: { GlIcon }, - gitlabTeamMemberLabel: GITLAB_TEAM_MEMBER_LABEL, -}; -</script> - -<template> - <span - v-gl-tooltip.hover - :title="$options.gitlabTeamMemberLabel" - role="img" - :aria-label="$options.gitlabTeamMemberLabel" - class="d-inline-block align-middle" - > - <gl-icon name="tanuki-verified" class="gl-text-purple d-block" /> - </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue deleted file mode 100644 index 7ed4da84120..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue +++ /dev/null @@ -1,38 +0,0 @@ -<script> -/* This is a re-usable vue component for rendering a user avatar svg (typically - for a blank state). It will receive styles comparable to the user avatar, - but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported. - The svg and avatar size can be configured by props passed to this component. - - Sample configuration: - - <user-avatar-svg - :svg="potentialApproverSvg" - :size="20" - /> - -*/ - -export default { - props: { - svg: { - type: String, - required: true, - }, - size: { - type: Number, - required: false, - default: 20, - }, - }, - computed: { - avatarSizeClass() { - return `s${this.size}`; - }, - }, -}; -</script> - -<template> - <svg :class="avatarSizeClass" :height="size" :width="size" v-html="svg" /> -</template> 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 f9e3f3df0cc..c93b3d37a63 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 @@ -9,21 +9,46 @@ import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/c export default { methods: { onChangeTab(scope) { - this.updateContent({ scope, page: '1' }); + let params = { + scope, + page: '1', + }; + + params = this.onChangeWithFilter(params); + + this.updateContent(params); }, onChangePage(page) { /* URLS parameters are strings, we need to parse to match types */ - const params = { + let params = { page: Number(page).toString(), }; if (this.scope) { params.scope = this.scope; } + + params = this.onChangeWithFilter(params); + this.updateContent(params); }, + onChangeWithFilter(params) { + const { username, ref } = this.requestData; + const paramsData = params; + + if (username) { + paramsData.username = username; + } + + if (ref) { + paramsData.ref = ref; + } + + return paramsData; + }, + updateInternalState(parameters) { // stop polling this.poll.stop(); |