diff options
Diffstat (limited to 'app')
1331 files changed, 27593 insertions, 6165 deletions
diff --git a/app/assets/images/cluster_app_logos/fluentd.png b/app/assets/images/cluster_app_logos/fluentd.png Binary files differnew file mode 100644 index 00000000000..6d42578f2ce --- /dev/null +++ b/app/assets/images/cluster_app_logos/fluentd.png 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(); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 657e52674db..cc4d13db150 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -37,4 +37,12 @@ @import "application_ee"; // CSS util classes +/** + These are deprecated in favor of the Gitlab UI utilities imported below. + Please check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss + to see the available utility classes. +**/ @import "utilities"; + +// Gitlab UI util classes +@import "@gitlab/ui/src/scss/utilities"; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index ed5c133950d..1c15400542a 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -211,7 +211,7 @@ h3.popover-header { } .info-well { - background: $gray-50; + background: $gray-10; color: $gl-text-color; border: 1px solid $border-color; border-radius: 4px; diff --git a/app/assets/stylesheets/components/dashboard_skeleton.scss b/app/assets/stylesheets/components/dashboard_skeleton.scss index 2e2c1fefc79..ce33aa94df3 100644 --- a/app/assets/stylesheets/components/dashboard_skeleton.scss +++ b/app/assets/stylesheets/components/dashboard_skeleton.scss @@ -68,7 +68,7 @@ background-size: cover; background-image: linear-gradient(to right, $gray-100 0%, - $gray-50 20%, + $gray-10 20%, $gray-100 40%, $gray-100 100%); border-radius: $gl-padding; diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss new file mode 100644 index 00000000000..1061aae2bbb --- /dev/null +++ b/app/assets/stylesheets/components/design_management/design.scss @@ -0,0 +1,140 @@ +.design-detail { + background-color: rgba($black, 0.9); + + .with-performance-bar & { + top: 35px; + } + + .inactive { + opacity: 0.5; + } +} + +.design-presentation-wrapper { + top: 0; + left: 0; +} + +.design-scaler { + z-index: 1; +} + +.design-scaler-wrapper { + bottom: 0; + left: 50%; + transform: translateX(-50%); +} + +.design-checkbox { + position: absolute; + top: $gl-padding; + left: 30px; +} + +.image-notes { + overflow-y: scroll; + padding: $gl-padding; + padding-top: 50px; + background-color: $white; + flex-shrink: 0; + min-width: 400px; + flex-basis: 28%; + + .badge.badge-pill { + margin-left: $gl-padding; + background-color: $blue-400; + color: $white; + border: $white 1px solid; + min-height: 28px; + padding: 7px 10px; + border-radius: $gl-padding; + } + + .design-discussion { + margin: $gl-padding 0; + + &::before { + content: ''; + border-left: 1px solid $gray-200; + position: absolute; + left: 28px; + top: -18px; + height: 18px; + } + + .design-note { + padding: $gl-padding; + list-style: none; + + a { + color: inherit; + } + + .note-text a { + color: $blue-600; + } + } + + .reply-wrapper { + padding: $gl-padding; + } + } + + .reply-wrapper { + border-top: 1px solid $border-color; + } + + .new-discussion-disclaimer { + line-height: 20px; + } +} + +@media (max-width: map-get($grid-breakpoints, lg)) { + .design-detail { + overflow-y: scroll; + } + + .image-notes { + overflow-y: auto; + min-width: 100%; + flex-grow: 1; + flex-basis: auto; + } +} + +.design-dropzone-border { + border: 2px dashed $gray-200; +} + +.design-dropzone-card { + transition: border $general-hover-transition-duration $general-hover-transition-curve; + + &:focus, + &:active { + outline: none; + border: 2px dashed $purple; + color: $gl-text-color; + } + + &:hover { + border-color: $gray-500; + } +} + +.design-dropzone-overlay { + border: 2px dashed $purple; + top: 0; + left: 0; + pointer-events: none; + opacity: 1; +} + +.design-dropzone-fade-enter-active, +.design-dropzone-fade-leave-active { + transition: opacity $general-hover-transition-duration $general-hover-transition-curve; +} + +.design-dropzone-fade-enter, +.design-dropzone-fade-leave-to { + opacity: 0; +} diff --git a/app/assets/stylesheets/components/design_management/design_list_item.scss b/app/assets/stylesheets/components/design_management/design_list_item.scss new file mode 100644 index 00000000000..aacb1f91e59 --- /dev/null +++ b/app/assets/stylesheets/components/design_management/design_list_item.scss @@ -0,0 +1,19 @@ +.design-list-item { + height: 280px; + text-decoration: none; + + .icon-version-status { + position: absolute; + right: 10px; + top: 10px; + } + + .design-event { + top: $gl-padding; + right: $gl-padding; + } + + .card-body { + height: 230px; + } +} diff --git a/app/assets/stylesheets/components/design_management/design_version_dropdown.scss b/app/assets/stylesheets/components/design_management/design_version_dropdown.scss new file mode 100644 index 00000000000..f79d672e238 --- /dev/null +++ b/app/assets/stylesheets/components/design_management/design_version_dropdown.scss @@ -0,0 +1,3 @@ +.design-version-dropdown > button { + background: inherit; +} diff --git a/app/assets/stylesheets/components/milestone_combobox.scss b/app/assets/stylesheets/components/milestone_combobox.scss new file mode 100644 index 00000000000..e0637088bbb --- /dev/null +++ b/app/assets/stylesheets/components/milestone_combobox.scss @@ -0,0 +1,13 @@ +.selected-item::before { + content: '\f00c'; + color: $green-500; + position: absolute; + left: 16px; + top: 16px; + transform: translateY(-50%); + font: 14px FontAwesome; +} + +.dropdown-item-space { + padding: 8px 12px; +} diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index ce1039832d3..61f971a3185 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -1,9 +1,11 @@ $item-path-max-width: 160px; $item-milestone-max-width: 120px; $item-weight-max-width: 48px; +$item-remove-button-space: 42px; .related-items-list { padding: $gl-padding-4; + padding-right: $gl-padding-6; &, .list-item:last-child { @@ -11,16 +13,16 @@ $item-weight-max-width: 48px; } } -.sortable-link { - max-width: 85%; -} - .related-items-tree { .card-header { .gl-label { line-height: $gl-line-height; } } + + .sortable-link { + white-space: normal; + } } .item-body { @@ -48,17 +50,12 @@ $item-weight-max-width: 48px; cursor: help; } - .issue-token-state-icon-open, - .issue-token-state-icon-closed { - margin-right: $gl-padding-4; - } - .confidential-icon { color: $orange-600; } .item-title-wrapper { - max-width: 100%; + max-width: calc(100% - #{$item-remove-button-space}); } .item-title { @@ -69,11 +66,6 @@ $item-weight-max-width: 48px; font-weight: $gl-font-weight-bold; } - .issue-token-state-icon-open, - .issue-token-state-icon-closed { - display: none; - } - .sortable-link { color: $gray-900; font-weight: normal; @@ -90,17 +82,14 @@ $item-weight-max-width: 48px; white-space: nowrap; } - @include media-breakpoint-down(lg) { - .issue-count-badge { - padding-left: 0; - } + .health-label-short { + display: none; } } .item-body, .card-header { .health-label-short { - display: initial; max-width: 0; } @@ -135,6 +124,12 @@ $item-weight-max-width: 48px; } } +.card-header { + .health-label-short { + display: initial; + } +} + .item-meta { flex-basis: 100%; font-size: $gl-font-size; @@ -227,25 +222,28 @@ $item-weight-max-width: 48px; font-weight: $gl-font-weight-bold; max-width: $item-path-max-width; } - - .issue-token-state-icon-open, - .issue-token-state-icon-closed { - display: block; - } } .btn-item-remove { position: absolute; - right: 0; top: $gl-padding-4 / 2; + right: 0; padding: $gl-padding-4; margin-right: $gl-padding-4 / 2; line-height: 0; border-color: transparent; color: $gl-text-color-secondary; + .related-items-tree & { + position: relative; + top: initial; + padding: $btn-sm-side-margin; + margin-right: initial; + } + &:hover { color: $gl-text-color; + border-color: $border-color; } } @@ -269,7 +267,6 @@ $item-weight-max-width: 48px; max-width: 90%; } - .item-body, .card-header { .health-label-short { max-width: 30px; @@ -279,6 +276,15 @@ $item-weight-max-width: 48px; /* Small devices (landscape phones, 768px and up) */ @include media-breakpoint-up(md) { + .item-body .item-contents { + max-width: 95%; + } + + .related-items-tree .item-contents, + .item-body .item-title { + max-width: 100%; + } + .sortable-link { text-overflow: ellipsis; overflow: hidden; @@ -290,27 +296,8 @@ $item-weight-max-width: 48px; .item-contents { min-width: 0; } - - .item-title { - flex-basis: unset; - // 95% because we compensate - // for remove button which is - // positioned absolutely - width: 95%; - } - - .btn-item-remove { - order: 1; - } - } - - .item-meta { - .item-meta-child { - flex-basis: unset; - } } - .item-body, .card-header { .health-label-short { max-width: 60px; @@ -330,7 +317,6 @@ $item-weight-max-width: 48px; } } - .item-body, .card-header { .health-label-short { max-width: 100px; @@ -346,32 +332,13 @@ $item-weight-max-width: 48px; @include media-breakpoint-up(xl) { .item-body { .item-title { - min-width: 0; width: auto; flex-basis: auto; flex-shrink: 1; font-weight: $gl-font-weight-normal; - - .issue-token-state-icon-open, - .issue-token-state-icon-closed { - display: block; - margin-right: $gl-padding-8; - } - } - - .item-title-wrapper { - max-width: calc(100% - 500px); - } - - .item-info-area { - flex-basis: auto; } } - .health-label-short { - display: initial; - } - .health-label-long { display: none; } @@ -380,16 +347,7 @@ $item-weight-max-width: 48px; overflow: hidden; } - .item-meta { - flex: 1; - } - .item-assignees { - .avatar { - height: $gl-padding-24; - width: $gl-padding-24; - } - .avatar-counter { height: $gl-padding-24; min-width: $gl-padding-24; @@ -401,12 +359,8 @@ $item-weight-max-width: 48px; .btn-item-remove { position: relative; top: initial; - right: 0; padding: $btn-sm-side-margin; - - &:hover { - border-color: $border-color; - } + margin-right: $gl-padding-4 / 2; } .sortable-link { @@ -415,8 +369,7 @@ $item-weight-max-width: 48px; } @media only screen and (min-width: 1500px) { - .card-header, - .item-body { + .card-header { .health-label-short { display: none; } @@ -425,10 +378,4 @@ $item-weight-max-width: 48px; display: initial; } } - - .item-body { - .item-title-wrapper { - max-width: calc(100% - 640px); - } - } } diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss new file mode 100644 index 00000000000..eca0f1114af --- /dev/null +++ b/app/assets/stylesheets/components/rich_content_editor.scss @@ -0,0 +1,11 @@ +// Overrides styles from ToastUI editor +.tui-editor-defaultUI-toolbar .toolbar-button { + color: $gl-gray-600; + border: 0; + + &:hover, + &:active { + color: $blue-500; + border: 0; + } +} diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index d222fc4aefe..13174687e5d 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -193,7 +193,7 @@ a { background-size: cover; background-image: linear-gradient(to right, $gray-100 0%, - $gray-50 20%, + $gray-10 20%, $gray-100 40%, $gray-100 100%); height: 10px; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index ecf2097dc87..f47d0cab31f 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -507,6 +507,10 @@ opacity: 1 !important; cursor: default !important; + &.cursor-not-allowed { + cursor: not-allowed !important; + } + i { color: $gl-text-color-disabled !important; } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 320bd4adaaa..93361c21642 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -392,6 +392,10 @@ img.emoji { } /** COMMON CLASSES **/ +/** + 🚨 Do not use these classes — they are deprecated and being removed. 🚨 + See https://gitlab.com/gitlab-org/gitlab/-/issues/217418 for more details. +**/ .prepend-top-0 { margin-top: 0; } .prepend-top-2 { margin-top: 2px; } .prepend-top-4 { margin-top: $gl-padding-4; } @@ -434,6 +438,7 @@ img.emoji { .append-bottom-20 { margin-bottom: 20px; } .append-bottom-default { margin-bottom: $gl-padding; } .prepend-bottom-32 { margin-bottom: 32px; } +.ml-10 { margin-left: 4.5rem; } .inline { display: inline-block; } .center { text-align: center; } .block { display: block; } @@ -490,7 +495,8 @@ img.emoji { 🚨 Do not use these classes — they are deprecated and being removed. 🚨 See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details. - Instead, if you need a spacing class, add it below using the following values. + Instead, if you need a spacing class, please use one from Gitlab UI — + https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss — which uses the following scale. $gl-spacing-scale-0: 0; $gl-spacing-scale-1: 2px; $gl-spacing-scale-2: 4px; @@ -505,21 +511,38 @@ img.emoji { $gl-spacing-scale-11: 64px; $gl-spacing-scale-12: 80px; $gl-spacing-scale-13: 96px; - - E.g., a padding top of 96px can be added using: - .gl-shim-pt-13 { - padding-top: 96px; - } - - Please use -shim- so it can be differentiated from the old scale classes. - These will be replaced when the Gitlab UI utilities are included. **/ @each $index, $padding in $spacing-scale { - #{'.gl-p-#{$index}'} { padding: $padding; } - #{'.gl-pl-#{$index}'} { padding-left: $padding; } - #{'.gl-pr-#{$index}'} { padding-right: $padding; } - #{'.gl-pt-#{$index}'} { padding-top: $padding; } - #{'.gl-pb-#{$index}'} { padding-bottom: $padding; } + #{'.gl-p-#{$index}-deprecated-no-really-do-not-use-me'} { padding: $padding; } + #{'.gl-pl-#{$index}-deprecated-no-really-do-not-use-me'} { padding-left: $padding; } + #{'.gl-pr-#{$index}-deprecated-no-really-do-not-use-me'} { padding-right: $padding; } + #{'.gl-pt-#{$index}-deprecated-no-really-do-not-use-me'} { padding-top: $padding; } + #{'.gl-pb-#{$index}-deprecated-no-really-do-not-use-me'} { padding-bottom: $padding; } +} + +/** + The zero-indexed classes will not change and do not need to be updated. + These can be removed when the Gitlab UI class include is merged. +**/ + +.gl-p-0 { + padding: 0; +} + +.gl-pl-0 { + padding-left: 0; +} + +.gl-pr-0 { + padding-right: 0; +} + +.gl-pt-0 { + padding-top: 0; +} + +.gl-pb-0 { + padding-bottom: 0; } /** @@ -610,15 +633,13 @@ img.emoji { } } -.gl-font-size-small { font-size: $gl-font-size-small; } -.gl-font-size-large { font-size: $gl-font-size-large; } +.gl-font-sm { font-size: $gl-font-size-small; } +.gl-font-lg { font-size: $gl-font-size-large; } +.gl-font-base { font-size: $gl-font-size-14; } .gl-line-height-24 { line-height: $gl-line-height-24; } .gl-font-size-0 { font-size: 0; } -.gl-font-size-12 { font-size: $gl-font-size-12; } -.gl-font-size-14 { font-size: $gl-font-size-14; } -.gl-font-size-16 { font-size: $gl-font-size-16; } .gl-font-size-28 { font-size: $gl-font-size-28; } .gl-font-size-42 { font-size: $gl-font-size-42; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index f746d7e6f69..1df9818a877 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -317,13 +317,6 @@ } } - // Temporary fix to ensure tick is aligned - // Follow up Issue to remove after the GlNewDropdownItem component is fixed - // > https://gitlab.com/gitlab-org/gitlab/-/issues/213948 - li:not(.gl-new-dropdown-item) .dropdown-item { - @include dropdown-link; - } - .divider { height: 1px; margin: #{$grid-size / 2} 0; @@ -384,6 +377,10 @@ } } +.dropdown-item { + @include dropdown-link; +} + .droplab-dropdown { .dropdown-toggle > i { pointer-events: none; @@ -1032,6 +1029,16 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { } .labels-select-wrapper { + &.is-standalone { + .labels-select-dropdown-contents { + max-height: 350px; + + .dropdown-content { + height: 250px; + } + } + } + .labels-select-dropdown-contents { min-height: $dropdown-min-height; max-height: 330px; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index a0a020ec548..2c7e9428ef1 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -553,6 +553,7 @@ vertical-align: text-top; } + a.upgrade-plan-link gl-emoji, a.ci-minutes-emoji gl-emoji, a.trial-link gl-emoji { font-size: $gl-font-size; diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 79f203091f2..bd262b65dc3 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -146,11 +146,13 @@ display: inline-block; position: relative; - /* Medium devices (desktops, 992px and up) */ - @include media-breakpoint-up(md) { width: 200px; } + &:not[type='checkbox'] { + /* Medium devices (desktops, 992px and up) */ + @include media-breakpoint-up(md) { width: 200px; } - /* Large devices (large desktops, 1200px and up) */ - @include media-breakpoint-up(lg) { width: 250px; } + /* Large devices (large desktops, 1200px and up) */ + @include media-breakpoint-up(lg) { width: 250px; } + } } @include media-breakpoint-down(sm) { diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index 514bd090e28..5739f048e86 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -4,6 +4,21 @@ } table { + /* + * TODO + * This is a temporary workaround until we fix the neutral + * color palette in https://gitlab.com/gitlab-org/gitlab/-/issues/213570 + * + * The overwrites here affected the security dashboard tables, when removing + * this code, table-th-transparent and original-text-color classes should + * be removed there. + * + * Remove this code as soon as this happens + */ + &.gl-table { + @include gl-text-gray-700; + } + &.table { margin-bottom: $gl-padding; @@ -32,8 +47,7 @@ table { } th { - background-color: $gray-light; - font-weight: $gl-font-weight-normal; + @include gl-bg-gray-100; border-bottom: 0; &.wide { @@ -44,6 +58,11 @@ table { background: none; color: $gl-text-color-secondary; } + + &.original-gl-th { + @include gl-text-gray-700; + border-bottom: 1px solid $cycle-analytics-light-gray; + } } td { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 816dbc6931c..1afcbc6d514 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -86,7 +86,7 @@ line-height: 10px; color: $gl-gray-700; vertical-align: middle; - background-color: $gray-50; + background-color: $gray-10; border-width: 1px; border-style: solid; border-color: $gray-200 $gray-200 $gray-400; @@ -533,6 +533,17 @@ margin: 0; font-size: $gl-font-size-small; } + + ul.dropdown-menu { + margin-top: 4px; + margin-bottom: 24px; + padding: 8px 0; + + li { + margin: 0; + padding: 0 1px; + } + } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index c23623005b0..ac4d431ea57 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -163,7 +163,8 @@ $red-800: #8b2615; $red-900: #711e11; $red-950: #4b140b; -$gray-50: #fafafa; +$gray-10: #fafafa; +$gray-50: #f0f0f0; $gray-100: #f2f2f2; $gray-200: #dfdfdf; $gray-300: #ccc; @@ -232,6 +233,7 @@ $reds: ( ); $grays: ( + '10': $gray-10, '50': $gray-50, '100': $gray-100, '200': $gray-200, @@ -398,6 +400,7 @@ $tooltip-font-size: 12px; * Padding */ $gl-padding-4: 4px; +$gl-padding-6: 6px; $gl-padding-8: 8px; $gl-padding-12: 12px; $gl-padding: 16px; @@ -447,6 +450,7 @@ $breadcrumb-min-height: 48px; $home-panel-title-row-height: 64px; $home-panel-avatar-mobile-size: 24px; $gl-line-height: 16px; +$gl-line-height-18: 18px; $gl-line-height-20: 20px; $gl-line-height-24: 24px; $gl-line-height-14: 14px; @@ -697,7 +701,7 @@ $logs-p-color: #333; */ $input-height: 34px; $input-danger-bg: #f2dede; -$input-group-addon-bg: $gray-50; +$input-group-addon-bg: $gray-10; $gl-field-focus-shadow: rgba(0, 0, 0, 0.075); $gl-field-focus-shadow-error: rgba($red-500, 0.6); $input-short-width: 200px; diff --git a/app/assets/stylesheets/page_bundles/_ide_mixins.scss b/app/assets/stylesheets/page_bundles/_ide_mixins.scss index 9465dd5bed6..48b8a7230b1 100644 --- a/app/assets/stylesheets/page_bundles/_ide_mixins.scss +++ b/app/assets/stylesheets/page_bundles/_ide_mixins.scss @@ -9,7 +9,6 @@ top: 0; font-size: 12px; border-top-right-radius: $border-radius-default; - margin-left: -$gl-padding; .controllers { @include build-controllers(15px, center, false, 0, inline, 0); diff --git a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss index 1aa112e0957..5675835a622 100644 --- a/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_monaco_overrides.scss @@ -69,8 +69,17 @@ display: none !important; } } +} + +.multi-file-editor-holder { + height: 100%; + min-height: 0; // firefox fix +} - .monaco-diff-editor.vs { +// Apply theme related overrides only to the white theme and none theme +.theme-white .blob-editor-container, +.theme-none .blob-editor-container { + .monaco-diff-editor { .editor.modified { box-shadow: none; } @@ -131,16 +140,14 @@ } } -.multi-file-editor-holder { - height: 100%; - min-height: 0; // firefox fix - - &.is-readonly .vs, - .vs .editor.original { +.theme-white .multi-file-editor-holder, +.theme-none .multi-file-editor-holder { + &.is-readonly, + .editor.original { .monaco-editor, .monaco-editor-background, .monaco-editor .inputarea.ime-input { - background-color: $gray-50; + background-color: $gray-10; } } } diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss new file mode 100644 index 00000000000..e4c01c2bd6c --- /dev/null +++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss @@ -0,0 +1,308 @@ +// ------- +// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes +// ------- +.ide.theme-dark { + a:not(.btn) { + color: var(--ide-link-color); + } + + h1, + h2, + h3, + h4, + h5, + h6, + code, + .md table:not(.code), + .md, + .md p, + .context-header > a, + input, + textarea, + .md-area.is-focused, + .dropdown-menu li button, + .dropdown-menu-selectable li a.is-active, + .dropdown-menu-inner-title, + .dropdown-menu-inner-content, + .nav-links:not(.quick-links) li:not(.md-header-toolbar) a, + .nav-links:not(.quick-links) li:not(.md-header-toolbar) a:hover, + .nav-links:not(.quick-links) li:not(.md-header-toolbar) a.active .badge.badge-pill, + .nav-links:not(.quick-links) li:not(.md-header-toolbar) a:hover .badge.badge-pill, + .badge.badge-pill, + .bs-callout, + .ide-pipeline .top-bar, + .ide-pipeline .top-bar .controllers .controllers-buttons { + color: var(--ide-text-color); + } + + .drag-handle:hover, + .card-header .badge.badge-pill { + background-color: var(--ide-dropdown-hover-background); + } + + .file-row .file-row-icon svg, + .file-row:hover .file-row-icon svg, + .controllers-buttons svg { + color: var(--ide-text-color-secondary); + } + + .text-secondary { + color: var(--ide-text-color-secondary) !important; + } + + input[type='search']::placeholder, + input[type='text']::placeholder, + textarea::placeholder, + .dropdown-input .fa { + color: var(--ide-input-border); + } + + .ide-nav-form .input-icon { + color: var(--ide-input-border); + } + + code, + .badge.badge-pill, + .card-header, + .bs-callout, + .ide-pipeline .top-bar, + .ide-terminal .top-bar { + background-color: var(--ide-background); + } + + .bs-callout { + border-color: var(--ide-dropdown-background); + + code { + background-color: var(--ide-dropdown-background); + } + } + + .nav-links:not(.quick-links) li:not(.md-header-toolbar) a:hover { + border-color: var(--ide-dropdown-hover-background); + } + + .common-note-form .md-area { + border-color: var(--ide-input-border); + } + + &, + .md table:not(.code) tr th, + .common-note-form .md-area, + .card { + background-color: var(--ide-highlight-background); + } + + .card, + .card-header, + .ide-terminal .top-bar, + .ide-pipeline .top-bar { + border-color: var(--ide-border-color); + } + + hr, + .md h1, + .md h2, + .md blockquote, + pre, + .md table:not(.code) tbody td, + .md table:not(.code) tr th, + .nav-links:not(.quick-links) { + border-color: var(--ide-border-color-alt); + } + + .ide-sidebar-link.active { + color: var(--ide-highlight-accent); + box-shadow: inset 3px 0 var(--ide-highlight-accent); + + &.is-right { + box-shadow: inset -3px 0 var(--ide-highlight-accent); + } + } + + .nav-links li.active a, + .nav-links li a.active { + border-color: var(--ide-highlight-accent); + color: var(--ide-text-color); + } + + .avatar-container { + &, + .avatar { + color: var(--ide-text-color); + background-color: var(--ide-highlight-background); + border-color: var(--ide-highlight-background); + } + } + + input[type='text'], + input[type='search'], + .filtered-search-box { + border-color: var(--ide-input-border); + background: var(--ide-input-background) !important; + } + + input[type='text'], + input[type='search'], + .filtered-search-box, + textarea { + color: var(--ide-input-color) !important; + } + + .filtered-search-box input[type='search'] { + border-color: transparent; + } + + .filtered-search-token .value-container, + .filtered-search-term .value-container { + background-color: var(--ide-dropdown-hover-background); + color: var(--ide-text-color); + + &:hover { + background-color: var(--ide-input-border); + } + } + + @function calc-btn-hover-padding($original-padding, $original-border: 1px) { + @return calc(#{$original-padding + $original-border} - var(--ide-btn-hover-border-width)); + } + + .btn:not(.btn-link):not([disabled]):hover { + border-width: var(--ide-btn-hover-border-width); + padding: calc-btn-hover-padding(6px) calc-btn-hover-padding(10px); + } + + .btn:not([disabled]).btn-sm:hover { + padding: calc-btn-hover-padding(4px) calc-btn-hover-padding(10px); + } + + .btn:not([disabled]).btn-block:hover { + padding: calc-btn-hover-padding(6px) 0; + } + + .btn-inverted, + .btn-default, + .dropdown, + .dropdown-menu-toggle { + background-color: var(--ide-input-background) !important; + color: var(--ide-input-color) !important; + border-color: var(--ide-btn-default-border); + } + + .btn-inverted, + .btn-default { + &:hover, + &:focus { + border-color: var(--ide-btn-default-hover-border) !important; + } + } + + .dropdown, + .dropdown-menu-toggle { + &:hover, + &:focus { + background-color: var(--ide-dropdown-btn-hover-background) !important; + border-color: var(--ide-dropdown-btn-hover-border) !important; + } + } + + .dropdown-menu { + color: var(--ide-text-color); + border-color: var(--ide-background); + background-color: var(--ide-dropdown-background); + + .divider, + .nav-links:not(.quick-links) { + background-color: var(--ide-dropdown-hover-background); + border-color: var(--ide-dropdown-hover-background); + } + + .nav-links li a.active { + border-color: var(--ide-highlight-accent); + } + + .nav-links:not(.quick-links) li:not(.md-header-toolbar) a { + color: var(--ide-text-color); + + &.active { + color: var(--ide-text-color); + } + } + + li > a:not(.disable-hover):hover, + li > a:not(.disable-hover):focus, + li button:not(.disable-hover):hover, + li button:not(.disable-hover):focus, + li button.is-focused { + background-color: var(--ide-dropdown-hover-background); + color: var(--ide-text-color); + } + } + + .dropdown-title, + .dropdown-input { + border-color: var(--ide-dropdown-hover-background) !important; + } + + .btn-primary, + .btn-info { + background-color: var(--ide-btn-primary-background); + border-color: var(--ide-btn-primary-border) !important; + + &:hover, + &:focus { + border-color: var(--ide-btn-primary-hover-border) !important; + } + } + + .btn-success { + background-color: var(--ide-btn-success-background); + border-color: var(--ide-btn-success-border) !important; + + &:hover, + &:focus { + border-color: var(--ide-btn-success-hover-border) !important; + } + } + + .btn[disabled] { + background: var(--ide-btn-default-background) !important; + border: 1px solid var(--ide-btn-disabled-border) !important; + color: var(--ide-btn-disabled-color) !important; + } + + pre code, + .md table:not(.code) tbody { + background-color: var(--ide-border-color); + } + + .animation-container { + [class^='skeleton-line-'] { + background-color: var(--ide-animation-gradient-1); + + &::after { + background-image: linear-gradient(to right, + var(--ide-animation-gradient-1) 0%, + var(--ide-animation-gradient-2) 20%, + var(--ide-animation-gradient-1) 40%, + var(--ide-animation-gradient-1) 100%); + } + } + } + + .idiff.addition { + background-color: var(--ide-diff-insert); + } + + .idiff.deletion { + background-color: var(--ide-diff-remove); + } +} + +.navbar.theme-dark { + border-bottom-color: transparent; +} + +.theme-dark ~ .popover { + box-shadow: none; +} diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 024c1781bf8..61914740ac0 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -2,6 +2,9 @@ @import 'framework/mixins'; @import './ide_mixins'; @import './ide_monaco_overrides'; +@import './ide_theme_overrides'; + +@import './ide_themes/dark'; $search-list-icon-width: 18px; $ide-activity-bar-width: 60px; @@ -25,7 +28,7 @@ $ide-commit-header-height: 48px; position: relative; margin-top: 0; padding-bottom: $ide-statusbar-height; - color: $gl-text-color; + color: var(--ide-text-color, $gl-text-color); min-height: 0; // firefox fix &.is-collapsed { @@ -61,14 +64,14 @@ $ide-commit-header-height: 48px; display: flex; flex-direction: column; flex: 1; - border-left: 1px solid $white-dark; + border-left: 1px solid var(--ide-border-color, $white-dark); overflow: hidden; } .multi-file-tabs { display: flex; - background-color: $gray-light; - box-shadow: inset 0 -1px $white-dark; + background-color: var(--ide-background, $gray-light); + box-shadow: inset 0 -1px var(--ide-border-color, $white-dark); > ul { display: flex; @@ -79,13 +82,13 @@ $ide-commit-header-height: 48px; display: flex; align-items: center; padding: $grid-size $gl-padding; - background-color: $gray-normal; - border-right: 1px solid $white-dark; - border-bottom: 1px solid $white-dark; + background-color: var(--ide-background-hover, $gray-normal); + border-right: 1px solid var(--ide-border-color, $white-dark); + border-bottom: 1px solid var(--ide-border-color, $white-dark); &.active { - background-color: $white; - border-bottom-color: $white; + background-color: var(--ide-highlight-background, $white); + border-bottom-color: var(--ide-border-color, $white); } &:not(.disabled) { @@ -118,7 +121,7 @@ $ide-commit-header-height: 48px; background: none; border: 0; border-radius: $border-radius-default; - color: $gray-900; + color: var(--ide-text-color, $gray-900); svg { position: relative; @@ -133,11 +136,11 @@ $ide-commit-header-height: 48px; } &:not([disabled]):hover { - background-color: $gray-200; + background-color: var(--ide-input-border, $gray-200); } &:not([disabled]):focus { - background-color: $blue-500; + background-color: var(--ide-link-color, $blue-500); color: $white; outline: 0; @@ -164,10 +167,11 @@ $ide-commit-header-height: 48px; height: 100%; overflow: auto; padding: $gl-padding; + background-color: var(--ide-border-color, transparent); } .file-container { - background-color: $gray-darker; + background-color: var(--ide-border-color, $gray-darker); display: flex; height: 100%; align-items: center; @@ -183,13 +187,13 @@ $ide-commit-header-height: 48px; .file-info { font-size: $label-font-size; - color: $diff-image-info-color; + color: var(--ide-text-color, $diff-image-info-color); } } } .ide-mode-tabs { - border-bottom: 1px solid $white-dark; + border-bottom: 1px solid var(--ide-border-color, $white-dark); li a { padding: $gl-padding-8 $gl-padding; @@ -203,9 +207,10 @@ $ide-commit-header-height: 48px; } .ide-status-bar { - border-top: 1px solid $white-dark; + color: var(--ide-text-color, $gl-text-color); + border-top: 1px solid var(--ide-border-color, $white-dark); padding: 2px $gl-padding-8 0; - background: $white; + background-color: var(--ide-footer-background, $white); display: flex; justify-content: space-between; height: $ide-statusbar-height; @@ -278,8 +283,7 @@ $ide-commit-header-height: 48px; position: relative; width: 340px; padding: 0; - background-color: $gray-light; - padding-right: 1px; + background-color: var(--ide-background, $gray-light); .context-header { width: auto; @@ -306,9 +310,9 @@ $ide-commit-header-height: 48px; display: flex; flex: 1; flex-direction: column; - background-color: $white; - border-left: 1px solid $white-dark; - border-top: 1px solid $white-dark; + background-color: var(--ide-highlight-background, $white); + border-left: 1px solid var(--ide-border-color, $white-dark); + border-top: 1px solid var(--ide-border-color, $white-dark); border-top-left-radius: $border-radius-small; min-height: 0; // firefox fix } @@ -333,15 +337,10 @@ $ide-commit-header-height: 48px; .multi-file-commit-panel-header { height: $ide-commit-header-height; - border-bottom: 1px solid $white-dark; + border-bottom: 1px solid var(--ide-border-color-alt, $white-dark); padding: 12px 0; } -.multi-file-commit-panel-collapse-btn { - border-left: 1px solid $white-dark; - margin-left: auto; -} - .multi-file-commit-list { flex: 1; overflow: auto; @@ -363,7 +362,7 @@ $ide-commit-header-height: 48px; display: block; margin-left: auto; margin-right: auto; - color: $gray-700; + color: var(--ide-text-color-secondary, $gray-700); } .file-status-icon { @@ -387,17 +386,17 @@ $ide-commit-header-height: 48px; &:hover, &:focus { - background: $gray-100; + background: var(--ide-background, $gray-100); outline: 0; } &:active { - background: $gray-200; + background: var(--ide-background, $gray-200); } &.is-active { - background-color: $white-normal; + background-color: var(--ide-background, $white-normal); } svg { @@ -418,8 +417,8 @@ $ide-commit-header-height: 48px; .multi-file-commit-form { position: relative; - background-color: $white; - border-left: 1px solid $white-dark; + background-color: var(--ide-highlight-background, $white); + border-left: 1px solid var(--ide-border-color, $white-dark); transition: all 0.3s ease; > form, @@ -427,7 +426,7 @@ $ide-commit-header-height: 48px; padding: $gl-padding 0; margin-left: $gl-padding; margin-right: $gl-padding; - border-top: 1px solid $white-dark; + border-top: 1px solid var(--ide-border-color-alt, $white-dark); } .btn { @@ -488,6 +487,7 @@ $ide-commit-header-height: 48px; height: 100vh; align-items: center; justify-content: center; + background-color: var(--ide-border-color, transparent); } .ide { @@ -504,7 +504,7 @@ $ide-commit-header-height: 48px; margin-right: $gl-padding; &.is-first { - border-bottom: 1px solid $white-dark; + border-bottom: 1px solid var(--ide-border-color-alt, $white-dark); } } @@ -512,12 +512,7 @@ $ide-commit-header-height: 48px; width: $ide-commit-row-height; height: $ide-commit-row-height; color: inherit; -} - -.ide-commit-file-count { - min-width: 22px; - background-color: $gray-light; - border: 1px solid $white-dark; + background-color: var(--ide-background, $white-normal); } .ide-commit-options { @@ -549,7 +544,7 @@ $ide-commit-header-height: 48px; height: 60px; width: 100%; padding: 0 $gl-padding; - color: $gl-text-color-secondary; + color: var(--ide-text-color-secondary, $gl-text-color-secondary); background-color: transparent; border: 0; border-top: 1px solid transparent; @@ -562,22 +557,22 @@ $ide-commit-header-height: 48px; } &:hover { - color: $gl-text-color; - background-color: $gray-100; + color: var(--ide-text-color, $gl-text-color); + background-color: var(--ide-background-hover, $gray-100); } &:focus { - color: $gl-text-color; - background-color: $gray-200; + color: var(--ide-text-color, $gl-text-color); + background-color: var(--ide-background-hover, $gray-200); } &.active { // extend width over border of sidebar section width: calc(100% + 1px); padding-right: $gl-padding + 1px; - background-color: $white; - border-top-color: $white-dark; - border-bottom-color: $white-dark; + background-color: var(--ide-highlight-background, $white); + border-top-color: var(--ide-border-color, $white-dark); + border-bottom-color: var(--ide-border-color, $white-dark); &::after { content: ''; @@ -586,7 +581,7 @@ $ide-commit-header-height: 48px; top: 0; bottom: 0; width: 1px; - background: $white; + background: var(--ide-highlight-background, $white); } &.is-right { @@ -609,7 +604,7 @@ $ide-commit-header-height: 48px; .ide-commit-message-field { height: 200px; - background-color: $white; + background-color: var(--ide-highlight-background, $white); .md-area { display: flex; @@ -623,7 +618,7 @@ $ide-commit-header-height: 48px; .form-text.text-muted { margin-top: 2px; - color: $blue-500; + color: var(--ide-link-color, $blue-500); cursor: pointer; } } @@ -686,14 +681,14 @@ $ide-commit-header-height: 48px; padding: 12px 0; margin-left: $ide-tree-padding; margin-right: $ide-tree-padding; - border-bottom: 1px solid $white-dark; + border-bottom: 1px solid var(--ide-border-color-alt, $white-dark); svg { - color: $gray-700; + color: var(--ide-text-color-secondary, $gray-700); &:focus, &:hover { - color: $blue-600; + color: var(--ide-link-color, $blue-600); } } @@ -702,7 +697,7 @@ $ide-commit-header-height: 48px; } button { - color: $gl-text-color; + color: var(--ide-text-color, $gl-text-color); } } @@ -718,21 +713,21 @@ $ide-commit-header-height: 48px; .dropdown-menu-toggle { svg { vertical-align: middle; - color: $gray-700; + &, &:hover { - color: $gray-700; + color: var(--ide-text-color-secondary, $gray-700); } } &:hover { - background-color: $white-normal; + background-color: var(--ide-dropdown-btn-hover-background, $white-normal); } } &.show { .dropdown-menu-toggle { - background-color: $white-dark; + background-color: var(--ide-input-background, $white-dark); } } } @@ -798,12 +793,12 @@ $ide-commit-header-height: 48px; } a { - color: $blue-600; + color: var(--ide-link-color, $blue-600); } } .ide-review-sub-header { - color: $gl-text-color-secondary; + color: var(--ide-text-color-secondary, $gl-text-color-secondary); } .ide-tree-changes { @@ -819,7 +814,7 @@ $ide-commit-header-height: 48px; bottom: 0; right: 0; z-index: 10; - background: $white; + background-color: var(--ide-highlight-background, $white); overflow: auto; display: flex; flex-direction: column; @@ -883,14 +878,18 @@ $ide-commit-header-height: 48px; .ide-right-sidebar { .ide-activity-bar { - border-left: 1px solid $white-dark; + border-left: 1px solid var(--ide-border-color, $white-dark); } .multi-file-commit-panel-inner { width: 350px; - padding: $grid-size $gl-padding; - background-color: $white; - border-left: 1px solid $white-dark; + padding: $grid-size 0; + background-color: var(--ide-highlight-background, $white); + border-left: 1px solid var(--ide-border-color, $white-dark); + } + + .ide-right-sidebar-jobs-detail { + padding-bottom: 0; } .ide-right-sidebar-clientside { @@ -901,6 +900,10 @@ $ide-commit-header-height: 48px; .ide-pipeline { @include ide-trace-view(); + svg { + --svg-status-bg: var(--ide-background, $white); + } + .empty-state { p { margin: $grid-size 0; @@ -913,15 +916,12 @@ $ide-commit-header-height: 48px; margin: 0; } } - - .build-trace { - margin-left: -$gl-padding; - } } .ide-pipeline-list { flex: 1; overflow: auto; + padding: 0 $gl-padding; } .ide-pipeline-header { @@ -935,7 +935,7 @@ $ide-commit-header-height: 48px; padding: 16px; &:not(:last-child) { - border-bottom: 1px solid $border-color; + border-bottom: 1px solid var(--ide-border-color, $border-color); } .ci-status-icon { @@ -964,6 +964,7 @@ $ide-commit-header-height: 48px; .ide-job-header { min-height: 60px; + padding: 0 $gl-padding; } .ide-nav-form { @@ -976,7 +977,7 @@ $ide-commit-header-height: 48px; text-align: center; &:not(.active) { - background-color: $gray-light; + background-color: var(--ide-dropdown-background, $gray-light); } } } @@ -1025,7 +1026,7 @@ $ide-commit-header-height: 48px; .ide-merge-request-project-path { font-size: 12px; line-height: 16px; - color: $gl-text-color-secondary; + color: var(--ide-text-color-secondary, $gl-text-color-secondary); } .ide-merge-request-info { @@ -1041,17 +1042,17 @@ $ide-commit-header-height: 48px; .ide-entry-dropdown-toggle { padding: $gl-padding-4; - color: $gl-text-color; - background-color: $gray-100; + color: var(--ide-text-color, $gl-text-color); + background-color: var(--ide-background, $gray-100); &:hover { - background-color: $gray-200; + background-color: var(--ide-file-row-btn-hover-background, $gray-200); } &:active, &:focus { color: $white-normal; - background-color: $blue-500; + background-color: var(--ide-link-color, $blue-500); outline: 0; } } @@ -1065,14 +1066,14 @@ $ide-commit-header-height: 48px; .dropdown.show .ide-entry-dropdown-toggle { color: $white-normal; - background-color: $blue-500; + background-color: var(--ide-link-color, $blue-500); } } .ide-preview-header { padding: 0 $grid-size; - border-bottom: 1px solid $white-dark; - background-color: $gray-light; + border-bottom: 1px solid var(--ide-border-color-alt, $white-dark); + background-color: var(--ide-highlight-background, $gray-light); min-height: 44px; } @@ -1082,7 +1083,7 @@ $ide-commit-header-height: 48px; max-width: 24px; padding: 0; margin: 0 ($grid-size / 2); - color: $gl-gray-light; + color: var(--ide-text-color-secondary, $gl-gray-light); &:first-child { margin-left: 0; @@ -1096,7 +1097,7 @@ $ide-commit-header-height: 48px; &:focus { outline: 0; box-shadow: none; - border-color: $gray-200; + border-color: var(--ide-border-color, $gray-200); } } @@ -1108,8 +1109,8 @@ $ide-commit-header-height: 48px; .ide-file-templates { padding: $grid-size $gl-padding; - background-color: $gray-light; - border-bottom: 1px solid $white-dark; + background-color: var(--ide-background, $gray-light); + border-bottom: 1px solid var(--ide-border-color, $white-dark); .dropdown { min-width: 180px; @@ -1123,8 +1124,8 @@ $ide-commit-header-height: 48px; .ide-commit-editor-header { height: 65px; padding: 8px 16px; - background-color: $gray-50; - box-shadow: inset 0 -1px $white-dark; + background-color: var(--ide-background, $gray-10); + box-shadow: inset 0 -1px var(--ide-border-color, $white-dark); } .ide-commit-list-changed-icon { @@ -1135,16 +1136,26 @@ $ide-commit-header-height: 48px; .ide-file-icon-holder { display: flex; align-items: center; - color: $gray-700; + color: var(--ide-text-color-secondary, $gray-700); +} + +.file-row:active { + background: var(--ide-background, $gray-200); +} + +.file-row.is-active { + background: var(--ide-background, $gray-100); } .file-row:hover, .file-row:focus { + background: var(--ide-background, $gray-100); + .ide-new-btn { display: block; } .folder-icon { - fill: $gl-text-color-secondary; + fill: var(--ide-text-color-secondary, $gl-text-color-secondary); } } diff --git a/app/assets/stylesheets/page_bundles/ide_themes/README.md b/app/assets/stylesheets/page_bundles/ide_themes/README.md new file mode 100644 index 00000000000..535179cc4c2 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/ide_themes/README.md @@ -0,0 +1,53 @@ +# Web IDE Themes + +Web IDE currently supports 5 syntax highlighting themes based on themes from the user's profile preferences: + +* White +* Dark +* Monokai +* Solarized Dark +* Solarized Light + +Currently, the Web IDE supports the white theme by default, and the dark theme by the introduction of CSS +variables. + +The Web IDE automatically adds an appropriate theme class to the `ide.vue` component based on the current syntax +highlighting theme. Below are those theme classes, which come from the `gon.user_color_scheme` global setting: + +| # | Color Scheme | `gon.user_color_scheme` | Theme class | +|---|-----------------|-------------------------|-------------------------| +| 1 | White | `"white"` | `.theme-white` | +| 2 | Dark | `"dark"` | `.theme-dark` | +| 3 | Monokai | `"monokai"` | `.theme-monokai` | +| 4 | Solarized Dark | `"solarized-dark"` | `.theme-solarized-dark` | +| 5 | Solarized Light | `"solarized-light"` | `.theme-solarized-light` | +| 6 | None | `"none"` | `.theme-none` | + +## Adding New Themes (SCSS) + +To add a new theme, follow the following steps: + +1. Pick a theme from the table above, lets say **Solarized Dark**. +2. Create a new file in this folder called `_solarized_dark.scss`. +3. Copy over all the CSS variables from `_dark.scss` to `_solarized_dark.scss` and assign them your own values. + Put them under the selector `.ide.theme-solarized-dark`. +4. Import this newly created SCSS file in `ide.scss` file in the parent directory. +5. To make sure the variables apply to to your theme, add the selector `.ide.theme-solarized-dark` to the top + of `_ide_theme_overrides.scss` file. The file should now look like this: + + ```scss + .ide.theme-dark, + .ide.theme-solarized-dark { + /* file contents */ + } + ``` + + This step is temporary until all CSS variables in that file have their + default values assigned. +6. That's it! Raise a merge request with your newly added theme. + +## Modifying Monaco Themes + +Monaco themes are defined in Javascript and are stored in the `app/assets/javascripts/ide/lib/themes/` directory. +To modify any syntax highlighting colors or to synchronize the theme colors with syntax highlighting colors, you +can modify the files in that directory directly. diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss new file mode 100644 index 00000000000..37e6be9849b --- /dev/null +++ b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss @@ -0,0 +1,50 @@ +// ------- +// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes +// ------- +.ide.theme-dark { + --ide-border-color: #1d1f21; + --ide-border-color-alt: #333; + --ide-highlight-accent: #fff; + --ide-text-color: #ccc; + --ide-text-color-secondary: #ccc; + --ide-background: #333; + --ide-background-hover: #2d2d2d; + --ide-highlight-background: #252526; + --ide-link-color: #428fdc; + --ide-footer-background: #060606; + + --ide-input-border: #868686; + --ide-input-background: transparent; + --ide-input-color: #fff; + + --ide-btn-default-background: transparent; + --ide-btn-default-border: #bfbfbf; + --ide-btn-default-hover-border: #d8d8d8; + + --ide-btn-primary-background: #1068bf; + --ide-btn-primary-border: #428fdc; + --ide-btn-primary-hover-border: #63a6e9; + + --ide-btn-success-background: #217645; + --ide-btn-success-border: #108548; + --ide-btn-success-hover-border: #2da160; + + --ide-btn-disabled-border: rgba(223, 223, 223, 0.24); + --ide-btn-disabled-color: rgba(145, 145, 145, 0.48); + + --ide-btn-hover-border-width: 2px; + + --ide-dropdown-background: #404040; + --ide-dropdown-hover-background: #525252; + + --ide-dropdown-btn-hover-border: #{$gray-200}; + --ide-dropdown-btn-hover-background: #{$gray-900}; + + --ide-file-row-btn-hover-background: #{$gray-800}; + + --ide-diff-insert: rgba(155, 185, 85, 0.2); + --ide-diff-remove: rgba(255, 0, 0, 0.2); + + --ide-animation-gradient-1: #{$gray-800}; + --ide-animation-gradient-2: #{$gray-700}; +} diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/pages/alert_management/details.scss new file mode 100644 index 00000000000..89219e41644 --- /dev/null +++ b/app/assets/stylesheets/pages/alert_management/details.scss @@ -0,0 +1,42 @@ +.alert-management-details { + // these styles need to be deleted once GlTable component looks in GitLab same as in @gitlab/ui + table { + tr { + td { + @include gl-border-0; + @include gl-p-5; + border-color: transparent; + border-bottom: 1px solid $table-border-color; + + &:first-child { + div { + font-weight: bold; + } + } + + &:not(:first-child) { + &::before { + color: $gray-700; + font-weight: normal !important; + } + + div { + color: $gray-700; + } + } + + @include media-breakpoint-up(sm) { + div { + text-align: left !important; + } + } + } + } + } + + @include media-breakpoint-down(xs) { + .alert-details-create-issue-button { + width: 100%; + } + } +} diff --git a/app/assets/stylesheets/pages/alert_management/list.scss b/app/assets/stylesheets/pages/alert_management/list.scss new file mode 100644 index 00000000000..dc181342def --- /dev/null +++ b/app/assets/stylesheets/pages/alert_management/list.scss @@ -0,0 +1,83 @@ +.alert-management-list { + // consider adding these stateful variants to @gitlab-ui + // https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1178 + .hover-bg-blue-50:hover { + background-color: $blue-50; + } + + .hover-gl-cursor-pointer:hover { + cursor: pointer; + } + + .hover-gl-border-b-solid:hover { + @include gl-border-b-solid; + } + + .hover-gl-border-blue-200:hover { + border-color: $blue-200; + } + + // these styles need to be deleted once GlTable component looks in GitLab same as in @gitlab/ui + table { + color: $gray-700; + + tr { + &:focus { + outline: none; + } + + td, + th { + @include gl-p-5; + border: 0; // Remove cell border styling so that we can set border styling per row + + &.event-count { + @include gl-pr-9; + } + } + + th { + background-color: transparent; + font-weight: $gl-font-weight-bold; + color: $gl-gray-600; + } + + &:last-child { + td { + @include gl-border-0; + } + } + } + } + + @include media-breakpoint-down(sm) { + .alert-management-table { + .table-col { + min-height: 68px; + + &:last-child { + background-color: $gray-10; + + &::before { + content: none !important; + } + + div { + width: 100% !important; + padding: 0 !important; + } + } + } + } + } + + .gl-tab-nav-item { + color: $gl-gray-600; + + > .gl-tab-counter-badge { + color: inherit; + @include gl-font-sm; + background-color: $white-normal; + } + } +} diff --git a/app/assets/stylesheets/pages/alert_management/severity-icons.scss b/app/assets/stylesheets/pages/alert_management/severity-icons.scss new file mode 100644 index 00000000000..b400e80d5c5 --- /dev/null +++ b/app/assets/stylesheets/pages/alert_management/severity-icons.scss @@ -0,0 +1,26 @@ +.alert-management-list, +.alert-management-details { + .icon-critical { + color: $red-800; + } + + .icon-high { + color: $red-600; + } + + .icon-medium { + color: $orange-400; + } + + .icon-low { + color: $orange-300; + } + + .icon-info { + color: $blue-400; + } + + .icon-unknown { + color: $gray-400; + } +} diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 11291dad28b..d755170fe1f 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -548,3 +548,27 @@ */ height: $input-height; } + +.issue-boards-content.is-focused { + position: fixed; + width: 100%; + height: 100%; + top: 0; + left: 0; + background: $white; + z-index: 9000; + + @include media-breakpoint-down(sm) { + padding-top: 10px; + } + + .boards-list { + height: calc(100vh - #{$issue-boards-filter-height}); + overflow-x: scroll; + } + + .issue-boards-sidebar { + height: 100%; + top: 0; + } +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 230f390aa90..9a69afc6044 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -208,6 +208,18 @@ } } +.commit-nav-buttons { + a.btn, + button { + // See: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/730 + &:last-child > svg { + margin-left: 0.25rem; + margin-right: 0; + } + } +} + + .clipboard-group, .commit-sha-group { display: inline-flex; diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 0292919ea50..b97709e140f 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -187,7 +187,6 @@ .stage-events { width: 60%; - overflow: scroll; min-height: 467px; } diff --git a/app/assets/stylesheets/pages/error_list.scss b/app/assets/stylesheets/pages/error_list.scss index a61a85649b8..3ec3e4f6b43 100644 --- a/app/assets/stylesheets/pages/error_list.scss +++ b/app/assets/stylesheets/pages/error_list.scss @@ -17,7 +17,7 @@ min-height: 68px; &:last-child { - background-color: $gray-normal; + background-color: $gray-10; &::before { content: none !important; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 85fdcb753b4..b241d0a2bdc 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -167,10 +167,6 @@ } } - a.gl-label-icon { - color: $gray-500; - } - .gl-label .gl-label-link:hover { text-decoration: none; color: inherit; @@ -180,11 +176,6 @@ } } - .gl-label .gl-label-icon:hover { - text-decoration: none; - color: $gray-500; - } - .btn-link { color: inherit; } @@ -826,10 +817,6 @@ } } } - - .gl-label-icon { - color: $gray-500; - } } @media(max-width: map-get($grid-breakpoints, lg)-1) { diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index e74b420f9e9..0dd25ec5360 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -303,3 +303,13 @@ ul.related-merge-requests > li { } } } + +.issuable-list-root { + .gl-label-link { + text-decoration: none; + + &:hover { + color: inherit; + } + } +} diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 7f6542261b8..22c1cb127cd 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -307,7 +307,7 @@ } .label-name { - width: 150px; + width: 200px; flex-shrink: 0; .scoped-label-wrapper, @@ -460,8 +460,7 @@ // Label inside title of Delete Label Modal .modal-header .page-title { .scoped-label-wrapper { - .scoped-label, - .gl-label-icon { + .scoped-label { line-height: 20px; } diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index fa10ab022dc..c473cc44637 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -68,10 +68,6 @@ $status-box-line-height: 26px; .gl-label-link { color: inherit; } - - .gl-label-icon { - color: $gray-500; - } } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 8b51ba7ae62..bed147aa3a7 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -588,7 +588,8 @@ $note-form-margin-left: 72px; a { color: inherit; - &:hover { + &:hover, + &.hover { color: $blue-600; } @@ -605,6 +606,21 @@ $note-form-margin-left: 72px; .author-link { color: $gl-text-color; } + + // Prevent flickering of link when hovering between `author-name-link` and `.author-username-link` + .author-name-link + .author-username .author-username-link { + position: relative; + + &::before { + content: ''; + position: absolute; + right: 100%; + width: 0.25rem; + height: 100%; + top: 0; + bottom: 0; + } + } } .discussion-header { @@ -672,8 +688,7 @@ $note-form-margin-left: 72px; text-decoration: underline; } - .gl-label-link:hover, - .gl-label-icon:hover { + .gl-label-link:hover { text-decoration: none; color: inherit; @@ -892,11 +907,10 @@ $note-form-margin-left: 72px; border-right: 0; .line-resolve-btn { - margin-right: 5px; color: $gray-700; svg { - vertical-align: middle; + vertical-align: text-top; } } diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss index 85c4902eee2..81716991a36 100644 --- a/app/assets/stylesheets/pages/pipeline_schedules.scss +++ b/app/assets/stylesheets/pages/pipeline_schedules.scss @@ -21,11 +21,6 @@ .cron-interval-input { margin: 10px 10px 0 0; } - - .cron-syntax-link-wrap { - margin-right: 10px; - font-size: 12px; - } } .pipeline-schedule-table-row { diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index f61245bed24..0f56b98a78d 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -13,6 +13,14 @@ .form-group { margin-bottom: map-get($spacing-scale, 3); } + + .variables-section { + input { + @include media-breakpoint-up(sm) { + width: 160px; + } + } + } } .draggable { @@ -143,7 +151,7 @@ > .arrow::after { border-top: 6px solid transparent; border-bottom: 6px solid transparent; - border-left: 4px solid $gray-50; + border-left: 4px solid $gray-10; } .arrow-shadow { @@ -165,7 +173,7 @@ > .arrow::after { border-top: 6px solid transparent; border-bottom: 6px solid transparent; - border-right: 4px solid $gray-50; + border-right: 4px solid $gray-10; } .arrow-shadow { @@ -199,7 +207,7 @@ } > .popover-title { - background-color: $gray-50; + background-color: $gray-10; border-radius: $border-radius-default $border-radius-default 0 0; } } diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 2c0ca792ec3..d26c07ce51b 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -335,15 +335,6 @@ } } -.deprecated-service { - cursor: default; - - a { - font-weight: $gl-font-weight-bold; - color: $white; - } -} - .personal-access-tokens-never-expires-label { color: $note-disabled-comment-color; } @@ -401,4 +392,3 @@ } } } - diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss index 93a12cf28a2..d410a16a1d9 100644 --- a/app/assets/stylesheets/snippets.scss +++ b/app/assets/stylesheets/snippets.scss @@ -40,10 +40,9 @@ margin: 0; padding: 0; table-layout: fixed; + overflow-x: auto; .blob-content { - overflow-x: auto; - pre { height: 100%; padding: 10px; @@ -61,6 +60,7 @@ font-family: $monospace-font; font-size: $code-font-size; line-height: $code-line-height; + display: inline-block; } } @@ -73,7 +73,7 @@ font-family: $monospace-font; display: block; font-size: $code-font-size; - min-height: $code-line-height; + line-height: $code-line-height; white-space: nowrap; color: $black-transparent; min-width: 30px; diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index b829a7b518e..8cf5c533f1f 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -1,3 +1,10 @@ +/** + Please note: These are deprecated in favor of the Gitlab UI utility classes. + Check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss + to see the available utility classes. If you cannot find the class you need, + consider adding it to Gitlab UI before adding it here. +**/ + @each $variant, $range in $color-ranges { @each $suffix, $color in $range { #{'.bg-#{$variant}-#{$suffix}'} { @@ -7,6 +14,12 @@ #{'.text-#{$variant}-#{$suffix}'} { color: $color; } + + #{'.hover-text-#{$variant}-#{$suffix}'} { + &:hover { + color: $color; + } + } } } @@ -44,6 +57,7 @@ .border-color-default { border-color: $border-color; } .border-bottom-color-default { border-bottom-color: $border-color; } .border-radius-default { border-radius: $border-radius-default; } +.border-radius-small { border-radius: $border-radius-small; } .box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } .gl-children-ml-sm-3 > * { @@ -75,6 +89,19 @@ width: px-to-rem(16px); } +.gl-shim-pb-3 { + padding-bottom: 8px; +} + +.gl-shim-pt-5 { + padding-top: 16px; +} + +.gl-shim-mx-2 { + margin-left: 4px; + margin-right: 4px; +} + .gl-text-purple { color: $purple; } .gl-text-gray-800 { color: $gray-800; } .gl-bg-purple-light { background-color: $purple-light; } @@ -85,6 +112,7 @@ .gl-bg-blue-50 { @include gl-bg-blue-50; } .gl-bg-red-100 { @include gl-bg-red-100; } .gl-bg-orange-100 { @include gl-bg-orange-100; } +.gl-bg-gray-50 { @include gl-bg-gray-50; } .gl-bg-gray-100 { @include gl-bg-gray-100; } .gl-bg-green-100 { @include gl-bg-green-100;} .gl-bg-blue-500 { @include gl-bg-blue-500; } @@ -107,8 +135,14 @@ .gl-text-green-700 { @include gl-text-green-700; } .gl-align-items-center { @include gl-align-items-center; } + .d-sm-table-column { @include media-breakpoint-up(sm) { display: table-column !important; } } + +.gl-white-space-normal { @include gl-white-space-normal; } +.gl-word-break-all { @include gl-word-break-all; } +.gl-reset-line-height { @include gl-reset-line-height; } +.gl-reset-text-align { @include gl-reset-text-align; } diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 00000000000..9aec2305390 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 00000000000..87c833f3593 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + self.current_user = find_user_from_session_store + end + + private + + def find_user_from_session_store + session = ActiveSession.sessions_from_ids([session_id]).first + Warden::SessionSerializer.new('rack.session' => session).fetch(:user) + end + + def session_id + Rack::Session::SessionId.new(cookies[Gitlab::Application.config.session_options[:key]]) + end + end +end diff --git a/app/channels/issues_channel.rb b/app/channels/issues_channel.rb new file mode 100644 index 00000000000..5f3909b7716 --- /dev/null +++ b/app/channels/issues_channel.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class IssuesChannel < ApplicationCable::Channel + def subscribed + project = Project.find_by_full_path(params[:project_path]) + return reject unless project + + issue = project.issues.find_by_iid(params[:iid]) + return reject unless issue && Ability.allowed?(current_user, :read_issue, issue) + + stream_for issue + end +end diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb index 383ec2a7d16..8405f2a5cf8 100644 --- a/app/controllers/admin/appearances_controller.rb +++ b/app/controllers/admin/appearances_controller.rb @@ -73,6 +73,7 @@ class Admin::AppearancesController < Admin::ApplicationController favicon favicon_cache new_project_guidelines + profile_image_guidelines updated_by header_message footer_message diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 16254c74ba4..355662bbb38 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -5,7 +5,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController # NOTE: Use @application_setting in this controller when you need to access # application_settings after it has been modified. This is because the - # ApplicationSetting model uses Gitlab::ThreadMemoryCache for caching and the + # ApplicationSetting model uses Gitlab::ProcessMemoryCache for caching and the # cache might be stale immediately after an update. # https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/30233 before_action :set_application_setting, except: :integrations @@ -43,7 +43,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController def usage_data respond_to do |format| format.html do - usage_data_json = JSON.pretty_generate(Gitlab::UsageData.data) + usage_data_json = Gitlab::Json.pretty_generate(Gitlab::UsageData.data) render html: Gitlab::Highlight.highlight('payload.json', usage_data_json, language: 'json') end diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb new file mode 100644 index 00000000000..ca9b393550d --- /dev/null +++ b/app/controllers/admin/ci/variables_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class Admin::Ci::VariablesController < Admin::ApplicationController + def show + respond_to do |format| + format.json { render_instance_variables } + end + end + + def update + service = Ci::UpdateInstanceVariablesService.new(variables_params) + + if service.execute + respond_to do |format| + format.json { render_instance_variables } + end + else + respond_to do |format| + format.json { render_error(service.errors) } + end + end + end + + private + + def variables + @variables ||= Ci::InstanceVariable.all + end + + def render_instance_variables + render status: :ok, + json: { + variables: Ci::InstanceVariableSerializer.new.represent(variables) + } + end + + def render_error(errors) + render status: :bad_request, json: errors + end + + def variables_params + params.permit(variables_attributes: [*variable_params_attributes]) + end + + def variable_params_attributes + %i[id variable_type key secret_value protected masked _destroy] + end +end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index cd95105a893..b7b535e70df 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -13,6 +13,7 @@ class Admin::DashboardController < Admin::ApplicationController @users = User.order_id_desc.limit(10) @groups = Group.order_id_desc.with_route.limit(10) @notices = Gitlab::ConfigChecker::PumaRuggedChecker.check + @notices += Gitlab::ConfigChecker::ExternalDatabaseChecker.check end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb deleted file mode 100644 index 3ae0aef0fa4..00000000000 --- a/app/controllers/admin/logs_controller.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -class Admin::LogsController < Admin::ApplicationController - before_action :loggers - - def show - end - - private - - def loggers - @loggers ||= [ - Gitlab::AppJsonLogger, - Gitlab::GitLogger, - Gitlab::EnvironmentLogger, - Gitlab::SidekiqLogger, - Gitlab::RepositoryCheckLogger, - Gitlab::ProjectServiceLogger, - Gitlab::Kubernetes::Logger - ] - end -end - -Admin::LogsController.prepend_if_ee('EE::Admin::LogsController') diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb index 1dc1cd5fb82..0c0bbaf4d93 100644 --- a/app/controllers/admin/sessions_controller.rb +++ b/app/controllers/admin/sessions_controller.rb @@ -3,6 +3,7 @@ class Admin::SessionsController < ApplicationController include Authenticates2FAForAdminMode include InternalRedirect + include RendersLdapServers before_action :user_is_admin! diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 8414095d454..ee42baa8326 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -145,7 +145,7 @@ class Admin::UsersController < Admin::ApplicationController password_confirmation: params[:user][:password_confirmation] } - password_params[:password_expires_at] = Time.now unless changing_own_password? + password_params[:password_expires_at] = Time.current unless changing_own_password? user_params_with_pass.merge!(password_params) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b5695322eb6..54e3275662b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -18,6 +18,9 @@ class ApplicationController < ActionController::Base include Gitlab::Tracking::ControllerConcern include Gitlab::Experimentation::ControllerConcern include InitializesCurrentUserMode + include Impersonation + include Gitlab::Logging::CloudflareHelper + include Gitlab::Utils::StrongMemoize before_action :authenticate_user!, except: [:route_not_found] before_action :enforce_terms!, if: :should_enforce_terms? @@ -35,6 +38,10 @@ class ApplicationController < ActionController::Base before_action :check_impersonation_availability before_action :required_signup_info + # Make sure the `auth_user` is memoized so it can be logged, we do this after + # all other before filters that could have set the user. + before_action :auth_user + prepend_around_action :set_current_context around_action :sessionless_bypass_admin_mode!, if: :sessionless_user? @@ -141,16 +148,19 @@ class ApplicationController < ActionController::Base payload[:ua] = request.env["HTTP_USER_AGENT"] payload[:remote_ip] = request.remote_ip + payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id + payload[:metadata] = @current_context logged_user = auth_user - if logged_user.present? payload[:user_id] = logged_user.try(:id) payload[:username] = logged_user.try(:username) end payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] + + store_cloudflare_headers!(payload, request) end ## @@ -158,10 +168,12 @@ class ApplicationController < ActionController::Base # (e.g. tokens) to authenticate the user, whereas Devise sets current_user. # def auth_user - if user_signed_in? - current_user - else - try(:authenticated_user) + strong_memoize(:auth_user) do + if user_signed_in? + current_user + else + try(:authenticated_user) + end end end @@ -453,11 +465,16 @@ class ApplicationController < ActionController::Base def set_current_context(&block) Gitlab::ApplicationContext.with_context( - user: -> { auth_user }, - project: -> { @project }, - namespace: -> { @group }, - caller_id: full_action_name, - &block) + # Avoid loading the auth_user again after the request. Otherwise calling + # `auth_user` again would also trigger the Warden callbacks again + user: -> { auth_user if strong_memoized?(:auth_user) }, + project: -> { @project if @project&.persisted? }, + namespace: -> { @group if @group&.persisted? }, + caller_id: full_action_name) do + yield + ensure + @current_context = Labkit::Context.current.to_h + end end def set_locale(&block) @@ -525,36 +542,6 @@ class ApplicationController < ActionController::Base .execute end - def check_impersonation_availability - return unless session[:impersonator_id] - - unless Gitlab.config.gitlab.impersonation_enabled - stop_impersonation - access_denied! _('Impersonation has been disabled') - end - end - - def stop_impersonation - log_impersonation_event - - warden.set_user(impersonator, scope: :user) - session[:impersonator_id] = nil - - impersonated_user - end - - def impersonated_user - current_user - end - - def log_impersonation_event - Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{impersonated_user.username}") - end - - def impersonator - @impersonator ||= User.find(session[:impersonator_id]) if session[:impersonator_id] - end - def sentry_context(&block) Gitlab::ErrorTracking.with_context(current_user, &block) end diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 1bfff210ecf..a18c80b996e 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -20,9 +20,6 @@ module Boards skip_before_action :authenticate_user!, only: [:index] before_action :validate_id_list, only: [:bulk_move] before_action :can_move_issues?, only: [:bulk_move] - before_action do - push_frontend_feature_flag(:board_search_optimization, board.group, default_enabled: true) - end def index list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params) diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb index de14bd319e0..c533fe007d7 100644 --- a/app/controllers/clusters/applications_controller.rb +++ b/app/controllers/clusters/applications_controller.rb @@ -47,7 +47,7 @@ class Clusters::ApplicationsController < Clusters::BaseController end def cluster_application_params - params.permit(:application, :hostname, :pages_domain_id, :email, :stack, :modsecurity_enabled, :modsecurity_mode, :host, :port, :protocol) + params.permit(:application, :hostname, :pages_domain_id, :email, :stack, :modsecurity_enabled, :modsecurity_mode, :host, :port, :protocol, :waf_log_enabled, :cilium_log_enabled) end def cluster_application_destroy_params diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 2c9ee69c8c4..aa39d430b24 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -18,20 +18,19 @@ class Clusters::ClustersController < Clusters::BaseController STATUS_POLLING_INTERVAL = 10_000 def index - finder = ClusterAncestorsFinder.new(clusterable.subject, current_user) - clusters = finder.execute + @clusters = cluster_list - # Note: We are paginating through an array here but this should OK as: - # - # In CE, we can have a maximum group nesting depth of 21, so including - # project cluster, we can have max 22 clusters for a group hierarchy. - # In EE (Premium) we can have any number, as multiple clusters are - # supported, but the number of clusters are fairly low currently. - # - # See https://gitlab.com/gitlab-org/gitlab-foss/issues/55260 also. - @clusters = Kaminari.paginate_array(clusters).page(params[:page]).per(20) + respond_to do |format| + format.html + format.json do + serializer = ClusterSerializer.new(current_user: current_user) - @has_ancestor_clusters = finder.has_ancestor_clusters? + render json: { + clusters: serializer.with_pagination(request, response).represent_list(@clusters), + has_ancestor_clusters: @has_ancestor_clusters + } + end + end end def new @@ -158,6 +157,23 @@ class Clusters::ClustersController < Clusters::BaseController private + def cluster_list + finder = ClusterAncestorsFinder.new(clusterable.subject, current_user) + clusters = finder.execute + + @has_ancestor_clusters = finder.has_ancestor_clusters? + + # Note: We are paginating through an array here but this should OK as: + # + # In CE, we can have a maximum group nesting depth of 21, so including + # project cluster, we can have max 22 clusters for a group hierarchy. + # In EE (Premium) we can have any number, as multiple clusters are + # supported, but the number of clusters are fairly low currently. + # + # See https://gitlab.com/gitlab-org/gitlab-foss/issues/55260 also. + Kaminari.paginate_array(clusters).page(params[:page]).per(20) + end + def destroy_params params.permit(:cleanup) end diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb index eb1080cb3d2..9d40b9e8c88 100644 --- a/app/controllers/concerns/boards_actions.rb +++ b/app/controllers/concerns/boards_actions.rb @@ -10,6 +10,9 @@ module BoardsActions before_action :boards, only: :index before_action :board, only: :show before_action :push_wip_limits, only: [:index, :show] + before_action do + push_frontend_feature_flag(:not_issuable_queries, parent, default_enabled: true) + end end def index diff --git a/app/controllers/concerns/impersonation.rb b/app/controllers/concerns/impersonation.rb new file mode 100644 index 00000000000..a4f2c263eb4 --- /dev/null +++ b/app/controllers/concerns/impersonation.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Impersonation + include Gitlab::Utils::StrongMemoize + + def current_user + user = super + + user.impersonator = impersonator if impersonator + + user + end + + protected + + def check_impersonation_availability + return unless session[:impersonator_id] + + unless Gitlab.config.gitlab.impersonation_enabled + stop_impersonation + access_denied! _('Impersonation has been disabled') + end + end + + def stop_impersonation + log_impersonation_event + + warden.set_user(impersonator, scope: :user) + session[:impersonator_id] = nil + + current_user + end + + def log_impersonation_event + Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{current_user.username}") + end + + def impersonator + strong_memoize(:impersonator) do + User.find(session[:impersonator_id]) if session[:impersonator_id] + end + end +end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index ca43bf42580..0b1b3f2bcba 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -11,6 +11,9 @@ module IssuableActions before_action only: :show do push_frontend_feature_flag(:scoped_labels, default_enabled: true) end + before_action do + push_frontend_feature_flag(:not_issuable_queries, @project, default_enabled: true) + end end def permitted_keys diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb index 0a6f684a9fc..78b3c6771b3 100644 --- a/app/controllers/concerns/issuable_collections_action.rb +++ b/app/controllers/concerns/issuable_collections_action.rb @@ -32,6 +32,10 @@ module IssuableCollectionsAction private + def set_not_query_feature_flag(object = nil) + push_frontend_feature_flag(:not_issuable_queries, object, default_enabled: true) + end + def sorting_field case action_name when 'issues' diff --git a/app/controllers/concerns/known_sign_in.rb b/app/controllers/concerns/known_sign_in.rb new file mode 100644 index 00000000000..97883d8d08c --- /dev/null +++ b/app/controllers/concerns/known_sign_in.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module KnownSignIn + include Gitlab::Utils::StrongMemoize + + private + + def verify_known_sign_in + return unless current_user + + notify_user unless known_remote_ip? + end + + def known_remote_ip? + known_ip_addresses.include?(request.remote_ip) + end + + def sessions + strong_memoize(:session) do + ActiveSession.list(current_user).reject(&:is_impersonated) + end + end + + def known_ip_addresses + [current_user.last_sign_in_ip, sessions.map(&:ip_address)].flatten + end + + def notify_user + current_user.notification_service.unknown_sign_in(current_user, request.remote_ip) + end +end diff --git a/app/controllers/concerns/members_presentation.rb b/app/controllers/concerns/members_presentation.rb index 0a9d3d86245..ceccef8113f 100644 --- a/app/controllers/concerns/members_presentation.rb +++ b/app/controllers/concerns/members_presentation.rb @@ -5,6 +5,7 @@ module MembersPresentation def present_members(members) preload_associations(members) + Gitlab::View::Presenter::Factory.new( members, current_user: current_user, diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb index fa79f3bc4e6..1aea0e294a5 100644 --- a/app/controllers/concerns/metrics_dashboard.rb +++ b/app/controllers/concerns/metrics_dashboard.rb @@ -18,7 +18,7 @@ module MetricsDashboard if result result[:all_dashboards] = all_dashboards if include_all_dashboards? - result[:metrics_data] = metrics_data(project_for_dashboard, environment_for_dashboard) if project_for_dashboard && environment_for_dashboard + result[:metrics_data] = metrics_data(project_for_dashboard, environment_for_dashboard) end respond_to do |format| @@ -35,10 +35,9 @@ module MetricsDashboard private def all_dashboards - dashboards = dashboard_finder.find_all_paths(project_for_dashboard) - dashboards.map do |dashboard| - amend_dashboard(dashboard) - end + dashboard_finder + .find_all_paths(project_for_dashboard) + .map(&method(:amend_dashboard)) end def amend_dashboard(dashboard) @@ -46,10 +45,16 @@ module MetricsDashboard dashboard[:can_edit] = project_dashboard ? can_edit?(dashboard) : false dashboard[:project_blob_path] = project_dashboard ? dashboard_project_blob_path(dashboard) : nil + dashboard[:starred] = starred_dashboards.include?(dashboard[:path]) + dashboard[:user_starred_path] = project_for_dashboard ? user_starred_path(project_for_dashboard, dashboard[:path]) : nil dashboard end + def user_starred_path(project, path) + expose_path(api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: path })) + end + def dashboard_project_blob_path(dashboard) project_blob_path(project_for_dashboard, File.join(project_for_dashboard.default_branch, dashboard.fetch(:path, ""))) end @@ -73,6 +78,20 @@ module MetricsDashboard ::Gitlab::Metrics::Dashboard::Finder end + def starred_dashboards + @starred_dashboards ||= begin + if project_for_dashboard.present? + ::Metrics::UsersStarredDashboardsFinder + .new(user: current_user, project: project_for_dashboard) + .execute + .map(&:dashboard_path) + .to_set + else + Set.new + end + end + end + # Project is not defined for group and admin level clusters. def project_for_dashboard defined?(project) ? project : nil diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 7dd2f6e5706..d4b0d3b2674 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -13,7 +13,7 @@ module NotesActions end def index - current_fetched_at = Time.now.to_i + current_fetched_at = Time.current.to_i notes_json = { notes: [], last_fetched_at: current_fetched_at } diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index c7c9f2e9b70..ba15d611c0d 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -37,7 +37,7 @@ module PreviewMarkdown when 'groups' then { group: group } when 'projects' then projects_filter_params else {} - end.merge(requested_path: params[:path]) + end.merge(requested_path: params[:path], ref: params[:ref]) end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb index 4013596ba12..29164df4516 100644 --- a/app/controllers/concerns/record_user_last_activity.rb +++ b/app/controllers/concerns/record_user_last_activity.rb @@ -17,7 +17,6 @@ module RecordUserLastActivity def set_user_last_activity return unless request.get? - return unless Feature.enabled?(:set_user_last_activity, default_enabled: true) return if Gitlab::Database.read_only? if current_user && current_user.last_activity_on != Date.today diff --git a/app/controllers/concerns/renders_ldap_servers.rb b/app/controllers/concerns/renders_ldap_servers.rb new file mode 100644 index 00000000000..cc83ff47048 --- /dev/null +++ b/app/controllers/concerns/renders_ldap_servers.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module RendersLdapServers + extend ActiveSupport::Concern + + included do + helper_method :ldap_servers + end + + def ldap_servers + @ldap_servers ||= begin + if Gitlab::Auth::Ldap::Config.sign_in_enabled? + Gitlab::Auth::Ldap::Config.available_servers + else + [] + end + end + end +end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 3ccf227c431..e2c83f9a069 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -19,6 +19,7 @@ module ServiceParams :color, :colorize_messages, :comment_on_event_enabled, + :comment_detail, :confidential_issues_events, :default_irc_uri, :description, diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index 096c6efc0fc..e78723bdda2 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -53,10 +53,10 @@ module SnippetsActions def blob return unless snippet - @blob ||= if Feature.enabled?(:version_snippets, current_user) && !snippet.repository.empty? - snippet.blobs.first - else + @blob ||= if snippet.empty_repo? snippet.blob + else + snippet.blobs.first end end # rubocop:enable Gitlab/ModuleWithInstanceVariables @@ -65,11 +65,12 @@ module SnippetsActions params[:line_ending] == 'raw' ? content : content.gsub(/\r\n/, "\n") end - def check_repository_error - repository_errors = Array(snippet.errors.delete(:repository)) + def handle_repository_error(action) + errors = Array(snippet.errors.delete(:repository)) + + flash.now[:alert] = errors.first if errors.present? - flash.now[:alert] = repository_errors.first if repository_errors.present? - recaptcha_check_with_fallback(repository_errors.empty?) { render :edit } + recaptcha_check_with_fallback(errors.empty?) { render action } end def redirect_if_binary diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index 46ba270f328..50c93441dd4 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -82,6 +82,6 @@ module SpammableActions return false if spammable.errors.count > 1 # re-render "new" template in case there are other errors return false unless Gitlab::Recaptcha.enabled? - spammable.spam + spammable.needs_recaptcha? end end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index c173d7d2310..25c48fadf49 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -83,11 +83,13 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController def use_cte_for_finder? # The starred action loads public projects, which causes the CTE to be less efficient - action_name == 'index' && Feature.enabled?(:use_cte_for_projects_finder, default_enabled: true) + action_name == 'index' end def load_events - projects = load_projects(params.merge(non_public: true)) + projects = ProjectsFinder + .new(params: params.merge(non_public: true), current_user: current_user) + .execute @events = EventCollection .new(projects, offset: params[:offset].to_i, filter: event_filter) diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 1668cf004f8..dd9e6488bc5 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -10,6 +10,7 @@ class DashboardController < Dashboard::ApplicationController before_action :projects, only: [:issues, :merge_requests] before_action :set_show_full_reference, only: [:issues, :merge_requests] before_action :check_filters_presence!, only: [:issues, :merge_requests] + before_action :set_not_query_feature_flag respond_to :html diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb index ed0995e7ffd..5723ccc14a7 100644 --- a/app/controllers/google_api/authorizations_controller.rb +++ b/app/controllers/google_api/authorizations_controller.rb @@ -15,6 +15,9 @@ module GoogleApi session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = expires_at.to_s + rescue ::Faraday::TimeoutError, ::Faraday::ConnectionFailed + flash[:alert] = _('Timeout connecting to the Google API. Please try again.') + ensure redirect_to redirect_uri_from_session end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 522d171b5bf..a1348e4d858 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -3,7 +3,12 @@ class GraphqlController < ApplicationController # Unauthenticated users have access to the API for public data skip_before_action :authenticate_user! - skip_around_action :set_session_storage + + # If a user is using their session to access GraphQL, we need to have session + # storage, since the admin-mode check is session wide. + # We can't enable this for anonymous users because that would cause users using + # enforced SSO from using an auth token to access the API. + skip_around_action :set_session_storage, unless: :current_user # Allow missing CSRF tokens, this would mean that if a CSRF is invalid or missing, # the user won't be authenticated but can proceed as an anonymous user. @@ -14,6 +19,7 @@ class GraphqlController < ApplicationController before_action :authorize_access_api! before_action(only: [:execute]) { authenticate_sessionless_user!(:api) } + before_action :set_user_last_activity # Since we deactivate authentication from the main ApplicationController and # defer it to :authorize_access_api!, we need to override the bypass session @@ -42,6 +48,12 @@ class GraphqlController < ApplicationController private + def set_user_last_activity + return unless current_user + + Users::ActivityService.new(current_user).execute + end + def execute_multiplex GitlabSchema.multiplex(multiplex_queries, context: context) end diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb index 23daa29ac43..52ee69edaa5 100644 --- a/app/controllers/groups/group_links_controller.rb +++ b/app/controllers/groups/group_links_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class Groups::GroupLinksController < Groups::ApplicationController - before_action :check_feature_flag! before_action :authorize_admin_group! before_action :group_link, only: [:update, :destroy] @@ -51,8 +50,4 @@ class Groups::GroupLinksController < Groups::ApplicationController def group_link_params params.require(:group_link).permit(:group_access, :expires_at) end - - def check_feature_flag! - render_404 unless Feature.enabled?(:share_group_with_group, default_enabled: true) - end end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 664c58e8b7a..63311ab983b 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -21,19 +21,26 @@ class Groups::GroupMembersController < Groups::ApplicationController def index @sort = params[:sort].presence || sort_value_name + @project = @group.projects.find(params[:project_id]) if params[:project_id] - @members = find_members + + @members = GroupMembersFinder + .new(@group, current_user, params: filter_params) + .execute(include_relations: requested_relations) if can_manage_members @skip_groups = @group.related_group_ids - @invited_members = present_invited_members(@members) + + @invited_members = @members.invite + @invited_members = @invited_members.search_invite_email(params[:search_invited]) if params[:search_invited].present? + @invited_members = present_invited_members(@invited_members) end - @members = @members.non_invite - @members = present_group_members(@members) + @members = present_group_members(@members.non_invite) @requesters = present_members( - AccessRequestsFinder.new(@group).execute(current_user)) + AccessRequestsFinder.new(@group).execute(current_user) + ) @group_member = @group.group_members.new end @@ -43,30 +50,24 @@ class Groups::GroupMembersController < Groups::ApplicationController private - def present_invited_members(members) - invited_members = members.invite - - if params[:search_invited].present? - invited_members = invited_members.search_invite_email(params[:search_invited]) - end - - present_members(invited_members - .page(params[:invited_members_page]) - .per(MEMBER_PER_PAGE_LIMIT)) + def can_manage_members + can?(current_user, :admin_group_member, @group) end - def find_members - filter_params = params.slice(:two_factor, :search).merge(sort: @sort) - GroupMembersFinder.new(@group, current_user, params: filter_params).execute(include_relations: requested_relations) + def present_invited_members(invited_members) + present_members(invited_members + .page(params[:invited_members_page]) + .per(MEMBER_PER_PAGE_LIMIT)) end - def can_manage_members - can?(current_user, :admin_group_member, @group) + def present_group_members(members) + present_members(members + .page(params[:page]) + .per(MEMBER_PER_PAGE_LIMIT)) end - def present_group_members(original_members) - members = original_members.page(params[:page]).per(MEMBER_PER_PAGE_LIMIT) - present_members(members) + def filter_params + params.permit(:two_factor, :search).merge(sort: @sort) end end diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index 16aa6e50320..14651e0794a 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -9,7 +9,9 @@ module Groups respond_to do |format| format.html format.json do - @images = ContainerRepositoriesFinder.new(user: current_user, subject: group).execute.with_api_entity_associations + @images = ContainerRepositoriesFinder.new(user: current_user, subject: group, params: params.slice(:name)) + .execute + .with_api_entity_associations track_event(:list_repositories) diff --git a/app/controllers/groups/settings/repository_controller.rb b/app/controllers/groups/settings/repository_controller.rb index 6e8c5628d24..4af5e613296 100644 --- a/app/controllers/groups/settings/repository_controller.rb +++ b/app/controllers/groups/settings/repository_controller.rb @@ -46,7 +46,7 @@ module Groups end def deploy_token_params - params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :username) + params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :read_package_registry, :write_package_registry, :username) end end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 44120fda17c..d5f2239b16a 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -31,6 +31,10 @@ class GroupsController < Groups::ApplicationController push_frontend_feature_flag(:vue_issuables_list, @group) end + before_action do + set_not_query_feature_flag(@group) + end + before_action :export_rate_limit, only: [:export, :download_export] skip_cross_project_access_check :index, :new, :create, :edit, :update, @@ -142,7 +146,7 @@ class GroupsController < Groups::ApplicationController export_service = Groups::ImportExport::ExportService.new(group: @group, user: current_user) if export_service.async_execute - redirect_to edit_group_path(@group), notice: _('Group export started.') + redirect_to edit_group_path(@group), notice: _('Group export started. A download link will be sent by email and made available on this page.') else redirect_to edit_group_path(@group), alert: _('Group export could not be started.') end diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index 91bba1eb617..a1bbcf34f69 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -26,7 +26,7 @@ class HelpController < ApplicationController respond_to do |format| format.any(:markdown, :md, :html) do - # Note: We are purposefully NOT using `Rails.root.join` + # Note: We are purposefully NOT using `Rails.root.join` because of https://gitlab.com/gitlab-org/gitlab/-/issues/216028. path = File.join(Rails.root, 'doc', "#{@path}.md") if File.exist?(path) @@ -42,7 +42,7 @@ class HelpController < ApplicationController # Allow access to specific media files in the doc folder format.any(:png, :gif, :jpeg, :mp4, :mp3) do - # Note: We are purposefully NOT using `Rails.root.join` + # Note: We are purposefully NOT using `Rails.root.join` because of https://gitlab.com/gitlab-org/gitlab/-/issues/216028. path = File.join(Rails.root, 'doc', "#{@path}.#{params[:format]}") if File.exist?(path) diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb index bffbdf01f8f..8a838db04f9 100644 --- a/app/controllers/ide_controller.rb +++ b/app/controllers/ide_controller.rb @@ -6,10 +6,6 @@ class IdeController < ApplicationController include ClientsidePreviewCSP include StaticObjectExternalStorageCSP - before_action do - push_frontend_feature_flag(:webide_dark_theme) - end - def index Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 34af1ecd6a5..4e8ceae75bd 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -144,7 +144,7 @@ class Import::GithubController < Import::BaseController end def provider_rate_limit(exception) - reset_time = Time.at(exception.response_headers['x-ratelimit-reset'].to_i) + reset_time = Time.zone.at(exception.response_headers['x-ratelimit-reset'].to_i) session[access_token_key] = nil redirect_to new_import_url, alert: _("GitHub API rate limit exceeded. Try again after %{reset_time}") % { reset_time: reset_time } diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb index 4dddfbcd20d..03bde0345e3 100644 --- a/app/controllers/import/google_code_controller.rb +++ b/app/controllers/import/google_code_controller.rb @@ -15,7 +15,7 @@ class Import::GoogleCodeController < Import::BaseController end begin - dump = JSON.parse(dump_file.read) + dump = Gitlab::Json.parse(dump_file.read) rescue return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") }) end @@ -42,7 +42,7 @@ class Import::GoogleCodeController < Import::BaseController user_map_json = "{}" if user_map_json.blank? begin - user_map = JSON.parse(user_map_json) + user_map = Gitlab::Json.parse(user_map_json) rescue flash.now[:alert] = _("The entered user map is not a valid JSON user map.") diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index da39d64c93d..3e7755046cd 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -4,7 +4,9 @@ class JwtController < ApplicationController skip_around_action :set_session_storage skip_before_action :authenticate_user! skip_before_action :verify_authenticity_token - before_action :authenticate_project_or_user + + # Add this before other actions, since we want to have the user or project + prepend_before_action :auth_user, :authenticate_project_or_user SERVICES = { Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService @@ -75,4 +77,11 @@ class JwtController < ApplicationController Array(Rack::Utils.parse_query(request.query_string)['scope']) end + + def auth_user + strong_memoize(:auth_user) do + actor = @authentication_result&.actor + actor.is_a?(User) ? actor : nil + end + end end diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb index 8e4d8f3d21b..4b6339c21cd 100644 --- a/app/controllers/ldap/omniauth_callbacks_controller.rb +++ b/app/controllers/ldap/omniauth_callbacks_controller.rb @@ -16,6 +16,10 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController def ldap return unless Gitlab::Auth::Ldap::Config.sign_in_enabled? + if Feature.enabled?(:user_mode_in_session) + return admin_mode_flow(Gitlab::Auth::Ldap::User) if current_user_mode.admin_mode_requested? + end + sign_in_user_flow(Gitlab::Auth::Ldap::User) end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index d82a46e57ea..4c595313cb6 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -6,6 +6,9 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include Devise::Controllers::Rememberable include AuthHelper include InitializesCurrentUserMode + include KnownSignIn + + after_action :verify_known_sign_in protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true @@ -87,6 +90,14 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController private + def after_omniauth_failure_path_for(scope) + if Feature.enabled?(:user_mode_in_session) + return new_admin_session_path if current_user_mode.admin_mode_requested? + end + + super + end + def omniauth_flow(auth_module, identity_linker: nil) if fragment = request.env.dig('omniauth.params', 'redirect_fragment').presence store_redirect_fragment(fragment) diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb new file mode 100644 index 00000000000..0c0a91e136f --- /dev/null +++ b/app/controllers/projects/alert_management_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Projects::AlertManagementController < Projects::ApplicationController + before_action :authorize_read_alert_management_alert! + before_action do + push_frontend_feature_flag(:alert_list_status_filtering_enabled) + push_frontend_feature_flag(:create_issue_from_alert_enabled) + end + + def index + end + + def details + @alert_id = params[:id] + end +end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 50399a8cfbb..b8663bc59f2 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -10,7 +10,7 @@ class Projects::ArtifactsController < Projects::ApplicationController before_action :authorize_update_build!, only: [:keep] before_action :authorize_destroy_artifacts!, only: [:destroy] before_action :extract_ref_name_and_path - before_action :validate_artifacts!, except: [:index, :download, :destroy] + before_action :validate_artifacts!, except: [:index, :download, :raw, :destroy] before_action :entry, only: [:file] MAX_PER_PAGE = 20 @@ -22,7 +22,7 @@ class Projects::ArtifactsController < Projects::ApplicationController # issues: https://gitlab.com/gitlab-org/gitlab/issues/32281 return head :no_content unless Feature.enabled?(:artifacts_management_page, @project) - finder = ArtifactsFinder.new(@project, artifacts_params) + finder = Ci::JobArtifactsFinder.new(@project, artifacts_params) all_artifacts = finder.execute @artifacts = all_artifacts.page(params[:page]).per(MAX_PER_PAGE) @@ -73,9 +73,11 @@ class Projects::ArtifactsController < Projects::ApplicationController end def raw + return render_404 unless zip_artifact? + path = Gitlab::Ci::Build::Artifacts::Path.new(params[:path]) - send_artifacts_entry(build, path) + send_artifacts_entry(artifacts_file, path) end def keep @@ -138,6 +140,13 @@ class Projects::ArtifactsController < Projects::ApplicationController @artifacts_file ||= build&.artifacts_file_for_type(params[:file_type] || :archive) end + def zip_artifact? + types = HashWithIndifferentAccess.new(Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS) + file_type = params[:file_type] || :archive + + types[file_type] == :zip + end + def entry @entry = build.artifacts_metadata_entry(params[:path]) diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 09754409104..cc595740696 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -186,7 +186,6 @@ class Projects::BranchesController < Projects::ApplicationController end def confidential_issue_project - return unless helpers.create_confidential_merge_request_enabled? return if params[:confidential_issue_project_id].blank? confidential_issue_project = Project.find(params[:confidential_issue_project_id]) diff --git a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb new file mode 100644 index 00000000000..dfda5fca310 --- /dev/null +++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class Projects::Ci::DailyBuildGroupReportResultsController < Projects::ApplicationController + include Gitlab::Utils::StrongMemoize + + MAX_ITEMS = 1000 + REPORT_WINDOW = 90.days + + before_action :validate_feature_flag! + before_action :authorize_download_code! # Share the same authorization rules as the graphs controller + before_action :validate_param_type! + + def index + respond_to do |format| + format.csv { send_data(render_csv(results), type: 'text/csv; charset=utf-8') } + end + end + + private + + def validate_feature_flag! + render_404 unless Feature.enabled?(:ci_download_daily_code_coverage, project, default_enabled: true) + end + + def validate_param_type! + respond_422 unless allowed_param_types.include?(param_type) + end + + def render_csv(collection) + CsvBuilders::SingleBatch.new( + collection, + { + date: 'date', + group_name: 'group_name', + param_type => -> (record) { record.data[param_type] } + } + ).render + end + + def results + Ci::DailyBuildGroupReportResultsFinder.new(finder_params).execute + end + + def finder_params + { + current_user: current_user, + project: project, + ref_path: params.require(:ref_path), + start_date: start_date, + end_date: end_date, + limit: MAX_ITEMS + } + end + + def start_date + strong_memoize(:start_date) do + start_date = Date.parse(params.require(:start_date)) + + # The start_date cannot be older than `end_date - 90 days` + [start_date, end_date - REPORT_WINDOW].max + end + end + + def end_date + strong_memoize(:end_date) do + Date.parse(params.require(:end_date)) + end + end + + def allowed_param_types + Ci::DailyBuildGroupReportResult::PARAM_TYPES + end + + def param_type + params.require(:param_type) + end +end diff --git a/app/controllers/projects/design_management/designs/raw_images_controller.rb b/app/controllers/projects/design_management/designs/raw_images_controller.rb new file mode 100644 index 00000000000..beb7e9d294b --- /dev/null +++ b/app/controllers/projects/design_management/designs/raw_images_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Returns full-size design images +module Projects + module DesignManagement + module Designs + class RawImagesController < Projects::DesignManagement::DesignsController + include SendsBlob + + skip_before_action :default_cache_headers, only: :show + + def show + blob = design_repository.blob_at(ref, design.full_path) + + send_blob(design_repository, blob, inline: false, allow_caching: project.public?) + end + + private + + def design_repository + @design_repository ||= project.design_repository + end + + def ref + sha || design_repository.root_ref + end + end + end + end +end diff --git a/app/controllers/projects/design_management/designs/resized_image_controller.rb b/app/controllers/projects/design_management/designs/resized_image_controller.rb new file mode 100644 index 00000000000..50a997f32db --- /dev/null +++ b/app/controllers/projects/design_management/designs/resized_image_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# Returns smaller sized design images +module Projects + module DesignManagement + module Designs + class ResizedImageController < Projects::DesignManagement::DesignsController + include SendFileUpload + + before_action :validate_size! + + skip_before_action :default_cache_headers, only: :show + + def show + relation = design.actions + relation = relation.up_to_version(sha) if sha + action = relation.most_recent.first + + return render_404 unless action + + # This controller returns a 404 if the the `size` param + # is not one of our specific sizes, so using `send` here is safe. + uploader = action.public_send(:"image_#{size}") # rubocop:disable GitlabSecurity/PublicSend + + return render_404 unless uploader.file # The image has not been processed + + if stale?(etag: action.cache_key) + workhorse_set_content_type! + + send_upload(uploader, attachment: design.filename) + end + end + + private + + def validate_size! + render_404 unless ::DesignManagement::DESIGN_IMAGE_SIZES.include?(size) + end + + def size + params[:id] + end + end + end + end +end diff --git a/app/controllers/projects/design_management/designs_controller.rb b/app/controllers/projects/design_management/designs_controller.rb new file mode 100644 index 00000000000..fec09fa9515 --- /dev/null +++ b/app/controllers/projects/design_management/designs_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Projects::DesignManagement::DesignsController < Projects::ApplicationController + before_action :authorize_read_design! + + private + + def authorize_read_design! + unless can?(current_user, :read_design, design) + access_denied! + end + end + + def design + @design ||= project.designs.find(params[:design_id]) + end + + def sha + params[:sha].presence + end +end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 09dc4d118a1..5f4d88c57e9 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -4,6 +4,12 @@ class Projects::EnvironmentsController < Projects::ApplicationController include MetricsDashboard layout 'project' + + before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do + authorize_metrics_dashboard! + + push_frontend_feature_flag(:prometheus_computed_alerts) + end before_action :authorize_read_environment! before_action :authorize_create_environment!, only: [:new, :create] before_action :authorize_stop_environment!, only: [:stop] @@ -12,10 +18,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics, :cancel_auto_stop] before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? } - before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do - push_frontend_feature_flag(:prometheus_computed_alerts) - push_frontend_feature_flag(:metrics_dashboard_annotations) - end after_action :expire_etag_cache, only: [:cancel_auto_stop] def index @@ -27,12 +29,13 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.html format.json do Gitlab::PollingInterval.set_header(response, interval: 3_000) + environments_count_by_state = project.environments.count_by_state render json: { environments: serialize_environments(request, response, params[:nested]), review_app: serialize_review_app, - available_count: project.environments.available.count, - stopped_count: project.environments.stopped.count + available_count: environments_count_by_state[:available], + stopped_count: environments_count_by_state[:stopped] } end end diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb index 889dcefb65a..34246f27241 100644 --- a/app/controllers/projects/graphs_controller.rb +++ b/app/controllers/projects/graphs_controller.rb @@ -28,6 +28,7 @@ class Projects::GraphsController < Projects::ApplicationController def charts get_commits get_languages + get_daily_coverage_options end def ci @@ -52,6 +53,27 @@ class Projects::GraphsController < Projects::ApplicationController end end + def get_daily_coverage_options + return unless Feature.enabled?(:ci_download_daily_code_coverage, default_enabled: true) + + date_today = Date.current + report_window = Projects::Ci::DailyBuildGroupReportResultsController::REPORT_WINDOW + + @daily_coverage_options = { + base_params: { + start_date: date_today - report_window, + end_date: date_today, + ref_path: @project.repository.expand_ref(@ref), + param_type: 'coverage' + }, + download_path: namespace_project_ci_daily_build_group_report_results_path( + namespace_id: @project.namespace, + project_id: @project, + format: :csv + ) + } + end + def fetch_graph @commits = @project.repository.commits(@ref, limit: 6000, skip_merges: true) @log = [] diff --git a/app/controllers/projects/import/jira_controller.rb b/app/controllers/projects/import/jira_controller.rb index 4a70ed45404..711e23dc3ce 100644 --- a/app/controllers/projects/import/jira_controller.rb +++ b/app/controllers/projects/import/jira_controller.rb @@ -36,7 +36,7 @@ module Projects response = ::JiraImport::StartImportService.new(current_user, @project, jira_project_key).execute flash[:notice] = response.message if response.message.present? else - flash[:alert] = 'No jira project key has been provided.' + flash[:alert] = 'No Jira project key has been provided.' end redirect_to project_import_jira_path(@project) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 3aae8990f07..3e9d956f7b1 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -21,7 +21,6 @@ class Projects::IssuesController < Projects::ApplicationController prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) } prepend_before_action :authenticate_user!, only: [:new, :export_csv] - # designs is only applicable to EE, but defining a prepend_before_action in EE code would overwrite this prepend_before_action :store_uri, only: [:new, :show, :designs] before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update] @@ -50,6 +49,10 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:save_issuable_health_status, project.group, default_enabled: true) end + before_action only: :show do + push_frontend_feature_flag(:real_time_issue_sidebar, @project) + end + around_action :allow_gitaly_ref_name_caching, only: [:discussions] respond_to :html @@ -81,11 +84,13 @@ class Projects::IssuesController < Projects::ApplicationController ) build_params = issue_params.merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], - discussion_to_resolve: params[:discussion_to_resolve] + discussion_to_resolve: params[:discussion_to_resolve], + confidential: !!Gitlab::Utils.to_boolean(params[:issue][:confidential]) ) service = Issues::BuildService.new(project, current_user, build_params) @issue = @noteable = service.execute + @merge_request_to_resolve_discussions_of = service.merge_request_to_resolve_discussions_of @discussion_to_resolve = service.discussions_to_resolve.first if params[:discussion_to_resolve] @@ -154,7 +159,10 @@ class Projects::IssuesController < Projects::ApplicationController end def related_branches - @related_branches = Issues::RelatedBranchesService.new(project, current_user).execute(issue) + @related_branches = Issues::RelatedBranchesService + .new(project, current_user) + .execute(issue) + .map { |branch| branch.merge(link: branch_link(branch)) } respond_to do |format| format.json do @@ -179,7 +187,7 @@ class Projects::IssuesController < Projects::ApplicationController def create_merge_request create_params = params.slice(:branch_name, :ref).merge(issue_iid: issue.iid) - create_params[:target_project_id] = params[:target_project_id] if helpers.create_confidential_merge_request_enabled? + create_params[:target_project_id] = params[:target_project_id] result = ::MergeRequests::CreateFromIssueService.new(project, current_user, create_params).execute if result[:status] == :success @@ -193,7 +201,8 @@ class Projects::IssuesController < Projects::ApplicationController ExportCsvWorker.perform_async(current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker index_path = project_issues_path(project) - redirect_to(index_path, notice: "Your CSV export has started. It will be emailed to #{current_user.notification_email} when complete.") + message = _('Your CSV export has started. It will be emailed to %{email} when complete.') % { email: current_user.notification_email } + redirect_to(index_path, notice: message) end def import_csv @@ -305,6 +314,10 @@ class Projects::IssuesController < Projects::ApplicationController private + def branch_link(branch) + project_compare_path(project, from: project.default_branch, to: branch[:name]) + end + def create_rate_limit key = :issues_create diff --git a/app/controllers/projects/mattermosts_controller.rb b/app/controllers/projects/mattermosts_controller.rb index 085b1bc1498..cfaeddf711a 100644 --- a/app/controllers/projects/mattermosts_controller.rb +++ b/app/controllers/projects/mattermosts_controller.rb @@ -30,7 +30,7 @@ class Projects::MattermostsController < Projects::ApplicationController def configure_params params.require(:mattermost).permit(:trigger, :team_id).merge( url: service_trigger_url(@service), - icon_url: asset_url('slash-command-logo.png')) + icon_url: asset_url('slash-command-logo.png', skip_pipeline: true)) end def teams diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 23222cbd37c..28aa1b300aa 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -16,7 +16,6 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap end def create - @target_branches ||= [] @merge_request = ::MergeRequests::CreateService.new(project, current_user, merge_request_params).execute if @merge_request.valid? @@ -97,13 +96,6 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap def define_new_vars @noteable = @merge_request - - @target_branches = if @merge_request.target_project - @merge_request.target_project.repository.branch_names - else - [] - end - @target_project = @merge_request.target_project @source_project = @merge_request.source_project diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 79598c0aaff..2331674f42c 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -9,6 +9,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic before_action :define_diff_vars before_action :define_diff_comment_vars, except: [:diffs_batch, :diffs_metadata] + around_action :allow_gitaly_ref_name_caching + def show render_diffs end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 8c37d70d4c9..5613b5b9589 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -14,7 +14,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo skip_before_action :merge_request, only: [:index, :bulk_update] before_action :whitelist_query_limiting, only: [:assign_related_issues, :update] before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort] - before_action :authorize_read_actual_head_pipeline!, only: [:test_reports, :exposed_artifacts, :coverage_reports] + before_action :authorize_read_actual_head_pipeline!, only: [ + :test_reports, + :exposed_artifacts, + :coverage_reports, + :terraform_reports, + :accessibility_reports + ] before_action :set_issuables_index, only: [:index] before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] @@ -26,7 +32,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:code_navigation, @project) push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true) push_frontend_feature_flag(:merge_ref_head_comments, @project) - push_frontend_feature_flag(:diff_compare_with_head, @project) + push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true) end before_action do @@ -136,6 +142,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo reports_response(@merge_request.compare_test_reports) end + def accessibility_reports + if @merge_request.has_accessibility_reports? + reports_response(@merge_request.compare_accessibility_reports) + else + head :no_content + end + end + def coverage_reports if @merge_request.has_coverage_reports? reports_response(@merge_request.find_coverage_reports) @@ -144,6 +158,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end end + def terraform_reports + reports_response(@merge_request.find_terraform_reports) + end + def exposed_artifacts if @merge_request.has_exposed_artifacts? reports_response(@merge_request.find_exposed_artifacts) @@ -353,7 +371,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def define_edit_vars @source_project = @merge_request.source_project @target_project = @merge_request.target_project - @target_branches = @merge_request.target_project.repository.branch_names @noteable = @merge_request # FIXME: We have to assign a presenter to another instance variable diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 726ce8974c7..678d0862f48 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -11,7 +11,9 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action do - push_frontend_feature_flag(:junit_pipeline_view) + push_frontend_feature_flag(:junit_pipeline_view, project) + push_frontend_feature_flag(:filter_pipelines_search, default_enabled: true) + push_frontend_feature_flag(:dag_pipeline_tab) end before_action :ensure_pipeline, only: [:show] @@ -22,9 +24,8 @@ class Projects::PipelinesController < Projects::ApplicationController POLLING_INTERVAL = 10_000 def index - @scope = params[:scope] @pipelines = Ci::PipelinesFinder - .new(project, current_user, scope: @scope) + .new(project, current_user, index_params) .execute .page(params[:page]) .per(30) @@ -69,6 +70,8 @@ class Projects::PipelinesController < Projects::ApplicationController end def show + Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/26657') + respond_to do |format| format.html format.json do @@ -91,6 +94,10 @@ class Projects::PipelinesController < Projects::ApplicationController render_show end + def dag + render_show + end + def failures if @pipeline.failed_builds.present? render_show @@ -169,19 +176,9 @@ class Projects::PipelinesController < Projects::ApplicationController end format.json do - if pipeline_test_report == :error - render json: { status: :error_parsing_report } - else - test_reports = if params[:scope] == "with_attachment" - pipeline_test_report.with_attachment! - else - pipeline_test_report - end - - render json: TestReportSerializer - .new(current_user: @current_user) - .represent(test_reports, project: project) - end + render json: TestReportSerializer + .new(current_user: @current_user) + .represent(pipeline_test_report, project: project) end end end @@ -189,11 +186,7 @@ class Projects::PipelinesController < Projects::ApplicationController def test_reports_count return unless Feature.enabled?(:junit_pipeline_view, project) - begin - render json: { total_count: pipeline.test_reports_count }.to_json - rescue Gitlab::Ci::Parsers::ParserError - render json: { total_count: 0 }.to_json - end + render json: { total_count: pipeline.test_reports_count }.to_json end private @@ -262,18 +255,22 @@ class Projects::PipelinesController < Projects::ApplicationController end def limited_pipelines_count(project, scope = nil) - finder = Ci::PipelinesFinder.new(project, current_user, scope: scope) + finder = Ci::PipelinesFinder.new(project, current_user, index_params.merge(scope: scope)) view_context.limited_counter_with_delimiter(finder.execute) end def pipeline_test_report strong_memoize(:pipeline_test_report) do - @pipeline.test_reports - rescue Gitlab::Ci::Parsers::ParserError - :error + @pipeline.test_reports.tap do |reports| + reports.with_attachment! if params[:scope] == 'with_attachment' + end end end + + def index_params + params.permit(:scope, :username, :ref) + end end Projects::PipelinesController.prepend_if_ee('EE::Projects::PipelinesController') diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 109c8b7005f..3e52248f292 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -17,8 +17,9 @@ class Projects::ProjectMembersController < Projects::ApplicationController @group_links = @project.project_group_links @group_links = @group_links.search(params[:search]) if params[:search].present? - @project_members = MembersFinder.new(@project, current_user) - .execute(include_relations: requested_relations, params: params.merge(sort: @sort)) + @project_members = MembersFinder + .new(@project, current_user, params: filter_params) + .execute(include_relations: requested_relations) @project_members = present_members(@project_members.page(params[:page])) @@ -43,12 +44,17 @@ class Projects::ProjectMembersController < Projects::ApplicationController return render_404 end - redirect_to(project_project_members_path(project), - notice: notice) + redirect_to(project_project_members_path(project), notice: notice) end # MembershipActions concern alias_method :membershipable, :project + + private + + def filter_params + params.permit(:search).merge(sort: @sort) + end end Projects::ProjectMembersController.prepend_if_ee('EE::Projects::ProjectMembersController') diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index 7c606bd8c45..fcbeb5c840c 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -12,7 +12,7 @@ class Projects::RefsController < Projects::ApplicationController before_action :authorize_download_code! before_action only: [:logs_tree] do - push_frontend_feature_flag(:vue_file_list_lfs_badge) + push_frontend_feature_flag(:vue_file_list_lfs_badge, default_enabled: true) end def switch @@ -44,30 +44,25 @@ class Projects::RefsController < Projects::ApplicationController end def logs_tree - summary = ::Gitlab::TreeSummary.new( - @commit, - @project, - path: @path, - offset: params[:offset], - limit: 25 - ) - - @logs, commits = summary.summarize - @more_log_url = more_url(summary.next_offset) if summary.more? + tree_summary = ::Gitlab::TreeSummary.new( + @commit, @project, current_user, + path: @path, offset: params[:offset], limit: 25) respond_to do |format| format.html { render_404 } format.json do - response.headers["More-Logs-Url"] = @more_log_url if summary.more? - response.headers["More-Logs-Offset"] = summary.next_offset if summary.more? - render json: @logs + logs, next_offset = tree_summary.fetch_logs + + response.headers["More-Logs-Offset"] = next_offset if next_offset + + render json: logs end - # The commit titles must be rendered and redacted before being shown. - # Doing it here allows us to apply performance optimizations that avoid - # N+1 problems + # Deprecated due to https://gitlab.com/gitlab-org/gitlab/-/issues/36863 + # Will be removed soon https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29895 format.js do - prerender_commit_full_titles!(commits) + @logs, _ = tree_summary.summarize + @more_log_url = more_url(tree_summary.next_offset) if tree_summary.more? end end end @@ -78,14 +73,6 @@ class Projects::RefsController < Projects::ApplicationController logs_file_project_ref_path(@project, @ref, @path, offset: offset) end - def prerender_commit_full_titles!(commits) - # Preload commit authors as they are used in rendering - commits.each(&:lazy_author) - - renderer = Banzai::ObjectRenderer.new(user: current_user, default_project: @project) - renderer.render(commits, :full_title) - end - def validate_ref_id return not_found! if params[:id].present? && params[:id] !~ Gitlab::PathRegex.git_reference_regex end diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index 2418ea97409..19d0cb9acdc 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -10,7 +10,8 @@ module Projects respond_to do |format| format.html format.json do - @images = project.container_repositories + @images = ContainerRepositoriesFinder.new(user: current_user, subject: project, params: params.slice(:name)) + .execute track_event(:list_repositories) diff --git a/app/controllers/projects/settings/access_tokens_controller.rb b/app/controllers/projects/settings/access_tokens_controller.rb new file mode 100644 index 00000000000..d6b4c4dd5dc --- /dev/null +++ b/app/controllers/projects/settings/access_tokens_controller.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Projects + module Settings + class AccessTokensController < Projects::ApplicationController + include ProjectsHelper + + before_action :check_feature_availability + + def index + @project_access_token = PersonalAccessToken.new + set_index_vars + end + + def create + token_response = ResourceAccessTokens::CreateService.new(current_user, @project, create_params).execute + + if token_response.success? + @project_access_token = token_response.payload[:access_token] + PersonalAccessToken.redis_store!(key_identity, @project_access_token.token) + + redirect_to namespace_project_settings_access_tokens_path, notice: _("Your new project access token has been created.") + else + render :index + end + end + + def revoke + @project_access_token = finder.find(params[:id]) + revoked_response = ResourceAccessTokens::RevokeService.new(current_user, @project, @project_access_token).execute + + if revoked_response.success? + flash[:notice] = _("Revoked project access token %{project_access_token_name}!") % { project_access_token_name: @project_access_token.name } + else + flash[:alert] = _("Could not revoke project access token %{project_access_token_name}.") % { project_access_token_name: @project_access_token.name } + end + + redirect_to namespace_project_settings_access_tokens_path + end + + private + + def check_feature_availability + render_404 unless project_access_token_available?(@project) + end + + def create_params + params.require(:project_access_token).permit(:name, :expires_at, scopes: []) + end + + def set_index_vars + @scopes = Gitlab::Auth.resource_bot_scopes + @active_project_access_tokens = finder(state: 'active').execute + @inactive_project_access_tokens = finder(state: 'inactive', sort: 'expires_at_asc').execute + @new_project_access_token = PersonalAccessToken.redis_getdel(key_identity) + end + + def finder(options = {}) + PersonalAccessTokensFinder.new({ user: bot_users, impersonation: false }.merge(options)) + end + + def bot_users + @project.bots + end + + def key_identity + "#{current_user.id}:#{@project.id}" + end + end + end +end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index 0aa55dcc5b9..35ca9336613 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -89,7 +89,7 @@ module Projects end def deploy_token_params - params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :username) + params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :read_package_registry, :write_package_registry, :username) end def access_levels_options diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index da0e3a44f05..9233f063f55 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -52,15 +52,8 @@ class Projects::SnippetsController < Projects::ApplicationController create_params = snippet_params.merge(spammable_params) service_response = Snippets::CreateService.new(project, current_user, create_params).execute @snippet = service_response.payload[:snippet] - repository_operation_error = service_response.error? && !@snippet.persisted? && @snippet.valid? - if repository_operation_error - flash.now[:alert] = service_response.message - - render :new - else - recaptcha_check_with_fallback { render :new } - end + handle_repository_error(:new) end def update @@ -69,7 +62,7 @@ class Projects::SnippetsController < Projects::ApplicationController service_response = Snippets::UpdateService.new(project, current_user, update_params).execute(@snippet) @snippet = service_response.payload[:snippet] - check_repository_error + handle_repository_error(:edit) end def show diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index b8fe2a47b30..9cb345724cc 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -16,7 +16,7 @@ class Projects::TreeController < Projects::ApplicationController before_action :authorize_edit_tree!, only: [:create_dir] before_action only: [:show] do - push_frontend_feature_flag(:vue_file_list_lfs_badge) + push_frontend_feature_flag(:vue_file_list_lfs_badge, default_enabled: true) end def show diff --git a/app/controllers/projects/usage_ping_controller.rb b/app/controllers/projects/usage_ping_controller.rb index ebdf28bd59c..0e2c8f879b1 100644 --- a/app/controllers/projects/usage_ping_controller.rb +++ b/app/controllers/projects/usage_ping_controller.rb @@ -10,4 +10,10 @@ class Projects::UsagePingController < Projects::ApplicationController head(200) end + + def web_ide_pipelines_count + Gitlab::UsageDataCounters::WebIdeCounter.increment_pipelines_count + + head(200) + end end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 90ff798077a..508b1f5bd0a 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -10,8 +10,9 @@ class Projects::WikisController < Projects::ApplicationController before_action :authorize_admin_wiki!, only: :destroy before_action :load_project_wiki before_action :load_page, only: [:show, :edit, :update, :history, :destroy] - before_action :valid_encoding?, - if: -> { %w[show edit update].include?(action_name) && load_page } + before_action only: [:show, :edit, :update] do + @valid_encoding = valid_encoding? + end before_action only: [:edit, :update], unless: :valid_encoding? do redirect_to(project_wiki_path(@project, @page)) end @@ -64,7 +65,7 @@ class Projects::WikisController < Projects::ApplicationController def update return render('empty') unless can?(current_user, :create_wiki, @project) - @page = WikiPages::UpdateService.new(@project, current_user, wiki_params).execute(@page) + @page = WikiPages::UpdateService.new(container: @project, current_user: current_user, params: wiki_params).execute(@page) if @page.valid? redirect_to( @@ -80,7 +81,7 @@ class Projects::WikisController < Projects::ApplicationController end def create - @page = WikiPages::CreateService.new(@project, current_user, wiki_params).execute + @page = WikiPages::CreateService.new(container: @project, current_user: current_user, params: wiki_params).execute if @page.persisted? redirect_to( @@ -111,7 +112,7 @@ class Projects::WikisController < Projects::ApplicationController end def destroy - WikiPages::DestroyService.new(@project, current_user).execute(@page) + WikiPages::DestroyService.new(container: @project, current_user: current_user).execute(@page) redirect_to project_wiki_path(@project, :home), status: :found, @@ -144,7 +145,7 @@ class Projects::WikisController < Projects::ApplicationController @sidebar_page = @project_wiki.find_sidebar(params[:version_id]) unless @sidebar_page # Fallback to default sidebar - @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.list_pages(limit: 15)) + @sidebar_wiki_entries, @sidebar_limited = @project_wiki.sidebar_entries end rescue ProjectWiki::CouldNotCreateWikiError flash[:notice] = _("Could not create Wiki Repository at this time. Please try again later.") @@ -167,7 +168,11 @@ class Projects::WikisController < Projects::ApplicationController end def load_page - @page ||= @project_wiki.find_page(*page_params) + @page ||= find_page + end + + def find_page + @project_wiki.find_page(*page_params) end def page_params @@ -178,9 +183,11 @@ class Projects::WikisController < Projects::ApplicationController end def valid_encoding? - strong_memoize(:valid_encoding) do - @page.content.encoding == Encoding::UTF_8 - end + page_encoding == Encoding::UTF_8 + end + + def page_encoding + strong_memoize(:page_encoding) { @page&.content&.encoding } end def set_encoding_error diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index bb20ea1de49..2f86b945b06 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -36,10 +36,6 @@ class ProjectsController < Projects::ApplicationController layout :determine_layout - before_action do - push_frontend_feature_flag(:metrics_dashboard_visibility_switching_available) - end - def index redirect_to(current_user ? root_path : explore_root_path) end @@ -62,7 +58,7 @@ class ProjectsController < Projects::ApplicationController @project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute if @project.saved? - cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.at(0) } + cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.zone.at(0) } redirect_to( project_path(@project, custom_import_params), @@ -205,7 +201,7 @@ class ProjectsController < Projects::ApplicationController redirect_to( edit_project_path(@project, anchor: 'js-export-project'), - notice: _("Project export started. A download link will be sent by email.") + notice: _("Project export started. A download link will be sent by email and made available on this page.") ) end @@ -403,6 +399,10 @@ class ProjectsController < Projects::ApplicationController snippets_access_level wiki_access_level pages_access_level + metrics_dashboard_access_level + ], + project_setting_attributes: %i[ + show_default_award_emojis ] ] end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index a6c5a6d8526..ffbccbb01f2 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -8,7 +8,7 @@ class RegistrationsController < Devise::RegistrationsController layout :choose_layout - skip_before_action :required_signup_info, only: [:welcome, :update_registration] + skip_before_action :required_signup_info, :check_two_factor_requirement, only: [:welcome, :update_registration] prepend_before_action :check_captcha, only: :create before_action :whitelist_query_limiting, only: [:destroy] before_action :ensure_terms_accepted, @@ -137,7 +137,6 @@ class RegistrationsController < Devise::RegistrationsController def check_captcha ensure_correct_params! - return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true) # reCAPTCHA on the UI will still display however return unless show_recaptcha_sign_up? return unless Gitlab::Recaptcha.load_configurations! diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb index 118036de230..e3dbe6fcbdf 100644 --- a/app/controllers/repositories/git_http_controller.rb +++ b/app/controllers/repositories/git_http_controller.rb @@ -4,7 +4,6 @@ module Repositories class GitHttpController < Repositories::GitHttpClientController include WorkhorseRequest - before_action :snippet_request_allowed? before_action :access_check prepend_before_action :deny_head_requests, only: [:info_refs] @@ -121,13 +120,6 @@ module Repositories def log_user_activity Users::ActivityService.new(user).execute end - - def snippet_request_allowed? - if repo_type.snippet? && Feature.disabled?(:version_snippets, user) - Gitlab::AppLogger.info('Snippet access attempt with feature disabled') - render plain: 'Snippet git access is disabled.', status: :forbidden - end - end end end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index d1e15a72350..04d2b3068da 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -5,7 +5,6 @@ class SearchController < ApplicationController include SearchHelper include RendersCommits - before_action :override_snippet_scope, only: :show around_action :allow_gitaly_ref_name_caching skip_before_action :authenticate_user! @@ -104,14 +103,4 @@ class SearchController < ApplicationController Gitlab::UsageDataCounters::SearchCounter.increment_navbar_searches_count end - - # Disallow web snippet_blobs search as we migrate snippet - # from database-backed storage to git repository-based, - # and searching across multiple git repositories is not feasible. - # - # TODO: after 13.0 refactor this into Search::SnippetService - # See https://gitlab.com/gitlab-org/gitlab/issues/208882 - def override_snippet_scope - params[:scope] = 'snippet_titles' if params[:snippets] == 'true' - end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 2c87c3c890f..9e8075d4bcc 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -6,6 +6,8 @@ class SessionsController < Devise::SessionsController include Devise::Controllers::Rememberable include Recaptcha::ClientHelper include Recaptcha::Verify + include RendersLdapServers + include KnownSignIn skip_before_action :check_two_factor_requirement, only: [:destroy] # replaced with :require_no_authentication_without_flash @@ -16,7 +18,6 @@ class SessionsController < Devise::SessionsController if: -> { action_name == 'create' && two_factor_enabled? } prepend_before_action :check_captcha, only: [:create] prepend_before_action :store_redirect_uri, only: [:new] - prepend_before_action :ldap_servers, only: [:new, :create] prepend_before_action :require_no_authentication_without_flash, only: [:new, :create] prepend_before_action :ensure_password_authentication_enabled!, if: -> { action_name == 'create' && password_based_login? } @@ -27,6 +28,7 @@ class SessionsController < Devise::SessionsController before_action :frontend_tracking_data, only: [:new] after_action :log_failed_login, if: :action_new_and_failed_login? + after_action :verify_known_sign_in, only: [:create] helper_method :captcha_enabled?, :captcha_on_login_required? @@ -269,16 +271,6 @@ class SessionsController < Devise::SessionsController Gitlab::Recaptcha.load_configurations! end - def ldap_servers - @ldap_servers ||= begin - if Gitlab::Auth::Ldap::Config.sign_in_enabled? - Gitlab::Auth::Ldap::Config.available_servers - else - [] - end - end - end - def unverified_anonymous_user? exceeded_failed_login_attempts? || exceeded_anonymous_sessions? end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index a07baa1a045..425e0458b41 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -49,29 +49,22 @@ class SnippetsController < ApplicationController end def create - create_params = snippet_params.merge(spammable_params) + create_params = snippet_params.merge(files: params.delete(:files)) service_response = Snippets::CreateService.new(nil, current_user, create_params).execute @snippet = service_response.payload[:snippet] - repository_operation_error = service_response.error? && !@snippet.persisted? && @snippet.valid? - if repository_operation_error - flash.now[:alert] = service_response.message - - render :new + if service_response.error? && @snippet.errors[:repository].present? + handle_repository_error(:new) else - move_temporary_files if @snippet.valid? && params[:files] - recaptcha_check_with_fallback { render :new } end end def update - update_params = snippet_params.merge(spammable_params) - - service_response = Snippets::UpdateService.new(nil, current_user, update_params).execute(@snippet) + service_response = Snippets::UpdateService.new(nil, current_user, snippet_params).execute(@snippet) @snippet = service_response.payload[:snippet] - check_repository_error + handle_repository_error(:edit) end def show @@ -153,12 +146,6 @@ class SnippetsController < ApplicationController end def snippet_params - params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description) - end - - def move_temporary_files - params[:files].each do |file| - FileMover.new(file, from_model: current_user, to_model: @snippet).execute - end + params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description).merge(spammable_params) end end diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb index 4ee75218db1..06f422b9d90 100644 --- a/app/controllers/user_callouts_controller.rb +++ b/app/controllers/user_callouts_controller.rb @@ -5,7 +5,7 @@ class UserCalloutsController < ApplicationController callout = ensure_callout if callout.persisted? - callout.update(dismissed_at: Time.now) + callout.update(dismissed_at: Time.current) respond_to do |format| format.json { head :ok } end diff --git a/app/finders/alert_management/alerts_finder.rb b/app/finders/alert_management/alerts_finder.rb new file mode 100644 index 00000000000..cb35be43c15 --- /dev/null +++ b/app/finders/alert_management/alerts_finder.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module AlertManagement + class AlertsFinder + # @return [Hash<Integer,Integer>] Mapping of status id to count + # ex) { 0: 6, ...etc } + def self.counts_by_status(current_user, project, params = {}) + new(current_user, project, params).execute.counts_by_status + end + + def initialize(current_user, project, params) + @current_user = current_user + @project = project + @params = params + end + + def execute + return AlertManagement::Alert.none unless authorized? + + collection = project.alert_management_alerts + collection = by_status(collection) + collection = by_search(collection) + collection = by_iid(collection) + sort(collection) + end + + private + + attr_reader :current_user, :project, :params + + def by_iid(collection) + return collection unless params[:iid] + + collection.for_iid(params[:iid]) + end + + def by_status(collection) + values = AlertManagement::Alert::STATUSES.values & Array(params[:status]) + + values.present? ? collection.for_status(values) : collection + end + + def by_search(collection) + params[:search].present? ? collection.search(params[:search]) : collection + end + + def sort(collection) + params[:sort] ? collection.sort_by_attribute(params[:sort]) : collection + end + + def authorized? + Ability.allowed?(current_user, :read_alert_management_alert, project) + end + end +end diff --git a/app/finders/artifacts_finder.rb b/app/finders/artifacts_finder.rb deleted file mode 100644 index 81c5168d782..00000000000 --- a/app/finders/artifacts_finder.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -class ArtifactsFinder - def initialize(project, params = {}) - @project = project - @params = params - end - - def execute - artifacts = @project.job_artifacts - - sort(artifacts) - end - - private - - def sort_key - @params[:sort] || 'created_desc' - end - - def sort(artifacts) - artifacts.order_by(sort_key) - end -end diff --git a/app/finders/ci/daily_build_group_report_results_finder.rb b/app/finders/ci/daily_build_group_report_results_finder.rb new file mode 100644 index 00000000000..3c3c24c1479 --- /dev/null +++ b/app/finders/ci/daily_build_group_report_results_finder.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Ci + class DailyBuildGroupReportResultsFinder + include Gitlab::Allowable + + def initialize(current_user:, project:, ref_path:, start_date:, end_date:, limit: nil) + @current_user = current_user + @project = project + @ref_path = ref_path + @start_date = start_date + @end_date = end_date + @limit = limit + end + + def execute + return none unless can?(current_user, :download_code, project) + + Ci::DailyBuildGroupReportResult.recent_results( + { + project_id: project, + ref_path: ref_path, + date: start_date..end_date + }, + limit: @limit + ) + end + + private + + attr_reader :current_user, :project, :ref_path, :start_date, :end_date + + def none + Ci::DailyBuildGroupReportResult.none + end + end +end diff --git a/app/finders/ci/job_artifacts_finder.rb b/app/finders/ci/job_artifacts_finder.rb new file mode 100644 index 00000000000..808c159ced1 --- /dev/null +++ b/app/finders/ci/job_artifacts_finder.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Ci + class JobArtifactsFinder + def initialize(project, params = {}) + @project = project + @params = params + end + + def execute + artifacts = @project.job_artifacts + + sort(artifacts) + end + + private + + def sort_key + @params[:sort] || 'created_desc' + end + + def sort(artifacts) + artifacts.order_by(sort_key) + end + end +end diff --git a/app/finders/clusters/knative_services_finder.rb b/app/finders/clusters/knative_services_finder.rb index 71cebe4495e..af8c42f672f 100644 --- a/app/finders/clusters/knative_services_finder.rb +++ b/app/finders/clusters/knative_services_finder.rb @@ -11,6 +11,7 @@ module Clusters }.freeze self.reactive_cache_key = ->(finder) { finder.model_name } + self.reactive_cache_work_type = :external_dependency self.reactive_cache_worker_finder = ->(_id, *cache_args) { from_cache(*cache_args) } attr_reader :cluster, :environment diff --git a/app/finders/container_repositories_finder.rb b/app/finders/container_repositories_finder.rb index 34921df840b..5109efb361b 100644 --- a/app/finders/container_repositories_finder.rb +++ b/app/finders/container_repositories_finder.rb @@ -3,17 +3,18 @@ class ContainerRepositoriesFinder VALID_SUBJECTS = [Group, Project].freeze - def initialize(user:, subject:) + def initialize(user:, subject:, params: {}) @user = user @subject = subject + @params = params end def execute raise ArgumentError, "invalid subject_type" unless valid_subject_type? return unless authorized? - return project_repositories if @subject.is_a?(Project) - return group_repositories if @subject.is_a?(Group) + repositories = @subject.is_a?(Project) ? project_repositories : group_repositories + filter_by_image_name(repositories) end private @@ -32,6 +33,12 @@ class ContainerRepositoriesFinder ContainerRepository.for_group_and_its_subgroups(@subject) end + def filter_by_image_name(repositories) + return repositories unless @params[:name] + + repositories.search_by_name(@params[:name]) + end + def authorized? Ability.allowed?(@user, :read_container_image, @subject) end diff --git a/app/finders/design_management/designs_finder.rb b/app/finders/design_management/designs_finder.rb new file mode 100644 index 00000000000..10f95520d1e --- /dev/null +++ b/app/finders/design_management/designs_finder.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module DesignManagement + class DesignsFinder + include Gitlab::Allowable + + # Params: + # ids: integer[] + # filenames: string[] + # visible_at_version: ?version + # filenames: String[] + def initialize(issue, current_user, params = {}) + @issue = issue + @current_user = current_user + @params = params + end + + def execute + items = init_collection + + items = by_visible_at_version(items) + items = by_filename(items) + items = by_id(items) + + items + end + + private + + attr_reader :issue, :current_user, :params + + def init_collection + return ::DesignManagement::Design.none unless can?(current_user, :read_design, issue) + + issue.designs + end + + # Returns all designs that existed at a particular design version + def by_visible_at_version(items) + items.visible_at_version(params[:visible_at_version]) + end + + def by_filename(items) + return items if params[:filenames].nil? + return ::DesignManagement::Design.none if params[:filenames].empty? + + items.with_filename(params[:filenames]) + end + + def by_id(items) + return items if params[:ids].nil? + return ::DesignManagement::Design.none if params[:ids].empty? + + items.id_in(params[:ids]) + end + end +end diff --git a/app/finders/design_management/versions_finder.rb b/app/finders/design_management/versions_finder.rb new file mode 100644 index 00000000000..c4aefd3078e --- /dev/null +++ b/app/finders/design_management/versions_finder.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module DesignManagement + class VersionsFinder + attr_reader :design_or_collection, :current_user, :params + + # The `design_or_collection` argument should be either a: + # + # - DesignManagement::Design, or + # - DesignManagement::DesignCollection + # + # The object will have `#versions` called on it to set up the + # initial scope of the versions. + # + # valid params: + # - earlier_or_equal_to: Version + # - sha: String + # - version_id: Integer + # + def initialize(design_or_collection, current_user, params = {}) + @design_or_collection = design_or_collection + @current_user = current_user + @params = params + end + + def execute + unless Ability.allowed?(current_user, :read_design, design_or_collection) + return ::DesignManagement::Version.none + end + + items = design_or_collection.versions + items = by_earlier_or_equal_to(items) + items = by_sha(items) + items = by_version_id(items) + items.ordered + end + + private + + def by_earlier_or_equal_to(items) + return items unless params[:earlier_or_equal_to] + + items.earlier_or_equal_to(params[:earlier_or_equal_to]) + end + + def by_version_id(items) + return items unless params[:version_id] + + items.id_in(params[:version_id]) + end + + def by_sha(items) + return items unless params[:sha] + + items.by_sha(params[:sha]) + end + end +end diff --git a/app/finders/freeze_periods_finder.rb b/app/finders/freeze_periods_finder.rb new file mode 100644 index 00000000000..2a9bfbe12ba --- /dev/null +++ b/app/finders/freeze_periods_finder.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class FreezePeriodsFinder + def initialize(project, current_user = nil) + @project = project + @current_user = current_user + end + + def execute + return Ci::FreezePeriod.none unless Ability.allowed?(@current_user, :read_freeze_period, @project) + + @project.freeze_periods + end +end diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index a56d4ebb368..949af103eb3 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -9,7 +9,6 @@ class GroupMembersFinder < UnionFinder # search: string # created_after: datetime # created_before: datetime - attr_reader :params def initialize(group, user = nil, params: {}) @@ -22,7 +21,6 @@ class GroupMembersFinder < UnionFinder def execute(include_relations: [:inherited, :direct]) group_members = group.members relations = [] - @params = params return group_members if include_relations == [:direct] diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 5687b375cf0..7014f2ec205 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -40,7 +40,7 @@ class IssuableFinder requires_cross_project_access unless: -> { params.project? } - NEGATABLE_PARAMS_HELPER_KEYS = %i[include_subgroups in].freeze + NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze attr_accessor :current_user, :params @@ -68,7 +68,7 @@ class IssuableFinder # This should not be used in controller strong params! def negatable_scalar_params - @negatable_scalar_params ||= scalar_params + %i[project_id group_id] + @negatable_scalar_params ||= scalar_params - %i[search in] end # This should not be used in controller strong params! @@ -100,7 +100,7 @@ class IssuableFinder items = filter_items(items) # Let's see if we have to negate anything - items = by_negation(items) + items = filter_negated_items(items) # This has to be last as we use a CTE as an optimization fence # for counts by passing the force_cte param and enabling the @@ -132,6 +132,22 @@ class IssuableFinder by_my_reaction_emoji(items) end + # Negates all params found in `negatable_params` + def filter_negated_items(items) + return items unless Feature.enabled?(:not_issuable_queries, params.group || params.project, default_enabled: true) + + # API endpoints send in `nil` values so we test if there are any non-nil + return items unless not_params.present? && not_params.values.any? + + items = by_negated_author(items) + items = by_negated_assignee(items) + items = by_negated_label(items) + items = by_negated_milestone(items) + items = by_negated_release(items) + items = by_negated_my_reaction_emoji(items) + by_negated_iids(items) + end + def row_count Gitlab::IssuablesCountForState.new(self).for_state_or_opened(params[:state]) end @@ -189,6 +205,21 @@ class IssuableFinder private + def not_params + strong_memoize(:not_params) do + params_class.new(params[:not].dup, current_user, klass).tap do |not_params| + next unless not_params.present? + + # These are "helper" params that modify the results, like :in and :search. They usually come in at the top-level + # params, but if they do come in inside the `:not` params, the inner ones should take precedence. + not_helpers = params.slice(*NEGATABLE_PARAMS_HELPER_KEYS).merge(params[:not].slice(*NEGATABLE_PARAMS_HELPER_KEYS)) + not_helpers.each do |key, value| + not_params[key] = value unless not_params[key].present? + end + end + end + end + def force_cte? !!params[:force_cte] end @@ -215,33 +246,6 @@ class IssuableFinder klass.available_states.key(value) end - # Negates all params found in `negatable_params` - # rubocop: disable CodeReuse/ActiveRecord - def by_negation(items) - not_params = params[:not].dup - # API endpoints send in `nil` values so we test if there are any non-nil - return items unless not_params.present? && not_params.values.any? - - not_params.keep_if { |_k, v| v.present? }.each do |(key, value)| - # These aren't negatable params themselves, but rather help other searches, so we skip them. - # They will be added into all the NOT searches. - next if NEGATABLE_PARAMS_HELPER_KEYS.include?(key.to_sym) - next unless self.class.negatable_params.include?(key.to_sym) - - # These are "helper" params that are required inside the NOT to get the right results. They usually come in - # at the top-level params, but if they do come in inside the `:not` params, they should take precedence. - not_helpers = params.slice(*NEGATABLE_PARAMS_HELPER_KEYS).merge(params[:not].slice(*NEGATABLE_PARAMS_HELPER_KEYS)) - not_param = { key => value }.with_indifferent_access.merge(not_helpers).merge(not_query: true) - - items_to_negate = self.class.new(current_user, not_param).execute - - items = items.where.not(id: items_to_negate) - end - - items - end - # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def by_scope(items) return items.none if params.current_user_related? && !current_user @@ -326,6 +330,12 @@ class IssuableFinder # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord + def by_negated_iids(items) + not_params[:iids].present? ? items.where.not(iid: not_params[:iids]) : items + end + # rubocop: enable CodeReuse/ActiveRecord + + # rubocop: disable CodeReuse/ActiveRecord def sort(items) # Ensure we always have an explicit sort order (instead of inheriting # multiple orders when combining ActiveRecord::Relation objects). @@ -347,9 +357,19 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord - def by_assignee(items) - return items.assigned_to(params.assignees) if not_query? && params.assignees.any? + # rubocop: disable CodeReuse/ActiveRecord + def by_negated_author(items) + if not_params.author + items.where.not(author_id: not_params.author.id) + elsif not_params.author_id? || not_params.author_username? # author not found + items.none + else + items + end + end + # rubocop: enable CodeReuse/ActiveRecord + def by_assignee(items) if params.filter_by_no_assignee? items.unassigned elsif params.filter_by_any_assignee? @@ -363,6 +383,17 @@ class IssuableFinder end end + def by_negated_assignee(items) + # We want CE users to be able to say "Issues not assigned to either PersonA nor PersonB" + if not_params.assignees.present? + items.not_assigned_to(not_params.assignees) + elsif not_params.assignee_id? || not_params.assignee_username? # assignee not found + items.none + else + items + end + end + # rubocop: disable CodeReuse/ActiveRecord def by_milestone(items) return items unless params.milestones? @@ -382,6 +413,20 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord + # rubocop: disable CodeReuse/ActiveRecord + def by_negated_milestone(items) + return items unless not_params.milestones? + + if not_params.filter_by_upcoming_milestone? + items.joins(:milestone).merge(Milestone.not_upcoming) + elsif not_params.filter_by_started_milestone? + items.joins(:milestone).merge(Milestone.not_started) + else + items.without_particular_milestone(not_params[:milestone_title]) + end + end + # rubocop: enable CodeReuse/ActiveRecord + def by_release(items) return items unless params.releases? @@ -394,6 +439,12 @@ class IssuableFinder end end + def by_negated_release(items) + return items unless not_params.releases? + + items.without_particular_release(not_params[:release_tag], not_params[:project_id]) + end + def by_label(items) return items unless params.labels? @@ -402,10 +453,16 @@ class IssuableFinder elsif params.filter_by_any_label? items.any_label else - items.with_label(params.label_names, params[:sort], not_query: not_query?) + items.with_label(params.label_names, params[:sort]) end end + def by_negated_label(items) + return items unless not_params.labels? + + items.without_particular_labels(not_params.label_names) + end + def by_my_reaction_emoji(items) return items unless params[:my_reaction_emoji] && current_user @@ -418,11 +475,13 @@ class IssuableFinder end end - def by_non_archived(items) - params[:non_archived].present? ? items.non_archived : items + def by_negated_my_reaction_emoji(items) + return items unless not_params[:my_reaction_emoji] && current_user + + items.not_awarded(current_user, not_params[:my_reaction_emoji]) end - def not_query? - !!params[:not_query] + def by_non_archived(items) + params[:non_archived].present? ? items.non_archived : items end end diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb index 120ef364368..adf9f1ca9d8 100644 --- a/app/finders/issuable_finder/params.rb +++ b/app/finders/issuable_finder/params.rb @@ -132,6 +132,8 @@ class IssuableFinder def project strong_memoize(:project) do + next nil unless params[:project_id].present? + project = Project.find(params[:project_id]) project = nil unless Ability.allowed?(current_user, :"read_#{klass.to_ability_name}", project) diff --git a/app/finders/issues_finder/params.rb b/app/finders/issues_finder/params.rb index aaeead7c709..cd92b79265d 100644 --- a/app/finders/issues_finder/params.rb +++ b/app/finders/issues_finder/params.rb @@ -50,4 +50,4 @@ class IssuesFinder end end -IssuableFinder::Params.prepend_if_ee('EE::IssuesFinder::Params') +IssuesFinder::Params.prepend_if_ee('EE::IssuesFinder::Params') diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index 0617f34dc8c..e08ed737ca6 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -4,17 +4,19 @@ class MembersFinder # Params can be any of the following: # sort: string # search: string + attr_reader :params - def initialize(project, current_user) + def initialize(project, current_user, params: {}) @project = project - @current_user = current_user @group = project.group + @current_user = current_user + @params = params end - def execute(include_relations: [:inherited, :direct], params: {}) - members = find_members(include_relations, params) + def execute(include_relations: [:inherited, :direct]) + members = find_members(include_relations) - filter_members(members, params) + filter_members(members) end def can?(*args) @@ -25,7 +27,7 @@ class MembersFinder attr_reader :project, :current_user, :group - def find_members(include_relations, params) + def find_members(include_relations) project_members = project.project_members project_members = project_members.non_invite unless can?(current_user, :admin_project, project) @@ -39,7 +41,7 @@ class MembersFinder distinct_union_of_members(union_members) end - def filter_members(members, params) + def filter_members(members) members = members.search(params[:search]) if params[:search].present? members = members.sort_by_attribute(params[:sort]) if params[:sort].present? members diff --git a/app/finders/metrics/users_starred_dashboards_finder.rb b/app/finders/metrics/users_starred_dashboards_finder.rb new file mode 100644 index 00000000000..7244c51f9a7 --- /dev/null +++ b/app/finders/metrics/users_starred_dashboards_finder.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Metrics + class UsersStarredDashboardsFinder + def initialize(user:, project:, params: {}) + @user, @project, @params = user, project, params + end + + def execute + return ::Metrics::UsersStarredDashboard.none unless Ability.allowed?(user, :read_metrics_user_starred_dashboard, project) + + items = starred_dashboards + items = by_project(items) + by_dashboard(items) + end + + private + + attr_reader :user, :project, :params + + def by_project(items) + items.for_project(project) + end + + def by_dashboard(items) + return items unless params[:dashboard_path] + + items.merge(starred_dashboards.for_project_dashboard(project, params[:dashboard_path])) + end + + def starred_dashboards + @starred_dashboards ||= user.metrics_users_starred_dashboards + end + end +end diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb index 3b4ecbb5387..13f84e0e3a5 100644 --- a/app/finders/projects/serverless/functions_finder.rb +++ b/app/finders/projects/serverless/functions_finder.rb @@ -9,6 +9,7 @@ module Projects attr_reader :project self.reactive_cache_key = ->(finder) { finder.cache_key } + self.reactive_cache_work_type = :external_dependency self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } MAX_CLUSTERS = 10 diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 3a84600b09f..8846ff54eb2 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -151,11 +151,11 @@ class ProjectsFinder < UnionFinder end def by_personal(items) - (params[:personal].present? && current_user) ? items.personal(current_user) : items + params[:personal].present? && current_user ? items.personal(current_user) : items end def by_starred(items) - (params[:starred].present? && current_user) ? items.starred_by(current_user) : items + params[:starred].present? && current_user ? items.starred_by(current_user) : items end def by_trending(items) diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb index e58a90922a5..6a754fdb5a1 100644 --- a/app/finders/releases_finder.rb +++ b/app/finders/releases_finder.rb @@ -1,17 +1,31 @@ # frozen_string_literal: true class ReleasesFinder - def initialize(project, current_user = nil) + attr_reader :project, :current_user, :params + + def initialize(project, current_user = nil, params = {}) @project = project @current_user = current_user + @params = params end def execute(preload: true) - return Release.none unless Ability.allowed?(@current_user, :read_release, @project) + return Release.none unless Ability.allowed?(current_user, :read_release, project) # See https://gitlab.com/gitlab-org/gitlab/-/issues/211988 - releases = @project.releases.where.not(tag: nil) # rubocop:disable CodeReuse/ActiveRecord + releases = project.releases.where.not(tag: nil) # rubocop:disable CodeReuse/ActiveRecord + releases = by_tag(releases) releases = releases.preloaded if preload releases.sorted end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def by_tag(releases) + return releases unless params[:tag].present? + + releases.where(tag: params[:tag]) + end + # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index e56009be33d..672bbd52b07 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -23,7 +23,7 @@ class TodosFinder NONE = '0' - TODO_TYPES = Set.new(%w(Issue MergeRequest)).freeze + TODO_TYPES = Set.new(%w(Issue MergeRequest DesignManagement::Design)).freeze attr_accessor :current_user, :params diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb new file mode 100644 index 00000000000..ca2057d4845 --- /dev/null +++ b/app/graphql/mutations/alert_management/base.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Mutations + module AlertManagement + class Base < BaseMutation + include Mutations::ResolvesProject + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: "The project the alert to mutate is in" + + argument :iid, GraphQL::STRING_TYPE, + required: true, + description: "The iid of the alert to mutate" + + field :alert, + Types::AlertManagement::AlertType, + null: true, + description: "The alert after mutation" + + field :issue, + Types::IssueType, + null: true, + description: "The issue created after mutation" + + authorize :update_alert_management_alert + + private + + def find_object(project_path:, iid:) + project = resolve_project(full_path: project_path) + + return unless project + + resolver = Resolvers::AlertManagementAlertResolver.single.new(object: project, context: context, field: nil) + resolver.resolve(iid: iid) + end + end + end +end diff --git a/app/graphql/mutations/alert_management/create_alert_issue.rb b/app/graphql/mutations/alert_management/create_alert_issue.rb new file mode 100644 index 00000000000..adb048a4479 --- /dev/null +++ b/app/graphql/mutations/alert_management/create_alert_issue.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module AlertManagement + class CreateAlertIssue < Base + graphql_name 'CreateAlertIssue' + + def resolve(args) + alert = authorized_find!(project_path: args[:project_path], iid: args[:iid]) + result = create_alert_issue(alert, current_user) + + prepare_response(alert, result) + end + + private + + def create_alert_issue(alert, user) + ::AlertManagement::CreateAlertIssueService.new(alert, user).execute + end + + def prepare_response(alert, result) + { + alert: alert, + issue: result.payload[:issue], + errors: Array(result.message) + } + end + end + end +end diff --git a/app/graphql/mutations/alert_management/update_alert_status.rb b/app/graphql/mutations/alert_management/update_alert_status.rb new file mode 100644 index 00000000000..e73a591378a --- /dev/null +++ b/app/graphql/mutations/alert_management/update_alert_status.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Mutations + module AlertManagement + class UpdateAlertStatus < Base + graphql_name 'UpdateAlertStatus' + + argument :status, Types::AlertManagement::StatusEnum, + required: true, + description: 'The status to set the alert' + + def resolve(args) + alert = authorized_find!(project_path: args[:project_path], iid: args[:iid]) + result = update_status(alert, args[:status]) + + prepare_response(result) + end + + private + + def update_status(alert, status) + ::AlertManagement::UpdateAlertStatusService + .new(alert, current_user, status) + .execute + end + + def prepare_response(result) + { + alert: result.payload[:alert], + errors: result.error? ? [result.message] : [] + } + end + end + end +end diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb index 623f7c27584..30510cfab50 100644 --- a/app/graphql/mutations/base_mutation.rb +++ b/app/graphql/mutations/base_mutation.rb @@ -9,7 +9,7 @@ module Mutations field :errors, [GraphQL::STRING_TYPE], null: false, - description: "Reasons why the mutation failed." + description: "Errors encountered during execution of the mutation." def current_user context[:current_user] diff --git a/app/graphql/mutations/branches/create.rb b/app/graphql/mutations/branches/create.rb new file mode 100644 index 00000000000..127d5447d0a --- /dev/null +++ b/app/graphql/mutations/branches/create.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Mutations + module Branches + class Create < BaseMutation + include Mutations::ResolvesProject + + graphql_name 'CreateBranch' + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: 'Project full path the branch is associated with' + + argument :name, GraphQL::STRING_TYPE, + required: true, + description: 'Name of the branch' + + argument :ref, + GraphQL::STRING_TYPE, + required: true, + description: 'Branch name or commit SHA to create branch from' + + field :branch, + Types::BranchType, + null: true, + description: 'Branch after mutation' + + authorize :push_code + + def resolve(project_path:, name:, ref:) + project = authorized_find!(full_path: project_path) + + context.scoped_set!(:branch_project, project) + + result = ::Branches::CreateService.new(project, current_user) + .execute(name, ref) + + { + branch: (result[:branch] if result[:status] == :success), + errors: Array.wrap(result[:message]) + } + end + + private + + def find_object(full_path:) + resolve_project(full_path: full_path) + end + end + end +end diff --git a/app/graphql/mutations/design_management/base.rb b/app/graphql/mutations/design_management/base.rb new file mode 100644 index 00000000000..918e5709b94 --- /dev/null +++ b/app/graphql/mutations/design_management/base.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + module DesignManagement + class Base < ::Mutations::BaseMutation + include Mutations::ResolvesIssuable + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: "The project where the issue is to upload designs for" + + argument :iid, GraphQL::ID_TYPE, + required: true, + description: "The iid of the issue to modify designs for" + + private + + def find_object(project_path:, iid:) + resolve_issuable(type: :issue, parent_path: project_path, iid: iid) + end + end + end +end diff --git a/app/graphql/mutations/design_management/delete.rb b/app/graphql/mutations/design_management/delete.rb new file mode 100644 index 00000000000..d2ef2c9bcca --- /dev/null +++ b/app/graphql/mutations/design_management/delete.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Mutations + module DesignManagement + class Delete < Base + Errors = ::Gitlab::Graphql::Errors + + graphql_name "DesignManagementDelete" + + argument :filenames, [GraphQL::STRING_TYPE], + required: true, + description: "The filenames of the designs to delete", + prepare: ->(names, _ctx) do + names.presence || (raise Errors::ArgumentError, 'no filenames') + end + + field :version, Types::DesignManagement::VersionType, + null: true, # null on error + description: 'The new version in which the designs are deleted' + + authorize :destroy_design + + def resolve(project_path:, iid:, filenames:) + issue = authorized_find!(project_path: project_path, iid: iid) + project = issue.project + designs = resolve_designs(issue, filenames) + + result = ::DesignManagement::DeleteDesignsService + .new(project, current_user, issue: issue, designs: designs) + .execute + + { + version: result[:version], + errors: Array.wrap(result[:message]) + } + end + + private + + # Here we check that: + # * we find exactly as many designs as filenames + def resolve_designs(issue, filenames) + designs = issue.design_collection.designs_by_filename(filenames) + + validate_all_were_found!(designs, filenames) + + designs + end + + def validate_all_were_found!(designs, filenames) + found_filenames = designs.map(&:filename) + missing = filenames.difference(found_filenames) + + if missing.present? + raise Errors::ArgumentError, <<~MSG + Not all the designs you named currently exist. + The following filenames were not found: + #{missing.join(', ')} + + They may have already been deleted. + MSG + end + end + end + end +end diff --git a/app/graphql/mutations/design_management/upload.rb b/app/graphql/mutations/design_management/upload.rb new file mode 100644 index 00000000000..1ed7f8e49e6 --- /dev/null +++ b/app/graphql/mutations/design_management/upload.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module DesignManagement + class Upload < Base + graphql_name "DesignManagementUpload" + + argument :files, [ApolloUploadServer::Upload], + required: true, + description: "The files to upload" + + authorize :create_design + + field :designs, [Types::DesignManagement::DesignType], + null: false, + description: "The designs that were uploaded by the mutation" + + field :skipped_designs, [Types::DesignManagement::DesignType], + null: false, + description: "Any designs that were skipped from the upload due to there " \ + "being no change to their content since their last version" + + def resolve(project_path:, iid:, files:) + issue = authorized_find!(project_path: project_path, iid: iid) + project = issue.project + + result = ::DesignManagement::SaveDesignsService.new(project, current_user, issue: issue, files: files) + .execute + + { + designs: Array.wrap(result[:designs]), + skipped_designs: Array.wrap(result[:skipped_designs]), + errors: Array.wrap(result[:message]) + } + end + end + end +end diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb new file mode 100644 index 00000000000..f99688aeac6 --- /dev/null +++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Mutations + module Metrics + module Dashboard + module Annotations + class Create < BaseMutation + graphql_name 'CreateAnnotation' + + ANNOTATION_SOURCE_ARGUMENT_ERROR = 'Either a cluster or environment global id is required' + INVALID_ANNOTATION_SOURCE_ERROR = 'Invalid cluster or environment id' + + authorize :create_metrics_dashboard_annotation + + field :annotation, + Types::Metrics::Dashboards::AnnotationType, + null: true, + description: 'The created annotation' + + argument :environment_id, + GraphQL::ID_TYPE, + required: false, + description: 'The global id of the environment to add an annotation to' + + argument :cluster_id, + GraphQL::ID_TYPE, + required: false, + description: 'The global id of the cluster to add an annotation to' + + argument :starting_at, Types::TimeType, + required: true, + description: 'Timestamp indicating starting moment to which the annotation relates' + + argument :ending_at, Types::TimeType, + required: false, + description: 'Timestamp indicating ending moment to which the annotation relates' + + argument :dashboard_path, + GraphQL::STRING_TYPE, + required: true, + description: 'The path to a file defining the dashboard on which the annotation should be added' + + argument :description, + GraphQL::STRING_TYPE, + required: true, + description: 'The description of the annotation' + + AnnotationSource = Struct.new(:object, keyword_init: true) do + def type_keys + { 'Clusters::Cluster' => :cluster, 'Environment' => :environment } + end + + def klass + object.class.name + end + + def type + raise Gitlab::Graphql::Errors::ArgumentError, INVALID_ANNOTATION_SOURCE_ERROR unless type_keys[klass] + + type_keys[klass] + end + end + + def resolve(args) + annotation_response = ::Metrics::Dashboard::Annotations::CreateService.new(context[:current_user], annotation_create_params(args)).execute + + annotation = annotation_response[:annotation] + + { + annotation: annotation.valid? ? annotation : nil, + errors: errors_on_object(annotation) + } + end + + private + + def ready?(**args) + # Raise error if both cluster_id and environment_id are present or neither is present + unless args[:cluster_id].present? ^ args[:environment_id].present? + raise Gitlab::Graphql::Errors::ArgumentError, ANNOTATION_SOURCE_ARGUMENT_ERROR + end + + super(args) + end + + def find_object(id:) + GitlabSchema.object_from_id(id) + end + + def annotation_create_params(args) + annotation_source = AnnotationSource.new(object: annotation_source(args)) + + args[annotation_source.type] = annotation_source.object + + args + end + + def annotation_source(args) + annotation_source_id = args[:cluster_id] || args[:environment_id] + authorized_find!(id: annotation_source_id) + end + end + end + end + end +end diff --git a/app/graphql/mutations/snippets/base.rb b/app/graphql/mutations/snippets/base.rb index 9dc6d49774e..c8cc721b2e0 100644 --- a/app/graphql/mutations/snippets/base.rb +++ b/app/graphql/mutations/snippets/base.rb @@ -15,6 +15,8 @@ module Mutations end def authorized_resource?(snippet) + return false if snippet.nil? + Ability.allowed?(context[:current_user], ability_for(snippet), snippet) end diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index 266a123de82..6fc223fbee7 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -36,6 +36,10 @@ module Mutations required: false, description: 'The project full path the snippet is associated with' + argument :uploaded_files, [GraphQL::STRING_TYPE], + required: false, + description: 'The paths to files uploaded in the snippet description' + def resolve(args) project_path = args.delete(:project_path) @@ -45,9 +49,14 @@ module Mutations raise_resource_not_available_error! end + # We need to rename `uploaded_files` into `files` because + # it's the expected key param + args[:files] = args.delete(:uploaded_files) + service_response = ::Snippets::CreateService.new(project, context[:current_user], args).execute + snippet = service_response.payload[:snippet] { diff --git a/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb b/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb new file mode 100644 index 00000000000..7f4346632ca --- /dev/null +++ b/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + module AlertManagement + class AlertStatusCountsResolver < BaseResolver + type Types::AlertManagement::AlertStatusCountsType, null: true + + def resolve(**args) + ::Gitlab::AlertManagement::AlertStatusCounts.new(context[:current_user], object, args) + end + end + end +end diff --git a/app/graphql/resolvers/alert_management_alert_resolver.rb b/app/graphql/resolvers/alert_management_alert_resolver.rb new file mode 100644 index 00000000000..51ebbb96476 --- /dev/null +++ b/app/graphql/resolvers/alert_management_alert_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + class AlertManagementAlertResolver < BaseResolver + argument :iid, GraphQL::STRING_TYPE, + required: false, + description: 'IID of the alert. For example, "1"' + + argument :statuses, [Types::AlertManagement::StatusEnum], + as: :status, + required: false, + description: 'Alerts with the specified statues. For example, [TRIGGERED]' + + argument :sort, Types::AlertManagement::AlertSortEnum, + description: 'Sort alerts by this criteria', + required: false + + argument :search, GraphQL::STRING_TYPE, + description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.', + required: false + + type Types::AlertManagement::AlertType, null: true + + def resolve(**args) + parent = object.respond_to?(:sync) ? object.sync : object + return ::AlertManagement::Alert.none if parent.nil? + + ::AlertManagement::AlertsFinder.new(context[:current_user], parent, args).execute + end + end +end diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb new file mode 100644 index 00000000000..f8d62ba86af --- /dev/null +++ b/app/graphql/resolvers/board_lists_resolver.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Resolvers + class BoardListsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::BoardListType, null: true + + alias_method :board, :object + + def resolve(lookahead: nil) + authorize!(board) + + lists = board_lists + + if load_preferences?(lookahead) + List.preload_preferences_for_user(lists, context[:current_user]) + end + + Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(lists) + end + + private + + def board_lists + service = Boards::Lists::ListService.new(board.resource_parent, context[:current_user]) + service.execute(board, create_default_lists: false) + end + + def authorized_resource?(board) + Ability.allowed?(context[:current_user], :read_list, board) + end + + def load_preferences?(lookahead) + lookahead&.selection(:edges)&.selection(:node)&.selects?(:collapsed) + end + end +end diff --git a/app/graphql/resolvers/branch_commit_resolver.rb b/app/graphql/resolvers/branch_commit_resolver.rb new file mode 100644 index 00000000000..11c49e17bc5 --- /dev/null +++ b/app/graphql/resolvers/branch_commit_resolver.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Resolvers + class BranchCommitResolver < BaseResolver + type Types::CommitType, null: true + + alias_method :branch, :object + + def resolve(**args) + return unless branch + + commit = branch.dereferenced_target + + ::Commit.new(commit, context[:branch_project]) if commit + end + end +end diff --git a/app/graphql/resolvers/design_management/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/design_at_version_resolver.rb new file mode 100644 index 00000000000..fd9b349f974 --- /dev/null +++ b/app/graphql/resolvers/design_management/design_at_version_resolver.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class DesignAtVersionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::DesignManagement::DesignAtVersionType, null: false + + authorize :read_design + + argument :id, GraphQL::ID_TYPE, + required: true, + description: 'The Global ID of the design at this version' + + def resolve(id:) + authorized_find!(id: id) + end + + def find_object(id:) + dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion) + return unless consistent?(dav) + + dav + end + + def self.single + self + end + + private + + # If this resolver is mounted on something that has an issue + # (such as design collection for instance), then we should check + # that the DesignAtVersion as found by its ID does in fact belong + # to this issue. + def consistent?(dav) + issue.nil? || (dav&.design&.issue_id == issue.id) + end + + def issue + object&.issue + end + end + end +end diff --git a/app/graphql/resolvers/design_management/design_resolver.rb b/app/graphql/resolvers/design_management/design_resolver.rb new file mode 100644 index 00000000000..05bdbbbe407 --- /dev/null +++ b/app/graphql/resolvers/design_management/design_resolver.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class DesignResolver < BaseResolver + argument :id, GraphQL::ID_TYPE, + required: false, + description: 'Find a design by its ID' + + argument :filename, GraphQL::STRING_TYPE, + required: false, + description: 'Find a design by its filename' + + def resolve(filename: nil, id: nil) + params = parse_args(filename, id) + + build_finder(params).execute.first + end + + def self.single + self + end + + private + + def issue + object.issue + end + + def build_finder(params) + ::DesignManagement::DesignsFinder.new(issue, current_user, params) + end + + def error(msg) + raise ::Gitlab::Graphql::Errors::ArgumentError, msg + end + + def parse_args(filename, id) + provided = [filename, id].map(&:present?) + + if provided.none? + error('one of id or filename must be passed') + elsif provided.all? + error('only one of id or filename may be passed') + elsif filename.present? + { filenames: [filename] } + else + { ids: [parse_gid(id)] } + end + end + + def parse_gid(gid) + GitlabSchema.parse_gid(gid, expected_type: ::DesignManagement::Design).model_id + end + end + end +end diff --git a/app/graphql/resolvers/design_management/designs_resolver.rb b/app/graphql/resolvers/design_management/designs_resolver.rb new file mode 100644 index 00000000000..81f94d5cb30 --- /dev/null +++ b/app/graphql/resolvers/design_management/designs_resolver.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class DesignsResolver < BaseResolver + argument :ids, + [GraphQL::ID_TYPE], + required: false, + description: 'Filters designs by their ID' + argument :filenames, + [GraphQL::STRING_TYPE], + required: false, + description: 'Filters designs by their filename' + argument :at_version, + GraphQL::ID_TYPE, + required: false, + description: 'Filters designs to only those that existed at the version. ' \ + 'If argument is omitted or nil then all designs will reflect the latest version' + + def self.single + ::Resolvers::DesignManagement::DesignResolver + end + + def resolve(ids: nil, filenames: nil, at_version: nil) + ::DesignManagement::DesignsFinder.new( + issue, + current_user, + ids: design_ids(ids), + filenames: filenames, + visible_at_version: version(at_version), + order: :id + ).execute + end + + private + + def version(at_version) + GitlabSchema.object_from_id(at_version)&.sync if at_version + end + + def design_ids(ids) + ids&.map { |id| GlobalID.parse(id).model_id } + end + + def issue + object.issue + end + end + end +end diff --git a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb new file mode 100644 index 00000000000..03f7908780c --- /dev/null +++ b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + module Version + # Resolver for a DesignAtVersion object given an implicit version context + class DesignAtVersionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::DesignManagement::DesignAtVersionType, null: true + + authorize :read_design + + argument :id, GraphQL::ID_TYPE, + required: false, + as: :design_at_version_id, + description: 'The ID of the DesignAtVersion' + argument :design_id, GraphQL::ID_TYPE, + required: false, + description: 'The ID of a specific design' + argument :filename, GraphQL::STRING_TYPE, + required: false, + description: 'The filename of a specific design' + + def self.single + self + end + + def resolve(design_id: nil, filename: nil, design_at_version_id: nil) + validate_arguments(design_id, filename, design_at_version_id) + + return unless Ability.allowed?(current_user, :read_design, issue) + return specific_design_at_version(design_at_version_id) if design_at_version_id + + find(design_id, filename).map { |d| make(d) }.first + end + + private + + def validate_arguments(design_id, filename, design_at_version_id) + args = { filename: filename, id: design_at_version_id, design_id: design_id } + passed = args.compact.keys + + return if passed.size == 1 + + msg = "Exactly one of #{args.keys.join(', ')} expected, got #{passed}" + + raise Gitlab::Graphql::Errors::ArgumentError, msg + end + + def specific_design_at_version(id) + dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion) + return unless consistent?(dav) + + dav + end + + # Test that the DAV found by ID actually belongs on this version, and + # that it is visible at this version. + def consistent?(dav) + return false unless dav.present? + + dav.design.issue_id == issue.id && + dav.version.id == version.id && + dav.design.visible_in?(version) + end + + def find(id, filename) + ids = [parse_design_id(id).model_id] if id + filenames = [filename] if filename + + ::DesignManagement::DesignsFinder + .new(issue, current_user, ids: ids, filenames: filenames, visible_at_version: version) + .execute + end + + def parse_design_id(id) + GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design) + end + + def issue + version.issue + end + + def version + object + end + + def make(design) + ::DesignManagement::DesignAtVersion.new(design: design, version: version) + end + end + end + end +end diff --git a/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb new file mode 100644 index 00000000000..5ccb2f3e311 --- /dev/null +++ b/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + module Version + # Resolver for DesignAtVersion objects given an implicit version context + class DesignsAtVersionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::DesignManagement::DesignAtVersionType, null: true + + authorize :read_design + + argument :ids, + [GraphQL::ID_TYPE], + required: false, + description: 'Filters designs by their ID' + argument :filenames, + [GraphQL::STRING_TYPE], + required: false, + description: 'Filters designs by their filename' + + def self.single + ::Resolvers::DesignManagement::Version::DesignAtVersionResolver + end + + def resolve(ids: nil, filenames: nil) + find(ids, filenames).execute.map { |d| make(d) } + end + + private + + def find(ids, filenames) + ids = ids&.map { |id| parse_design_id(id).model_id } + + ::DesignManagement::DesignsFinder.new(issue, current_user, + ids: ids, + filenames: filenames, + visible_at_version: version) + end + + def parse_design_id(id) + GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design) + end + + def issue + version.issue + end + + def version + object + end + + def make(design) + ::DesignManagement::DesignAtVersion.new(design: design, version: version) + end + end + end + end +end diff --git a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb new file mode 100644 index 00000000000..9e729172881 --- /dev/null +++ b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class VersionInCollectionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::DesignManagement::VersionType, null: true + + authorize :read_design + + alias_method :collection, :object + + argument :sha, GraphQL::STRING_TYPE, + required: false, + description: "The SHA256 of a specific version" + argument :id, GraphQL::ID_TYPE, + required: false, + description: 'The Global ID of the version' + + def resolve(id: nil, sha: nil) + check_args(id, sha) + + gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id + + ::DesignManagement::VersionsFinder + .new(collection, current_user, sha: sha, version_id: gid&.model_id) + .execute + .first + end + + def self.single + self + end + + private + + def check_args(id, sha) + return if id.present? || sha.present? + + raise ::Gitlab::Graphql::Errors::ArgumentError, 'one of id or sha is required' + end + end + end +end diff --git a/app/graphql/resolvers/design_management/version_resolver.rb b/app/graphql/resolvers/design_management/version_resolver.rb new file mode 100644 index 00000000000..b0e0843e6c8 --- /dev/null +++ b/app/graphql/resolvers/design_management/version_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class VersionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::DesignManagement::VersionType, null: true + + authorize :read_design + + argument :id, GraphQL::ID_TYPE, + required: true, + description: 'The Global ID of the version' + + def resolve(id:) + authorized_find!(id: id) + end + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version) + end + end + end +end diff --git a/app/graphql/resolvers/design_management/versions_resolver.rb b/app/graphql/resolvers/design_management/versions_resolver.rb new file mode 100644 index 00000000000..a62258dad5c --- /dev/null +++ b/app/graphql/resolvers/design_management/versions_resolver.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class VersionsResolver < BaseResolver + type Types::DesignManagement::VersionType.connection_type, null: false + + alias_method :design_or_collection, :object + + argument :earlier_or_equal_to_sha, GraphQL::STRING_TYPE, + as: :sha, + required: false, + description: 'The SHA256 of the most recent acceptable version' + + argument :earlier_or_equal_to_id, GraphQL::ID_TYPE, + as: :id, + required: false, + description: 'The Global ID of the most recent acceptable version' + + # This resolver has a custom singular resolver + def self.single + ::Resolvers::DesignManagement::VersionInCollectionResolver + end + + def resolve(parent: nil, id: nil, sha: nil) + version = cutoff(parent, id, sha) + + raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'cutoff not found' unless version.present? + + if version == :unconstrained + find + else + find(earlier_or_equal_to: version) + end + end + + private + + # Find the most recent version that the client will accept + def cutoff(parent, id, sha) + if sha.present? || id.present? + specific_version(id, sha) + elsif at_version = at_version_arg(parent) + by_id(at_version) + else + :unconstrained + end + end + + def specific_version(id, sha) + gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id + find(sha: sha, version_id: gid&.model_id).first + end + + def find(**params) + ::DesignManagement::VersionsFinder + .new(design_or_collection, current_user, params) + .execute + end + + def by_id(id) + GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version).sync + end + + # Find an `at_version` argument passed to a parent node. + # + # If one is found, then a design collection further up the AST + # has been filtered to reflect designs at that version, and so + # for consistency we should only present versions up to the given + # version here. + def at_version_arg(parent) + ::Gitlab::Graphql::FindArgumentInParent.find(parent, :at_version, limit_depth: 4) + end + end + end +end diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 04da54a6bb6..f103da07666 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -52,6 +52,10 @@ module Resolvers type Types::IssueType, null: true + NON_STABLE_CURSOR_SORTS = %i[priority_asc priority_desc + label_priority_asc label_priority_desc + milestone_due_asc milestone_due_desc].freeze + def resolve(**args) # The project could have been loaded in batch by `BatchLoader`. # At this point we need the `id` of the project to query for issues, so @@ -70,7 +74,15 @@ module Resolvers args[:iids] ||= [args[:iid]].compact args[:attempt_project_search_optimizations] = args[:search].present? - IssuesFinder.new(context[:current_user], args).execute + issues = IssuesFinder.new(context[:current_user], args).execute + + if non_stable_cursor_sort?(args[:sort]) + # Certain complex sorts are not supported by the stable cursor pagination yet. + # In these cases, we use offset pagination, so we return the correct connection. + Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(issues) + else + issues + end end def self.resolver_complexity(args, child_complexity:) @@ -79,5 +91,9 @@ module Resolvers complexity end + + def non_stable_cursor_sort?(sort) + NON_STABLE_CURSOR_SORTS.include?(sort) + end end end diff --git a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb index 068323a3073..2dd224bb17b 100644 --- a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb +++ b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb @@ -18,7 +18,6 @@ module Resolvers def resolve(**args) return [] unless dashboard - return [] unless Feature.enabled?(:metrics_dashboard_annotations, dashboard.environment&.project) ::Metrics::Dashboards::AnnotationsFinder.new(dashboard: dashboard, params: args).execute end diff --git a/app/graphql/resolvers/milestone_resolver.rb b/app/graphql/resolvers/milestone_resolver.rb index 2e7b6fdfd5f..6c6513e0ee4 100644 --- a/app/graphql/resolvers/milestone_resolver.rb +++ b/app/graphql/resolvers/milestone_resolver.rb @@ -9,6 +9,10 @@ module Resolvers required: false, description: 'Filter milestones by state' + argument :include_descendants, GraphQL::BOOLEAN_TYPE, + required: false, + description: 'Return also milestones in all subgroups and subprojects' + type Types::MilestoneType, null: true def resolve(**args) @@ -26,16 +30,16 @@ module Resolvers state: args[:state] || 'all', start_date: args[:start_date], end_date: args[:end_date] - }.merge(parent_id_parameter) + }.merge(parent_id_parameter(args)) end def parent @parent ||= object.respond_to?(:sync) ? object.sync : object end - def parent_id_parameter + def parent_id_parameter(args) if parent.is_a?(Group) - { group_ids: parent.id } + group_parameters(args) elsif parent.is_a?(Project) { project_ids: parent.id } end @@ -46,5 +50,26 @@ module Resolvers def authorize! Ability.allowed?(context[:current_user], :read_milestone, parent) || raise_resource_not_available_error! end + + def group_parameters(args) + return { group_ids: parent.id } unless include_descendants?(args) + + { + group_ids: parent.self_and_descendants.public_or_visible_to_user(current_user).select(:id), + project_ids: group_projects.with_issues_or_mrs_available_for_user(current_user) + } + end + + def include_descendants?(args) + args[:include_descendants].present? && Feature.enabled?(:group_milestone_descendants, parent) + end + + def group_projects + GroupProjectsFinder.new( + group: parent, + current_user: current_user, + options: { include_subgroups: true } + ).execute + end end end diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb index f5b60f91be6..e841132eea7 100644 --- a/app/graphql/resolvers/namespace_projects_resolver.rb +++ b/app/graphql/resolvers/namespace_projects_resolver.rb @@ -29,3 +29,5 @@ module Resolvers end end end + +Resolvers::NamespaceProjectsResolver.prepend_if_ee('::EE::Resolvers::NamespaceProjectsResolver') diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb new file mode 100644 index 00000000000..068546cd39f --- /dev/null +++ b/app/graphql/resolvers/projects_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + class ProjectsResolver < BaseResolver + type Types::ProjectType, null: true + + argument :membership, GraphQL::BOOLEAN_TYPE, + required: false, + description: 'Limit projects that the current user is a member of' + + argument :search, GraphQL::STRING_TYPE, + required: false, + description: 'Search criteria' + + def resolve(**args) + ProjectsFinder + .new(current_user: current_user, params: project_finder_params(args)) + .execute + end + + private + + def project_finder_params(params) + { + without_deleted: true, + non_public: params[:membership], + search: params[:search] + }.compact + end + end +end diff --git a/app/graphql/resolvers/release_resolver.rb b/app/graphql/resolvers/release_resolver.rb new file mode 100644 index 00000000000..9bae8b8cd13 --- /dev/null +++ b/app/graphql/resolvers/release_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + class ReleaseResolver < BaseResolver + type Types::ReleaseType, null: true + + argument :tag_name, GraphQL::STRING_TYPE, + required: true, + description: 'The name of the tag associated to the release' + + alias_method :project, :object + + def self.single + self + end + + def resolve(tag_name:) + ReleasesFinder.new( + project, + current_user, + { tag: tag_name } + ).execute.first + end + end +end diff --git a/app/graphql/resolvers/releases_resolver.rb b/app/graphql/resolvers/releases_resolver.rb new file mode 100644 index 00000000000..b2afbb92684 --- /dev/null +++ b/app/graphql/resolvers/releases_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + class ReleasesResolver < BaseResolver + type Types::ReleaseType.connection_type, null: true + + alias_method :project, :object + + # This resolver has a custom singular resolver + def self.single + Resolvers::ReleaseResolver + end + + def resolve(**args) + ReleasesFinder.new( + project, + current_user + ).execute + end + end +end diff --git a/app/graphql/types/alert_management/alert_sort_enum.rb b/app/graphql/types/alert_management/alert_sort_enum.rb new file mode 100644 index 00000000000..e6d38af8170 --- /dev/null +++ b/app/graphql/types/alert_management/alert_sort_enum.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module AlertManagement + class AlertSortEnum < SortEnum + graphql_name 'AlertManagementAlertSort' + description 'Values for sorting alerts' + + value 'START_TIME_ASC', 'Start time by ascending order', value: :start_time_asc + value 'START_TIME_DESC', 'Start time by descending order', value: :start_time_desc + value 'END_TIME_ASC', 'End time by ascending order', value: :end_time_asc + value 'END_TIME_DESC', 'End time by descending order', value: :end_time_desc + value 'CREATED_TIME_ASC', 'Created time by ascending order', value: :created_at_asc + value 'CREATED_TIME_DESC', 'Created time by descending order', value: :created_at_desc + value 'UPDATED_TIME_ASC', 'Created time by ascending order', value: :updated_at_asc + value 'UPDATED_TIME_DESC', 'Created time by descending order', value: :updated_at_desc + value 'EVENTS_COUNT_ASC', 'Events count by ascending order', value: :events_count_asc + value 'EVENTS_COUNT_DESC', 'Events count by descending order', value: :events_count_desc + value 'SEVERITY_ASC', 'Severity by ascending order', value: :severity_asc + value 'SEVERITY_DESC', 'Severity by descending order', value: :severity_desc + value 'STATUS_ASC', 'Status by ascending order', value: :status_asc + value 'STATUS_DESC', 'Status by descending order', value: :status_desc + end + end +end diff --git a/app/graphql/types/alert_management/alert_status_counts_type.rb b/app/graphql/types/alert_management/alert_status_counts_type.rb new file mode 100644 index 00000000000..f80b289eabc --- /dev/null +++ b/app/graphql/types/alert_management/alert_status_counts_type.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Service for managing alert counts and cache updates. +module Types + module AlertManagement + class AlertStatusCountsType < BaseObject + graphql_name 'AlertManagementAlertStatusCountsType' + description "Represents total number of alerts for the represented categories" + + authorize :read_alert_management_alert + + ::Gitlab::AlertManagement::AlertStatusCounts::STATUSES.each_key do |status| + field status, + GraphQL::INT_TYPE, + null: true, + description: "Number of alerts with status #{status.upcase} for the project" + end + + field :open, + GraphQL::INT_TYPE, + null: true, + description: 'Number of alerts with status TRIGGERED or ACKNOWLEDGED for the project' + + field :all, + GraphQL::INT_TYPE, + null: true, + description: 'Total number of alerts for the project' + end + end +end diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb new file mode 100644 index 00000000000..a766fb3236d --- /dev/null +++ b/app/graphql/types/alert_management/alert_type.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Types + module AlertManagement + class AlertType < BaseObject + graphql_name 'AlertManagementAlert' + description "Describes an alert from the project's Alert Management" + + authorize :read_alert_management_alert + + field :iid, + GraphQL::ID_TYPE, + null: false, + description: 'Internal ID of the alert' + + field :issue_iid, + GraphQL::ID_TYPE, + null: true, + description: 'Internal ID of the GitLab issue attached to the alert' + + field :title, + GraphQL::STRING_TYPE, + null: true, + description: 'Title of the alert' + + field :description, + GraphQL::STRING_TYPE, + null: true, + description: 'Description of the alert' + + field :severity, + AlertManagement::SeverityEnum, + null: true, + description: 'Severity of the alert' + + field :status, + AlertManagement::StatusEnum, + null: true, + description: 'Status of the alert' + + field :service, + GraphQL::STRING_TYPE, + null: true, + description: 'Service the alert came from' + + field :monitoring_tool, + GraphQL::STRING_TYPE, + null: true, + description: 'Monitoring tool the alert came from' + + field :hosts, + [GraphQL::STRING_TYPE], + null: true, + description: 'List of hosts the alert came from' + + field :started_at, + Types::TimeType, + null: true, + description: 'Timestamp the alert was raised' + + field :ended_at, + Types::TimeType, + null: true, + description: 'Timestamp the alert ended' + + field :event_count, + GraphQL::INT_TYPE, + null: true, + description: 'Number of events of this alert', + method: :events + + field :details, + GraphQL::Types::JSON, + null: true, + description: 'Alert details' + + field :created_at, + Types::TimeType, + null: true, + description: 'Timestamp the alert was created' + + field :updated_at, + Types::TimeType, + null: true, + description: 'Timestamp the alert was last updated' + end + end +end diff --git a/app/graphql/types/alert_management/severity_enum.rb b/app/graphql/types/alert_management/severity_enum.rb new file mode 100644 index 00000000000..99ea56da02c --- /dev/null +++ b/app/graphql/types/alert_management/severity_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module AlertManagement + class SeverityEnum < BaseEnum + graphql_name 'AlertManagementSeverity' + description 'Alert severity values' + + ::AlertManagement::Alert.severities.keys.each do |severity| + value severity.upcase, value: severity, description: "#{severity.titleize} severity" + end + end + end +end diff --git a/app/graphql/types/alert_management/status_enum.rb b/app/graphql/types/alert_management/status_enum.rb new file mode 100644 index 00000000000..4ff6c4a9505 --- /dev/null +++ b/app/graphql/types/alert_management/status_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module AlertManagement + class StatusEnum < BaseEnum + graphql_name 'AlertManagementStatus' + description 'Alert status values' + + ::AlertManagement::Alert::STATUSES.each do |name, value| + value name.upcase, value: value, description: "#{name.to_s.titleize} status" + end + end + end +end diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb new file mode 100644 index 00000000000..e94ff898807 --- /dev/null +++ b/app/graphql/types/board_list_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class BoardListType < BaseObject + graphql_name 'BoardList' + description 'Represents a list for an issue board' + + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID (global ID) of the list' + field :title, GraphQL::STRING_TYPE, null: false, + description: 'Title of the list' + field :list_type, GraphQL::STRING_TYPE, null: false, + description: 'Type of the list' + field :position, GraphQL::INT_TYPE, null: true, + description: 'Position of list within the board' + field :label, Types::LabelType, null: true, + description: 'Label of the list' + field :collapsed, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if list is collapsed for this user', + resolve: -> (list, _args, ctx) { list.collapsed?(ctx[:current_user]) } + end + # rubocop: enable Graphql/AuthorizeTypes +end + +Types::BoardListType.prepend_if_ee('::EE::Types::BoardListType') diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb index 9c95a987fe4..c0be782ed1e 100644 --- a/app/graphql/types/board_type.rb +++ b/app/graphql/types/board_type.rb @@ -11,6 +11,13 @@ module Types description: 'ID (global ID) of the board' field :name, type: GraphQL::STRING_TYPE, null: true, description: 'Name of the board' + + field :lists, + Types::BoardListType.connection_type, + null: true, + description: 'Lists of the project board', + resolver: Resolvers::BoardListsResolver, + extras: [:lookahead] end end diff --git a/app/graphql/types/branch_type.rb b/app/graphql/types/branch_type.rb new file mode 100644 index 00000000000..b15038a46de --- /dev/null +++ b/app/graphql/types/branch_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class BranchType < BaseObject + graphql_name 'Branch' + + field :name, + GraphQL::STRING_TYPE, + null: false, + description: 'Name of the branch' + + field :commit, Types::CommitType, + null: true, resolver: Resolvers::BranchCommitResolver, + description: 'Commit for the branch' + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index aaf2dfd8488..be5165da545 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -14,6 +14,7 @@ module Types description: 'SHA1 ID of the commit' field :title, type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true, description: 'Title of the commit message' + markdown_field :title_html, null: true field :description, type: GraphQL::STRING_TYPE, null: true, description: 'Description of the commit message' field :message, type: GraphQL::STRING_TYPE, null: true, diff --git a/app/graphql/types/design_management/design_at_version_type.rb b/app/graphql/types/design_management/design_at_version_type.rb new file mode 100644 index 00000000000..343d4cf4ff4 --- /dev/null +++ b/app/graphql/types/design_management/design_at_version_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + class DesignAtVersionType < BaseObject + graphql_name 'DesignAtVersion' + + description 'A design pinned to a specific version. ' \ + 'The image field reflects the design as of the associated version.' + + authorize :read_design + + delegate :design, :version, to: :object + delegate :issue, :filename, :full_path, :diff_refs, to: :design + + implements ::Types::DesignManagement::DesignFields + + field :version, + Types::DesignManagement::VersionType, + null: false, + description: 'The version this design-at-versions is pinned to' + + field :design, + Types::DesignManagement::DesignType, + null: false, + description: 'The underlying design.' + + def cached_stateful_version(_parent) + version + end + + def notes_count + design.user_notes_count + end + end + end +end diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb new file mode 100644 index 00000000000..194910831c6 --- /dev/null +++ b/app/graphql/types/design_management/design_collection_type.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + class DesignCollectionType < BaseObject + graphql_name 'DesignCollection' + description 'A collection of designs.' + + authorize :read_design + + field :project, Types::ProjectType, null: false, + description: 'Project associated with the design collection' + field :issue, Types::IssueType, null: false, + description: 'Issue associated with the design collection' + + field :designs, + Types::DesignManagement::DesignType.connection_type, + null: false, + resolver: Resolvers::DesignManagement::DesignsResolver, + description: 'All designs for the design collection', + complexity: 5 + + field :versions, + Types::DesignManagement::VersionType.connection_type, + resolver: Resolvers::DesignManagement::VersionsResolver, + description: 'All versions related to all designs, ordered newest first' + + field :version, + Types::DesignManagement::VersionType, + resolver: Resolvers::DesignManagement::VersionsResolver.single, + description: 'A specific version' + + field :design_at_version, ::Types::DesignManagement::DesignAtVersionType, + null: true, + resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver, + description: 'Find a design as of a version' + + field :design, ::Types::DesignManagement::DesignType, + null: true, + resolver: ::Resolvers::DesignManagement::DesignResolver, + description: 'Find a specific design' + end + end +end diff --git a/app/graphql/types/design_management/design_fields.rb b/app/graphql/types/design_management/design_fields.rb new file mode 100644 index 00000000000..b03b3927392 --- /dev/null +++ b/app/graphql/types/design_management/design_fields.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + module DesignFields + include BaseInterface + + field_class Types::BaseField + + field :id, GraphQL::ID_TYPE, description: 'The ID of this design', null: false + field :project, Types::ProjectType, null: false, description: 'The project the design belongs to' + field :issue, Types::IssueType, null: false, description: 'The issue the design belongs to' + field :filename, GraphQL::STRING_TYPE, null: false, description: 'The filename of the design' + field :full_path, GraphQL::STRING_TYPE, null: false, description: 'The full path to the design file' + field :image, GraphQL::STRING_TYPE, null: false, extras: [:parent], description: 'The URL of the full-sized image' + field :image_v432x230, GraphQL::STRING_TYPE, null: true, extras: [:parent], + description: 'The URL of the design resized to fit within the bounds of 432x230. ' \ + 'This will be `null` if the image has not been generated' + field :diff_refs, Types::DiffRefsType, + null: false, + calls_gitaly: true, + extras: [:parent], + description: 'The diff refs for this design' + field :event, Types::DesignManagement::DesignVersionEventEnum, + null: false, + extras: [:parent], + description: 'How this design was changed in the current version' + field :notes_count, + GraphQL::INT_TYPE, + null: false, + method: :user_notes_count, + description: 'The total count of user-created notes for this design' + + def diff_refs(parent:) + version = cached_stateful_version(parent) + version.diff_refs + end + + def image(parent:) + sha = cached_stateful_version(parent).sha + + Gitlab::UrlBuilder.build(design, ref: sha) + end + + def image_v432x230(parent:) + version = cached_stateful_version(parent) + action = design.actions.up_to_version(version).most_recent.first + + # A `nil` return value indicates that the image has not been processed + return unless action.image_v432x230.file + + Gitlab::UrlBuilder.build(design, ref: version.sha, size: :v432x230) + end + + def event(parent:) + version = cached_stateful_version(parent) + + action = cached_actions_for_version(version)[design.id] + + action&.event || ::Types::DesignManagement::DesignVersionEventEnum::NONE + end + + def cached_actions_for_version(version) + Gitlab::SafeRequestStore.fetch(['DesignFields', 'actions_for_version', version.id]) do + version.actions.to_h { |dv| [dv.design_id, dv] } + end + end + + def project + ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Project, design.project_id).find + end + + def issue + ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Issue, design.issue_id).find + end + end + end +end diff --git a/app/graphql/types/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb new file mode 100644 index 00000000000..3c84dc151bd --- /dev/null +++ b/app/graphql/types/design_management/design_type.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + class DesignType < BaseObject + graphql_name 'Design' + description 'A single design' + + authorize :read_design + + alias_method :design, :object + + implements(Types::Notes::NoteableType) + implements(Types::DesignManagement::DesignFields) + + field :versions, + Types::DesignManagement::VersionType.connection_type, + resolver: Resolvers::DesignManagement::VersionsResolver, + description: "All versions related to this design ordered newest first", + extras: [:parent] + + # Returns a `DesignManagement::Version` for this query based on the + # `atVersion` argument passed to a parent node if present, or otherwise + # the most recent `Version` for the issue. + def cached_stateful_version(parent_node) + version_gid = Gitlab::Graphql::FindArgumentInParent.find(parent_node, :at_version) + + # Caching is scoped to an `issue_id` to allow us to cache the + # most recent `Version` for an issue + Gitlab::SafeRequestStore.fetch([request_cache_base_key, 'stateful_version', object.issue_id, version_gid]) do + if version_gid + GitlabSchema.object_from_id(version_gid)&.sync + else + object.issue.design_versions.most_recent + end + end + end + + def request_cache_base_key + self.class.name + end + end + end +end diff --git a/app/graphql/types/design_management/design_version_event_enum.rb b/app/graphql/types/design_management/design_version_event_enum.rb new file mode 100644 index 00000000000..ea4bc1ffbfa --- /dev/null +++ b/app/graphql/types/design_management/design_version_event_enum.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + class DesignVersionEventEnum < BaseEnum + graphql_name 'DesignVersionEvent' + description 'Mutation event of a design within a version' + + NONE = 'NONE' + + value NONE, 'No change' + + ::DesignManagement::Action.events.keys.each do |event_name| + value event_name.upcase, value: event_name, description: "A #{event_name} event" + end + end + end +end diff --git a/app/graphql/types/design_management/version_type.rb b/app/graphql/types/design_management/version_type.rb new file mode 100644 index 00000000000..c774f5d1bdf --- /dev/null +++ b/app/graphql/types/design_management/version_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + class VersionType < ::Types::BaseObject + # Just `Version` might be a bit to general to expose globally so adding + # a `Design` prefix to specify the class exposed in GraphQL + graphql_name 'DesignVersion' + + description 'A specific version in which designs were added, modified or deleted' + + authorize :read_design + + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the design version' + field :sha, GraphQL::ID_TYPE, null: false, + description: 'SHA of the design version' + + field :designs, + ::Types::DesignManagement::DesignType.connection_type, + null: false, + description: 'All designs that were changed in the version' + + field :designs_at_version, + ::Types::DesignManagement::DesignAtVersionType.connection_type, + null: false, + description: 'All designs that are visible at this version, as of this version', + resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver + + field :design_at_version, + ::Types::DesignManagement::DesignAtVersionType, + null: false, + description: 'A particular design as of this version, provided it is visible at this version', + resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver.single + end + end +end diff --git a/app/graphql/types/design_management_type.rb b/app/graphql/types/design_management_type.rb new file mode 100644 index 00000000000..ec85b8a0c1f --- /dev/null +++ b/app/graphql/types/design_management_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# rubocop: disable Graphql/AuthorizeTypes +module Types + class DesignManagementType < BaseObject + graphql_name 'DesignManagement' + + field :version, ::Types::DesignManagement::VersionType, + null: true, + resolver: ::Resolvers::DesignManagement::VersionResolver, + description: 'Find a version' + + field :design_at_version, ::Types::DesignManagement::DesignAtVersionType, + null: true, + resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver, + description: 'Find a design as of a version' + end +end diff --git a/app/graphql/types/grafana_integration_type.rb b/app/graphql/types/grafana_integration_type.rb index c0582b266ab..7db733fc62a 100644 --- a/app/graphql/types/grafana_integration_type.rb +++ b/app/graphql/types/grafana_integration_type.rb @@ -9,7 +9,7 @@ module Types field :id, GraphQL::ID_TYPE, null: false, description: 'Internal ID of the Grafana integration' field :grafana_url, GraphQL::STRING_TYPE, null: false, - description: 'Url for the Grafana host for the Grafana integration' + description: 'URL for the Grafana host for the Grafana integration' field :enabled, GraphQL::BOOLEAN_TYPE, null: false, description: 'Indicates whether Grafana integration is enabled' field :created_at, Types::TimeType, null: false, diff --git a/app/graphql/types/issuable_sort_enum.rb b/app/graphql/types/issuable_sort_enum.rb index 9fb1249d582..a6d52124d99 100644 --- a/app/graphql/types/issuable_sort_enum.rb +++ b/app/graphql/types/issuable_sort_enum.rb @@ -4,5 +4,12 @@ module Types class IssuableSortEnum < SortEnum graphql_name 'IssuableSort' description 'Values for sorting issuables' + + value 'PRIORITY_ASC', 'Priority by ascending order', value: :priority_asc + value 'PRIORITY_DESC', 'Priority by descending order', value: :priority_desc + value 'LABEL_PRIORITY_ASC', 'Label priority by ascending order', value: :label_priority_asc + value 'LABEL_PRIORITY_DESC', 'Label priority by descending order', value: :label_priority_desc + value 'MILESTONE_DUE_ASC', 'Milestone due date by ascending order', value: :milestone_due_asc + value 'MILESTONE_DUE_DESC', 'Milestone due date by descending order', value: :milestone_due_desc end end diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb index c8d8f3ef079..e458d6e02c5 100644 --- a/app/graphql/types/issue_sort_enum.rb +++ b/app/graphql/types/issue_sort_enum.rb @@ -5,9 +5,9 @@ module Types graphql_name 'IssueSort' description 'Values for sorting issues' - value 'DUE_DATE_ASC', 'Due date by ascending order', value: 'due_date_asc' - value 'DUE_DATE_DESC', 'Due date by descending order', value: 'due_date_desc' - value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: 'relative_position_asc' + value 'DUE_DATE_ASC', 'Due date by ascending order', value: :due_date_asc + value 'DUE_DATE_DESC', 'Due date by descending order', value: :due_date_desc + value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: :relative_position_asc end end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 11850e5865f..73219ca9e1e 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -85,6 +85,14 @@ module Types field :task_completion_status, Types::TaskCompletionStatus, null: false, description: 'Task completion status of the issue' + + field :designs, Types::DesignManagement::DesignCollectionType, null: true, + method: :design_collection, + deprecated: { reason: 'Use `designCollection`', milestone: '12.2' }, + description: 'The designs associated with this issue' + + field :design_collection, Types::DesignManagement::DesignCollectionType, null: true, + description: 'Collection of design images associated with this issue' end end diff --git a/app/graphql/types/jira_import_type.rb b/app/graphql/types/jira_import_type.rb index ccd463370b6..4a124566ffb 100644 --- a/app/graphql/types/jira_import_type.rb +++ b/app/graphql/types/jira_import_type.rb @@ -7,9 +7,10 @@ module Types class JiraImportType < BaseObject graphql_name 'JiraImport' - field :scheduled_at, Types::TimeType, null: true, - method: :created_at, + field :created_at, Types::TimeType, null: true, description: 'Timestamp of when the Jira import was created' + field :scheduled_at, Types::TimeType, null: true, + description: 'Timestamp of when the Jira import was scheduled' field :scheduled_by, Types::UserType, null: true, description: 'User that started the Jira import' field :jira_project_key, GraphQL::STRING_TYPE, null: false, diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb index e7d09866bb5..d684533ff94 100644 --- a/app/graphql/types/metrics/dashboard_type.rb +++ b/app/graphql/types/metrics/dashboard_type.rb @@ -11,8 +11,7 @@ module Types description: 'Path to a file with the dashboard definition' field :annotations, Types::Metrics::Dashboards::AnnotationType.connection_type, null: true, - description: 'Annotations added to the dashboard. Will always return `null` ' \ - 'if `metrics_dashboard_annotations` feature flag is disabled', + description: 'Annotations added to the dashboard', resolver: Resolvers::Metrics::Dashboards::AnnotationResolver end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/metrics/dashboards/annotation_type.rb b/app/graphql/types/metrics/dashboards/annotation_type.rb index 055d2544eff..0f8f95c187b 100644 --- a/app/graphql/types/metrics/dashboards/annotation_type.rb +++ b/app/graphql/types/metrics/dashboards/annotation_type.rb @@ -16,10 +16,10 @@ module Types field :panel_id, GraphQL::STRING_TYPE, null: true, description: 'ID of a dashboard panel to which the annotation should be scoped' - field :starting_at, GraphQL::STRING_TYPE, null: true, + field :starting_at, Types::TimeType, null: true, description: 'Timestamp marking start of annotated time span' - field :ending_at, GraphQL::STRING_TYPE, null: true, + field :ending_at, Types::TimeType, null: true, description: 'Timestamp marking end of annotated time span' def panel_id diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index ab25d5baf71..aeff84b83b8 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -7,9 +7,12 @@ module Types graphql_name 'Mutation' mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs + mount_mutation Mutations::AlertManagement::CreateAlertIssue + mount_mutation Mutations::AlertManagement::UpdateAlertStatus mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle + mount_mutation Mutations::Branches::Create, calls_gitaly: true mount_mutation Mutations::Issues::SetConfidential mount_mutation Mutations::Issues::SetDueDate mount_mutation Mutations::Issues::Update @@ -19,6 +22,7 @@ module Types mount_mutation Mutations::MergeRequests::SetSubscription mount_mutation Mutations::MergeRequests::SetWip, calls_gitaly: true mount_mutation Mutations::MergeRequests::SetAssignees + mount_mutation Mutations::Metrics::Dashboard::Annotations::Create mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true mount_mutation Mutations::Notes::Create::DiffNote, calls_gitaly: true mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true @@ -40,6 +44,8 @@ module Types mount_mutation Mutations::Snippets::Create mount_mutation Mutations::Snippets::MarkAsSpam mount_mutation Mutations::JiraImport::Start + mount_mutation Mutations::DesignManagement::Upload, calls_gitaly: true + mount_mutation Mutations::DesignManagement::Delete, calls_gitaly: true end end diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb index 2ac66452841..187c9109f8c 100644 --- a/app/graphql/types/notes/noteable_type.rb +++ b/app/graphql/types/notes/noteable_type.rb @@ -17,6 +17,8 @@ module Types Types::MergeRequestType when Snippet Types::SnippetType + when ::DesignManagement::Design + Types::DesignManagement::DesignType else raise "Unknown GraphQL type for #{object}" end @@ -25,5 +27,3 @@ module Types end end end - -Types::Notes::NoteableType.extend_if_ee('::EE::Types::Notes::NoteableType') diff --git a/app/graphql/types/permission_types/issue.rb b/app/graphql/types/permission_types/issue.rb index e26c5950e73..94e1bffd685 100644 --- a/app/graphql/types/permission_types/issue.rb +++ b/app/graphql/types/permission_types/issue.rb @@ -6,11 +6,9 @@ module Types description 'Check permissions for the current user on a issue' graphql_name 'IssuePermissions' - abilities :read_issue, :admin_issue, - :update_issue, :create_note, - :reopen_issue + abilities :read_issue, :admin_issue, :update_issue, :reopen_issue, + :read_design, :create_design, :destroy_design, + :create_note end end end - -Types::PermissionTypes::Issue.prepend_if_ee('::EE::Types::PermissionTypes::Issue') diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb index f773fce0c63..5747e63d195 100644 --- a/app/graphql/types/permission_types/project.rb +++ b/app/graphql/types/permission_types/project.rb @@ -17,7 +17,7 @@ module Types :admin_wiki, :admin_project, :update_pages, :admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki, :create_pages, :destroy_pages, :read_pages_content, :admin_operations, - :read_merge_request + :read_merge_request, :read_design, :create_design, :destroy_design permission_field :create_snippet @@ -27,5 +27,3 @@ module Types end end end - -Types::PermissionTypes::Project.prepend_if_ee('EE::Types::PermissionTypes::Project') diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 8356e763be9..4e438ed2576 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -205,6 +205,38 @@ module Types null: true, description: 'Project services', resolver: Resolvers::Projects::ServicesResolver + + field :alert_management_alerts, + Types::AlertManagement::AlertType.connection_type, + null: true, + description: 'Alert Management alerts of the project', + resolver: Resolvers::AlertManagementAlertResolver + + field :alert_management_alert, + Types::AlertManagement::AlertType, + null: true, + description: 'A single Alert Management alert of the project', + resolver: Resolvers::AlertManagementAlertResolver.single + + field :alert_management_alert_status_counts, + Types::AlertManagement::AlertStatusCountsType, + null: true, + description: 'Counts of alerts by status for the project', + resolver: Resolvers::AlertManagement::AlertStatusCountsResolver + + field :releases, + Types::ReleaseType.connection_type, + null: true, + description: 'Releases of the project', + resolver: Resolvers::ReleasesResolver, + feature_flag: :graphql_release_data + + field :release, + Types::ReleaseType, + null: true, + description: 'A single release of the project', + resolver: Resolvers::ReleasesResolver.single, + feature_flag: :graphql_release_data end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index e8f6eeff3e9..70cdcb62bc6 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -4,11 +4,19 @@ module Types class QueryType < ::Types::BaseObject graphql_name 'Query' + # The design management context object needs to implement #issue + DesignManagementObject = Struct.new(:issue) + field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver, description: "Find a project" + field :projects, Types::ProjectType.connection_type, + null: true, + resolver: Resolvers::ProjectsResolver, + description: "Find projects visible to the current user" + field :group, Types::GroupType, null: true, resolver: Resolvers::GroupResolver, @@ -35,9 +43,17 @@ module Types resolver: Resolvers::SnippetsResolver, description: 'Find Snippets visible to the current user' + field :design_management, Types::DesignManagementType, + null: false, + description: 'Fields related to design management' + field :echo, GraphQL::STRING_TYPE, null: false, description: 'Text to echo back', resolver: Resolvers::EchoResolver + + def design_management + DesignManagementObject.new(nil) + end end end diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb new file mode 100644 index 00000000000..632351be5d3 --- /dev/null +++ b/app/graphql/types/release_type.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Types + class ReleaseType < BaseObject + graphql_name 'Release' + + authorize :read_release + + alias_method :release, :object + + present_using ReleasePresenter + + field :tag_name, GraphQL::STRING_TYPE, null: false, method: :tag, + description: 'Name of the tag associated with the release' + field :tag_path, GraphQL::STRING_TYPE, null: true, + description: 'Relative web path to the tag associated with the release' + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description (also known as "release notes") of the release' + markdown_field :description_html, null: true + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the release' + field :created_at, Types::TimeType, null: true, + description: 'Timestamp of when the release was created' + field :released_at, Types::TimeType, null: true, + description: 'Timestamp of when the release was released' + field :milestones, Types::MilestoneType.connection_type, null: true, + description: 'Milestones associated to the release' + + field :author, Types::UserType, null: true, + description: 'User that created the release' + + def author + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, release.author_id).find + end + + field :commit, Types::CommitType, null: true, + complexity: 10, calls_gitaly: true, + description: 'The commit associated with the release', + authorize: :reporter_access + + def commit + return if release.sha.nil? + + release.project.commit_by(oid: release.sha) + end + end +end diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb index 4ebdbd5766c..b23c4f71ffa 100644 --- a/app/graphql/types/snippet_type.rb +++ b/app/graphql/types/snippet_type.rb @@ -14,7 +14,7 @@ module Types expose_permissions Types::PermissionTypes::Snippet field :id, GraphQL::ID_TYPE, - description: 'Id of the snippet', + description: 'ID of the snippet', null: false field :title, GraphQL::STRING_TYPE, diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb index feff5d20874..dcde1e5a73b 100644 --- a/app/graphql/types/snippets/blob_type.rb +++ b/app/graphql/types/snippets/blob_type.rb @@ -14,6 +14,7 @@ module Types field :plain_data, GraphQL::STRING_TYPE, description: 'Blob plain highlighted data', + calls_gitaly: true, null: true field :raw_path, GraphQL::STRING_TYPE, @@ -48,6 +49,15 @@ module Types field :mode, type: GraphQL::STRING_TYPE, description: 'Blob mode', null: true + + field :external_storage, type: GraphQL::STRING_TYPE, + description: 'Blob external storage', + null: true + + field :rendered_as_text, type: GraphQL::BOOLEAN_TYPE, + description: 'Shows whether the blob is rendered as text', + method: :rendered_as_text?, + null: false end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/snippets/blob_viewer_type.rb b/app/graphql/types/snippets/blob_viewer_type.rb index 3e653576d07..50d0b0522d6 100644 --- a/app/graphql/types/snippets/blob_viewer_type.rb +++ b/app/graphql/types/snippets/blob_viewer_type.rb @@ -17,12 +17,14 @@ module Types field :collapsed, GraphQL::BOOLEAN_TYPE, description: 'Shows whether the blob should be displayed collapsed', method: :collapsed?, - null: false + null: false, + resolve: -> (viewer, _args, _ctx) { !!viewer&.collapsed? } field :too_large, GraphQL::BOOLEAN_TYPE, description: 'Shows whether the blob too large to be displayed', method: :too_large?, - null: false + null: false, + resolve: -> (viewer, _args, _ctx) { !!viewer&.too_large? } field :render_error, GraphQL::STRING_TYPE, description: 'Error rendering the blob content', diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb index 8358a86b35c..a377c3aafdc 100644 --- a/app/graphql/types/todo_target_enum.rb +++ b/app/graphql/types/todo_target_enum.rb @@ -5,6 +5,7 @@ module Types value 'COMMIT', value: 'Commit', description: 'A Commit' value 'ISSUE', value: 'Issue', description: 'An Issue' value 'MERGEREQUEST', value: 'MergeRequest', description: 'A MergeRequest' + value 'DESIGN', value: 'DesignManagement::Design', description: 'A Design' end end diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb index 5ce5093c55e..08e7fabeb74 100644 --- a/app/graphql/types/todo_type.rb +++ b/app/graphql/types/todo_type.rb @@ -10,7 +10,7 @@ module Types authorize :read_todo field :id, GraphQL::ID_TYPE, - description: 'Id of the todo', + description: 'ID of the todo', null: false field :project, Types::ProjectType, diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index e530641d6ae..29a3f5d452f 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -10,8 +10,12 @@ module Types expose_permissions Types::PermissionTypes::User + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the user' field :name, GraphQL::STRING_TYPE, null: false, description: 'Human-readable name of the user' + field :state, GraphQL::STRING_TYPE, null: false, + description: 'State of the issue' field :username, GraphQL::STRING_TYPE, null: false, description: 'Username of the user. Unique within this instance of GitLab' field :avatar_url, GraphQL::STRING_TYPE, null: true, diff --git a/app/helpers/access_tokens_helper.rb b/app/helpers/access_tokens_helper.rb new file mode 100644 index 00000000000..877ad6db576 --- /dev/null +++ b/app/helpers/access_tokens_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module AccessTokensHelper + def scope_description(prefix) + prefix == :project_access_token ? [:doorkeeper, :project_access_token_scope_desc] : [:doorkeeper, :scope_desc] + end +end diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 0c1b2c7d093..3ae9f93a27a 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -25,6 +25,10 @@ module AppearancesHelper markdown_field(current_appearance, :new_project_guidelines) end + def brand_profile_image_guidelines + markdown_field(current_appearance, :profile_image_guidelines) + end + def current_appearance strong_memoize(:current_appearance) do Appearance.current diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a815b378f8b..2df33073a89 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -54,6 +54,10 @@ module ApplicationHelper args.any? { |v| v.to_s.downcase == action_name } end + def admin_section? + controller.class.ancestors.include?(Admin::ApplicationController) + end + def last_commit(project) if project.repo_exists? time_ago_with_tooltip(project.repository.commit.committed_date) diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 443451cd394..b9f0e3582df 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -229,14 +229,7 @@ module ApplicationSettingsHelper :max_artifacts_size, :max_attachment_size, :max_pages_size, - :metrics_enabled, - :metrics_host, :metrics_method_call_threshold, - :metrics_packet_size, - :metrics_pool_size, - :metrics_port, - :metrics_sample_interval, - :metrics_timeout, :minimum_password_length, :mirror_available, :pages_domain_verification_enabled, @@ -310,7 +303,9 @@ module ApplicationSettingsHelper :custom_http_clone_url_root, :snippet_size_limit, :email_restrictions_enabled, - :email_restrictions + :email_restrictions, + :issues_create_limit, + :raw_blob_request_limit ] end @@ -365,7 +360,7 @@ module ApplicationSettingsHelper end end -ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule +ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper') # The methods in `EE::ApplicationSettingsHelper` should be available as both # instance and class methods. diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 1f1ff75359d..a57e27d23c8 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -145,10 +145,14 @@ module AuthHelper IdentityProviderPolicy.new(current_user, provider).can?(:link) end + def allow_admin_mode_password_authentication_for_web? + current_user.allow_password_authentication_for_web? && !current_user.password_automatically_set? + end + extend self end -AuthHelper.prepend_if_ee('EE::AuthHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule +AuthHelper.prepend_if_ee('EE::AuthHelper') # The methods added in EE should be available as both class and instance # methods, just like the methods provided by `AuthHelper` itself. diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 4debf66db64..69fe3303840 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -17,7 +17,7 @@ module BlobHelper options[:link_opts]) end - def ide_edit_path(project = @project, ref = @ref, path = @path, options = {}) + def ide_edit_path(project = @project, ref = @ref, path = @path) project_path = if !current_user || can?(current_user, :push_code, project) project.full_path @@ -52,28 +52,25 @@ module BlobHelper edit_button_tag(blob, common_classes, _('Edit'), - Feature.enabled?(:web_ide_default) ? ide_edit_path(project, ref, path, options) : edit_blob_path(project, ref, path, options), + Feature.enabled?(:web_ide_default) ? ide_edit_path(project, ref, path) : edit_blob_path(project, ref, path, options), project, ref) end - def ide_edit_button(project = @project, ref = @ref, path = @path, options = {}) + def ide_edit_button(project = @project, ref = @ref, path = @path, blob:) return if Feature.enabled?(:web_ide_default) - return unless blob = readable_blob(options, path, project, ref) + return unless blob edit_button_tag(blob, 'btn btn-inverted btn-primary ide-edit-button ml-2', _('Web IDE'), - ide_edit_path(project, ref, path, options), + ide_edit_path(project, ref, path), project, ref) end - def modify_file_button(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:) + def modify_file_button(project = @project, ref = @ref, path = @path, blob:, label:, action:, btn_class:, modal_type:) return unless current_user - - blob = project.repository.blob_at(ref, path) rescue nil - return unless blob common_classes = "btn btn-#{btn_class}" @@ -89,11 +86,12 @@ module BlobHelper end end - def replace_blob_link(project = @project, ref = @ref, path = @path) + def replace_blob_link(project = @project, ref = @ref, path = @path, blob:) modify_file_button( project, ref, path, + blob: blob, label: _("Replace"), action: "replace", btn_class: "default", @@ -101,11 +99,12 @@ module BlobHelper ) end - def delete_blob_link(project = @project, ref = @ref, path = @path) + def delete_blob_link(project = @project, ref = @ref, path = @path, blob:) modify_file_button( project, ref, path, + blob: blob, label: _("Delete"), action: "delete", btn_class: "default", diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index c14bc454bb9..f8c00f3a4cd 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -16,7 +16,8 @@ module BoardsHelper full_path: full_path, bulk_update_path: @bulk_issues_path, time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s, - recent_boards_endpoint: recent_boards_path + recent_boards_endpoint: recent_boards_path, + parent: current_board_parent.model_name.param_key } end diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index e1aed5393ea..c999d1f94ad 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -93,8 +93,8 @@ module ButtonHelper content_tag (href ? :a : :span), (href ? button_content : title), class: "#{title.downcase}-selector #{active_class}", - href: (href if href), - data: (data if data) + href: href, + data: data end end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index a97216f8a22..39aaf242231 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -17,6 +17,17 @@ module ClustersHelper end end + def provider_icon(provider = nil) + case provider + when 'aws' + image_tag 'illustrations/logos/amazon_eks.svg', alt: s_('ClusterIntegration|Amazon EKS'), class: 'gl-h-full' + when 'gcp' + image_tag 'illustrations/logos/google_gke.svg', alt: s_('ClusterIntegration|Google GKE'), class: 'gl-h-full' + else + image_tag 'illustrations/logos/kubernetes.svg', alt: _('Kubernetes Cluster'), class: 'gl-h-full' + end + end + def render_gcp_signup_offer return if Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? return unless show_gcp_signup_offer? diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index ace8bae03ac..2a0c2e73dd6 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -215,6 +215,8 @@ module CommitsHelper def commit_path(project, commit, merge_request: nil) if merge_request&.persisted? diffs_project_merge_request_path(project, merge_request, commit_id: commit.id) + elsif merge_request + project_commit_path(merge_request&.source_project, commit) else project_commit_path(project, commit) end diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index 52f189b122f..bd400009c96 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -25,7 +25,7 @@ module EnvironmentHelper def deployment_link(deployment, text: nil) return unless deployment - link_label = text ? text : "##{deployment.iid}" + link_label = text || "##{deployment.iid}" link_to link_label, deployment_path(deployment) end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 5b640ea6538..e7b561af3da 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -2,7 +2,6 @@ module EnvironmentsHelper include ActionView::Helpers::AssetUrlHelper - prepend_if_ee('::EE::EnvironmentsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule def environments_list_data { @@ -23,31 +22,13 @@ module EnvironmentsHelper end def metrics_data(project, environment) - { - "settings-path" => edit_project_service_path(project, 'prometheus'), - "clusters-path" => project_clusters_path(project), - "current-environment-name" => environment.name, - "documentation-path" => help_page_path('administration/monitoring/prometheus/index.md'), - "empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'), - "empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'), - "empty-no-data-svg-path" => image_path('illustrations/monitoring/no_data.svg'), - "empty-no-data-small-svg-path" => image_path('illustrations/chart-empty-state-small.svg'), - "empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'), - "metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json), - "dashboards-endpoint" => project_performance_monitoring_dashboards_path(project, format: :json), - "dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json), - "deployments-endpoint" => project_environment_deployments_path(project, environment, format: :json), - "default-branch" => project.default_branch, - "project-path" => project_path(project), - "tags-path" => project_tags_path(project), - "has-metrics" => "#{environment.has_metrics?}", - "prometheus-status" => "#{environment.prometheus_status}", - "external-dashboard-url" => project.metrics_setting_external_dashboard_url, - "environment-state" => "#{environment.state}", - "custom-metrics-path" => project_prometheus_metrics_path(project), - "validate-query-path" => validate_query_project_prometheus_metrics_path(project), - "custom-metrics-available" => "#{custom_metrics_available?(project)}" - } + metrics_data = {} + metrics_data.merge!(project_metrics_data(project)) if project + metrics_data.merge!(environment_metrics_data(environment)) if environment + metrics_data.merge!(project_and_environment_metrics_data(project, environment)) if project && environment + metrics_data.merge!(static_metrics_data) + + metrics_data end def environment_logs_data(project, environment) @@ -62,4 +43,60 @@ module EnvironmentsHelper def can_destroy_environment?(environment) can?(current_user, :destroy_environment, environment) end + + private + + def project_metrics_data(project) + return {} unless project + + { + 'settings-path' => edit_project_service_path(project, 'prometheus'), + 'clusters-path' => project_clusters_path(project), + 'dashboards-endpoint' => project_performance_monitoring_dashboards_path(project, format: :json), + 'default-branch' => project.default_branch, + 'project-path' => project_path(project), + 'tags-path' => project_tags_path(project), + 'external-dashboard-url' => project.metrics_setting_external_dashboard_url, + 'custom-metrics-path' => project_prometheus_metrics_path(project), + 'validate-query-path' => validate_query_project_prometheus_metrics_path(project), + 'custom-metrics-available' => "#{custom_metrics_available?(project)}", + 'prometheus-alerts-available' => "#{can?(current_user, :read_prometheus_alerts, project)}" + } + end + + def environment_metrics_data(environment) + return {} unless environment + + { + 'current-environment-name' => environment.name, + 'has-metrics' => "#{environment.has_metrics?}", + 'prometheus-status' => "#{environment.prometheus_status}", + 'environment-state' => "#{environment.state}" + } + end + + def project_and_environment_metrics_data(project, environment) + return {} unless project && environment + + { + 'metrics-endpoint' => additional_metrics_project_environment_path(project, environment, format: :json), + 'dashboard-endpoint' => metrics_dashboard_project_environment_path(project, environment, format: :json), + 'deployments-endpoint' => project_environment_deployments_path(project, environment, format: :json), + 'alerts-endpoint' => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json) + + } + end + + def static_metrics_data + { + 'documentation-path' => help_page_path('administration/monitoring/prometheus/index.md'), + 'empty-getting-started-svg-path' => image_path('illustrations/monitoring/getting_started.svg'), + 'empty-loading-svg-path' => image_path('illustrations/monitoring/loading.svg'), + 'empty-no-data-svg-path' => image_path('illustrations/monitoring/no_data.svg'), + 'empty-no-data-small-svg-path' => image_path('illustrations/chart-empty-state-small.svg'), + 'empty-unable-to-connect-svg-path' => image_path('illustrations/monitoring/unable_to_connect.svg') + } + end end + +EnvironmentsHelper.prepend_if_ee('::EE::EnvironmentsHelper') diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index ba8e046f504..e93aeba6dfd 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -4,12 +4,14 @@ module EventsHelper ICON_NAMES_BY_EVENT_TYPE = { 'pushed to' => 'commit', 'pushed new' => 'commit', + 'updated' => 'commit', 'created' => 'status_open', 'opened' => 'status_open', 'closed' => 'status_closed', 'accepted' => 'fork', 'commented on' => 'comment', 'deleted' => 'remove', + 'destroyed' => 'remove', 'imported' => 'import', 'joined' => 'users' }.freeze @@ -167,6 +169,8 @@ module EventsHelper project_issue_url(event.project, id: event.note_target, anchor: dom_id(event.target)) elsif event.merge_request_note? project_merge_request_url(event.project, id: event.note_target, anchor: dom_id(event.target)) + elsif event.design_note? + design_url(event.note_target, anchor: dom_id(event.note)) else polymorphic_url([event.project.namespace.becomes(Namespace), event.project, event.note_target], @@ -237,6 +241,16 @@ module EventsHelper concat content_tag(:span, event.author.to_reference, class: "username") end end + + private + + def design_url(design, opts) + designs_project_issue_url( + design.project, + design.issue, + opts.merge(vueroute: design.filename) + ) + end end EventsHelper.prepend_if_ee('EE::EventsHelper') diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index d03fa6eadb2..483b350b99b 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -9,7 +9,18 @@ module ExportHelper _('Project configuration, including services'), _('Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities'), _('LFS objects'), - _('Issue Boards') + _('Issue Boards'), + _('Design Management files and data') + ] + end + + def group_export_descriptions + [ + _('Milestones'), + _('Labels'), + _('Boards and Board Lists'), + _('Badges'), + _('Subgroups') ] end end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index b611f700d21..ecacde65c10 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true module FormHelper - prepend_if_ee('::EE::FormHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule - def form_errors(model, type: 'form', truncate: []) return unless model.errors.any? @@ -79,3 +77,5 @@ module FormHelper new_options end end + +FormHelper.prepend_if_ee('::EE::FormHelper') diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 91f8bc33e3e..a6c3c97a873 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -49,6 +49,10 @@ module GroupsHelper can?(current_user, :change_visibility_level, group) end + def can_update_default_branch_protection?(group) + can?(current_user, :update_default_branch_protection, group) + end + def can_change_share_with_group_lock?(group) can?(current_user, :change_share_with_group_lock, group) end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 876789e0d4a..8a32d3c8a3f 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -158,6 +158,6 @@ module IconsHelper def known_sprites return if Rails.env.production? - @known_sprites ||= JSON.parse(File.read(Rails.root.join('node_modules/@gitlab/svgs/dist/icons.json')))['icons'] + @known_sprites ||= Gitlab::Json.parse(File.read(Rails.root.join('node_modules/@gitlab/svgs/dist/icons.json')))['icons'] end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 7e0cc591308..1ce99652463 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -196,7 +196,7 @@ module IssuablesHelper author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline") author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none") - author_output << gitlab_team_member_badge(issuable.author, css_class: 'ml-1') + author_output << issuable_meta_author_slot(issuable.author, css_class: 'ml-1') if status = user_status(issuable.author) author_output << "#{status}".html_safe @@ -213,6 +213,11 @@ module IssuablesHelper output.join.html_safe end + # This is a dummy method, and has an override defined in ee + def issuable_meta_author_slot(author, css_class: nil) + nil + end + def issuable_labels_tooltip(labels, limit: 5) first, last = labels.partition.with_index { |_, i| i < limit } @@ -242,27 +247,6 @@ module IssuablesHelper html.html_safe end - def gitlab_team_member_badge(author, css_class: nil) - return unless author.gitlab_employee? - - default_css_class = 'd-inline-block align-middle' - gitlab_team_member = _('GitLab Team Member') - - content_tag( - :span, - class: css_class ? "#{default_css_class} #{css_class}" : default_css_class, - data: { toggle: 'tooltip', title: gitlab_team_member, container: 'body' }, - role: 'img', - aria: { label: gitlab_team_member } - ) do - sprite_icon( - 'tanuki-verified', - size: 16, - css_class: 'gl-text-purple d-block' - ) - end - end - def issuable_first_contribution_icon content_tag(:span, class: 'fa-stack') do concat(icon('certificate', class: "fa-stack-2x")) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 34b6ba05a62..39edfeea81e 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -9,13 +9,6 @@ module IssuesHelper classes.join(' ') end - # Returns an OpenStruct object suitable for use by <tt>options_from_collection_for_select</tt> - # to allow filtering issues by an unassigned User or Milestone - def unassigned_filter - # Milestone uses :title, Issue uses :name - OpenStruct.new(id: 0, title: 'None (backlog)', name: 'Unassigned') - end - def url_for_issue(issue_iid, project = @project, options = {}) return '' if project.nil? @@ -145,17 +138,12 @@ module IssuesHelper can?(current_user, :create_issue, project) end - def create_confidential_merge_request_enabled? - Feature.enabled?(:create_confidential_merge_request, @project, default_enabled: true) - end - def show_new_branch_button? can_create_confidential_merge_request? || !@issue.confidential? end def can_create_confidential_merge_request? @issue.confidential? && !@project.private? && - create_confidential_merge_request_enabled? && can?(current_user, :create_merge_request_in, @project) end @@ -177,6 +165,10 @@ module IssuesHelper end end + def show_moved_service_desk_issue_warning?(issue) + false + end + # Required for Banzai::Filter::IssueReferenceFilter module_function :url_for_issue module_function :url_for_internal_issue diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 11d5591d509..31995c27fac 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -15,7 +15,18 @@ module MembersHelper elsif member.invite? "revoke the invitation for #{member.invite_email} to join" else - "remove #{member.user.name} from" + if member.user + "remove #{member.user.name} from" + else + e = RuntimeError.new("Data integrity error: no associated user for member ID #{member.id}") + Gitlab::ErrorTracking.track_exception(e, + member_id: member.id, + invite_email: member.invite_email, + invite_accepted_at: member.invite_accepted_at, + source_id: member.source_id, + source_type: member.source_type) + "remove this orphaned member from" + end end "#{text} #{action} the #{member.source.human_name} #{source_text(member)}?" diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 2f5aac892ab..df1ee54c5ac 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -249,7 +249,7 @@ module MilestonesHelper if milestone.legacy_group_milestone? group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: params) else - group_milestone_path(@group, milestone.iid, milestone: params) + group_milestone_path(milestone.group, milestone.iid, milestone: params) end end diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 9de28fb3ed9..228dc2cc27f 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -80,8 +80,8 @@ module NamespacesHelper visibility_level: n.visibility_level_value, visibility: n.visibility, name: n.name, - show_path: (type == 'group') ? group_path(n) : user_path(n), - edit_path: (type == 'group') ? edit_group_path(n) : nil + show_path: type == 'group' ? group_path(n) : user_path(n), + edit_path: type == 'group' ? edit_group_path(n) : nil }] end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 6013475acb1..9ea0b9cb584 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -54,11 +54,12 @@ module NavHelper current_path?('merge_requests#show') || current_path?('projects/merge_requests/conflicts#show') || current_path?('issues#show') || - current_path?('milestones#show') + current_path?('milestones#show') || + current_path?('issues#designs') end def admin_monitoring_nav_links - %w(system_info background_jobs logs health_check requests_profiles) + %w(system_info background_jobs health_check requests_profiles) end def group_issues_sub_menu_items diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 070089d6ef8..7a0462e1b2c 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -70,7 +70,7 @@ module PreferencesHelper end def language_choices - Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] } + Gitlab::I18n::AVAILABLE_LANGUAGES.map(&:reverse).sort end private diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb new file mode 100644 index 00000000000..af86ef715c2 --- /dev/null +++ b/app/helpers/projects/alert_management_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Projects::AlertManagementHelper + def alert_management_data(current_user, project) + { + 'project-path' => project.full_path, + 'enable-alert-management-path' => edit_project_service_path(project, AlertsService), + 'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'), + 'user-can-enable-alert-management' => can?(current_user, :admin_project, project).to_s, + 'alert-management-enabled' => (!!project.alerts_service_activated?).to_s + } + end + + def alert_management_detail_data(project, alert_id) + { + 'alert-id' => alert_id, + 'project-path' => project.full_path, + 'new-issue-path' => new_project_issue_path(project) + } + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 8bec7599158..d743ea6aeea 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true module ProjectsHelper - prepend_if_ee('::EE::ProjectsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule - def project_incident_management_setting @project_incident_management_setting ||= @project.incident_management_setting || @project.build_incident_management_setting @@ -297,11 +295,11 @@ module ProjectsHelper end def show_merge_request_count?(disabled: false, compact_mode: false) - !disabled && !compact_mode && Feature.enabled?(:project_list_show_mr_count, default_enabled: true) + !disabled && !compact_mode end def show_issue_count?(disabled: false, compact_mode: false) - !disabled && !compact_mode && Feature.enabled?(:project_list_show_issue_count, default_enabled: true) + !disabled && !compact_mode end # overridden in EE @@ -448,6 +446,7 @@ module ProjectsHelper clusters: :read_cluster, serverless: :read_cluster, error_tracking: :read_sentry_issue, + alert_management: :read_alert_management_alert, labels: :read_label, issues: :read_issue, project_members: :read_project_member, @@ -588,7 +587,9 @@ module ProjectsHelper pagesAccessLevel: feature.pages_access_level, containerRegistryEnabled: !!project.container_registry_enabled, lfsEnabled: !!project.lfs_enabled, - emailsDisabled: project.emails_disabled? + emailsDisabled: project.emails_disabled?, + metricsDashboardAccessLevel: feature.metrics_dashboard_access_level, + showDefaultAwardEmojis: project.show_default_award_emojis? } end @@ -674,6 +675,7 @@ module ProjectsHelper services#edit hooks#index hooks#edit + access_tokens#index hook_logs#show repository#show ci_cd#show @@ -708,6 +710,7 @@ module ProjectsHelper clusters functions error_tracking + alert_management user gcp logs @@ -737,6 +740,12 @@ module ProjectsHelper Gitlab.config.registry.enabled && can?(current_user, :destroy_container_image, project) end + + def project_access_token_available?(project) + return false if ::Gitlab.com? + + ::Feature.enabled?(:resource_access_token, project) + end end ProjectsHelper.prepend_if_ee('EE::ProjectsHelper') diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index af51427dc91..1238567a4ed 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -30,7 +30,9 @@ module ReleasesHelper markdown_docs_path: help_page_path('user/markdown'), releases_page_path: project_releases_path(@project, anchor: @release.tag), update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'), - release_assets_docs_path: help_page(anchor: 'release-assets') + release_assets_docs_path: help_page(anchor: 'release-assets'), + manage_milestones_path: project_milestones_path(@project), + new_milestone_path: new_project_milestone_url(@project) } end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index e478f76818f..5ad65c59a2e 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -58,8 +58,6 @@ module SearchHelper ns_('SearchResults|comment', 'SearchResults|comments', count) when 'projects' ns_('SearchResults|project', 'SearchResults|projects', count) - when 'snippet_blobs' - ns_('SearchResults|snippet result', 'SearchResults|snippet results', count) when 'snippet_titles' ns_('SearchResults|snippet', 'SearchResults|snippets', count) when 'users' @@ -209,11 +207,11 @@ module SearchHelper end end - def search_filter_input_options(type) + def search_filter_input_options(type, placeholder = _('Search or filter results...')) opts = { id: "filtered-search-#{type}", - placeholder: _('Search or filter results...'), + placeholder: placeholder, data: { 'username-params' => UserSerializer.new.represent(@users) }, diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index f3f4cdc857f..b13cc93436f 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -51,17 +51,13 @@ module ServicesHelper end end - def service_save_button(service) - button_tag(class: 'btn btn-success', type: 'submit', disabled: service.deprecated?, data: { qa_selector: 'save_changes_button' }) do + def service_save_button + button_tag(class: 'btn btn-success', type: 'submit', data: { qa_selector: 'save_changes_button' }) do icon('spinner spin', class: 'hidden js-btn-spinner') + content_tag(:span, 'Save changes', class: 'js-btn-label') end end - def disable_fields_service?(service) - !current_controller?("admin/services") && service.deprecated? - end - def scoped_integrations_path if @project.present? project_settings_integrations_path(@project) @@ -84,7 +80,7 @@ module ServicesHelper def scoped_edit_integration_path(integration) if @project.present? - edit_project_settings_integration_path(@project, integration) + edit_project_service_path(@project, integration) elsif @group.present? edit_group_settings_integration_path(@group, integration) else @@ -105,7 +101,7 @@ module ServicesHelper extend self end -ServicesHelper.prepend_if_ee('EE::ServicesHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule +ServicesHelper.prepend_if_ee('EE::ServicesHelper') # The methods in `EE::ServicesHelper` should be available as both instance and # class methods. diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index a9f90a8f5e4..d6a9e447fbc 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -42,72 +42,6 @@ module SnippetsHelper (lower..upper).to_a end - # Returns a sorted set of lines to be included in a snippet preview. - # This ensures matching adjacent lines do not display duplicated - # surrounding code. - # - # @returns Array, unique and sorted. - def matching_lines(lined_content, surrounding_lines, query) - used_lines = [] - lined_content.each_with_index do |line, line_number| - used_lines.concat bounded_line_numbers( - line_number, - 0, - lined_content.size, - surrounding_lines - ) if line.downcase.include?(query.downcase) - end - - used_lines.uniq.sort - end - - # 'Chunkify' entire snippet. Splits the snippet data into matching lines + - # surrounding_lines() worth of unmatching lines. - # - # @returns a hash with {snippet_object, snippet_chunks:{data,start_line}} - def chunk_snippet(snippet, query, surrounding_lines = 3) - lined_content = snippet.content.split("\n") - used_lines = matching_lines(lined_content, surrounding_lines, query) - - snippet_chunk = [] - snippet_chunks = [] - snippet_start_line = 0 - last_line = -1 - - # Go through each used line, and add consecutive lines as a single chunk - # to the snippet chunk array. - used_lines.each do |line_number| - if last_line < 0 - # Start a new chunk. - snippet_start_line = line_number - snippet_chunk << lined_content[line_number] - elsif last_line == line_number - 1 - # Consecutive line, continue chunk. - snippet_chunk << lined_content[line_number] - else - # Non-consecutive line, add chunk to chunk array. - snippet_chunks << { - data: snippet_chunk.join("\n"), - start_line: snippet_start_line + 1 - } - - # Start a new chunk. - snippet_chunk = [lined_content[line_number]] - snippet_start_line = line_number - end - - last_line = line_number - end - # Add final chunk to chunk array - snippet_chunks << { - data: snippet_chunk.join("\n"), - start_line: snippet_start_line + 1 - } - - # Return snippet with chunk array - { snippet_object: snippet, snippet_chunks: snippet_chunks } - end - def snippet_embed_tag(snippet) content_tag(:script, nil, src: gitlab_snippet_url(snippet, format: :js)) end @@ -160,14 +94,4 @@ module SnippetsHelper title: 'Download', rel: 'noopener noreferrer') end - - def snippet_file_name(snippet) - blob = if Feature.enabled?(:version_snippets, current_user) && !snippet.repository.empty? - snippet.blobs.first - else - snippet.blob - end - - blob.name - end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 3e448087db0..ed1b35338ae 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true module SortingHelper - prepend_if_ee('::EE::SortingHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule - def sort_options_hash { sort_value_created_date => sort_title_created_date, @@ -584,3 +582,5 @@ module SortingHelper 'expired_asc' end end + +SortingHelper.prepend_if_ee('::EE::SortingHelper') diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index d3b6ecf2bd7..7baa615d36f 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -27,7 +27,11 @@ module SystemNoteHelper 'locked' => 'lock', 'unlocked' => 'lock-open', 'due_date' => 'calendar', - 'health_status' => 'status-health' + 'health_status' => 'status-health', + 'designs_added' => 'doc-image', + 'designs_modified' => 'doc-image', + 'designs_removed' => 'doc-image', + 'designs_discussion_added' => 'doc-image' }.freeze def system_note_icon_name(note) @@ -42,7 +46,7 @@ module SystemNoteHelper extend self end -SystemNoteHelper.prepend_if_ee('EE::SystemNoteHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule +SystemNoteHelper.prepend_if_ee('EE::SystemNoteHelper') # The methods in `EE::SystemNoteHelper` should be available as both instance and # class methods. diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 0211a22a8c4..41f39c7e798 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -53,6 +53,8 @@ module TodosHelper end def todo_target_type_name(todo) + return _('design') if todo.for_design? + todo.target_type.titleize.downcase end @@ -63,6 +65,8 @@ module TodosHelper if todo.for_commit? project_commit_path(todo.project, todo.target, path_options) + elsif todo.for_design? + todos_design_path(todo, path_options) else path = [todo.resource_parent, todo.target] @@ -151,7 +155,8 @@ module TodosHelper [ { id: '', text: 'Any Type' }, { id: 'Issue', text: 'Issue' }, - { id: 'MergeRequest', text: 'Merge Request' } + { id: 'MergeRequest', text: 'Merge Request' }, + { id: 'DesignManagement::Design', text: 'Design' } ] end @@ -188,6 +193,18 @@ module TodosHelper private + def todos_design_path(todo, path_options) + design = todo.target + + designs_project_issue_path( + todo.resource_parent, + design.issue, + path_options.merge( + vueroute: design.filename + ) + ) + end + def todo_action_subject(todo) todo.self_added? ? 'yourself' : 'you' end diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index bb5b1555dc4..f74b53d68a1 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -36,8 +36,8 @@ module WorkhorseHelper end # Send an entry from artifacts through Workhorse - def send_artifacts_entry(build, entry) - headers.store(*Gitlab::Workhorse.send_artifacts_entry(build, entry)) + def send_artifacts_entry(file, entry) + headers.store(*Gitlab::Workhorse.send_artifacts_entry(file, entry)) head :ok end diff --git a/app/helpers/x509_helper.rb b/app/helpers/x509_helper.rb index c330b599d74..009635fb629 100644 --- a/app/helpers/x509_helper.rb +++ b/app/helpers/x509_helper.rb @@ -16,4 +16,8 @@ module X509Helper rescue {} end + + def x509_signature?(sig) + sig.is_a?(X509CommitSignature) || sig.is_a?(Gitlab::X509::Signature) + end end diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb new file mode 100644 index 00000000000..07812a01202 --- /dev/null +++ b/app/mailers/emails/groups.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Emails + module Groups + def group_was_exported_email(current_user, group) + group_email(current_user, group, _('Group was exported')) + end + + def group_was_not_exported_email(current_user, group, errors) + group_email(current_user, group, _('Group export error'), errors: errors) + end + + def group_email(current_user, group, subj, errors: nil) + @group = group + @errors = errors + mail(to: current_user.notification_email_for(@group), subject: subject(subj)) + end + end +end diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 6dd4ccb510a..4b56ff60f09 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -40,6 +40,18 @@ module Emails mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id, reason)) end + def note_design_email(recipient_id, note_id, reason = nil) + setup_note_mail(note_id, recipient_id) + + design = @note.noteable + @target_url = ::Gitlab::Routing.url_helpers.designs_project_issue_url( + @note.resource_parent, + design.issue, + note_target_url_query_params.merge(vueroute: design.filename) + ) + mail_answer_note_thread(design, @note, note_thread_options(recipient_id, reason)) + end + private def note_target_url_options diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 441439444d5..4b19149a833 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -44,6 +44,16 @@ module Emails mail(to: @user.notification_email, subject: subject(_("Your Personal Access Tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire })) end end + + def unknown_sign_in_email(user, ip) + @user = user + @ip = ip + @target_url = edit_profile_password_url + + Gitlab::I18n.with_locale(@user.preferred_language) do + mail(to: @user.notification_email, subject: subject(_("Unknown sign-in from new location"))) + end + end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 49eacc44519..d9483bab543 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -17,6 +17,7 @@ class Notify < ApplicationMailer include Emails::AutoDevops include Emails::RemoteMirrors include Emails::Releases + include Emails::Groups helper MilestonesHelper helper MergeRequestsHelper diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 38e1d9532a6..c931b5a848f 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -161,6 +161,10 @@ class NotifyPreview < ActionMailer::Preview Notify.remote_mirror_update_failed_email(remote_mirror.id, user.id).message end + def unknown_sign_in_email + Notify.unknown_sign_in_email(user, '127.0.0.1').message + end + private def project diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 050155398ab..065bd5507be 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -124,7 +124,7 @@ class ActiveSession end end - # Lists the ActiveSession objects for the given session IDs. + # Lists the session Hash objects for the given session IDs. # # session_ids - An array of Rack::Session::SessionId objects # @@ -143,7 +143,7 @@ class ActiveSession end end - # Deserializes an ActiveSession object from Redis. + # Deserializes a session Hash object from Redis. # # raw_session - Raw bytes from Redis # diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb new file mode 100644 index 00000000000..acaf474ecc2 --- /dev/null +++ b/app/models/alert_management/alert.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +module AlertManagement + class Alert < ApplicationRecord + include AtomicInternalId + include ShaAttribute + include Sortable + include Gitlab::SQL::Pattern + + STATUSES = { + triggered: 0, + acknowledged: 1, + resolved: 2, + ignored: 3 + }.freeze + + STATUS_EVENTS = { + triggered: :trigger, + acknowledged: :acknowledge, + resolved: :resolve, + ignored: :ignore + }.freeze + + belongs_to :project + belongs_to :issue, optional: true + has_internal_id :iid, scope: :project, init: ->(s) { s.project.alert_management_alerts.maximum(:iid) } + + self.table_name = 'alert_management_alerts' + + sha_attribute :fingerprint + + HOSTS_MAX_LENGTH = 255 + + validates :title, length: { maximum: 200 }, presence: true + validates :description, length: { maximum: 1_000 } + validates :service, length: { maximum: 100 } + validates :monitoring_tool, length: { maximum: 100 } + validates :project, presence: true + validates :events, presence: true + validates :severity, presence: true + validates :status, presence: true + validates :started_at, presence: true + validates :fingerprint, uniqueness: { scope: :project }, allow_blank: true + validate :hosts_length + + enum severity: { + critical: 0, + high: 1, + medium: 2, + low: 3, + info: 4, + unknown: 5 + } + + state_machine :status, initial: :triggered do + state :triggered, value: STATUSES[:triggered] + + state :acknowledged, value: STATUSES[:acknowledged] + + state :resolved, value: STATUSES[:resolved] do + validates :ended_at, presence: true + end + + state :ignored, value: STATUSES[:ignored] + + state :triggered, :acknowledged, :ignored do + validates :ended_at, absence: true + end + + event :trigger do + transition any => :triggered + end + + event :acknowledge do + transition any => :acknowledged + end + + event :resolve do + transition any => :resolved + end + + event :ignore do + transition any => :ignored + end + + before_transition to: [:triggered, :acknowledged, :ignored] do |alert, _transition| + alert.ended_at = nil + end + + before_transition to: :resolved do |alert, transition| + ended_at = transition.args.first + alert.ended_at = ended_at || Time.current + end + end + + delegate :iid, to: :issue, prefix: true, allow_nil: true + + scope :for_iid, -> (iid) { where(iid: iid) } + scope :for_status, -> (status) { where(status: status) } + scope :for_fingerprint, -> (project, fingerprint) { where(project: project, fingerprint: fingerprint) } + scope :search, -> (query) { fuzzy_search(query, [:title, :description, :monitoring_tool, :service]) } + + scope :order_start_time, -> (sort_order) { order(started_at: sort_order) } + scope :order_end_time, -> (sort_order) { order(ended_at: sort_order) } + scope :order_events_count, -> (sort_order) { order(events: sort_order) } + scope :order_severity, -> (sort_order) { order(severity: sort_order) } + scope :order_status, -> (sort_order) { order(status: sort_order) } + + scope :counts_by_status, -> { group(:status).count } + + def self.sort_by_attribute(method) + case method.to_s + when 'start_time_asc' then order_start_time(:asc) + when 'start_time_desc' then order_start_time(:desc) + when 'end_time_asc' then order_end_time(:asc) + when 'end_time_desc' then order_end_time(:desc) + when 'events_count_asc' then order_events_count(:asc) + when 'events_count_desc' then order_events_count(:desc) + when 'severity_asc' then order_severity(:asc) + when 'severity_desc' then order_severity(:desc) + when 'status_asc' then order_status(:asc) + when 'status_desc' then order_status(:desc) + else + order_by(method) + end + end + + def details + details_payload = payload.except(*attributes.keys) + + Gitlab::Utils::InlineHash.merge_keys(details_payload) + end + + def prometheus? + monitoring_tool == Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus] + end + + private + + def hosts_length + return unless hosts + + errors.add(:hosts, "hosts array is over #{HOSTS_MAX_LENGTH} chars") if hosts.join.length > HOSTS_MAX_LENGTH + end + end +end diff --git a/app/models/appearance.rb b/app/models/appearance.rb index 9da4dfd43b5..00a95070691 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -8,6 +8,7 @@ class Appearance < ApplicationRecord cache_markdown_field :description cache_markdown_field :new_project_guidelines + cache_markdown_field :profile_image_guidelines cache_markdown_field :header_message, pipeline: :broadcast_message cache_markdown_field :footer_message, pipeline: :broadcast_message @@ -15,12 +16,14 @@ class Appearance < ApplicationRecord validates :header_logo, file_size: { maximum: 1.megabyte } validates :message_background_color, allow_blank: true, color: true validates :message_font_color, allow_blank: true, color: true + validates :profile_image_guidelines, length: { maximum: 4096 } validate :single_appearance_row, on: :create default_value_for :title, '' default_value_for :description, '' default_value_for :new_project_guidelines, '' + default_value_for :profile_image_guidelines, '' default_value_for :header_message, '' default_value_for :footer_message, '' default_value_for :message_background_color, '#E75E40' diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 0aa0216558f..b29d6731b08 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -144,7 +144,7 @@ class ApplicationSetting < ApplicationRecord validates :default_artifacts_expire_in, presence: true, duration: true validates :container_expiration_policies_enable_historic_entries, - inclusion: { in: [true, false], message: 'must be a boolean value' } + inclusion: { in: [true, false], message: 'must be a boolean value' } validates :container_registry_token_expire_delay, presence: true, @@ -263,6 +263,8 @@ class ApplicationSetting < ApplicationRecord validates :email_restrictions, untrusted_regexp: true + validates :hashed_storage_enabled, inclusion: { in: [true], message: _("Hashed storage can't be disabled anymore for new projects") } + SUPPORTED_KEY_TYPES.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end @@ -345,6 +347,12 @@ class ApplicationSetting < ApplicationRecord presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :issues_create_limit, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + validates :raw_blob_request_limit, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + attr_encrypted :asset_proxy_secret_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, @@ -412,7 +420,7 @@ class ApplicationSetting < ApplicationRecord # can cause a significant amount of load on Redis, let's cache it in # memory. def self.cache_backend - Gitlab::ThreadMemoryCache.cache_backend + Gitlab::ProcessMemoryCache.cache_backend end def recaptcha_or_login_protection_enabled diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index c96f086684f..221e4d5e0c6 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -43,7 +43,10 @@ module ApplicationSettingImplementation authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand commit_email_hostname: default_commit_email_hostname, container_expiration_policies_enable_historic_entries: false, + container_registry_features: [], container_registry_token_expire_delay: 5, + container_registry_vendor: '', + container_registry_version: '', default_artifacts_expire_in: '30 days', default_branch_protection: Settings.gitlab['default_branch_protection'], default_ci_config_path: nil, @@ -93,7 +96,7 @@ module ApplicationSettingImplementation plantuml_url: nil, polling_interval_multiplier: 1, project_export_enabled: true, - protected_ci_variables: false, + protected_ci_variables: true, push_event_hooks_limit: 3, push_event_activities_limit: 3, raw_blob_request_limit: 300, diff --git a/app/models/blob.rb b/app/models/blob.rb index cdc5838797b..c8df6c7732a 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -86,8 +86,8 @@ class Blob < SimpleDelegator new(blob, container) end - def self.lazy(container, commit_id, path, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE) - BatchLoader.for([commit_id, path]).batch(key: container.repository) do |items, loader, args| + def self.lazy(repository, commit_id, path, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE) + BatchLoader.for([commit_id, path]).batch(key: repository) do |items, loader, args| args[:key].blobs_at(items, blob_size_limit: blob_size_limit).each do |blob| loader.call([blob.commit_id, blob.path], blob) if blob end @@ -129,7 +129,7 @@ class Blob < SimpleDelegator def external_storage_error? if external_storage == :lfs - !project&.lfs_enabled? + !repository.lfs_enabled? else false end diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb index 711465c7c79..a851f22bfcd 100644 --- a/app/models/blob_viewer/dependency_manager.rb +++ b/app/models/blob_viewer/dependency_manager.rb @@ -32,7 +32,7 @@ module BlobViewer def json_data @json_data ||= begin prepare! - JSON.parse(blob.data) + Gitlab::Json.parse(blob.data) rescue {} end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index 0a536a01f72..856f86201ec 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -105,7 +105,10 @@ class BroadcastMessage < ApplicationRecord def matches_current_path(current_path) return true if current_path.blank? || target_path.blank? - current_path.match(Regexp.escape(target_path).gsub('\\*', '.*')) + escaped = Regexp.escape(target_path).gsub('\\*', '.*') + regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE + + regexp.match(current_path) end def flush_redis_cache diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 76882dfcb0d..1e92a47ab49 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -166,6 +166,10 @@ module Ci end end + def dependency_variables + [] + end + private def cross_project_params diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index e515447e394..7f64ea7dd97 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -25,13 +25,16 @@ module Ci RUNNER_FEATURES = { upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }, - refspecs: -> (build) { build.merge_request_ref? } + refspecs: -> (build) { build.merge_request_ref? }, + artifacts_exclude: -> (build) { build.supports_artifacts_exclude? } }.freeze DEFAULT_RETRIES = { scheduler_failure: 2 }.freeze + DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD' + has_one :deployment, as: :deployable, class_name: 'Deployment' has_one :resource, class_name: 'Ci::Resource', inverse_of: :build has_many :trace_sections, class_name: 'Ci::BuildTraceSection' @@ -87,8 +90,12 @@ module Ci scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } - scope :with_artifacts_archive, ->() do - where('EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) + scope :with_downloadable_artifacts, ->() do + where('EXISTS (?)', + Ci::JobArtifact.select(1) + .where('ci_builds.id = ci_job_artifacts.job_id') + .where(file_type: Ci::JobArtifact::DOWNLOADABLE_TYPES) + ) end scope :with_existing_job_artifacts, ->(query) do @@ -130,8 +137,8 @@ module Ci .includes(:metadata, :job_artifacts_metadata) end - scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } - scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } + scope :with_artifacts_not_expired, ->() { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } + scope :with_expired_artifacts, ->() { with_downloadable_artifacts.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) } scope :scheduled_actions, ->() { where(when: :delayed, status: COMPLETED_STATUSES + %i[scheduled]) } @@ -486,8 +493,7 @@ module Ci end def requires_resource? - Feature.enabled?(:ci_resource_group, project, default_enabled: true) && - self.resource_group_id.present? + self.resource_group_id.present? end def has_environment? @@ -530,6 +536,7 @@ module Ci .concat(job_variables) .concat(environment_changed_page_variables) .concat(persisted_environment_variables) + .concat(deploy_freeze_variables) .to_runner_variables end end @@ -585,6 +592,26 @@ module Ci end end + def deploy_freeze_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless freeze_period? + + variables.append(key: 'CI_DEPLOY_FREEZE', value: 'true') + end + end + + def freeze_period? + Ci::FreezePeriodStatus.new(project: project).execute + end + + def dependency_variables + return [] if all_dependencies.empty? + + Gitlab::Ci::Variables::Collection.new.concat( + Ci::JobVariable.where(job: all_dependencies).dotenv_source + ) + end + def features { trace_sections: true } end @@ -870,6 +897,14 @@ module Ci end end + def collect_accessibility_reports!(accessibility_report) + each_report(Ci::JobArtifact::ACCESSIBILITY_REPORT_FILE_TYPES) do |file_type, blob| + Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, accessibility_report) + end + + accessibility_report + end + def collect_coverage_reports!(coverage_report) each_report(Ci::JobArtifact::COVERAGE_REPORT_FILE_TYPES) do |file_type, blob| Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, coverage_report) @@ -878,6 +913,14 @@ module Ci coverage_report end + def collect_terraform_reports!(terraform_reports) + each_report(::Ci::JobArtifact::TERRAFORM_REPORT_FILE_TYPES) do |file_type, blob, report_artifact| + ::Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, terraform_reports, artifact: report_artifact) + end + + terraform_reports + end + def report_artifacts job_artifacts.with_reports end @@ -902,6 +945,16 @@ module Ci failure_reason: :data_integrity_failure) end + def supports_artifacts_exclude? + options&.dig(:artifacts, :exclude)&.any? && + Gitlab::Ci::Features.artifacts_exclude_enabled? + end + + def degradation_threshold + var = yaml_variables.find { |v| v[:key] == DEGRADATION_THRESHOLD_VARIABLE_NAME } + var[:value]&.to_i if var + end + private def dependencies diff --git a/app/models/ci/daily_build_group_report_result.rb b/app/models/ci/daily_build_group_report_result.rb new file mode 100644 index 00000000000..3506b27e974 --- /dev/null +++ b/app/models/ci/daily_build_group_report_result.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ci + class DailyBuildGroupReportResult < ApplicationRecord + extend Gitlab::Ci::Model + + PARAM_TYPES = %w[coverage].freeze + + belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id + belongs_to :project + + def self.upsert_reports(data) + upsert_all(data, unique_by: :index_daily_build_group_report_results_unique_columns) if data.any? + end + + def self.recent_results(attrs, limit: nil) + where(attrs).order(date: :desc, group_name: :asc).limit(limit) + end + end +end diff --git a/app/models/ci/daily_report_result.rb b/app/models/ci/daily_report_result.rb deleted file mode 100644 index 3c1c5f11ed4..00000000000 --- a/app/models/ci/daily_report_result.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Ci - class DailyReportResult < ApplicationRecord - extend Gitlab::Ci::Model - - belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id - belongs_to :project - - # TODO: Refactor this out when BuildReportResult is implemented. - # They both need to share the same enum values for param. - REPORT_PARAMS = { - coverage: 0 - }.freeze - - enum param_type: REPORT_PARAMS - - def self.upsert_reports(data) - upsert_all(data, unique_by: :index_daily_report_results_unique_columns) if data.any? - end - end -end diff --git a/app/models/ci/freeze_period.rb b/app/models/ci/freeze_period.rb new file mode 100644 index 00000000000..bf03b92259a --- /dev/null +++ b/app/models/ci/freeze_period.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ci + class FreezePeriod < ApplicationRecord + include StripAttribute + self.table_name = 'ci_freeze_periods' + + default_scope { order(created_at: :asc) } + + belongs_to :project, inverse_of: :freeze_periods + + strip_attributes :freeze_start, :freeze_end + + validates :freeze_start, cron: true, presence: true + validates :freeze_end, cron: true, presence: true + validates :cron_timezone, cron_freeze_period_timezone: true, presence: true + end +end diff --git a/app/models/ci/freeze_period_status.rb b/app/models/ci/freeze_period_status.rb new file mode 100644 index 00000000000..befa935e750 --- /dev/null +++ b/app/models/ci/freeze_period_status.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Ci + class FreezePeriodStatus + attr_reader :project + + def initialize(project:) + @project = project + end + + def execute + project.freeze_periods.any? { |period| within_freeze_period?(period) } + end + + def within_freeze_period?(period) + # previous_freeze_end, ..., previous_freeze_start, ..., NOW, ..., next_freeze_end, ..., next_freeze_start + # Current time is within a freeze period if + # it falls between a previous freeze start and next freeze end + start_freeze = Gitlab::Ci::CronParser.new(period.freeze_start, period.cron_timezone) + end_freeze = Gitlab::Ci::CronParser.new(period.freeze_end, period.cron_timezone) + + previous_freeze_start = previous_time(start_freeze) + previous_freeze_end = previous_time(end_freeze) + next_freeze_start = next_time(start_freeze) + next_freeze_end = next_time(end_freeze) + + previous_freeze_end < previous_freeze_start && + previous_freeze_start <= time_zone_now && + time_zone_now <= next_freeze_end && + next_freeze_end < next_freeze_start + end + + private + + def previous_time(cron_parser) + cron_parser.previous_time_from(time_zone_now) + end + + def next_time(cron_parser) + cron_parser.next_time_from(time_zone_now) + end + + def time_zone_now + @time_zone_now ||= Time.zone.now + end + end +end diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb index 15dc1ca8954..4b2081f2977 100644 --- a/app/models/ci/group.rb +++ b/app/models/ci/group.rb @@ -46,7 +46,7 @@ module Ci end def self.fabricate(project, stage) - stage.statuses.ordered.latest + stage.latest_statuses .sort_by(&:sortable_name).group_by(&:group_name) .map do |group_name, grouped_statuses| self.new(project, stage, name: group_name, jobs: grouped_statuses) diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb new file mode 100644 index 00000000000..c674f76d229 --- /dev/null +++ b/app/models/ci/instance_variable.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Ci + class InstanceVariable < ApplicationRecord + extend Gitlab::Ci::Model + include Ci::NewHasVariable + include Ci::Maskable + + alias_attribute :secret_value, :value + + validates :key, uniqueness: { + message: "(%{value}) has already been taken" + } + + scope :unprotected, -> { where(protected: false) } + after_commit { self.class.touch_redis_cache_timestamp } + + class << self + def all_cached + cached_data[:all] + end + + def unprotected_cached + cached_data[:unprotected] + end + + def touch_redis_cache_timestamp(time = Time.current.to_f) + shared_backend.write(:ci_instance_variable_changed_at, time) + end + + private + + def cached_data + fetch_memory_cache(:ci_instance_variable_data) do + all_records = unscoped.all.to_a + + { all: all_records, unprotected: all_records.reject(&:protected?) } + end + end + + def fetch_memory_cache(key, &payload) + cache = process_backend.read(key) + + if cache && !stale_cache?(cache) + cache[:data] + else + store_cache(key, &payload) + end + end + + def stale_cache?(cache_info) + shared_timestamp = shared_backend.read(:ci_instance_variable_changed_at) + return true unless shared_timestamp + + shared_timestamp.to_f > cache_info[:cached_at].to_f + end + + def store_cache(key) + data = yield + time = Time.current.to_f + + process_backend.write(key, data: data, cached_at: time) + touch_redis_cache_timestamp(time) + data + end + + def shared_backend + Rails.cache + end + + def process_backend + Gitlab::ProcessMemoryCache.cache_backend + end + end + end +end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index ef0701b3874..d931428dccd 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -12,7 +12,10 @@ module Ci TEST_REPORT_FILE_TYPES = %w[junit].freeze COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze + ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze NON_ERASABLE_FILE_TYPES = %w[trace].freeze + TERRAFORM_REPORT_FILE_TYPES = %w[terraform].freeze + UNSUPPORTED_FILE_TYPES = %i[license_management].freeze DEFAULT_FILE_NAMES = { archive: nil, metadata: nil, @@ -20,6 +23,7 @@ module Ci metrics_referee: nil, network_referee: nil, junit: 'junit.xml', + accessibility: 'gl-accessibility.json', codequality: 'gl-code-quality-report.json', sast: 'gl-sast-report.json', dependency_scanning: 'gl-dependency-scanning-report.json', @@ -32,7 +36,8 @@ module Ci lsif: 'lsif.json', dotenv: '.env', cobertura: 'cobertura-coverage.xml', - terraform: 'tfplan.json' + terraform: 'tfplan.json', + cluster_applications: 'gl-cluster-applications.json' }.freeze INTERNAL_TYPES = { @@ -46,13 +51,15 @@ module Ci metrics: :gzip, metrics_referee: :gzip, network_referee: :gzip, - lsif: :gzip, dotenv: :gzip, cobertura: :gzip, + cluster_applications: :gzip, + lsif: :zip, # All these file formats use `raw` as we need to store them uncompressed # for Frontend to fetch the files and do analysis # When they will be only used by backend, they can be `gzipped`. + accessibility: :raw, codequality: :raw, sast: :raw, dependency_scanning: :raw, @@ -64,15 +71,38 @@ module Ci terraform: :raw }.freeze + DOWNLOADABLE_TYPES = %w[ + accessibility + archive + cobertura + codequality + container_scanning + dast + dependency_scanning + dotenv + junit + license_management + license_scanning + lsif + metrics + performance + sast + ].freeze + TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze + # This is required since we cannot add a default to the database + # https://gitlab.com/gitlab-org/gitlab/-/issues/215418 + attribute :locked, :boolean, default: false + belongs_to :project belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id mount_uploader :file, JobArtifactUploader validates :file_format, presence: true, unless: :trace?, on: :create - validate :valid_file_format?, unless: :trace?, on: :create + validate :validate_supported_file_format!, on: :create + validate :validate_file_format!, unless: :trace?, on: :create before_save :set_size, if: :file_changed? update_project_statistics project_statistics_name: :build_artifacts_size @@ -82,6 +112,7 @@ module Ci scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) } scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) } + scope :for_ref, ->(ref, project_id) { joins(job: :pipeline).where(ci_pipelines: { ref: ref, project_id: project_id }) } scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) } scope :with_file_types, -> (file_types) do @@ -98,10 +129,18 @@ module Ci with_file_types(TEST_REPORT_FILE_TYPES) end + scope :accessibility_reports, -> do + with_file_types(ACCESSIBILITY_REPORT_FILE_TYPES) + end + scope :coverage_reports, -> do with_file_types(COVERAGE_REPORT_FILE_TYPES) end + scope :terraform_reports, -> do + with_file_types(TERRAFORM_REPORT_FILE_TYPES) + end + scope :erasable, -> do types = self.file_types.reject { |file_type| NON_ERASABLE_FILE_TYPES.include?(file_type) }.values @@ -109,6 +148,8 @@ module Ci end scope :expired, -> (limit) { where('expire_at < ?', Time.now).limit(limit) } + scope :locked, -> { where(locked: true) } + scope :unlocked, -> { where(locked: [false, nil]) } scope :scoped_project, -> { where('ci_job_artifacts.project_id = projects.id') } @@ -133,7 +174,9 @@ module Ci lsif: 15, # LSIF data for code navigation dotenv: 16, cobertura: 17, - terraform: 18 # Transformed json + terraform: 18, # Transformed json + accessibility: 19, + cluster_applications: 20 } enum file_format: { @@ -161,7 +204,15 @@ module Ci raw: Gitlab::Ci::Build::Artifacts::Adapters::RawStream }.freeze - def valid_file_format? + def validate_supported_file_format! + return if Feature.disabled?(:drop_license_management_artifact, project, default_enabled: true) + + if UNSUPPORTED_FILE_TYPES.include?(self.file_type&.to_sym) + errors.add(:base, _("File format is no longer supported")) + end + end + + def validate_file_format! unless TYPE_AND_FORMAT_PAIRS[self.file_type&.to_sym] == self.file_format&.to_sym errors.add(:base, _('Invalid file format with specified file type')) end diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb index f156219ea81..250306e2be4 100644 --- a/app/models/ci/legacy_stage.rb +++ b/app/models/ci/legacy_stage.rb @@ -41,6 +41,10 @@ module Ci .fabricate! end + def latest_statuses + statuses.ordered.latest + end + def statuses @statuses ||= pipeline.statuses.where(stage: name) end diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb index 76139f5d676..91163c85a9e 100644 --- a/app/models/ci/persistent_ref.rb +++ b/app/models/ci/persistent_ref.rb @@ -14,16 +14,12 @@ module Ci delegate :ref_exists?, :create_ref, :delete_refs, to: :repository def exist? - return unless enabled? - ref_exists?(path) rescue false end def create - return unless enabled? - create_ref(sha, path) rescue => e Gitlab::ErrorTracking @@ -31,8 +27,6 @@ module Ci end def delete - return unless enabled? - delete_refs(path) rescue Gitlab::Git::Repository::NoRepository # no-op @@ -44,11 +38,5 @@ module Ci def path "refs/#{Repository::REF_PIPELINES}/#{pipeline.id}" end - - private - - def enabled? - Feature.enabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true) - end end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 8a3ca2e758c..5db1635f64d 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -82,7 +82,7 @@ module Ci has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline - has_many :daily_report_results, class_name: 'Ci::DailyReportResult', foreign_key: :last_pipeline_id + has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult', foreign_key: :last_pipeline_id accepts_nested_attributes_for :variables, reject_if: :persisted? @@ -115,8 +115,11 @@ module Ci state_machine :status, initial: :created do event :enqueue do - transition [:created, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending + transition [:created, :manual, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending transition [:success, :failed, :canceled] => :running + + # this is needed to ensure tests to be covered + transition [:running] => :running end event :request_resource do @@ -194,7 +197,7 @@ module Ci # We wait a little bit to ensure that all BuildFinishedWorkers finish first # because this is where some metrics like code coverage is parsed and stored # in CI build records which the daily build metrics worker relies on. - pipeline.run_after_commit { Ci::DailyReportResultsWorker.perform_in(10.minutes, pipeline.id) } + pipeline.run_after_commit { Ci::DailyBuildGroupReportResultsWorker.perform_in(10.minutes, pipeline.id) } end after_transition do |pipeline, transition| @@ -393,16 +396,18 @@ module Ci false end - ## - # TODO We do not completely switch to persisted stages because of - # race conditions with setting statuses gitlab-foss#23257. - # def ordered_stages - return legacy_stages unless complete? - - if Feature.enabled?('ci_pipeline_persisted_stages', default_enabled: true) + if Feature.enabled?(:ci_atomic_processing, project, default_enabled: false) + # The `Ci::Stage` contains all up-to date data + # as atomic processing updates all data in-bulk + stages + elsif Feature.enabled?(:ci_pipeline_persisted_stages, default_enabled: true) && complete? + # The `Ci::Stage` contains up-to date data only for `completed` pipelines + # this is due to asynchronous processing of pipeline, and stages possibly + # not updated inline with processing of pipeline stages else + # In other cases, we need to calculate stages dynamically legacy_stages end end @@ -440,7 +445,7 @@ module Ci end def legacy_stages - if Feature.enabled?(:ci_composite_status, default_enabled: false) + if Feature.enabled?(:ci_composite_status, project, default_enabled: false) legacy_stages_using_composite_status else legacy_stages_using_sql @@ -681,6 +686,8 @@ module Ci variables.concat(merge_request.predefined_variables) end + variables.append(key: 'CI_KUBERNETES_ACTIVE', value: 'true') if has_kubernetes_active? + if external_pull_request_event? && external_pull_request variables.concat(external_pull_request.predefined_variables) end @@ -781,7 +788,7 @@ module Ci end def find_job_with_archive_artifacts(name) - builds.latest.with_artifacts_archive.find_by_name(name) + builds.latest.with_downloadable_artifacts.find_by_name(name) end def latest_builds_with_artifacts @@ -809,6 +816,14 @@ module Ci end end + def accessibility_reports + Gitlab::Ci::Reports::AccessibilityReports.new.tap do |accessibility_reports| + builds.latest.with_reports(Ci::JobArtifact.accessibility_reports).each do |build| + build.collect_accessibility_reports!(accessibility_reports) + end + end + end + def coverage_reports Gitlab::Ci::Reports::CoverageReports.new.tap do |coverage_reports| builds.latest.with_reports(Ci::JobArtifact.coverage_reports).each do |build| @@ -817,6 +832,14 @@ module Ci end end + def terraform_reports + ::Gitlab::Ci::Reports::TerraformReports.new.tap do |terraform_reports| + builds.latest.with_reports(::Ci::JobArtifact.terraform_reports).each do |build| + build.collect_terraform_reports!(terraform_reports) + end + end + end + def has_exposed_artifacts? complete? && builds.latest.with_exposed_artifacts.exists? end @@ -938,6 +961,14 @@ module Ci end end + # Set scheduling type of processables if they were created before scheduling_type + # data was deployed (https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22246). + def ensure_scheduling_type! + return unless ::Gitlab::Ci::Features.ensure_scheduling_type_enabled? + + processables.populate_scheduling_type! + end + private def pipeline_data diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index f5785000062..8c9ad343f32 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -6,6 +6,10 @@ module Ci include Importable include StripAttribute include Schedulable + include Limitable + + self.limit_name = 'ci_pipeline_schedules' + self.limit_scope = :project belongs_to :project belongs_to :owner, class_name: 'User' diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index c123bd7c33b..cc00500662d 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -49,7 +49,7 @@ module Ci end validates :type, presence: true - validates :scheduling_type, presence: true, on: :create, if: :validate_scheduling_type? + validates :scheduling_type, presence: true, on: :create, unless: :importing? delegate :merge_request?, :merge_request_ref?, @@ -83,7 +83,7 @@ module Ci # Overriding scheduling_type enum's method for nil `scheduling_type`s def scheduling_type_dag? - super || find_legacy_scheduling_type == :dag + scheduling_type.nil? ? find_legacy_scheduling_type == :dag : super end # scheduling_type column of previous builds/bridges have not been populated, @@ -100,10 +100,12 @@ module Ci end end - private + def ensure_scheduling_type! + # If this has a scheduling_type, it means all processables in the pipeline already have. + return if scheduling_type - def validate_scheduling_type? - !importing? && Feature.enabled?(:validate_scheduling_type_of_processables, project) + pipeline.ensure_scheduling_type! + reset end end end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 93bd42f8734..a316b4718e0 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -13,6 +13,7 @@ module Ci belongs_to :pipeline has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id + has_many :latest_statuses, -> { ordered.latest }, class_name: 'CommitStatus', foreign_key: :stage_id has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id has_many :builds, foreign_key: :stage_id has_many :bridges, foreign_key: :stage_id @@ -42,8 +43,7 @@ module Ci state_machine :status, initial: :created do event :enqueue do - transition [:created, :waiting_for_resource, :preparing] => :pending - transition [:success, :failed, :canceled, :skipped] => :running + transition any - [:pending] => :pending end event :request_resource do diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb index afdc1c91c69..0d029aabc3b 100644 --- a/app/models/clusters/applications/elastic_stack.rb +++ b/app/models/clusters/applications/elastic_stack.rb @@ -3,7 +3,7 @@ module Clusters module Applications class ElasticStack < ApplicationRecord - VERSION = '1.9.0' + VERSION = '3.0.0' ELASTICSEARCH_PORT = 9200 @@ -18,7 +18,11 @@ module Clusters default_value_for :version, VERSION def chart - 'stable/elastic-stack' + 'elastic-stack/elastic-stack' + end + + def repository + 'https://charts.gitlab.io' end def install_command @@ -27,7 +31,9 @@ module Clusters version: VERSION, rbac: cluster.platform_kubernetes_rbac?, chart: chart, + repository: repository, files: files, + preinstall: migrate_to_3_script, postinstall: post_install_script ) end @@ -49,7 +55,7 @@ module Clusters strong_memoize(:elasticsearch_client) do next unless kube_client - proxy_url = kube_client.proxy_url('service', 'elastic-stack-elasticsearch-client', ::Clusters::Applications::ElasticStack::ELASTICSEARCH_PORT, Gitlab::Kubernetes::Helm::NAMESPACE) + proxy_url = kube_client.proxy_url('service', service_name, ::Clusters::Applications::ElasticStack::ELASTICSEARCH_PORT, Gitlab::Kubernetes::Helm::NAMESPACE) Elasticsearch::Client.new(url: proxy_url) do |faraday| # ensures headers containing auth data are appended to original client options @@ -69,23 +75,54 @@ module Clusters end end + def chart_above_v2? + Gem::Version.new(version) >= Gem::Version.new('2.0.0') + end + + def chart_above_v3? + Gem::Version.new(version) >= Gem::Version.new('3.0.0') + end + private + def service_name + chart_above_v3? ? 'elastic-stack-elasticsearch-master' : 'elastic-stack-elasticsearch-client' + end + + def pvc_selector + chart_above_v3? ? "app=elastic-stack-elasticsearch-master" : "release=elastic-stack" + end + def post_install_script [ - "timeout -t60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-client:9200" + "timeout -t60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-master:9200" ] end def post_delete_script [ - Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack") + Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", pvc_selector, "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE) ] end def kube_client cluster&.kubeclient&.core_client end + + def migrate_to_3_script + return [] if !updating? || chart_above_v3? + + # Chart version 3.0.0 moves to our own chart at https://gitlab.com/gitlab-org/charts/elastic-stack + # and is not compatible with pre-existing resources. We first remove them. + [ + Gitlab::Kubernetes::Helm::DeleteCommand.new( + name: 'elastic-stack', + rbac: cluster.platform_kubernetes_rbac?, + files: files + ).delete_command, + Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack", "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE) + ] + end end end end diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb index a33b1e39ace..3fd6e870edc 100644 --- a/app/models/clusters/applications/fluentd.rb +++ b/app/models/clusters/applications/fluentd.rb @@ -4,6 +4,7 @@ module Clusters module Applications class Fluentd < ApplicationRecord VERSION = '2.4.0' + CILIUM_CONTAINER_NAME = 'cilium-monitor' self.table_name = 'clusters_applications_fluentd' @@ -18,6 +19,8 @@ module Clusters enum protocol: { tcp: 0, udp: 1 } + validate :has_at_least_one_log_enabled? + def chart 'stable/fluentd' end @@ -39,6 +42,12 @@ module Clusters private + def has_at_least_one_log_enabled? + if !waf_log_enabled && !cilium_log_enabled + errors.add(:base, _("At least one logging option is required to be enabled")) + end + end + def content_values YAML.load_file(chart_values_file).deep_merge!(specification) end @@ -62,7 +71,7 @@ module Clusters program fluentd hostname ${kubernetes_host} protocol #{protocol} - packet_size 65535 + packet_size 131072 <buffer kubernetes_host> </buffer> <format> @@ -85,7 +94,7 @@ module Clusters <source> @type tail @id in_tail_container_logs - path /var/log/containers/*#{Ingress::MODSECURITY_LOG_CONTAINER_NAME}*.log + path #{path_to_logs} pos_file /var/log/fluentd-containers.log.pos tag kubernetes.* read_from_head true @@ -96,6 +105,13 @@ module Clusters </source> EOF end + + def path_to_logs + path = [] + path << "/var/log/containers/*#{Ingress::MODSECURITY_LOG_CONTAINER_NAME}*.log" if waf_log_enabled + path << "/var/log/containers/*#{CILIUM_CONTAINER_NAME}*.log" if cilium_log_enabled + path.join(',') + end end end end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 5985e08d73e..dd354198910 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -17,6 +17,7 @@ module Clusters include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData include AfterCommitQueue + include UsageStatistics default_value_for :ingress_type, :nginx default_value_for :modsecurity_enabled, true @@ -29,6 +30,10 @@ module Clusters enum modsecurity_mode: { logging: 0, blocking: 1 } + scope :modsecurity_not_installed, -> { where(modsecurity_enabled: nil) } + scope :modsecurity_enabled, -> { where(modsecurity_enabled: true) } + scope :modsecurity_disabled, -> { where(modsecurity_enabled: false) } + FETCH_IP_ADDRESS_DELAY = 30.seconds state_machine :status do @@ -98,7 +103,7 @@ module Clusters "args" => [ "/bin/sh", "-c", - "tail -f /var/log/modsec/audit.log" + "tail -F /var/log/modsec/audit.log" ], "volumeMounts" => [ { diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index 42fa4a6f179..056ea355de6 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -5,7 +5,7 @@ require 'securerandom' module Clusters module Applications class Jupyter < ApplicationRecord - VERSION = '0.9.0-beta.2' + VERSION = '0.9.0' self.table_name = 'clusters_applications_jupyter' diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 1f90318f845..3047da12dd9 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -4,8 +4,8 @@ module Clusters module Applications class Knative < ApplicationRecord VERSION = '0.9.0' - REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts' - METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml' + REPOSITORY = 'https://charts.gitlab.io' + METRICS_CONFIG = 'https://gitlab.com/gitlab-org/charts/knative/-/raw/v0.9.0/vendor/istio-metrics.yml' FETCH_IP_ADDRESS_DELAY = 30.seconds API_GROUPS_PATH = 'config/knative/api_groups.yml' diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 7d67e258991..a861126908f 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.15.0' + VERSION = '0.16.1' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 430a9b3c43e..83f558af1a1 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -26,6 +26,8 @@ module Clusters KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN' APPLICATIONS_ASSOCIATIONS = APPLICATIONS.values.map(&:association_name).freeze + self.reactive_cache_work_type = :external_dependency + belongs_to :user belongs_to :management_project, class_name: '::Project', optional: true @@ -33,6 +35,7 @@ module Clusters has_many :projects, through: :cluster_projects, class_name: '::Project' has_one :cluster_project, -> { order(id: :desc) }, class_name: 'Clusters::Project' has_many :deployment_clusters + has_many :deployments, inverse_of: :cluster has_many :cluster_groups, class_name: 'Clusters::Group' has_many :groups, through: :cluster_groups, class_name: '::Group' @@ -203,10 +206,16 @@ module Clusters end end + def nodes + with_reactive_cache do |data| + data[:nodes] + end + end + def calculate_reactive_cache return unless enabled? - { connection_status: retrieve_connection_status } + { connection_status: retrieve_connection_status, nodes: retrieve_nodes } end def persisted_applications @@ -214,11 +223,19 @@ module Clusters end def applications - APPLICATIONS_ASSOCIATIONS.map do |association_name| - public_send(association_name) || public_send("build_#{association_name}") # rubocop:disable GitlabSecurity/PublicSend + APPLICATIONS.each_value.map do |application_class| + find_or_build_application(application_class) end end + def find_or_build_application(application_class) + raise ArgumentError, "#{application_class} is not in APPLICATIONS" unless APPLICATIONS.value?(application_class) + + association_name = application_class.association_name + + public_send(association_name) || public_send("build_#{association_name}") # rubocop:disable GitlabSecurity/PublicSend + end + def provider if gcp? provider_gcp @@ -345,32 +362,55 @@ module Clusters end def retrieve_connection_status - kubeclient.core_client.discover - rescue *Gitlab::Kubernetes::Errors::CONNECTION - :unreachable - rescue *Gitlab::Kubernetes::Errors::AUTHENTICATION - :authentication_failure - rescue Kubeclient::HttpError => e - kubeclient_error_status(e.message) - rescue => e - Gitlab::ErrorTracking.track_exception(e, cluster_id: id) - - :unknown_failure - else - :connected - end - - # KubeClient uses the same error class - # For connection errors (eg. timeout) and - # for Kubernetes errors. - def kubeclient_error_status(message) - if message&.match?(/timed out|timeout/i) - :unreachable - else - :authentication_failure + result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.core_client.discover } + result[:status] + end + + def retrieve_nodes + result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.get_nodes } + cluster_nodes = result[:response].to_a + + result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.metrics_client.get_nodes } + nodes_metrics = result[:response].to_a + + cluster_nodes.inject([]) do |memo, node| + sliced_node = filter_relevant_node_attributes(node) + + matched_node_metric = nodes_metrics.find { |node_metric| node_metric.metadata.name == node.metadata.name } + + sliced_node_metrics = matched_node_metric ? filter_relevant_node_metrics_attributes(matched_node_metric) : {} + + memo << sliced_node.merge(sliced_node_metrics) end end + def filter_relevant_node_attributes(node) + { + 'metadata' => { + 'name' => node.metadata.name + }, + 'status' => { + 'capacity' => { + 'cpu' => node.status.capacity.cpu, + 'memory' => node.status.capacity.memory + }, + 'allocatable' => { + 'cpu' => node.status.allocatable.cpu, + 'memory' => node.status.allocatable.memory + } + } + } + end + + def filter_relevant_node_metrics_attributes(node_metrics) + { + 'usage' => { + 'cpu' => node_metrics.usage.cpu, + 'memory' => node_metrics.usage.memory + } + } + end + # To keep backward compatibility with AUTO_DEVOPS_DOMAIN # environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN # is set if AUTO_DEVOPS_DOMAIN is set on any of the following options: diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 14237439a8d..0b915126f8a 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -27,6 +27,7 @@ module Clusters state :update_errored, value: 6 state :uninstalling, value: 7 state :uninstall_errored, value: 8 + state :uninstalled, value: 10 # Used for applications that are pre-installed by the cluster, # e.g. Knative in GCP Cloud Run enabled clusters @@ -35,6 +36,14 @@ module Clusters # and no exit transitions. state :pre_installed, value: 9 + event :make_externally_installed do + transition any => :installed + end + + event :make_externally_uninstalled do + transition any => :uninstalled + end + event :make_scheduled do transition [:installable, :errored, :installed, :updated, :update_errored, :uninstall_errored] => :scheduled end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 046f131b041..7e99f128dad 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -7,8 +7,6 @@ class CommitStatus < ApplicationRecord include Presentable include EnumWithNil - prepend_if_ee('::EE::CommitStatus') # rubocop: disable Cop/InjectEnterpriseEditionModule - self.table_name = 'ci_builds' belongs_to :user @@ -267,8 +265,16 @@ class CommitStatus < ApplicationRecord end end + def recoverable? + failed? && !unrecoverable_failure? + end + private + def unrecoverable_failure? + script_failure? || missing_dependency_failure? || archived_failure? || scheduler_failure? || data_integrity_failure? + end + def schedule_stage_and_pipeline_update if Feature.enabled?(:ci_atomic_processing, project) # Atomic Processing requires only single Worker @@ -284,3 +290,5 @@ class CommitStatus < ApplicationRecord end end end + +CommitStatus.prepend_if_ee('::EE::CommitStatus') diff --git a/app/models/concerns/async_devise_email.rb b/app/models/concerns/async_devise_email.rb new file mode 100644 index 00000000000..38c99dc7e71 --- /dev/null +++ b/app/models/concerns/async_devise_email.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module AsyncDeviseEmail + extend ActiveSupport::Concern + + private + + # Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration + def send_devise_notification(notification, *args) + return true unless can?(:receive_notifications) + + devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend + end +end diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index 0f2a389f0a3..896f0916d8c 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -14,32 +14,29 @@ module Awardable class_methods do def awarded(user, name = nil) - sql = <<~EOL - EXISTS ( - SELECT TRUE - FROM award_emoji - WHERE user_id = :user_id AND - #{"name = :name AND" if name.present?} - awardable_type = :awardable_type AND - awardable_id = #{self.arel_table.name}.id - ) - EOL + award_emoji_table = Arel::Table.new('award_emoji') + inner_query = award_emoji_table + .project('true') + .where(award_emoji_table[:user_id].eq(user.id)) + .where(award_emoji_table[:awardable_type].eq(self.name)) + .where(award_emoji_table[:awardable_id].eq(self.arel_table[:id])) + + inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present? - where(sql, user_id: user.id, name: name, awardable_type: self.name) + where(inner_query.exists) end - def not_awarded(user) - sql = <<~EOL - NOT EXISTS ( - SELECT TRUE - FROM award_emoji - WHERE user_id = :user_id AND - awardable_type = :awardable_type AND - awardable_id = #{self.arel_table.name}.id - ) - EOL + def not_awarded(user, name = nil) + award_emoji_table = Arel::Table.new('award_emoji') + inner_query = award_emoji_table + .project('true') + .where(award_emoji_table[:user_id].eq(user.id)) + .where(award_emoji_table[:awardable_type].eq(self.name)) + .where(award_emoji_table[:awardable_id].eq(self.arel_table[:id])) + + inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present? - where(sql, user_id: user.id, awardable_type: self.name) + where(inner_query.exists.not) end def order_upvotes_desc @@ -77,7 +74,7 @@ module Awardable # By default we always load award_emoji user association awards = award_emoji.group_by(&:name) - if with_thumbs + if with_thumbs && (!project || project.show_default_award_emojis?) awards[AwardEmoji::UPVOTE_NAME] ||= [] awards[AwardEmoji::DOWNVOTE_NAME] ||= [] end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index cc13f279c4d..e4e0f55d5f4 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -161,7 +161,6 @@ module CacheMarkdownField define_method(invalidation_method) do changed_fields = changed_attributes.keys invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY] - invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html") !invalidations.empty? || !cached_html_up_to_date?(markdown_field) end end diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb index 5ff537a7837..ccd90ea5900 100644 --- a/app/models/concerns/ci/contextable.rb +++ b/app/models/concerns/ci/contextable.rb @@ -18,6 +18,8 @@ module Ci variables.concat(deployment_variables(environment: environment)) variables.concat(yaml_variables) variables.concat(user_variables) + variables.concat(dependency_variables) if Feature.enabled?(:ci_dependency_variables, project) + variables.concat(secret_instance_variables) variables.concat(secret_group_variables) variables.concat(secret_project_variables(environment: environment)) variables.concat(trigger_request.user_variables) if trigger_request @@ -81,6 +83,12 @@ module Ci ) end + def secret_instance_variables + return [] unless ::Feature.enabled?(:ci_instance_level_variables, project, default_enabled: true) + + project.ci_instance_variables_for(ref: git_ref) + end + def secret_group_variables return [] unless project.group diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb index 6484a3157b1..cea3c7d119c 100644 --- a/app/models/concerns/diff_positionable_note.rb +++ b/app/models/concerns/diff_positionable_note.rb @@ -17,12 +17,14 @@ module DiffPositionableNote %i(original_position position change_position).each do |meth| define_method "#{meth}=" do |new_position| if new_position.is_a?(String) - new_position = JSON.parse(new_position) rescue nil + new_position = Gitlab::Json.parse(new_position) rescue nil end if new_position.is_a?(Hash) new_position = new_position.with_indifferent_access new_position = Gitlab::Diff::Position.new(new_position) + elsif !new_position.is_a?(Gitlab::Diff::Position) + new_position = nil end return if new_position == read_attribute(meth) diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index af7afd6604a..29d31b8bb4f 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -9,7 +9,6 @@ # needs any special behavior. module HasRepository extend ActiveSupport::Concern - include AfterCommitQueue include Referable include Gitlab::ShellAdapter include Gitlab::Utils::StrongMemoize diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb new file mode 100644 index 00000000000..8a238dc736c --- /dev/null +++ b/app/models/concerns/has_user_type.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module HasUserType + extend ActiveSupport::Concern + + USER_TYPES = { + human: nil, + support_bot: 1, + alert_bot: 2, + visual_review_bot: 3, + service_user: 4, + ghost: 5, + project_bot: 6, + migration_bot: 7 + }.with_indifferent_access.freeze + + BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot].freeze + NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].freeze + INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze + + included do + scope :humans, -> { where(user_type: :human) } + scope :bots, -> { where(user_type: BOT_USER_TYPES) } + scope :bots_without_project_bot, -> { where(user_type: BOT_USER_TYPES - ['project_bot']) } + scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) } + scope :without_ghosts, -> { humans.or(where.not(user_type: :ghost)) } + scope :without_project_bot, -> { humans.or(where.not(user_type: :project_bot)) } + + enum user_type: USER_TYPES + + def human? + super || user_type.nil? + end + end + + def bot? + BOT_USER_TYPES.include?(user_type) + end + + # The explicit check for project_bot will be removed with Bot Categorization + # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945 + def internal? + ghost? || (bot? && !project_bot?) + end +end diff --git a/app/models/concerns/has_wiki.rb b/app/models/concerns/has_wiki.rb new file mode 100644 index 00000000000..4dd72216e77 --- /dev/null +++ b/app/models/concerns/has_wiki.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module HasWiki + extend ActiveSupport::Concern + + included do + validate :check_wiki_path_conflict + end + + def create_wiki + wiki.wiki + true + rescue Wiki::CouldNotCreateWikiError + errors.add(:base, _('Failed to create wiki')) + false + end + + def wiki + strong_memoize(:wiki) do + Wiki.for_container(self, self.owner) + end + end + + def wiki_repository_exists? + wiki.repository_exists? + end + + def after_wiki_activity + true + end + + private + + def check_wiki_path_conflict + return if path.blank? + + path_to_check = path.ends_with?('.wiki') ? path.chomp('.wiki') : "#{path}.wiki" + + if Project.in_namespace(parent_id).where(path: path_to_check).exists? || + GroupsFinder.new(nil, parent: parent_id).execute.where(path: path_to_check).exists? + errors.add(:name, _('has already been taken')) + end + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 37f2209b9d2..a1b14dca4ac 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -115,9 +115,31 @@ module Issuable end # rubocop:enable GitlabSecurity/SqlInjection + scope :not_assigned_to, ->(users) do + assignees_table = Arel::Table.new("#{to_ability_name}_assignees") + sql = assignees_table.project('true') + .where(assignees_table[:user_id].in(users)) + .where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) + where(sql.exists.not) + end + + scope :without_particular_labels, ->(label_names) do + labels_table = Label.arel_table + label_links_table = LabelLink.arel_table + issuables_table = klass.arel_table + inner_query = label_links_table.project('true') + .join(labels_table, Arel::Nodes::InnerJoin).on(labels_table[:id].eq(label_links_table[:label_id])) + .where(label_links_table[:target_type].eq(name) + .and(label_links_table[:target_id].eq(issuables_table[:id])) + .and(labels_table[:title].in(label_names))) + .exists.not + + where(inner_query) + end + scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) } scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) } - scope :any_label, -> { joins(:label_links).group(:id) } + scope :any_label, -> { joins(:label_links).distinct } scope :join_project, -> { joins(:project) } scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) } scope :references_project, -> { references(:project) } @@ -286,9 +308,8 @@ module Issuable .reorder(Gitlab::Database.nulls_last_order('highest_priority', direction)) end - def with_label(title, sort = nil, not_query: false) - multiple_labels = title.is_a?(Array) && title.size > 1 - if multiple_labels && !not_query + def with_label(title, sort = nil) + if title.is_a?(Array) && title.size > 1 joins(:labels).where(labels: { title: title }).group(*grouping_columns(sort)).having("COUNT(DISTINCT labels.title) = #{title.size}") else joins(:labels).where(labels: { title: title }) diff --git a/app/models/concerns/issue_resource_event.rb b/app/models/concerns/issue_resource_event.rb new file mode 100644 index 00000000000..1c24032dbbb --- /dev/null +++ b/app/models/concerns/issue_resource_event.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module IssueResourceEvent + extend ActiveSupport::Concern + + included do + belongs_to :issue + + scope :by_issue, ->(issue) { where(issue_id: issue.id) } + + scope :by_issue_ids_and_created_at_earlier_or_equal_to, ->(issue_ids, time) { where(issue_id: issue_ids).where('created_at <= ?', time) } + end +end diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb new file mode 100644 index 00000000000..f320f54bb82 --- /dev/null +++ b/app/models/concerns/limitable.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Limitable + extend ActiveSupport::Concern + + included do + class_attribute :limit_scope + class_attribute :limit_name + self.limit_name = self.name.demodulize.tableize + + validate :validate_plan_limit_not_exceeded, on: :create + end + + private + + def validate_plan_limit_not_exceeded + scope_relation = self.public_send(limit_scope) # rubocop:disable GitlabSecurity/PublicSend + return unless scope_relation + + relation = self.class.where(limit_scope => scope_relation) + + if scope_relation.actual_limits.exceeded?(limit_name, relation) + errors.add(:base, _("Maximum number of %{name} (%{count}) exceeded") % + { name: limit_name.humanize(capitalize: false), count: scope_relation.actual_limits.public_send(limit_name) }) # rubocop:disable GitlabSecurity/PublicSend + end + end +end diff --git a/app/models/concerns/merge_request_resource_event.rb b/app/models/concerns/merge_request_resource_event.rb new file mode 100644 index 00000000000..7fb7fb4ec62 --- /dev/null +++ b/app/models/concerns/merge_request_resource_event.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module MergeRequestResourceEvent + extend ActiveSupport::Concern + + included do + belongs_to :merge_request + + scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) } + end +end diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index 3ffb32f94fc..8f8494a9678 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -17,8 +17,10 @@ module Milestoneable scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :any_milestone, -> { where('milestone_id IS NOT NULL') } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } + scope :without_particular_milestone, ->(title) { left_outer_joins(:milestone).where("milestones.title != ? OR milestone_id IS NULL", title) } scope :any_release, -> { joins_milestone_releases } scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) } + scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not( milestones: { releases: { tag: tag, project_id: project_id } } ) } scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") } scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) } diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index a7f1fb66a88..933a0b167e2 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -17,7 +17,7 @@ module Noteable # `Noteable` class names that support resolvable notes. def resolvable_types - %w(MergeRequest) + %w(MergeRequest DesignManagement::Design) end end @@ -138,15 +138,25 @@ module Noteable end def note_etag_key + return Gitlab::Routing.url_helpers.designs_project_issue_path(project, issue, { vueroute: filename }) if self.is_a?(DesignManagement::Design) + Gitlab::Routing.url_helpers.project_noteable_notes_path( project, target_type: self.class.name.underscore, target_id: id ) end + + def after_note_created(_note) + # no-op + end + + def after_note_destroyed(_note) + # no-op + end end Noteable.extend(Noteable::ClassMethods) -Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods') # rubocop: disable Cop/InjectEnterpriseEditionModule +Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods') Noteable.prepend_if_ee('EE::Noteable') diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index abc41a1c476..761a151a474 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -9,6 +9,7 @@ module PrometheusAdapter self.reactive_cache_lease_timeout = 30.seconds self.reactive_cache_refresh_interval = 30.seconds self.reactive_cache_lifetime = 1.minute + self.reactive_cache_work_type = :external_dependency def prometheus_client raise NotImplementedError diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index 7373f006d64..d1e3d9b2aff 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -50,8 +50,8 @@ module ProtectedRefAccess end end -ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes') # rubocop: disable Cop/InjectEnterpriseEditionModule -ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess') # rubocop: disable Cop/InjectEnterpriseEditionModule +ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes') +ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess') # When using `prepend` (or `include` for that matter), the `ClassMethods` # constants are not merged. This means that `class_methods` in diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 4b472cfdf45..d294563139c 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -8,6 +8,11 @@ module ReactiveCaching InvalidateReactiveCache = Class.new(StandardError) ExceededReactiveCacheLimit = Class.new(StandardError) + WORK_TYPE = { + default: ReactiveCachingWorker, + external_dependency: ExternalServiceReactiveCachingWorker + }.freeze + included do extend ActiveModel::Naming @@ -16,6 +21,7 @@ module ReactiveCaching class_attribute :reactive_cache_refresh_interval class_attribute :reactive_cache_lifetime class_attribute :reactive_cache_hard_limit + class_attribute :reactive_cache_work_type class_attribute :reactive_cache_worker_finder # defaults @@ -24,6 +30,7 @@ module ReactiveCaching self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 10.minutes self.reactive_cache_hard_limit = 1.megabyte + self.reactive_cache_work_type = :default self.reactive_cache_worker_finder = ->(id, *_args) do find_by(primary_key => id) end @@ -112,7 +119,7 @@ module ReactiveCaching def refresh_reactive_cache!(*args) clear_reactive_cache!(*args) keep_alive_reactive_cache!(*args) - ReactiveCachingWorker.perform_async(self.class, id, *args) + worker_class.perform_async(self.class, id, *args) end def keep_alive_reactive_cache!(*args) @@ -145,7 +152,11 @@ module ReactiveCaching def enqueuing_update(*args) yield - ReactiveCachingWorker.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args) + worker_class.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args) + end + + def worker_class + WORK_TYPE.fetch(self.class.reactive_cache_work_type.to_sym) end def check_exceeded_reactive_cache_limit!(data) diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb index 4bb4ffe2a8e..2d4ed51ce3b 100644 --- a/app/models/concerns/redis_cacheable.rb +++ b/app/models/concerns/redis_cacheable.rb @@ -26,7 +26,7 @@ module RedisCacheable end def cache_attributes(values) - Gitlab::Redis::SharedState.with do |redis| + Gitlab::Redis::Cache.with do |redis| redis.set(cache_attribute_key, values.to_json, ex: CACHED_ATTRIBUTES_EXPIRY_TIME) end @@ -41,9 +41,9 @@ module RedisCacheable def cached_attributes strong_memoize(:cached_attributes) do - Gitlab::Redis::SharedState.with do |redis| + Gitlab::Redis::Cache.with do |redis| data = redis.get(cache_attribute_key) - JSON.parse(data, symbolize_names: true) if data + Gitlab::Json.parse(data, symbolize_names: true) if data end end end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 4fbb5dcb649..9cd1a22b203 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -13,9 +13,13 @@ module Spammable has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent attr_accessor :spam + attr_accessor :needs_recaptcha attr_accessor :spam_log + alias_method :spam?, :spam + alias_method :needs_recaptcha?, :needs_recaptcha + # if spam errors are added before validation, they will be wiped after_validation :invalidate_if_spam, on: [:create, :update] cattr_accessor :spammable_attrs, instance_accessor: false do @@ -38,24 +42,35 @@ module Spammable end def needs_recaptcha! - self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\ - "Please, change the content or solve the reCAPTCHA to proceed.") + self.needs_recaptcha = true end - def unrecoverable_spam_error! - self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.") + def spam! + self.spam = true end - def invalidate_if_spam - return unless spam? + def clear_spam_flags! + self.spam = false + self.needs_recaptcha = false + end - if Gitlab::Recaptcha.enabled? - needs_recaptcha! - else + def invalidate_if_spam + if needs_recaptcha? && Gitlab::Recaptcha.enabled? + recaptcha_error! + elsif needs_recaptcha? || spam? unrecoverable_spam_error! end end + def recaptcha_error! + self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\ + "Please, change the content or solve the reCAPTCHA to proceed.") + end + + def unrecoverable_spam_error! + self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.") + end + def spammable_entity_type self.class.name.underscore end diff --git a/app/models/concerns/state_eventable.rb b/app/models/concerns/state_eventable.rb new file mode 100644 index 00000000000..68129798543 --- /dev/null +++ b/app/models/concerns/state_eventable.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module StateEventable + extend ActiveSupport::Concern + + included do + has_many :resource_state_events + end +end diff --git a/app/models/concerns/storage/legacy_project_wiki.rb b/app/models/concerns/storage/legacy_project_wiki.rb deleted file mode 100644 index a377fa1e5de..00000000000 --- a/app/models/concerns/storage/legacy_project_wiki.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Storage - module LegacyProjectWiki - extend ActiveSupport::Concern - - def disk_path - project.disk_path + '.wiki' - end - end -end diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb new file mode 100644 index 00000000000..d29e6a01c56 --- /dev/null +++ b/app/models/concerns/timebox.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +module Timebox + extend ActiveSupport::Concern + + include AtomicInternalId + include CacheMarkdownField + include Gitlab::SQL::Pattern + include IidRoutes + include StripAttribute + + TimeboxStruct = Struct.new(:title, :name, :id) do + # Ensure these models match the interface required for exporting + def serializable_hash(_opts = {}) + { title: title, name: name, id: id } + end + end + + # Represents a "No Timebox" state used for filtering Issues and Merge + # Requests that have no timeboxes assigned. + None = TimeboxStruct.new('No Timebox', 'No Timebox', 0) + Any = TimeboxStruct.new('Any Timebox', '', -1) + Upcoming = TimeboxStruct.new('Upcoming', '#upcoming', -2) + Started = TimeboxStruct.new('Started', '#started', -3) + + included do + # Defines the same constants above, but inside the including class. + const_set :None, TimeboxStruct.new("No #{self.name}", "No #{self.name}", 0) + const_set :Any, TimeboxStruct.new("Any #{self.name}", '', -1) + const_set :Upcoming, TimeboxStruct.new('Upcoming', '#upcoming', -2) + const_set :Started, TimeboxStruct.new('Started', '#started', -3) + + alias_method :timebox_id, :id + + validates :group, presence: true, unless: :project + validates :project, presence: true, unless: :group + validates :title, presence: true + + validate :uniqueness_of_title, if: :title_changed? + validate :timebox_type_check + validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } + validate :dates_within_4_digits + + cache_markdown_field :title, pipeline: :single_line + cache_markdown_field :description + + belongs_to :project + belongs_to :group + + has_many :issues + has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues + has_many :merge_requests + + scope :of_projects, ->(ids) { where(project_id: ids) } + scope :of_groups, ->(ids) { where(group_id: ids) } + scope :closed, -> { with_state(:closed) } + scope :for_projects, -> { where(group: nil).includes(:project) } + scope :with_title, -> (title) { where(title: title) } + + scope :for_projects_and_groups, -> (projects, groups) do + projects = projects.compact if projects.is_a? Array + projects = [] if projects.nil? + + groups = groups.compact if groups.is_a? Array + groups = [] if groups.nil? + + where(project_id: projects).or(where(group_id: groups)) + end + + scope :within_timeframe, -> (start_date, end_date) do + where('start_date is not NULL or due_date is not NULL') + .where('start_date is NULL or start_date <= ?', end_date) + .where('due_date is NULL or due_date >= ?', start_date) + end + + strip_attributes :title + + alias_attribute :name, :title + end + + class_methods do + # Searches for timeboxes with a matching title or description. + # + # This method uses ILIKE on PostgreSQL + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. + def search(query) + fuzzy_search(query, [:title, :description]) + end + + # Searches for timeboxes with a matching title. + # + # This method uses ILIKE on PostgreSQL + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. + def search_title(query) + fuzzy_search(query, [:title]) + end + + def filter_by_state(timeboxes, state) + case state + when 'closed' then timeboxes.closed + when 'all' then timeboxes + else timeboxes.active + end + end + + def count_by_state + reorder(nil).group(:state).count + end + + def predefined_id?(id) + [Any.id, None.id, Upcoming.id, Started.id].include?(id) + end + + def predefined?(timebox) + predefined_id?(timebox&.id) + end + end + + def title=(value) + write_attribute(:title, sanitize_title(value)) if value.present? + end + + def timebox_name + model_name.singular + end + + def group_timebox? + group_id.present? + end + + def project_timebox? + project_id.present? + end + + def safe_title + title.to_slug.normalize.to_s + end + + def resource_parent + group || project + end + + def to_ability_name + model_name.singular + end + + def merge_requests_enabled? + if group_timebox? + # Assume that groups have at least one project with merge requests enabled. + # Otherwise, we would need to load all of the projects from the database. + true + elsif project_timebox? + project&.merge_requests_enabled? + end + end + + private + + # Timebox titles must be unique across project and group timeboxes + def uniqueness_of_title + if project + relation = self.class.for_projects_and_groups([project_id], [project.group&.id]) + elsif group + relation = self.class.for_projects_and_groups(group.projects.select(:id), [group.id]) + end + + title_exists = relation.find_by_title(title) + errors.add(:title, _("already being used for another group or project %{timebox_name}.") % { timebox_name: timebox_name }) if title_exists + end + + # Timebox should be either a project timebox or a group timebox + def timebox_type_check + if group_id && project_id + field = project_id_changed? ? :project_id : :group_id + errors.add(field, _("%{timebox_name} should belong either to a project or a group.") % { timebox_name: timebox_name }) + end + end + + def start_date_should_be_less_than_due_date + if due_date <= start_date + errors.add(:due_date, _("must be greater than start date")) + end + end + + def dates_within_4_digits + if start_date && start_date > Date.new(9999, 12, 31) + errors.add(:start_date, _("date must not be after 9999-12-31")) + end + + if due_date && due_date > Date.new(9999, 12, 31) + errors.add(:due_date, _("date must not be after 9999-12-31")) + end + end + + def sanitize_title(value) + CGI.unescape_html(Sanitize.clean(value.to_s)) + end +end diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb index a84fb1cf56d..6cf012680d8 100644 --- a/app/models/concerns/update_project_statistics.rb +++ b/app/models/concerns/update_project_statistics.rb @@ -68,21 +68,11 @@ module UpdateProjectStatistics def schedule_update_project_statistic(delta) return if delta.zero? + return if project.nil? - if Feature.enabled?(:update_project_statistics_after_commit, default_enabled: true) - # Update ProjectStatistics after the transaction - run_after_commit do - ProjectStatistics.increment_statistic( - project_id, self.class.project_statistics_name, delta) - end - else - # Use legacy-way to update within transaction + run_after_commit do ProjectStatistics.increment_statistic( project_id, self.class.project_statistics_name, delta) - end - - run_after_commit do - next if project.nil? Namespaces::ScheduleAggregationWorker.perform_async( project.namespace_id) diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 3bff7cb06c1..455c672cea3 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -2,6 +2,7 @@ class ContainerRepository < ApplicationRecord include Gitlab::Utils::StrongMemoize + include Gitlab::SQL::Pattern belongs_to :project @@ -17,6 +18,7 @@ class ContainerRepository < ApplicationRecord scope :for_group_and_its_subgroups, ->(group) do where(project_id: Project.for_group_and_its_subgroups(group).with_container_registry.select(:id)) end + scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) } def self.exists_by_path?(path) where( diff --git a/app/models/cycle_analytics/group_level.rb b/app/models/cycle_analytics/group_level.rb deleted file mode 100644 index a41e1375484..00000000000 --- a/app/models/cycle_analytics/group_level.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module CycleAnalytics - class GroupLevel - include LevelBase - attr_reader :options, :group - - def initialize(group:, options:) - @group = group - @options = options.merge(group: group) - end - - def summary - @summary ||= ::Gitlab::CycleAnalytics::GroupStageSummary.new(group, options: options).data - end - - def permissions(*) - STAGES.each_with_object({}) do |stage, obj| - obj[stage] = true - end - end - - def stats - @stats ||= STAGES.map do |stage_name| - self[stage_name].as_json(serializer: GroupAnalyticsStageSerializer) - end - end - end -end diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 69245710f01..395260b5201 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -7,7 +7,8 @@ class DeployToken < ApplicationRecord include Gitlab::Utils::StrongMemoize add_authentication_token_field :token, encrypted: :optional - AVAILABLE_SCOPES = %i(read_repository read_registry write_registry).freeze + AVAILABLE_SCOPES = %i(read_repository read_registry write_registry + read_package_registry write_package_registry).freeze GITLAB_DEPLOY_TOKEN_NAME = 'gitlab-deploy-token' default_value_for(:expires_at) { Forever.date } @@ -105,7 +106,7 @@ class DeployToken < ApplicationRecord end def ensure_at_least_one_scope - errors.add(:base, _("Scopes can't be blank")) unless read_repository || read_registry || write_registry + errors.add(:base, _("Scopes can't be blank")) unless scopes.any? end def default_username diff --git a/app/models/design_management.rb b/app/models/design_management.rb new file mode 100644 index 00000000000..81e170f7e59 --- /dev/null +++ b/app/models/design_management.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module DesignManagement + DESIGN_IMAGE_SIZES = %w(v432x230).freeze + + def self.designs_directory + 'designs' + end + + def self.table_name_prefix + 'design_management_' + end +end diff --git a/app/models/design_management/action.rb b/app/models/design_management/action.rb new file mode 100644 index 00000000000..ecd7973a523 --- /dev/null +++ b/app/models/design_management/action.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_dependency 'design_management' + +module DesignManagement + class Action < ApplicationRecord + include WithUploads + + self.table_name = "#{DesignManagement.table_name_prefix}designs_versions" + + mount_uploader :image_v432x230, DesignManagement::DesignV432x230Uploader + + belongs_to :design, class_name: "DesignManagement::Design", inverse_of: :actions + belongs_to :version, class_name: "DesignManagement::Version", inverse_of: :actions + + enum event: { creation: 0, modification: 1, deletion: 2 } + + # we assume sequential ordering. + scope :ordered, -> { order(version_id: :asc) } + + # For each design, only select the most recent action + scope :most_recent, -> do + selection = Arel.sql("DISTINCT ON (#{table_name}.design_id) #{table_name}.*") + + order(arel_table[:design_id].asc, arel_table[:version_id].desc).select(selection) + end + + # Find all records created before or at the given version, or all if nil + scope :up_to_version, ->(version = nil) do + case version + when nil + all + when DesignManagement::Version + where(arel_table[:version_id].lteq(version.id)) + when ::Gitlab::Git::COMMIT_ID + versions = DesignManagement::Version.arel_table + subquery = versions.project(versions[:id]).where(versions[:sha].eq(version)) + where(arel_table[:version_id].lteq(subquery)) + else + raise ArgumentError, "Expected a DesignManagement::Version, got #{version}" + end + end + end +end diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb new file mode 100644 index 00000000000..e9b69eab7a7 --- /dev/null +++ b/app/models/design_management/design.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +module DesignManagement + class Design < ApplicationRecord + include Importable + include Noteable + include Gitlab::FileTypeDetection + include Gitlab::Utils::StrongMemoize + include Referable + include Mentionable + include WhereComposite + + belongs_to :project, inverse_of: :designs + belongs_to :issue + + has_many :actions + has_many :versions, through: :actions, class_name: 'DesignManagement::Version', inverse_of: :designs + # This is a polymorphic association, so we can't count on FK's to delete the + # data + has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :user_mentions, class_name: 'DesignUserMention', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + + validates :project, :filename, presence: true + validates :issue, presence: true, unless: :importing? + validates :filename, uniqueness: { scope: :issue_id } + validate :validate_file_is_image + + alias_attribute :title, :filename + + # Pre-fetching scope to include the data necessary to construct a + # reference using `to_reference`. + scope :for_reference, -> { includes(issue: [{ project: [:route, :namespace] }]) } + + # A design can be uniquely identified by issue_id and filename + # Takes one or more sets of composite IDs of the form: + # `{issue_id: Integer, filename: String}`. + # + # @see WhereComposite::where_composite + # + # e.g: + # + # by_issue_id_and_filename(issue_id: 1, filename: 'homescreen.jpg') + # by_issue_id_and_filename([]) # returns ActiveRecord::NullRelation + # by_issue_id_and_filename([ + # { issue_id: 1, filename: 'homescreen.jpg' }, + # { issue_id: 2, filename: 'homescreen.jpg' }, + # { issue_id: 1, filename: 'menu.png' } + # ]) + # + scope :by_issue_id_and_filename, ->(composites) do + where_composite(%i[issue_id filename], composites) + end + + # Find designs visible at the given version + # + # @param version [nil, DesignManagement::Version]: + # the version at which the designs must be visible + # Passing `nil` is the same as passing the most current version + # + # Restricts to designs + # - created at least *before* the given version + # - not deleted as of the given version. + # + # As a query, we ascertain this by finding the last event prior to + # (or equal to) the cut-off, and seeing whether that version was a deletion. + scope :visible_at_version, -> (version) do + deletion = ::DesignManagement::Action.events[:deletion] + designs = arel_table + actions = ::DesignManagement::Action + .most_recent.up_to_version(version) + .arel.as('most_recent_actions') + + join = designs.join(actions) + .on(actions[:design_id].eq(designs[:id])) + + joins(join.join_sources).where(actions[:event].not_eq(deletion)).order(:id) + end + + scope :with_filename, -> (filenames) { where(filename: filenames) } + scope :on_issue, ->(issue) { where(issue_id: issue) } + + # Scope called by our REST API to avoid N+1 problems + scope :with_api_entity_associations, -> { preload(:issue) } + + # A design is current if the most recent event is not a deletion + scope :current, -> { visible_at_version(nil) } + + def status + if new_design? + :new + elsif deleted? + :deleted + else + :current + end + end + + def deleted? + most_recent_action&.deletion? + end + + # A design is visible_in? a version if: + # * it was created before that version + # * the most recent action before the version was not a deletion + def visible_in?(version) + map = strong_memoize(:visible_in) do + Hash.new do |h, k| + h[k] = self.class.visible_at_version(k).where(id: id).exists? + end + end + + map[version] + end + + def most_recent_action + strong_memoize(:most_recent_action) { actions.ordered.last } + end + + # A reference for a design is the issue reference, indexed by the filename + # with an optional infix when full. + # + # e.g. + # #123[homescreen.png] + # other-project#72[sidebar.jpg] + # #38/designs[transition.gif] + # #12["filename with [] in it.jpg"] + def to_reference(from = nil, full: false) + infix = full ? '/designs' : '' + totally_simple = %r{ \A #{self.class.simple_file_name} \z }x + safe_name = if totally_simple.match?(filename) + filename + elsif filename =~ /[<>]/ + %Q{base64:#{Base64.strict_encode64(filename)}} + else + escaped = filename.gsub(%r{[\\"]}) { |x| "\\#{x}" } + %Q{"#{escaped}"} + end + + "#{issue.to_reference(from, full: full)}#{infix}[#{safe_name}]" + end + + def self.reference_pattern + @reference_pattern ||= begin + # Filenames can be escaped with double quotes to name filenames + # that include square brackets, or other special characters + %r{ + #{Issue.reference_pattern} + (\/designs)? + \[ + (?<design> #{simple_file_name} | #{quoted_file_name} | #{base_64_encoded_name}) + \] + }x + end + end + + def self.simple_file_name + %r{ + (?<simple_file_name> + ( \w | [_:,'-] | \. | \s )+ + \. + \w+ + ) + }x + end + + def self.base_64_encoded_name + %r{ + base64: + (?<base_64_encoded_name> + [A-Za-z0-9+\n]+ + =? + ) + }x + end + + def self.quoted_file_name + %r{ + " + (?<escaped_filename> + (\\ \\ | \\ " | [^"\\])+ + ) + " + }x + end + + def self.link_reference_pattern + @link_reference_pattern ||= begin + exts = SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT + path_segment = %r{issues/#{Gitlab::Regex.issue}/designs} + filename_pattern = %r{(?<simple_file_name>[a-z0-9_=-]+\.(#{exts.join('|')}))}i + + super(path_segment, filename_pattern) + end + end + + def to_ability_name + 'design' + end + + def description + '' + end + + def new_design? + strong_memoize(:new_design) { actions.none? } + end + + def full_path + @full_path ||= File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", filename) + end + + def diff_refs + strong_memoize(:diff_refs) { head_version&.diff_refs } + end + + def clear_version_cache + [versions, actions].each(&:reset) + %i[new_design diff_refs head_sha visible_in most_recent_action].each do |key| + clear_memoization(key) + end + end + + def repository + project.design_repository + end + + def user_notes_count + user_notes_count_service.count + end + + def after_note_changed(note) + user_notes_count_service.delete_cache unless note.system? + end + alias_method :after_note_created, :after_note_changed + alias_method :after_note_destroyed, :after_note_changed + + private + + def head_version + strong_memoize(:head_sha) { versions.ordered.first } + end + + def allow_dangerous_images? + Feature.enabled?(:design_management_allow_dangerous_images, project) + end + + def valid_file_extensions + allow_dangerous_images? ? (SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT) : SAFE_IMAGE_EXT + end + + def validate_file_is_image + unless image? || (dangerous_image? && allow_dangerous_images?) + message = _('does not have a supported extension. Only %{extension_list} are supported') % { + extension_list: valid_file_extensions.to_sentence + } + errors.add(:filename, message) + end + end + + def user_notes_count_service + strong_memoize(:user_notes_count_service) do + ::DesignManagement::DesignUserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass + end + end + end +end diff --git a/app/models/design_management/design_action.rb b/app/models/design_management/design_action.rb new file mode 100644 index 00000000000..22baa916296 --- /dev/null +++ b/app/models/design_management/design_action.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module DesignManagement + # Parameter object which is a tuple of the database record and the + # last gitaly call made to change it. This serves to perform the + # logical mapping from git action to database representation. + class DesignAction + include ActiveModel::Validations + + EVENT_FOR_GITALY_ACTION = { + create: DesignManagement::Action.events[:creation], + update: DesignManagement::Action.events[:modification], + delete: DesignManagement::Action.events[:deletion] + }.freeze + + attr_reader :design, :action, :content + + delegate :issue_id, to: :design + + validates :design, presence: true + validates :action, presence: true, inclusion: { in: EVENT_FOR_GITALY_ACTION.keys } + validates :content, + absence: { if: :forbids_content?, + message: 'this action forbids content' }, + presence: { if: :needs_content?, + message: 'this action needs content' } + + # Parameters: + # - design [DesignManagement::Design]: the design that was changed + # - action [Symbol]: the action that gitaly performed + def initialize(design, action, content = nil) + @design, @action, @content = design, action, content + validate! + end + + def row_attrs(version) + { design_id: design.id, version_id: version.id, event: event } + end + + def gitaly_action + { action: action, file_path: design.full_path, content: content }.compact + end + + # This action has been performed - do any post-creation actions + # such as clearing method caches. + def performed + design.clear_version_cache + end + + private + + def needs_content? + action != :delete + end + + def forbids_content? + action == :delete + end + + def event + EVENT_FOR_GITALY_ACTION[action] + end + end +end diff --git a/app/models/design_management/design_at_version.rb b/app/models/design_management/design_at_version.rb new file mode 100644 index 00000000000..b4cafb93c2c --- /dev/null +++ b/app/models/design_management/design_at_version.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# Tuple of design and version +# * has a composite ID, with lazy_find +module DesignManagement + class DesignAtVersion + include ActiveModel::Validations + include GlobalID::Identification + include Gitlab::Utils::StrongMemoize + + attr_reader :version + attr_reader :design + + validates :version, presence: true + validates :design, presence: true + + validate :design_and_version_belong_to_the_same_issue + validate :design_and_version_have_issue_id + + def initialize(design: nil, version: nil) + @design, @version = design, version + end + + def self.instantiate(attrs) + new(attrs).tap { |obj| obj.validate! } + end + + # The ID, needed by GraphQL types and as part of the Lazy-fetch + # protocol, includes information about both the design and the version. + # + # The particular format is not interesting, and should be treated as opaque + # by all callers. + def id + "#{design.id}.#{version.id}" + end + + def ==(other) + return false unless other && self.class == other.class + + other.id == id + end + + alias_method :eql?, :== + + def self.lazy_find(id) + BatchLoader.for(id).batch do |ids, callback| + find(ids).each do |record| + callback.call(record.id, record) + end + end + end + + def self.find(ids) + pairs = ids.map { |id| id.split('.').map(&:to_i) } + + design_ids = pairs.map(&:first).uniq + version_ids = pairs.map(&:second).uniq + + designs = ::DesignManagement::Design + .where(id: design_ids) + .index_by(&:id) + + versions = ::DesignManagement::Version + .where(id: version_ids) + .index_by(&:id) + + pairs.map do |(design_id, version_id)| + design = designs[design_id] + version = versions[version_id] + + obj = new(design: design, version: version) + + obj if obj.valid? + end.compact + end + + def status + if not_created_yet? + :not_created_yet + elsif deleted? + :deleted + else + :current + end + end + + def deleted? + action&.deletion? + end + + def not_created_yet? + action.nil? + end + + private + + def action + strong_memoize(:most_recent_action) do + ::DesignManagement::Action + .most_recent.up_to_version(version) + .find_by(design: design) + end + end + + def design_and_version_belong_to_the_same_issue + id_a, id_b = [design, version].map { |obj| obj&.issue_id } + + return if id_a == id_b + + errors.add(:issue, 'must be the same on design and version') + end + + def design_and_version_have_issue_id + return if [design, version].all? { |obj| obj.try(:issue_id).present? } + + errors.add(:issue, 'must be present on both design and version') + end + end +end diff --git a/app/models/design_management/design_collection.rb b/app/models/design_management/design_collection.rb new file mode 100644 index 00000000000..18d1541e9c7 --- /dev/null +++ b/app/models/design_management/design_collection.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module DesignManagement + class DesignCollection + attr_reader :issue + + delegate :designs, :project, to: :issue + + def initialize(issue) + @issue = issue + end + + def find_or_create_design!(filename:) + designs.find { |design| design.filename == filename } || + designs.safe_find_or_create_by!(project: project, filename: filename) + end + + def versions + @versions ||= DesignManagement::Version.for_designs(designs) + end + + def repository + project.design_repository + end + + def designs_by_filename(filenames) + designs.current.where(filename: filenames) + end + end +end diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb new file mode 100644 index 00000000000..985d6317d5d --- /dev/null +++ b/app/models/design_management/repository.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module DesignManagement + class Repository < ::Repository + extend ::Gitlab::Utils::Override + + # We define static git attributes for the design repository as this + # repository is entirely GitLab-managed rather than user-facing. + # + # Enable all uploaded files to be stored in LFS. + MANAGED_GIT_ATTRIBUTES = <<~GA.freeze + /#{DesignManagement.designs_directory}/* filter=lfs diff=lfs merge=lfs -text + GA + + def initialize(project) + full_path = project.full_path + Gitlab::GlRepository::DESIGN.path_suffix + disk_path = project.disk_path + Gitlab::GlRepository::DESIGN.path_suffix + + super(full_path, project, shard: project.repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::DESIGN) + end + + # Override of a method called on Repository instances but sent via + # method_missing to Gitlab::Git::Repository where it is defined + def info_attributes + @info_attributes ||= Gitlab::Git::AttributesParser.new(MANAGED_GIT_ATTRIBUTES) + end + + # Override of a method called on Repository instances but sent via + # method_missing to Gitlab::Git::Repository where it is defined + def attributes(path) + info_attributes.attributes(path) + end + + # Override of a method called on Repository instances but sent via + # method_missing to Gitlab::Git::Repository where it is defined + def gitattribute(path, name) + attributes(path)[name] + end + + # Override of a method called on Repository instances but sent via + # method_missing to Gitlab::Git::Repository where it is defined + def attributes_at(_ref = nil) + info_attributes + end + + override :copy_gitattributes + def copy_gitattributes(_ref = nil) + true + end + end +end diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb new file mode 100644 index 00000000000..6be98fe3d44 --- /dev/null +++ b/app/models/design_management/version.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module DesignManagement + class Version < ApplicationRecord + include Importable + include ShaAttribute + include AfterCommitQueue + include Gitlab::Utils::StrongMemoize + extend Gitlab::ExclusiveLeaseHelpers + + NotSameIssue = Class.new(StandardError) + + class CouldNotCreateVersion < StandardError + attr_reader :sha, :issue_id, :actions + + def initialize(sha, issue_id, actions) + @sha, @issue_id, @actions = sha, issue_id, actions + end + + def message + "could not create version from commit: #{sha}" + end + + def sentry_extra_data + { + sha: sha, + issue_id: issue_id, + design_ids: actions.map { |a| a.design.id } + } + end + end + + belongs_to :issue + belongs_to :author, class_name: 'User' + has_many :actions + has_many :designs, + through: :actions, + class_name: "DesignManagement::Design", + source: :design, + inverse_of: :versions + + validates :designs, presence: true, unless: :importing? + validates :sha, presence: true + validates :sha, uniqueness: { case_sensitive: false, scope: :issue_id } + validates :author, presence: true + # We are not validating the issue object as it incurs an extra query to fetch + # the record from the DB. Instead, we rely on the foreign key constraint to + # ensure referential integrity. + validates :issue_id, presence: true, unless: :importing? + + sha_attribute :sha + + delegate :project, to: :issue + + scope :for_designs, -> (designs) do + where(id: ::DesignManagement::Action.where(design_id: designs).select(:version_id)).distinct + end + scope :earlier_or_equal_to, -> (version) { where("(#{table_name}.id) <= ?", version) } # rubocop:disable GitlabSecurity/SqlInjection + scope :ordered, -> { order(id: :desc) } + scope :for_issue, -> (issue) { where(issue: issue) } + scope :by_sha, -> (sha) { where(sha: sha) } + + # This is the one true way to create a Version. + # + # This method means you can avoid the paradox of versions being invalid without + # designs, and not being able to add designs without a saved version. Also this + # method inserts designs in bulk, rather than one by one. + # + # Before calling this method, callers must guard against concurrent + # modification by obtaining the lock on the design repository. See: + # `DesignManagement::Version.with_lock`. + # + # Parameters: + # - design_actions [DesignManagement::DesignAction]: + # the actions that have been performed in the repository. + # - sha [String]: + # the SHA of the commit that performed them + # - author [User]: + # the user who performed the commit + # returns [DesignManagement::Version] + def self.create_for_designs(design_actions, sha, author) + issue_id, not_uniq = design_actions.map(&:issue_id).compact.uniq + raise NotSameIssue, 'All designs must belong to the same issue!' if not_uniq + + transaction do + version = new(sha: sha, issue_id: issue_id, author: author) + version.save(validate: false) # We need it to have an ID. Validate later when designs are present + + rows = design_actions.map { |action| action.row_attrs(version) } + + Gitlab::Database.bulk_insert(::DesignManagement::Action.table_name, rows) + version.designs.reset + version.validate! + design_actions.each(&:performed) + + version + end + rescue + raise CouldNotCreateVersion.new(sha, issue_id, design_actions) + end + + CREATION_TTL = 5.seconds + RETRY_DELAY = ->(num) { 0.2.seconds * num**2 } + + def self.with_lock(project_id, repository, &block) + key = "with_lock:#{name}:{#{project_id}}" + + in_lock(key, ttl: CREATION_TTL, retries: 5, sleep_sec: RETRY_DELAY) do |_retried| + repository.create_if_not_exists + yield + end + end + + def designs_by_event + actions + .includes(:design) + .group_by(&:event) + .transform_values { |group| group.map(&:design) } + end + + def author + super || (commit_author if persisted?) + end + + def diff_refs + strong_memoize(:diff_refs) { commit&.diff_refs } + end + + def reset + %i[diff_refs commit].each { |k| clear_memoization(k) } + super + end + + private + + def commit_author + commit&.author + end + + def commit + strong_memoize(:commit) { issue.project.design_repository.commit(sha) } + end + end +end diff --git a/app/models/design_user_mention.rb b/app/models/design_user_mention.rb new file mode 100644 index 00000000000..baf4db29a0f --- /dev/null +++ b/app/models/design_user_mention.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class DesignUserMention < UserMention + belongs_to :design, class_name: 'DesignManagement::Design' + belongs_to :note +end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index e3df61dadae..ff39dbb59f3 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -9,7 +9,7 @@ class DiffNote < Note include Gitlab::Utils::StrongMemoize def self.noteable_types - %w(MergeRequest Commit) + %w(MergeRequest Commit DesignManagement::Design) end validates :original_position, presence: true @@ -60,6 +60,8 @@ class DiffNote < Note # Returns the diff file from `position` def latest_diff_file strong_memoize(:latest_diff_file) do + next if for_design? + position.diff_file(repository) end end @@ -67,6 +69,8 @@ class DiffNote < Note # Returns the diff file from `original_position` def diff_file strong_memoize(:diff_file) do + next if for_design? + enqueue_diff_file_creation_job if should_create_diff_file? fetch_diff_file @@ -145,7 +149,7 @@ class DiffNote < Note end def supported? - for_commit? || self.noteable.has_complete_diff_refs? + for_commit? || for_design? || self.noteable.has_complete_diff_refs? end def set_line_code @@ -184,5 +188,3 @@ class DiffNote < Note noteable.respond_to?(:repository) ? noteable.repository : project.repository end end - -DiffNote.prepend_if_ee('::EE::DiffNote') diff --git a/app/models/email.rb b/app/models/email.rb index 580633d3232..c5154267ff0 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -6,7 +6,8 @@ class Email < ApplicationRecord belongs_to :user, optional: false - validates :email, presence: true, uniqueness: true, devise_email: true + validates :email, presence: true, uniqueness: true + validate :validate_email_format validate :unique_email, if: ->(email) { email.email_changed? } scope :confirmed, -> { where.not(confirmed_at: nil) } @@ -14,9 +15,14 @@ class Email < ApplicationRecord after_commit :update_invalid_gpg_signatures, if: -> { previous_changes.key?('confirmed_at') } devise :confirmable + + # This module adds async behaviour to Devise emails + # and should be added after Devise modules are initialized. + include AsyncDeviseEmail + self.reconfirmable = false # currently email can't be changed, no need to reconfirm - delegate :username, to: :user + delegate :username, :can?, to: :user def email=(value) write_attribute(:email, value.downcase.strip) @@ -30,6 +36,10 @@ class Email < ApplicationRecord user.accept_pending_invitations! end + def validate_email_format + self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email) + end + # once email is confirmed, update the gpg signatures def update_invalid_gpg_signatures user.update_invalid_gpg_signatures if confirmed? diff --git a/app/models/environment.rb b/app/models/environment.rb index b2391f33aca..21044771bbb 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -8,6 +8,7 @@ class Environment < ApplicationRecord self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 55.seconds self.reactive_cache_hard_limit = 10.megabytes + self.reactive_cache_work_type = :external_dependency belongs_to :project, required: true @@ -151,6 +152,14 @@ class Environment < ApplicationRecord .preload(:user, :metadata, :deployment) end + def count_by_state + environments_count_by_state = group(:state).count + + valid_states.each_with_object({}) do |state, count_hash| + count_hash[state] = environments_count_by_state[state.to_s] || 0 + end + end + private def cte_for_deployments_with_stop_action diff --git a/app/models/epic.rb b/app/models/epic.rb index 04e19c17e18..e09dc1080e6 100644 --- a/app/models/epic.rb +++ b/app/models/epic.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Placeholder class for model that is implemented in EE -# It reserves '&' as a reference prefix, but the table does not exists in CE +# It reserves '&' as a reference prefix, but the table does not exist in FOSS class Epic < ApplicationRecord include IgnorableColumns diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 133850b6ab6..fa32c8a5450 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -22,6 +22,7 @@ module ErrorTracking }x.freeze self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] } + self.reactive_cache_work_type = :external_dependency belongs_to :project diff --git a/app/models/event.rb b/app/models/event.rb index 447ab753421..12b85697690 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -96,6 +96,8 @@ class Event < ApplicationRecord end scope :for_milestone_id, ->(milestone_id) { where(target_type: "Milestone", target_id: milestone_id) } + scope :for_wiki_meta, ->(meta) { where(target_type: 'WikiPage::Meta', target_id: meta.id) } + scope :created_at, ->(time) { where(created_at: time) } # Authors are required as they're used to display who pushed data. # @@ -313,6 +315,10 @@ class Event < ApplicationRecord note? && target && target.for_personal_snippet? end + def design_note? + note? && note.for_design? + end + def note_target target.noteable end @@ -380,6 +386,11 @@ class Event < ApplicationRecord protected + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + # + # TODO Refactor this method so we no longer need to disable the above cops + # https://gitlab.com/gitlab-org/gitlab/-/issues/216879. def capability @capability ||= begin if push_action? || commit_note? @@ -396,9 +407,13 @@ class Event < ApplicationRecord :read_milestone elsif wiki_page? :read_wiki + elsif design_note? + :read_design end end end + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/PerceivedComplexity private diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index d0cec0e9fc6..43de7454cb7 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -2,7 +2,6 @@ # Global Milestones are milestones that can be shared across multiple projects class GlobalMilestone include Milestoneish - include_if_ee('::EE::GlobalMilestone') # rubocop: disable Cop/InjectEnterpriseEditionModule STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze @@ -11,7 +10,7 @@ class GlobalMilestone delegate :title, :state, :due_date, :start_date, :participants, :project, :group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title, - :milestoneish_id, :resource_parent, :releases, to: :milestone + :timebox_id, :milestoneish_id, :resource_parent, :releases, to: :milestone def to_hash { @@ -105,3 +104,5 @@ class GlobalMilestone true end end + +GlobalMilestone.include_if_ee('::EE::GlobalMilestone') diff --git a/app/models/group.rb b/app/models/group.rb index 55a2c4ba9a9..04cb6b8b4da 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -30,6 +30,7 @@ class Group < Namespace has_many :members_and_requesters, as: :source, class_name: 'GroupMember' has_many :milestones + has_many :iterations has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink' has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink' has_many :shared_groups, through: :shared_group_links, source: :shared_group @@ -59,6 +60,8 @@ class Group < Namespace has_many :import_failures, inverse_of: :group + has_one :import_state, class_name: 'GroupImportState', inverse_of: :group + has_many :group_deploy_tokens has_many :deploy_tokens, through: :group_deploy_tokens @@ -168,7 +171,7 @@ class Group < Namespace notification_settings.find { |n| n.notification_email.present? }&.notification_email end - def to_reference(_from = nil, full: nil) + def to_reference(_from = nil, target_project: nil, full: nil) "#{self.class.reference_prefix}#{full_path}" end @@ -302,9 +305,10 @@ class Group < Namespace # rubocop: enable CodeReuse/ServiceClass # rubocop: disable CodeReuse/ServiceClass - def refresh_members_authorized_projects(blocking: true) - UserProjectAccessChangedService.new(user_ids_for_project_authorizations) - .execute(blocking: blocking) + def refresh_members_authorized_projects(blocking: true, priority: UserProjectAccessChangedService::HIGH_PRIORITY) + UserProjectAccessChangedService + .new(user_ids_for_project_authorizations) + .execute(blocking: blocking, priority: priority) end # rubocop: enable CodeReuse/ServiceClass @@ -332,6 +336,11 @@ class Group < Namespace .where(source_id: source_ids) end + def members_from_self_and_ancestors_with_effective_access_level + members_with_parents.select([:user_id, 'MAX(access_level) AS access_level']) + .group(:user_id) + end + def members_with_descendants GroupMember .active_without_invites_and_requests @@ -475,14 +484,14 @@ class Group < Namespace false end - def wiki_access_level - # TODO: Remove this method once we implement group-level features. - # https://gitlab.com/gitlab-org/gitlab/-/issues/208412 - if Feature.enabled?(:group_wiki, self) - ProjectFeature::ENABLED - else - ProjectFeature::DISABLED - end + def execute_hooks(data, hooks_scope) + # NOOP + # TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904 + end + + def execute_services(data, hooks_scope) + # NOOP + # TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904 end private @@ -516,8 +525,6 @@ class Group < Namespace end def max_member_access_for_user_from_shared_groups(user) - return unless Feature.enabled?(:share_group_with_group, default_enabled: true) - group_group_link_table = GroupGroupLink.arel_table group_member_table = GroupMember.arel_table diff --git a/app/models/group_import_state.rb b/app/models/group_import_state.rb new file mode 100644 index 00000000000..7773b887249 --- /dev/null +++ b/app/models/group_import_state.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class GroupImportState < ApplicationRecord + self.primary_key = :group_id + + belongs_to :group, inverse_of: :import_state + + validates :group, :status, :jid, presence: true + + state_machine :status, initial: :created do + state :created, value: 0 + state :started, value: 1 + state :finished, value: 2 + state :failed, value: -1 + + event :start do + transition created: :started + end + + event :finish do + transition started: :finished + end + + event :fail_op do + transition any => :failed + end + + after_transition any => :failed do |state, transition| + last_error = transition.args.first + + state.update_column(:last_error, last_error) if last_error + end + end +end diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb index 87338512d99..60e97174e50 100644 --- a/app/models/group_milestone.rb +++ b/app/models/group_milestone.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true # Group Milestones are milestones that can be shared among many projects within the same group class GroupMilestone < GlobalMilestone - include_if_ee('::EE::GroupMilestone') # rubocop: disable Cop/InjectEnterpriseEditionModule attr_reader :group, :milestones def self.build_collection(group, projects, params) @@ -46,3 +45,5 @@ class GroupMilestone < GlobalMilestone true end end + +GroupMilestone.include_if_ee('::EE::GroupMilestone') diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index bc480b14e67..71494b6de4d 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -3,6 +3,9 @@ class ProjectHook < WebHook include TriggerableHooks include Presentable + include Limitable + + self.limit_scope = :project triggerable_hooks [ :push_hooks, diff --git a/app/models/internal_id_enums.rb b/app/models/internal_id_enums.rb index 2f7d7aeff2f..125ae7573b6 100644 --- a/app/models/internal_id_enums.rb +++ b/app/models/internal_id_enums.rb @@ -3,7 +3,18 @@ module InternalIdEnums def self.usage_resources # when adding new resource, make sure it doesn't conflict with EE usage_resources - { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5, operations_feature_flags: 6 } + { + issues: 0, + merge_requests: 1, + deployments: 2, + milestones: 3, + epics: 4, + ci_pipelines: 5, + operations_feature_flags: 6, + operations_user_lists: 7, + alert_management_alerts: 8, + sprints: 9 # iterations + } end end diff --git a/app/models/issue.rb b/app/models/issue.rb index cdd7429bc58..a04ac412940 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -17,6 +17,7 @@ class Issue < ApplicationRecord include IgnorableColumns include MilestoneEventable include WhereComposite + include StateEventable DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze @@ -29,9 +30,12 @@ class Issue < ApplicationRecord SORTING_PREFERENCE_FIELD = :issues_sort belongs_to :project - belongs_to :moved_to, class_name: 'Issue' belongs_to :duplicated_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' + belongs_to :iteration, foreign_key: 'sprint_id' + + belongs_to :moved_to, class_name: 'Issue' + has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.issues&.maximum(:iid) } @@ -46,8 +50,15 @@ class Issue < ApplicationRecord has_many :zoom_meetings has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :sent_notifications, as: :noteable + has_many :designs, class_name: 'DesignManagement::Design', inverse_of: :issue + has_many :design_versions, class_name: 'DesignManagement::Version', inverse_of: :issue do + def most_recent + ordered.first + end + end has_one :sentry_issue + has_one :alert_management_alert, class_name: 'AlertManagement::Alert' accepts_nested_attributes_for :sentry_issue @@ -63,6 +74,7 @@ class Issue < ApplicationRecord scope :due_before, ->(date) { where('issues.due_date < ?', date) } scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } scope :due_tomorrow, -> { where(due_date: Date.tomorrow) } + scope :not_authored_by, ->(user) { where.not(author_id: user) } scope :order_due_date_asc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'ASC')) } scope :order_due_date_desc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) } @@ -73,11 +85,13 @@ class Issue < ApplicationRecord scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) } scope :with_api_entity_associations, -> { preload(:timelogs, :assignees, :author, :notes, :labels, project: [:route, { namespace: :route }] ) } + scope :with_label_attributes, ->(label_attributes) { joins(:labels).where(labels: label_attributes) } scope :public_only, -> { where(confidential: false) } scope :confidential_only, -> { where(confidential: true) } scope :counts_by_state, -> { reorder(nil).group(:state_id).count } + scope :with_alert_management_alerts, -> { joins(:alert_management_alert) } # An issue can be uniquely identified by project_id and iid # Takes one or more sets of composite IDs, expressed as hash-like records of @@ -330,6 +344,10 @@ class Issue < ApplicationRecord previous_changes['updated_at']&.first || updated_at end + def design_collection + @design_collection ||= ::DesignManagement::DesignCollection.new(self) + end + private def ensure_metrics @@ -343,7 +361,7 @@ class Issue < ApplicationRecord # for performance reasons, check commit: 002ad215818450d2cbbc5fa065850a953dc7ada8 # Make sure to sync this method with issue_policy.rb def readable_by?(user) - if user.admin? + if user.can_read_all_resources? true elsif project.owner == user true diff --git a/app/models/iteration.rb b/app/models/iteration.rb new file mode 100644 index 00000000000..1acd08f2063 --- /dev/null +++ b/app/models/iteration.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +class Iteration < ApplicationRecord + include Timebox + + self.table_name = 'sprints' + + attr_accessor :skip_future_date_validation + + STATE_ENUM_MAP = { + upcoming: 1, + started: 2, + closed: 3 + }.with_indifferent_access.freeze + + include AtomicInternalId + + has_many :issues, foreign_key: 'sprint_id' + has_many :merge_requests, foreign_key: 'sprint_id' + + belongs_to :project + belongs_to :group + + has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.iterations&.maximum(:iid) } + has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.iterations&.maximum(:iid) } + + validates :start_date, presence: true + validates :due_date, presence: true + + validate :dates_do_not_overlap, if: :start_or_due_dates_changed? + validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation + + scope :upcoming, -> { with_state(:upcoming) } + scope :started, -> { with_state(:started) } + + state_machine :state_enum, initial: :upcoming do + event :start do + transition upcoming: :started + end + + event :close do + transition [:upcoming, :started] => :closed + end + + state :upcoming, value: Iteration::STATE_ENUM_MAP[:upcoming] + state :started, value: Iteration::STATE_ENUM_MAP[:started] + state :closed, value: Iteration::STATE_ENUM_MAP[:closed] + end + + # Alias to state machine .with_state_enum method + # This needs to be defined after the state machine block to avoid errors + class << self + alias_method :with_state, :with_state_enum + alias_method :with_states, :with_state_enums + + def filter_by_state(iterations, state) + case state + when 'closed' then iterations.closed + when 'started' then iterations.started + when 'opened' then iterations.started.or(iterations.upcoming) + when 'all' then iterations + else iterations.upcoming + end + end + end + + def state + STATE_ENUM_MAP.key(state_enum) + end + + def state=(value) + self.state_enum = STATE_ENUM_MAP[value] + end + + private + + def start_or_due_dates_changed? + start_date_changed? || due_date_changed? + end + + # ensure dates do not overlap with other Iterations in the same group/project + def dates_do_not_overlap + return unless resource_parent.iterations.within_timeframe(start_date, due_date).exists? + + errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations")) + end + + # ensure dates are in the future + def future_date + if start_date_changed? + errors.add(:start_date, s_("Iteration|cannot be in the past")) if start_date < Date.current + errors.add(:start_date, s_("Iteration|cannot be more than 500 years in the future")) if start_date > 500.years.from_now + end + + if due_date_changed? + errors.add(:due_date, s_("Iteration|cannot be in the past")) if due_date < Date.current + errors.add(:due_date, s_("Iteration|cannot be more than 500 years in the future")) if due_date > 500.years.from_now + end + end +end diff --git a/app/models/jira_import_state.rb b/app/models/jira_import_state.rb index bde2795e7b8..92147794e88 100644 --- a/app/models/jira_import_state.rb +++ b/app/models/jira_import_state.rb @@ -3,6 +3,7 @@ class JiraImportState < ApplicationRecord include AfterCommitQueue include ImportState::SidekiqJobTracker + include UsageStatistics self.table_name = 'jira_imports' @@ -46,7 +47,7 @@ class JiraImportState < ApplicationRecord after_transition initial: :scheduled do |state, _| state.run_after_commit do job_id = Gitlab::JiraImport::Stage::StartImportWorker.perform_async(project.id) - state.update(jid: job_id) if job_id + state.update(jid: job_id, scheduled_at: Time.now) if job_id end end @@ -97,4 +98,8 @@ class JiraImportState < ApplicationRecord } ) end + + def self.finished_imports_count + finished.sum(:imported_issues_count) + end end diff --git a/app/models/list.rb b/app/models/list.rb index 64247fdb983..ec211dfd497 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -3,8 +3,6 @@ class List < ApplicationRecord include Importable - prepend_if_ee('::EE::List') # rubocop: disable Cop/InjectEnterpriseEditionModule - belongs_to :board belongs_to :label has_many :list_user_preferences @@ -74,14 +72,18 @@ class List < ApplicationRecord label? ? label.name : list_type.humanize end + def collapsed?(user) + preferences = preferences_for(user) + + preferences.collapsed? + end + def as_json(options = {}) super(options).tap do |json| json[:collapsed] = false if options.key?(:collapsed) - preferences = preferences_for(options[:current_user]) - - json[:collapsed] = preferences.collapsed? + json[:collapsed] = collapsed?(options[:current_user]) end if options.key?(:label) @@ -100,3 +102,5 @@ class List < ApplicationRecord throw(:abort) unless destroyable? # rubocop:disable Cop/BanCatchThrow end end + +List.prepend_if_ee('::EE::List') diff --git a/app/models/member.rb b/app/models/member.rb index 5b33333aa23..791073da095 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Member < ApplicationRecord + include EachBatch include AfterCommitQueue include Sortable include Importable diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 68c51860c47..fa2e0cb8198 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -17,6 +17,11 @@ class ProjectMember < Member .where('projects.namespace_id in (?)', groups.select(:id)) end + scope :without_project_bots, -> do + left_join_users + .merge(User.without_project_bot) + end + class << self # Add users to projects with passed access option # diff --git a/app/models/members_preloader.rb b/app/models/members_preloader.rb index 1ed0434eacf..6da8d5f3161 100644 --- a/app/models/members_preloader.rb +++ b/app/models/members_preloader.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class MembersPreloader - prepend_if_ee('EE::MembersPreloader') # rubocop: disable Cop/InjectEnterpriseEditionModule - attr_reader :members def initialize(members) @@ -16,3 +14,5 @@ class MembersPreloader ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :u2f_registrations) end end + +MembersPreloader.prepend_if_ee('EE::MembersPreloader') diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index a28e054e13c..b4d0b729454 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -19,6 +19,7 @@ class MergeRequest < ApplicationRecord include ShaAttribute include IgnorableColumns include MilestoneEventable + include StateEventable sha_attribute :squash_commit_sha @@ -32,6 +33,7 @@ class MergeRequest < ApplicationRecord belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" belongs_to :merge_user, class_name: "User" + belongs_to :iteration, foreign_key: 'sprint_id' has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) } @@ -864,7 +866,7 @@ class MergeRequest < ApplicationRecord check_service = MergeRequests::MergeabilityCheckService.new(self) - if async && Feature.enabled?(:async_merge_request_check_mergeability, project) + if async && Feature.enabled?(:async_merge_request_check_mergeability, project, default_enabled: true) check_service.async_execute else check_service.execute(retry_lease: false) @@ -873,7 +875,7 @@ class MergeRequest < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def diffable_merge_ref? - Feature.enabled?(:diff_compare_with_head, target_project) && can_be_merged? && merge_ref_head.present? + can_be_merged? && merge_ref_head.present? end # Returns boolean indicating the merge_status should be rechecked in order to @@ -1129,26 +1131,6 @@ class MergeRequest < ApplicationRecord end end - # Return array of possible target branches - # depends on target project of MR - def target_branches - if target_project.nil? - [] - else - target_project.repository.branch_names - end - end - - # Return array of possible source branches - # depends on source project of MR - def source_branches - if source_project.nil? - [] - else - source_project.repository.branch_names - end - end - def has_ci? return false if has_no_commits? @@ -1319,12 +1301,30 @@ class MergeRequest < ApplicationRecord compare_reports(Ci::CompareTestReportsService) end + def has_accessibility_reports? + return false unless Feature.enabled?(:accessibility_report_view, project) + + actual_head_pipeline.present? && actual_head_pipeline.has_reports?(Ci::JobArtifact.accessibility_reports) + end + def has_coverage_reports? return false unless Feature.enabled?(:coverage_report_view, project) actual_head_pipeline&.has_reports?(Ci::JobArtifact.coverage_reports) end + def has_terraform_reports? + actual_head_pipeline&.has_reports?(Ci::JobArtifact.terraform_reports) + end + + def compare_accessibility_reports + unless has_accessibility_reports? + return { status: :error, status_reason: _('This merge request does not have accessibility reports') } + end + + compare_reports(Ci::CompareAccessibilityReportsService) + end + # TODO: this method and compare_test_reports use the same # result type, which is handled by the controller's #reports_response. # we should minimize mistakes by isolating the common parts. @@ -1337,9 +1337,15 @@ class MergeRequest < ApplicationRecord compare_reports(Ci::GenerateCoverageReportsService) end - def has_exposed_artifacts? - return false unless Feature.enabled?(:ci_expose_arbitrary_artifacts_in_mr, default_enabled: true) + def find_terraform_reports + unless has_terraform_reports? + return { status: :error, status_reason: 'This merge request does not have terraform reports' } + end + compare_reports(Ci::GenerateTerraformReportsService) + end + + def has_exposed_artifacts? actual_head_pipeline&.has_exposed_artifacts? end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 7b15d21c095..f793bd3d76f 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -141,7 +141,7 @@ class MergeRequestDiff < ApplicationRecord after_create :save_git_content, unless: :importing? after_create_commit :set_as_latest_diff, unless: :importing? - after_save :update_external_diff_store, if: -> { !importing? && saved_change_to_external_diff? } + after_save :update_external_diff_store def self.find_by_diff_refs(diff_refs) find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha) @@ -385,34 +385,11 @@ class MergeRequestDiff < ApplicationRecord end end - # Carrierwave defines `write_uploader` dynamically on this class, so `super` - # does not work. Alias the carrierwave method so we can call it when needed - alias_method :carrierwave_write_uploader, :write_uploader - - # The `external_diff`, `external_diff_store`, and `stored_externally` - # columns were introduced in GitLab 11.8, but some background migration specs - # use factories that rely on current code with an old schema. Without these - # `has_attribute?` guards, they fail with a `MissingAttributeError`. - # - # For more details, see: https://gitlab.com/gitlab-org/gitlab-foss/issues/44990 - - def write_uploader(column, identifier) - carrierwave_write_uploader(column, identifier) if has_attribute?(column) - end - def update_external_diff_store - update_column(:external_diff_store, external_diff.object_store) if - has_attribute?(:external_diff_store) - end - - def saved_change_to_external_diff? - super if has_attribute?(:external_diff) - end + return unless saved_change_to_external_diff? || saved_change_to_stored_externally? - def stored_externally - super if has_attribute?(:stored_externally) + update_column(:external_diff_store, external_diff.object_store) end - alias_method :stored_externally?, :stored_externally # If enabled, yields the external file containing the diff. Otherwise, yields # nil. This method is not thread-safe, but it *is* re-entrant, which allows @@ -575,7 +552,6 @@ class MergeRequestDiff < ApplicationRecord end def use_external_diff? - return false unless has_attribute?(:external_diff) return false unless Gitlab.config.external_diffs.enabled case Gitlab.config.external_diffs.when diff --git a/app/models/metrics/users_starred_dashboard.rb b/app/models/metrics/users_starred_dashboard.rb new file mode 100644 index 00000000000..07748eb1431 --- /dev/null +++ b/app/models/metrics/users_starred_dashboard.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Metrics + class UsersStarredDashboard < ApplicationRecord + self.table_name = 'metrics_users_starred_dashboards' + + belongs_to :user, inverse_of: :metrics_users_starred_dashboards + belongs_to :project, inverse_of: :metrics_users_starred_dashboards + + validates :user_id, presence: true + validates :project_id, presence: true + validates :dashboard_path, presence: true, length: { maximum: 255 } + validates :dashboard_path, uniqueness: { scope: %i[user_id project_id] } + + scope :for_project, ->(project) { where(project: project) } + scope :for_project_dashboard, ->(project, path) { for_project(project).where(dashboard_path: path) } + end +end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 4ccfe314526..b5e4f62792e 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -1,88 +1,37 @@ # frozen_string_literal: true class Milestone < ApplicationRecord - # Represents a "No Milestone" state used for filtering Issues and Merge - # Requests that have no milestone assigned. - MilestoneStruct = Struct.new(:title, :name, :id) do - # Ensure these models match the interface required for exporting - def serializable_hash(_opts = {}) - { title: title, name: name, id: id } - end - end - - None = MilestoneStruct.new('No Milestone', 'No Milestone', 0) - Any = MilestoneStruct.new('Any Milestone', '', -1) - Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2) - Started = MilestoneStruct.new('Started', '#started', -3) - - include CacheMarkdownField - include AtomicInternalId - include IidRoutes include Sortable include Referable - include StripAttribute + include Timebox include Milestoneish include FromUnion include Importable - include Gitlab::SQL::Pattern prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule - cache_markdown_field :title, pipeline: :single_line - cache_markdown_field :description - - belongs_to :project - belongs_to :group - has_many :milestone_releases has_many :releases, through: :milestone_releases has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.milestones&.maximum(:iid) } has_internal_id :iid, scope: :group, track_if: -> { !importing? }, init: ->(s) { s&.group&.milestones&.maximum(:iid) } - has_many :issues - has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues - has_many :merge_requests has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent - scope :of_projects, ->(ids) { where(project_id: ids) } - scope :of_groups, ->(ids) { where(group_id: ids) } scope :active, -> { with_state(:active) } - scope :closed, -> { with_state(:closed) } - scope :for_projects, -> { where(group: nil).includes(:project) } scope :started, -> { active.where('milestones.start_date <= CURRENT_DATE') } - - scope :for_projects_and_groups, -> (projects, groups) do - projects = projects.compact if projects.is_a? Array - projects = [] if projects.nil? - - groups = groups.compact if groups.is_a? Array - groups = [] if groups.nil? - - where(project_id: projects).or(where(group_id: groups)) - end - - scope :within_timeframe, -> (start_date, end_date) do - where('start_date is not NULL or due_date is not NULL') - .where('start_date is NULL or start_date <= ?', end_date) - .where('due_date is NULL or due_date >= ?', start_date) + scope :not_started, -> { active.where('milestones.start_date > CURRENT_DATE') } + scope :not_upcoming, -> do + active + .where('milestones.due_date <= CURRENT_DATE') + .order(:project_id, :group_id, :due_date) end scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) } scope :reorder_by_due_date_asc, -> { reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC')) } - validates :group, presence: true, unless: :project - validates :project, presence: true, unless: :group - validates :title, presence: true - - validate :uniqueness_of_title, if: :title_changed? - validate :milestone_type_check - validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } - validate :dates_within_4_digits validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } - strip_attributes :title - state_machine :state, initial: :active do event :close do transition active: :closed @@ -97,52 +46,6 @@ class Milestone < ApplicationRecord state :active end - alias_attribute :name, :title - - class << self - # Searches for milestones with a matching title or description. - # - # This method uses ILIKE on PostgreSQL and LIKE on MySQL. - # - # query - The search query as a String - # - # Returns an ActiveRecord::Relation. - def search(query) - fuzzy_search(query, [:title, :description]) - end - - # Searches for milestones with a matching title. - # - # This method uses ILIKE on PostgreSQL and LIKE on MySQL. - # - # query - The search query as a String - # - # Returns an ActiveRecord::Relation. - def search_title(query) - fuzzy_search(query, [:title]) - end - - def filter_by_state(milestones, state) - case state - when 'closed' then milestones.closed - when 'all' then milestones - else milestones.active - end - end - - def count_by_state - reorder(nil).group(:state).count - end - - def predefined_id?(id) - [Any.id, None.id, Upcoming.id, Started.id].include?(id) - end - - def predefined?(milestone) - predefined_id?(milestone&.id) - end - end - def self.reference_prefix '%' end @@ -220,7 +123,7 @@ class Milestone < ApplicationRecord end ## - # Returns the String necessary to reference this Milestone in Markdown. Group + # Returns the String necessary to reference a Milestone in Markdown. Group # milestones only support name references, and do not support cross-project # references. # @@ -248,10 +151,6 @@ class Milestone < ApplicationRecord self.class.reference_prefix + self.title end - def milestoneish_id - id - end - def for_display self end @@ -264,62 +163,24 @@ class Milestone < ApplicationRecord nil end - def title=(value) - write_attribute(:title, sanitize_title(value)) if value.present? - end + # TODO: remove after all code paths use `timebox_id` + # https://gitlab.com/gitlab-org/gitlab/-/issues/215688 + alias_method :milestoneish_id, :timebox_id + # TODO: remove after all code paths use (group|project)_timebox? + # https://gitlab.com/gitlab-org/gitlab/-/issues/215690 + alias_method :group_milestone?, :group_timebox? + alias_method :project_milestone?, :project_timebox? - def safe_title - title.to_slug.normalize.to_s - end - - def resource_parent - group || project - end - - def to_ability_name - model_name.singular - end - - def group_milestone? - group_id.present? - end - - def project_milestone? - project_id.present? - end - - def merge_requests_enabled? + def parent if group_milestone? - # Assume that groups have at least one project with merge requests enabled. - # Otherwise, we would need to load all of the projects from the database. - true - elsif project_milestone? - project&.merge_requests_enabled? + group + else + project end end private - # Milestone titles must be unique across project milestones and group milestones - def uniqueness_of_title - if project - relation = Milestone.for_projects_and_groups([project_id], [project.group&.id]) - elsif group - relation = Milestone.for_projects_and_groups(group.projects.select(:id), [group.id]) - end - - title_exists = relation.find_by_title(title) - errors.add(:title, _("already being used for another group or project milestone.")) if title_exists - end - - # Milestone should be either a project milestone or a group milestone - def milestone_type_check - if group_id && project_id - field = project_id_changed? ? :project_id : :group_id - errors.add(field, _("milestone should belong either to a project or a group.")) - end - end - def milestone_format_reference(format = :iid) raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format) @@ -334,26 +195,6 @@ class Milestone < ApplicationRecord end end - def sanitize_title(value) - CGI.unescape_html(Sanitize.clean(value.to_s)) - end - - def start_date_should_be_less_than_due_date - if due_date <= start_date - errors.add(:due_date, _("must be greater than start date")) - end - end - - def dates_within_4_digits - if start_date && start_date > Date.new(9999, 12, 31) - errors.add(:start_date, _("date must not be after 9999-12-31")) - end - - if due_date && due_date > Date.new(9999, 12, 31) - errors.add(:due_date, _("date must not be after 9999-12-31")) - end - end - def issues_finder_params { project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact end diff --git a/app/models/milestone_note.rb b/app/models/milestone_note.rb index 2ff9791feb0..19171e682b7 100644 --- a/app/models/milestone_note.rb +++ b/app/models/milestone_note.rb @@ -17,6 +17,6 @@ class MilestoneNote < SyntheticNote def note_text(html: false) format = milestone&.group_milestone? ? :name : :iid - milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}" + event.remove? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}" end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 9e7589a1f18..8116f7a256f 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -14,6 +14,7 @@ class Namespace < ApplicationRecord include IgnorableColumns ignore_column :plan_id, remove_with: '13.1', remove_after: '2020-06-22' + ignore_column :trial_ends_on, remove_with: '13.2', remove_after: '2020-07-22' # Prevent users from creating unreasonably deep level of nesting. # The number 20 was taken based on maximum nesting level of @@ -135,11 +136,6 @@ class Namespace < ApplicationRecord name = host.delete_suffix(gitlab_host) Namespace.where(parent_id: nil).by_path(name) end - - # overridden in ee - def reset_ci_minutes!(namespace_id) - false - end end def default_branch_protection @@ -180,6 +176,10 @@ class Namespace < ApplicationRecord kind == 'user' end + def group? + type == 'Group' + end + def find_fork_of(project) return unless project.fork_network @@ -346,6 +346,21 @@ class Namespace < ApplicationRecord .try(name) end + def actual_plan + Plan.default + end + + def actual_limits + # We default to PlanLimits.new otherwise a lot of specs would fail + # On production each plan should already have associated limits record + # https://gitlab.com/gitlab-org/gitlab/issues/36037 + actual_plan.actual_limits + end + + def actual_plan_name + actual_plan.name + end + private def all_projects_with_pages diff --git a/app/models/namespace/root_storage_size.rb b/app/models/namespace/root_storage_size.rb new file mode 100644 index 00000000000..d61917e468e --- /dev/null +++ b/app/models/namespace/root_storage_size.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Namespace::RootStorageSize + def initialize(root_namespace) + @root_namespace = root_namespace + end + + def above_size_limit? + return false if limit == 0 + + usage_ratio > 1 + end + + def usage_ratio + return 0 if limit == 0 + + current_size.to_f / limit.to_f + end + + def current_size + @current_size ||= root_namespace.root_storage_statistics&.storage_size + end + + def limit + @limit ||= Gitlab::CurrentSettings.namespace_storage_size_limit.megabytes + end + + private + + attr_reader :root_namespace +end diff --git a/app/models/note.rb b/app/models/note.rb index a2a711c987f..d174ba8fe83 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -159,6 +159,8 @@ class Note < ApplicationRecord after_save :touch_noteable, unless: :importing? after_destroy :expire_etag_cache after_save :store_mentions!, if: :any_mentionable_attributes_changed? + after_commit :notify_after_create, on: :create + after_commit :notify_after_destroy, on: :destroy class << self def model_name @@ -279,6 +281,10 @@ class Note < ApplicationRecord !for_personal_snippet? end + def for_design? + noteable_type == DesignManagement::Design.name + end + def for_issuable? for_issue? || for_merge_request? end @@ -505,6 +511,14 @@ class Note < ApplicationRecord noteable_object end + def notify_after_create + noteable&.after_note_created(self) + end + + def notify_after_destroy + noteable&.after_note_destroyed(self) + end + def banzai_render_context(field) super.merge(noteable: noteable, system_note: system?) end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 486da2c6b45..da5e4012f05 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -2,6 +2,7 @@ class PagesDomain < ApplicationRecord include Presentable + include FromUnion VERIFICATION_KEY = 'gitlab-pages-verification-code' VERIFICATION_THRESHOLD = 3.days.freeze @@ -58,12 +59,14 @@ class PagesDomain < ApplicationRecord end scope :need_auto_ssl_renewal, -> do - expiring = where(certificate_valid_not_after: nil).or( - where(arel_table[:certificate_valid_not_after].lt(SSL_RENEWAL_THRESHOLD.from_now))) + enabled_and_not_failed = where(auto_ssl_enabled: true, auto_ssl_failed: false) - user_provided_or_expiring = certificate_user_provided.or(expiring) + user_provided = enabled_and_not_failed.certificate_user_provided + certificate_not_valid = enabled_and_not_failed.where(certificate_valid_not_after: nil) + certificate_expiring = enabled_and_not_failed + .where(arel_table[:certificate_valid_not_after].lt(SSL_RENEWAL_THRESHOLD.from_now)) - where(auto_ssl_enabled: true).merge(user_provided_or_expiring) + from_union([user_provided, certificate_not_valid, certificate_expiring]) end scope :for_removal, -> { where("remove_at < ?", Time.now) } diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb index 30fb1935a27..57222c61b36 100644 --- a/app/models/performance_monitoring/prometheus_dashboard.rb +++ b/app/models/performance_monitoring/prometheus_dashboard.rb @@ -4,7 +4,7 @@ module PerformanceMonitoring class PrometheusDashboard include ActiveModel::Model - attr_accessor :dashboard, :panel_groups, :path, :environment, :priority + attr_accessor :dashboard, :panel_groups, :path, :environment, :priority, :templating validates :dashboard, presence: true validates :panel_groups, presence: true diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index af079f7ebc4..7afee2a35cb 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -4,6 +4,7 @@ class PersonalAccessToken < ApplicationRecord include Expirable include TokenAuthenticatable include Sortable + extend ::Gitlab::Utils::Override add_authentication_token_field :token, digest: true @@ -23,6 +24,8 @@ class PersonalAccessToken < ApplicationRecord scope :without_impersonation, -> { where(impersonation: false) } scope :for_user, -> (user) { where(user: user) } scope :preload_users, -> { preload(:user) } + scope :order_expires_at_asc, -> { reorder(expires_at: :asc) } + scope :order_expires_at_desc, -> { reorder(expires_at: :desc) } validates :scopes, presence: true validate :validate_scopes @@ -39,12 +42,14 @@ class PersonalAccessToken < ApplicationRecord def self.redis_getdel(user_id) Gitlab::Redis::SharedState.with do |redis| - encrypted_token = redis.get(redis_shared_state_key(user_id)) - redis.del(redis_shared_state_key(user_id)) + redis_key = redis_shared_state_key(user_id) + encrypted_token = redis.get(redis_key) + redis.del(redis_key) + begin Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) rescue => ex - logger.warn "Failed to decrypt PersonalAccessToken value stored in Redis for User ##{user_id}: #{ex.class}" + logger.warn "Failed to decrypt #{self.name} value stored in Redis for key ##{redis_key}: #{ex.class}" encrypted_token end end @@ -58,6 +63,16 @@ class PersonalAccessToken < ApplicationRecord end end + override :simple_sorts + def self.simple_sorts + super.merge( + { + 'expires_at_asc' => -> { order_expires_at_asc }, + 'expires_at_desc' => -> { order_expires_at_desc } + } + ) + end + protected def validate_scopes diff --git a/app/models/personal_snippet.rb b/app/models/personal_snippet.rb index 1b5be8698b1..197795dccfe 100644 --- a/app/models/personal_snippet.rb +++ b/app/models/personal_snippet.rb @@ -2,4 +2,8 @@ class PersonalSnippet < Snippet include WithUploads + + def skip_project_check? + true + end end diff --git a/app/models/plan.rb b/app/models/plan.rb new file mode 100644 index 00000000000..acac5f9aeae --- /dev/null +++ b/app/models/plan.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class Plan < ApplicationRecord + DEFAULT = 'default'.freeze + + has_one :limits, class_name: 'PlanLimits' + + ALL_PLANS = [DEFAULT].freeze + DEFAULT_PLANS = [DEFAULT].freeze + private_constant :ALL_PLANS, :DEFAULT_PLANS + + # This always returns an object + def self.default + Gitlab::SafeRequestStore.fetch(:plan_default) do + # find_by allows us to find object (cheaply) against replica DB + # safe_find_or_create_by does stick to primary DB + find_by(name: DEFAULT) || safe_find_or_create_by(name: DEFAULT) + end + end + + def self.all_plans + ALL_PLANS + end + + def self.default_plans + DEFAULT_PLANS + end + + def actual_limits + self.limits || PlanLimits.new + end + + def default? + self.class.default_plans.include?(name) + end + + def paid? + false + end +end + +Plan.prepend_if_ee('EE::Plan') diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb new file mode 100644 index 00000000000..575105cfd79 --- /dev/null +++ b/app/models/plan_limits.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class PlanLimits < ApplicationRecord + belongs_to :plan + + def exceeded?(limit_name, object) + return false unless enabled?(limit_name) + + if object.is_a?(Integer) + object >= read_attribute(limit_name) + else + # object.count >= limit value is slower than checking + # if a record exists at the limit value - 1 position. + object.offset(read_attribute(limit_name) - 1).exists? + end + end + + private + + def enabled?(limit_name) + read_attribute(limit_name) > 0 + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 5db349463d8..c0dd2eb8584 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -3,6 +3,7 @@ require 'carrierwave/orm/activerecord' class Project < ApplicationRecord + extend ::Gitlab::Utils::Override include Gitlab::ConfigHelper include Gitlab::VisibilityLevel include AccessRequestable @@ -18,6 +19,7 @@ class Project < ApplicationRecord include SelectForProjectAuthorization include Presentable include HasRepository + include HasWiki include Routable include GroupDescendant include Gitlab::SQL::Pattern @@ -175,6 +177,7 @@ class Project < ApplicationRecord has_one :packagist_service has_one :hangouts_chat_service has_one :unify_circuit_service + has_one :webex_teams_service has_one :root_of_fork_network, foreign_key: 'root_project_id', @@ -206,12 +209,14 @@ class Project < ApplicationRecord has_many :services has_many :events has_many :milestones + has_many :iterations has_many :notes has_many :snippets, class_name: 'ProjectSnippet' has_many :hooks, class_name: 'ProjectHook' has_many :protected_branches has_many :protected_tags has_many :repository_languages, -> { order "share DESC" } + has_many :designs, inverse_of: :project, class_name: 'DesignManagement::Design' has_many :project_authorizations has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User' @@ -254,6 +259,9 @@ class Project < ApplicationRecord has_many :prometheus_alerts, inverse_of: :project has_many :prometheus_alert_events, inverse_of: :project has_many :self_managed_prometheus_alert_events, inverse_of: :project + has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :project + + has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :project # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy @@ -295,6 +303,7 @@ class Project < ApplicationRecord has_many :project_deploy_tokens has_many :deploy_tokens, through: :project_deploy_tokens has_many :resource_groups, class_name: 'Ci::ResourceGroup', inverse_of: :project + has_many :freeze_periods, class_name: 'Ci::FreezePeriod', inverse_of: :project has_one :auto_devops, class_name: 'ProjectAutoDevops', inverse_of: :project, autosave: true has_many :custom_attributes, class_name: 'ProjectCustomAttribute' @@ -315,10 +324,13 @@ class Project < ApplicationRecord has_many :import_failures, inverse_of: :project has_many :jira_imports, -> { order 'jira_imports.created_at' }, class_name: 'JiraImportState', inverse_of: :project - has_many :daily_report_results, class_name: 'Ci::DailyReportResult' + has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult' + + has_many :repository_storage_moves, class_name: 'ProjectRepositoryStorageMove' accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature, update_only: true + accepts_nested_attributes_for :project_setting, update_only: true accepts_nested_attributes_for :import_data accepts_nested_attributes_for :auto_devops, update_only: true accepts_nested_attributes_for :ci_cd_settings, update_only: true @@ -342,6 +354,9 @@ class Project < ApplicationRecord :wiki_access_level, :snippets_access_level, :builds_access_level, :repository_access_level, :pages_access_level, :metrics_dashboard_access_level, to: :project_feature, allow_nil: true + delegate :show_default_award_emojis, :show_default_award_emojis=, + :show_default_award_emojis?, + to: :project_setting, allow_nil: true delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, prefix: :import, to: :import_state, allow_nil: true delegate :no_import?, to: :import_state, allow_nil: true @@ -355,6 +370,7 @@ class Project < ApplicationRecord delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings + delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true # Validations validates :creator, presence: true, on: :create @@ -386,7 +402,6 @@ class Project < ApplicationRecord validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? } validate :visibility_level_allowed_by_group, if: :should_validate_visibility_level? validate :visibility_level_allowed_as_fork, if: :should_validate_visibility_level? - validate :check_wiki_path_conflict validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) } validates :repository_storage, presence: true, @@ -515,12 +530,14 @@ class Project < ApplicationRecord def self.public_or_visible_to_user(user = nil, min_access_level = nil) min_access_level = nil if user&.admin? - if user + return public_to_user unless user + + if user.is_a?(DeployToken) + user.projects + else where('EXISTS (?) OR projects.visibility_level IN (?)', user.authorizations_for_projects(min_access_level: min_access_level), Gitlab::VisibilityLevel.levels_for_user(user)) - else - public_to_user end end @@ -785,6 +802,11 @@ class Project < ApplicationRecord Feature.enabled?(:jira_issue_import, self, default_enabled: true) end + # LFS and hashed repository storage are required for using Design Management. + def design_management_enabled? + lfs_enabled? && hashed_storage?(:repository) + end + def team @team ||= ProjectTeam.new(self) end @@ -793,6 +815,12 @@ class Project < ApplicationRecord @repository ||= Repository.new(full_path, self, shard: repository_storage, disk_path: disk_path) end + def design_repository + strong_memoize(:design_repository) do + DesignManagement::Repository.new(self) + end + end + def cleanup @repository = nil end @@ -819,7 +847,7 @@ class Project < ApplicationRecord latest_pipeline = ci_pipelines.latest_successful_for_ref(ref) return unless latest_pipeline - latest_pipeline.builds.latest.with_artifacts_archive.find_by(name: job_name) + latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name) end def latest_successful_build_for_sha(job_name, sha) @@ -828,7 +856,7 @@ class Project < ApplicationRecord latest_pipeline = ci_pipelines.latest_successful_for_sha(sha) return unless latest_pipeline - latest_pipeline.builds.latest.with_artifacts_archive.find_by(name: job_name) + latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name) end def latest_successful_build_for_ref!(job_name, ref = default_branch) @@ -865,10 +893,12 @@ class Project < ApplicationRecord raise Projects::ImportService::Error, _('Jira import feature is disabled.') unless jira_issues_import_feature_flag_enabled? raise Projects::ImportService::Error, _('Jira integration not configured.') unless jira_service&.active? - return unless user + if user + raise Projects::ImportService::Error, _('Cannot import because issues are not available in this project.') unless feature_available?(:issues, user) + raise Projects::ImportService::Error, _('You do not have permissions to run the import.') unless user.can?(:admin_project, self) + end - raise Projects::ImportService::Error, _('Cannot import because issues are not available in this project.') unless feature_available?(:issues, user) - raise Projects::ImportService::Error, _('You do not have permissions to run the import.') unless user.can?(:admin_project, self) + raise Projects::ImportService::Error, _('Unable to connect to the Jira instance. Please check your Jira integration configuration.') unless jira_service.test(nil)[:success] end def human_import_status_name @@ -1056,16 +1086,6 @@ class Project < ApplicationRecord self.errors.add(:visibility_level, _("%{level_name} is not allowed since the fork source project has lower visibility.") % { level_name: level_name }) end - def check_wiki_path_conflict - return if path.blank? - - path_to_check = path.ends_with?('.wiki') ? path.chomp('.wiki') : "#{path}.wiki" - - if Project.where(namespace_id: namespace_id, path: path_to_check).exists? - errors.add(:name, _('has already been taken')) - end - end - def pages_https_only return false unless Gitlab.config.pages.external_https @@ -1179,11 +1199,7 @@ class Project < ApplicationRecord end def issues_tracker - if external_issue_tracker - external_issue_tracker - else - default_issue_tracker - end + external_issue_tracker || default_issue_tracker end def external_issue_reference_pattern @@ -1328,11 +1344,7 @@ class Project < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def owner - if group - group - else - namespace.try(:owner) - end + group || namespace.try(:owner) end def to_ability_name @@ -1432,15 +1444,12 @@ class Project < ApplicationRecord # Expires various caches before a project is renamed. def expire_caches_before_rename(old_path) - repo = Repository.new(old_path, self, shard: repository_storage) - wiki = Repository.new("#{old_path}.wiki", self, shard: repository_storage, repo_type: Gitlab::GlRepository::WIKI) + project_repo = Repository.new(old_path, self, shard: repository_storage) + wiki_repo = Repository.new("#{old_path}#{Gitlab::GlRepository::WIKI.path_suffix}", self, shard: repository_storage, repo_type: Gitlab::GlRepository::WIKI) + design_repo = Repository.new("#{old_path}#{Gitlab::GlRepository::DESIGN.path_suffix}", self, shard: repository_storage, repo_type: Gitlab::GlRepository::DESIGN) - if repo.exists? - repo.before_delete - end - - if wiki.exists? - wiki.before_delete + [project_repo, wiki_repo, design_repo].each do |repo| + repo.before_delete if repo.exists? end end @@ -1517,6 +1526,10 @@ class Project < ApplicationRecord end end + def bots + users.project_bot + end + # Filters `users` to return only authorized users of the project def members_among(users) if users.is_a?(ActiveRecord::Relation) && !users.loaded? @@ -1565,10 +1578,6 @@ class Project < ApplicationRecord create_repository(force: true) unless repository_exists? end - def wiki_repository_exists? - wiki.repository_exists? - end - # update visibility_level of forks def update_forks_visibility_level return if unlink_forks_upon_visibility_decrease_enabled? @@ -1582,20 +1591,6 @@ class Project < ApplicationRecord end end - def create_wiki - ProjectWiki.new(self, self.owner).wiki - true - rescue ProjectWiki::CouldNotCreateWikiError - errors.add(:base, _('Failed create wiki')) - false - end - - def wiki - strong_memoize(:wiki) do - ProjectWiki.new(self, self.owner) - end - end - def allowed_to_share_with_group? !namespace.share_with_group_lock end @@ -2024,6 +2019,14 @@ class Project < ApplicationRecord end end + def ci_instance_variables_for(ref:) + if protected_for?(ref) + Ci::InstanceVariable.all_cached + else + Ci::InstanceVariable.unprotected_cached + end + end + def protected_for?(ref) raise Repository::AmbiguousRefError if repository.ambiguous_ref?(ref) @@ -2085,7 +2088,12 @@ class Project < ApplicationRecord raise ArgumentError unless ::Gitlab.config.repositories.storages.key?(new_repository_storage_key) - run_after_commit { ProjectUpdateRepositoryStorageWorker.perform_async(id, new_repository_storage_key) } + storage_move = repository_storage_moves.create!( + source_storage_name: repository_storage, + destination_storage_name: new_repository_storage_key + ) + storage_move.schedule! + self.repository_read_only = true end @@ -2425,6 +2433,11 @@ class Project < ApplicationRecord jira_imports.last end + override :after_wiki_activity + def after_wiki_activity + touch(:last_activity_at, :last_repository_updated_at) + end + private def find_service(services, name) diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index e81d9d0f5fe..366852d93bf 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -2,7 +2,6 @@ class ProjectAuthorization < ApplicationRecord include FromUnion - prepend_if_ee('::EE::ProjectAuthorization') # rubocop: disable Cop/InjectEnterpriseEditionModule belongs_to :user belongs_to :project @@ -30,3 +29,5 @@ class ProjectAuthorization < ApplicationRecord end end end + +ProjectAuthorization.prepend_if_ee('::EE::ProjectAuthorization') diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index 39e177e8bd8..c295837002a 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -37,8 +37,6 @@ class ProjectCiCdSetting < ApplicationRecord private def set_default_git_depth - return unless Feature.enabled?(:ci_set_project_default_git_depth, default_enabled: true) - self.default_git_depth ||= DEFAULT_GIT_DEPTH end end diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 31a3fa12c00..9201cd24d66 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -23,7 +23,7 @@ class ProjectFeature < ApplicationRecord PUBLIC = 30 FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze - PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze + PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER, metrics_dashboard: Gitlab::Access::REPORTER }.freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze STRING_OPTIONS = HashWithIndifferentAccess.new({ 'disabled' => DISABLED, diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb new file mode 100644 index 00000000000..e88cc5cfca6 --- /dev/null +++ b/app/models/project_repository_storage_move.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# ProjectRepositoryStorageMove are details of repository storage moves for a +# project. For example, moving a project to another gitaly node to help +# balance storage capacity. +class ProjectRepositoryStorageMove < ApplicationRecord + include AfterCommitQueue + + belongs_to :project, inverse_of: :repository_storage_moves + + validates :project, presence: true + validates :state, presence: true + validates :source_storage_name, + on: :create, + presence: true, + inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } } + validates :destination_storage_name, + on: :create, + presence: true, + inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } } + + state_machine initial: :initial do + event :schedule do + transition initial: :scheduled + end + + event :start do + transition scheduled: :started + end + + event :finish do + transition started: :finished + end + + event :do_fail do + transition [:initial, :scheduled, :started] => :failed + end + + after_transition initial: :scheduled do |storage_move, _| + storage_move.run_after_commit do + ProjectUpdateRepositoryStorageWorker.perform_async( + storage_move.project_id, + storage_move.destination_storage_name, + storage_move.id + ) + end + end + + state :initial, value: 1 + state :scheduled, value: 2 + state :started, value: 3 + state :finished, value: 4 + state :failed, value: 5 + end + + scope :order_created_at_desc, -> { order(created_at: :desc) } + scope :with_projects, -> { includes(project: :route) } +end diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb index dc62a4c8908..0a2d9120adc 100644 --- a/app/models/project_services/chat_message/merge_message.rb +++ b/app/models/project_services/chat_message/merge_message.rb @@ -2,8 +2,6 @@ module ChatMessage class MergeMessage < BaseMessage - prepend_if_ee('::EE::ChatMessage::MergeMessage') # rubocop: disable Cop/InjectEnterpriseEditionModule - attr_reader :merge_request_iid attr_reader :source_branch attr_reader :target_branch @@ -71,3 +69,5 @@ module ChatMessage end end end + +ChatMessage::MergeMessage.prepend_if_ee('::EE::ChatMessage::MergeMessage') diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index 50b982a803f..1cd3837433f 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -52,8 +52,6 @@ module ChatMessage def attachments return message if markdown - return [{ text: format(message), color: attachment_color }] unless fancy_notifications? - [{ fallback: format(message), color: attachment_color, @@ -103,10 +101,6 @@ module ChatMessage failed_jobs.uniq { |job| job[:name] }.reverse end - def fancy_notifications? - Feature.enabled?(:fancy_pipeline_slack_notifications, default_enabled: true) - end - def failed_stages_field { title: s_("ChatMessage|Failed stage").pluralize(failed_stages.length), @@ -166,42 +160,22 @@ module ChatMessage end def humanized_status - if fancy_notifications? - case status - when 'success' - detailed_status == "passed with warnings" ? s_("ChatMessage|has passed with warnings") : s_("ChatMessage|has passed") - when 'failed' - s_("ChatMessage|has failed") - else - status - end + case status + when 'success' + detailed_status == "passed with warnings" ? s_("ChatMessage|has passed with warnings") : s_("ChatMessage|has passed") + when 'failed' + s_("ChatMessage|has failed") else - case status - when 'success' - s_("ChatMessage|passed") - when 'failed' - s_("ChatMessage|failed") - else - status - end + status end end def attachment_color - if fancy_notifications? - case status - when 'success' - detailed_status == 'passed with warnings' ? 'warning' : 'good' - else - 'danger' - end + case status + when 'success' + detailed_status == 'passed with warnings' ? 'warning' : 'good' else - case status - when 'success' - 'good' - else - 'danger' - end + 'danger' end end @@ -230,7 +204,7 @@ module ChatMessage end def pipeline_url - if fancy_notifications? && failed_jobs.any? + if failed_jobs.any? pipeline_failed_jobs_url else "#{project_url}/pipelines/#{pipeline_id}" diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index eaddac9cce3..53da874ede8 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -25,6 +25,11 @@ class JiraService < IssueTrackerService before_update :reset_password + enum comment_detail: { + standard: 1, + all_details: 2 + } + alias_method :project_url, :url # When these are false GitLab does not create cross reference @@ -172,6 +177,7 @@ class JiraService < IssueTrackerService noteable_id = noteable.respond_to?(:iid) ? noteable.iid : noteable.id noteable_type = noteable_name(noteable) entity_url = build_entity_url(noteable_type, noteable_id) + entity_meta = build_entity_meta(noteable) data = { user: { @@ -180,12 +186,15 @@ class JiraService < IssueTrackerService }, project: { name: project.full_path, - url: resource_url(namespace_project_path(project.namespace, project)) # rubocop:disable Cop/ProjectPathHelper + url: resource_url(project_path(project)) }, entity: { + id: entity_meta[:id], name: noteable_type.humanize.downcase, url: entity_url, - title: noteable.title + title: noteable.title, + description: entity_meta[:description], + branch: entity_meta[:branch] } } @@ -259,14 +268,11 @@ class JiraService < IssueTrackerService end def add_comment(data, issue) - user_name = data[:user][:name] - user_url = data[:user][:url] entity_name = data[:entity][:name] entity_url = data[:entity][:url] entity_title = data[:entity][:title] - project_name = data[:project][:name] - message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title.chomp}'" + message = comment_message(data) link_title = "#{entity_name.capitalize} - #{entity_title}" link_props = build_remote_link_props(url: entity_url, title: link_title) @@ -275,6 +281,37 @@ class JiraService < IssueTrackerService end end + def comment_message(data) + user_link = build_jira_link(data[:user][:name], data[:user][:url]) + + entity = data[:entity] + entity_ref = all_details? ? "#{entity[:name]} #{entity[:id]}" : "a #{entity[:name]}" + entity_link = build_jira_link(entity_ref, entity[:url]) + + project_link = build_jira_link(project.full_name, Gitlab::Routing.url_helpers.project_url(project)) + branch = + if entity[:branch].present? + s_('JiraService| on branch %{branch_link}') % { + branch_link: build_jira_link(entity[:branch], project_tree_url(project, entity[:branch])) + } + end + + entity_message = entity[:description].presence if all_details? + entity_message ||= entity[:title].chomp + + s_('JiraService|%{user_link} mentioned this issue in %{entity_link} of %{project_link}%{branch}:{quote}%{entity_message}{quote}') % { + user_link: user_link, + entity_link: entity_link, + project_link: project_link, + branch: branch, + entity_message: entity_message + } + end + + def build_jira_link(title, url) + "[#{title}|#{url}]" + end + def has_resolution?(issue) issue.respond_to?(:resolution) && issue.resolution.present? end @@ -348,6 +385,23 @@ class JiraService < IssueTrackerService ) end + def build_entity_meta(noteable) + if noteable.is_a?(Commit) + { + id: noteable.short_id, + description: noteable.safe_message, + branch: noteable.ref_names(project.repository).first + } + elsif noteable.is_a?(MergeRequest) + { + id: noteable.to_reference, + branch: noteable.source_branch + } + else + {} + end + end + def noteable_name(noteable) name = noteable.model_name.singular diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb index ca324f68d2d..0fd85e3a5a9 100644 --- a/app/models/project_services/mattermost_slash_commands_service.rb +++ b/app/models/project_services/mattermost_slash_commands_service.rb @@ -36,6 +36,10 @@ class MattermostSlashCommandsService < SlashCommandsService [[], e.message] end + def chat_responder + ::Gitlab::Chat::Responder::Mattermost + end + private def command(params) diff --git a/app/models/project_services/mock_monitoring_service.rb b/app/models/project_services/mock_monitoring_service.rb index bcf8f1df5da..25ae0f6b60d 100644 --- a/app/models/project_services/mock_monitoring_service.rb +++ b/app/models/project_services/mock_monitoring_service.rb @@ -14,7 +14,7 @@ class MockMonitoringService < MonitoringService end def metrics(environment) - JSON.parse(File.read(Rails.root + 'spec/fixtures/metrics.json')) + Gitlab::Json.parse(File.read(Rails.root + 'spec/fixtures/metrics.json')) end def can_test? diff --git a/app/models/project_services/webex_teams_service.rb b/app/models/project_services/webex_teams_service.rb new file mode 100644 index 00000000000..1d791b19486 --- /dev/null +++ b/app/models/project_services/webex_teams_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class WebexTeamsService < ChatNotificationService + def title + 'Webex Teams' + end + + def description + 'Receive event notifications in Webex Teams' + end + + def self.to_param + 'webex_teams' + end + + def help + 'This service sends notifications about projects events to a Webex Teams conversation.<br /> + To set up this service: + <ol> + <li><a href="https://apphub.webex.com/teams/applications/incoming-webhooks-cisco-systems">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li> + <li>Paste the <strong>Webhook URL</strong> into the field below.</li> + <li>Select events below to enable notifications.</li> + </ol>' + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "e.g. https://api.ciscospark.com/v1/webhooks/incoming/…", required: true }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + ] + end + + private + + def notify(message, opts) + header = { 'Content-Type' => 'application/json' } + response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.pretext }.to_json) + + response if response.success? + end + + def custom_data(data) + super(data).merge(markdown: true) + end +end diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb index 0815e27850d..40203ad692d 100644 --- a/app/models/project_services/youtrack_service.rb +++ b/app/models/project_services/youtrack_service.rb @@ -27,8 +27,8 @@ class YoutrackService < IssueTrackerService def fields [ { type: 'text', name: 'description', placeholder: description }, - { type: 'text', name: 'project_url', placeholder: 'Project url', required: true }, - { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true } + { type: 'text', name: 'project_url', title: 'Project URL', placeholder: 'Project URL', required: true }, + { type: 'text', name: 'issues_url', title: 'Issue URL', placeholder: 'Issue URL', required: true } ] end end diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index b71ed75dde6..6f04a36392d 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -21,6 +21,9 @@ class ProjectStatistics < ApplicationRecord scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) } + scope :for_namespaces, -> (namespaces) { where(namespace: namespaces) } + scope :with_any_ci_minutes_used, -> { where.not(shared_runners_seconds: 0) } + def total_repository_size repository_size + lfs_objects_size end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 708b45cf5f0..5df0a33dc9a 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -1,219 +1,17 @@ # frozen_string_literal: true -class ProjectWiki - include Storage::LegacyProjectWiki - include Gitlab::Utils::StrongMemoize +class ProjectWiki < Wiki + alias_method :project, :container - MARKUPS = { - 'Markdown' => :markdown, - 'RDoc' => :rdoc, - 'AsciiDoc' => :asciidoc, - 'Org' => :org - }.freeze unless defined?(MARKUPS) + # Project wikis are tied to the main project storage + delegate :storage, :repository_storage, :hashed_storage?, to: :container - CouldNotCreateWikiError = Class.new(StandardError) - SIDEBAR = '_sidebar' - - TITLE_ORDER = 'title' - CREATED_AT_ORDER = 'created_at' - DIRECTION_DESC = 'desc' - DIRECTION_ASC = 'asc' - - attr_reader :project, :user - - # Returns a string describing what went wrong after - # an operation fails. - attr_reader :error_message - - def initialize(project, user = nil) - @project = project - @user = user - end - - delegate :repository_storage, :hashed_storage?, to: :project - - def path - @project.path + '.wiki' - end - - def full_path - @project.full_path + '.wiki' - end - alias_method :id, :full_path - - # @deprecated use full_path when you need it for an URL route or disk_path when you want to point to the filesystem - alias_method :path_with_namespace, :full_path - - def web_url(only_path: nil) - Gitlab::UrlBuilder.build(self, only_path: only_path) - end - - def url_to_repo - ssh_url_to_repo - end - - def ssh_url_to_repo - Gitlab::RepositoryUrlBuilder.build(repository.full_path, protocol: :ssh) - end - - def http_url_to_repo - Gitlab::RepositoryUrlBuilder.build(repository.full_path, protocol: :http) - end - - def wiki_base_path - [Gitlab.config.gitlab.relative_url_root, '/', @project.full_path, '/-', '/wikis'].join('') - end - - # Returns the Gitlab::Git::Wiki object. - def wiki - strong_memoize(:wiki) do - repository.create_if_not_exists - raise CouldNotCreateWikiError unless repository_exists? - - Gitlab::Git::Wiki.new(repository.raw) - end - rescue => err - Gitlab::ErrorTracking.track_exception(err, project_wiki: { project_id: project.id, full_path: full_path, disk_path: disk_path }) - raise CouldNotCreateWikiError - end - - def repository_exists? - !!repository.exists? - end - - def has_home_page? - !!find_page('home') - end - - def empty? - list_pages(limit: 1).empty? - end - - def exists? - !empty? - end - - # Lists wiki pages of the repository. - # - # limit - max number of pages returned by the method. - # sort - criterion by which the pages are sorted. - # direction - order of the sorted pages. - # load_content - option, which specifies whether the content inside the page - # will be loaded. - # - # Returns an Array of GitLab WikiPage instances or an - # empty Array if this Wiki has no pages. - def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false) - wiki.list_pages( - limit: limit, - sort: sort, - direction_desc: direction == DIRECTION_DESC, - load_content: load_content - ).map do |page| - WikiPage.new(self, page) - end - end - - # Finds a page within the repository based on a tile - # or slug. - # - # title - The human readable or parameterized title of - # the page. - # - # Returns an initialized WikiPage instance or nil - def find_page(title, version = nil) - page_title, page_dir = page_title_and_dir(title) - - if page = wiki.page(title: page_title, version: version, dir: page_dir) - WikiPage.new(self, page) - end - end - - def find_sidebar(version = nil) - find_page(SIDEBAR, version) - end - - def find_file(name, version = nil) - wiki.file(name, version) - end - - def create_page(title, content, format = :markdown, message = nil) - commit = commit_details(:created, message, title) - - wiki.write_page(title, format.to_sym, content, commit) - - update_project_activity - rescue Gitlab::Git::Wiki::DuplicatePageError => e - @error_message = "Duplicate page: #{e.message}" - false - end - - def update_page(page, content:, title: nil, format: :markdown, message: nil) - commit = commit_details(:updated, message, page.title) - - wiki.update_page(page.path, title || page.name, format.to_sym, content, commit) - - update_project_activity - end - - def delete_page(page, message = nil) - return unless page - - wiki.delete_page(page.path, commit_details(:deleted, message, page.title)) - - update_project_activity - end - - def page_title_and_dir(title) - return unless title - - title_array = title.split("/") - title = title_array.pop - [title, title_array.join("/")] - end - - def repository - @repository ||= Repository.new(full_path, @project, shard: repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::WIKI) - end - - def default_branch - wiki.class.default_ref - end - - def ensure_repository - raise CouldNotCreateWikiError unless wiki.repository_exists? - end - - def hook_attrs - { - web_url: web_url, - git_ssh_url: ssh_url_to_repo, - git_http_url: http_url_to_repo, - path_with_namespace: full_path, - default_branch: default_branch - } - end - - private - - def commit_details(action, message = nil, title = nil) - commit_message = message.presence || default_message(action, title) - git_user = Gitlab::Git::User.from_gitlab(user) - - Gitlab::Git::Wiki::CommitDetails.new(user.id, - git_user.username, - git_user.name, - git_user.email, - commit_message) - end - - def default_message(action, title) - "#{user.username} #{action} page: #{title}" - end - - def update_project_activity - @project.touch(:last_activity_at, :last_repository_updated_at) + override :disk_path + def disk_path(*args, &block) + container.disk_path + '.wiki' end end +# TODO: Remove this once we implement ES support for group wikis. +# https://gitlab.com/gitlab-org/gitlab/-/issues/207889 ProjectWiki.prepend_if_ee('EE::ProjectWiki') diff --git a/app/models/release.rb b/app/models/release.rb index 403087a2cad..a0245105cd9 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -34,8 +34,6 @@ class Release < ApplicationRecord delegate :repository, to: :project - after_commit :notify_new_release, on: :create, unless: :importing? - MAX_NUMBER_TO_DISPLAY = 3 def to_param @@ -81,14 +79,6 @@ class Release < ApplicationRecord self.milestones.map {|m| m.title }.sort.join(", ") end - def evidence_sha - evidences.first&.summary_sha - end - - def evidence_summary - evidences.first&.summary || {} - end - private def actual_sha @@ -100,10 +90,6 @@ class Release < ApplicationRecord repository.find_tag(tag) end end - - def notify_new_release - NewReleaseWorker.perform_async(id) - end end Release.prepend_if_ee('EE::Release') diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index 0334d63dd36..8e7612e63c8 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -106,7 +106,23 @@ class RemoteMirror < ApplicationRecord update_status == 'started' end - def update_repository(options) + def update_repository + Gitlab::Git::RemoteMirror.new( + project.repository.raw, + remote_name, + **options_for_update + ).update + end + + def options_for_update + options = { + keep_divergent_refs: keep_divergent_refs? + } + + if only_protected_branches? + options[:only_branches_matching] = project.protected_branches.pluck(:name) + end + if ssh_mirror_url? if ssh_key_auth? && ssh_private_key.present? options[:ssh_key] = ssh_private_key @@ -117,13 +133,7 @@ class RemoteMirror < ApplicationRecord end end - options[:keep_divergent_refs] = keep_divergent_refs? - - Gitlab::Git::RemoteMirror.new( - project.repository.raw, - remote_name, - **options - ).update + options end def sync? diff --git a/app/models/repository.rb b/app/models/repository.rb index a9ef0504a3d..2673033ff1f 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1120,6 +1120,17 @@ class Repository end end + # TODO: pass this in directly to `Blob` rather than delegating it to here + # + # https://gitlab.com/gitlab-org/gitlab/-/issues/201886 + def lfs_enabled? + if container.is_a?(Project) + container.lfs_enabled? + else + false # LFS is not supported for snippet or group repositories + end + end + private # TODO Genericize finder, later split this on finders by Ref or Oid diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index cd47c154eef..845be408d5e 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -2,16 +2,14 @@ class ResourceLabelEvent < ResourceEvent include CacheMarkdownField + include IssueResourceEvent + include MergeRequestResourceEvent cache_markdown_field :reference - belongs_to :issue - belongs_to :merge_request belongs_to :label scope :inc_relations, -> { includes(:label, :user) } - scope :by_issue, ->(issue) { where(issue_id: issue.id) } - scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) } validates :label, presence: { unless: :importing? }, on: :create validate :exactly_one_issuable diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb index a40af22061e..039f26d8e3f 100644 --- a/app/models/resource_milestone_event.rb +++ b/app/models/resource_milestone_event.rb @@ -2,14 +2,11 @@ class ResourceMilestoneEvent < ResourceEvent include IgnorableColumns + include IssueResourceEvent + include MergeRequestResourceEvent - belongs_to :issue - belongs_to :merge_request belongs_to :milestone - scope :by_issue, ->(issue) { where(issue_id: issue.id) } - scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) } - validate :exactly_one_issuable enum action: { @@ -25,4 +22,8 @@ class ResourceMilestoneEvent < ResourceEvent def self.issuable_attrs %i(issue merge_request).freeze end + + def milestone_title + milestone&.title + end end diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb new file mode 100644 index 00000000000..1d6573b180f --- /dev/null +++ b/app/models/resource_state_event.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ResourceStateEvent < ResourceEvent + include IssueResourceEvent + include MergeRequestResourceEvent + + validate :exactly_one_issuable + + # state is used for issue and merge request states. + enum state: Issue.available_states.merge(MergeRequest.available_states).merge(reopened: 5) + + def self.issuable_attrs + %i(issue merge_request).freeze + end +end diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb index e0cc0c87a83..bbabd54325e 100644 --- a/app/models/resource_weight_event.rb +++ b/app/models/resource_weight_event.rb @@ -3,7 +3,5 @@ class ResourceWeightEvent < ResourceEvent validates :issue, presence: true - belongs_to :issue - - scope :by_issue, ->(issue) { where(issue_id: issue.id) } + include IssueResourceEvent end diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index f3a9293376f..4165d3b753f 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -76,12 +76,14 @@ class SentNotification < ApplicationRecord def position=(new_position) if new_position.is_a?(String) - new_position = JSON.parse(new_position) rescue nil + new_position = Gitlab::Json.parse(new_position) rescue nil end if new_position.is_a?(Hash) new_position = new_position.with_indifferent_access new_position = Gitlab::Diff::Position.new(new_position) + else + new_position = nil end super(new_position) diff --git a/app/models/service.rb b/app/models/service.rb index 543869c71d6..fb4d9a77077 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -12,7 +12,7 @@ class Service < ApplicationRecord alerts asana assembla bamboo bugzilla buildkite campfire custom_issue_tracker discord drone_ci emails_on_push external_wiki flowdock hangouts_chat hipchat irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email - pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit youtrack + pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack ].freeze DEV_SERVICE_NAMES = %w[ @@ -81,6 +81,10 @@ class Service < ApplicationRecord active end + def operating? + active && persisted? + end + def show_active_box? true end @@ -345,14 +349,6 @@ class Service < ApplicationRecord service end - def deprecated? - false - end - - def deprecation_message - nil - end - # override if needed def supports_data_fields? false diff --git a/app/models/snippet.rb b/app/models/snippet.rb index dbf600cf0df..72ebdf61787 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -15,9 +15,11 @@ class Snippet < ApplicationRecord include FromUnion include IgnorableColumns include HasRepository + include AfterCommitQueue extend ::Gitlab::Utils::Override - MAX_FILE_COUNT = 1 + MAX_FILE_COUNT = 10 + MAX_SINGLE_FILE_COUNT = 1 cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description @@ -101,6 +103,10 @@ class Snippet < ApplicationRecord where(project_id: nil) end + def self.only_project_snippets + where.not(project_id: nil) + end + def self.only_include_projects_visible_to(current_user = nil) levels = Gitlab::VisibilityLevel.levels_for_user(current_user) @@ -164,6 +170,10 @@ class Snippet < ApplicationRecord Snippet.find_by(id: id, project: project) end + def self.max_file_limit(user) + Feature.enabled?(:snippet_multiple_files, user) ? MAX_FILE_COUNT : MAX_SINGLE_FILE_COUNT + end + def initialize(attributes = {}) # We can't use default_value_for because the database has a default # value of 0 for visibility_level. If someone attempts to create a @@ -199,7 +209,7 @@ class Snippet < ApplicationRecord def blobs return [] unless repository_exists? - repository.ls_files(repository.root_ref).map { |file| Blob.lazy(self, repository.root_ref, file) } + repository.ls_files(repository.root_ref).map { |file| Blob.lazy(repository, repository.root_ref, file) } end def hook_attrs @@ -318,8 +328,10 @@ class Snippet < ApplicationRecord Digest::SHA256.hexdigest("#{title}#{description}#{created_at}#{updated_at}") end - def versioned_enabled_for?(user) - ::Feature.enabled?(:version_snippets, user) && repository_exists? + def file_name_on_repo + return if repository.empty? + + repository.ls_files(repository.root_ref).first end class << self @@ -334,17 +346,6 @@ class Snippet < ApplicationRecord fuzzy_search(query, [:title, :description, :file_name]) end - # Searches for snippets with matching content. - # - # This method uses ILIKE on PostgreSQL and LIKE on MySQL. - # - # query - The search query as a String. - # - # Returns an ActiveRecord::Relation. - def search_code(query) - fuzzy_search(query, [:content]) - end - def parent_class ::Project end diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb index e60dbb4d141..2276851b7a1 100644 --- a/app/models/snippet_repository.rb +++ b/app/models/snippet_repository.rb @@ -7,6 +7,8 @@ class SnippetRepository < ApplicationRecord EMPTY_FILE_PATTERN = /^#{DEFAULT_EMPTY_FILE_NAME}(\d+)\.txt$/.freeze CommitError = Class.new(StandardError) + InvalidPathError = Class.new(CommitError) + InvalidSignatureError = Class.new(CommitError) belongs_to :snippet, inverse_of: :snippet_repository @@ -40,8 +42,12 @@ class SnippetRepository < ApplicationRecord rescue Gitlab::Git::Index::IndexError, Gitlab::Git::CommitError, Gitlab::Git::PreReceiveError, - Gitlab::Git::CommandError => e - raise CommitError, e.message + Gitlab::Git::CommandError, + ArgumentError => error + + logger.error(message: "Snippet git error. Reason: #{error.message}", snippet: snippet.id) + + raise commit_error_exception(error) end def transform_file_entries(files) @@ -85,4 +91,24 @@ class SnippetRepository < ApplicationRecord def build_empty_file_name(index) "#{DEFAULT_EMPTY_FILE_NAME}#{index}.txt" end + + def commit_error_exception(err) + if invalid_path_error?(err) + InvalidPathError.new('Invalid file name') # To avoid returning the message with the path included + elsif invalid_signature_error?(err) + InvalidSignatureError.new(err.message) + else + CommitError.new(err.message) + end + end + + def invalid_path_error?(err) + err.is_a?(Gitlab::Git::Index::IndexError) && + err.message.downcase.start_with?('invalid path', 'path cannot include directory traversal') + end + + def invalid_signature_error?(err) + err.is_a?(ArgumentError) && + err.message.downcase.match?(/failed to parse signature/) + end end diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index 9bd35d30845..72690ad7d04 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -24,6 +24,7 @@ class SshHostKey # This is achieved by making the lifetime shorter than the refresh interval. self.reactive_cache_refresh_interval = 15.minutes self.reactive_cache_lifetime = 10.minutes + self.reactive_cache_work_type = :external_dependency def self.find_by(opts = {}) opts = HashWithIndifferentAccess.new(opts) diff --git a/app/models/state_note.rb b/app/models/state_note.rb new file mode 100644 index 00000000000..cbcb1c2b49d --- /dev/null +++ b/app/models/state_note.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class StateNote < SyntheticNote + def self.from_event(event, resource: nil, resource_parent: nil) + attrs = note_attributes(event.state, event, resource, resource_parent) + + StateNote.new(attrs) + end + + def note_html + @note_html ||= "<p dir=\"auto\">#{note_text(html: true)}</p>" + end + + private + + def note_text(html: false) + event.state + end +end diff --git a/app/models/storage/hashed.rb b/app/models/storage/hashed.rb index 3dea50ab98b..c61cd3b6b30 100644 --- a/app/models/storage/hashed.rb +++ b/app/models/storage/hashed.rb @@ -6,6 +6,7 @@ module Storage delegate :gitlab_shell, :repository_storage, to: :container REPOSITORY_PATH_PREFIX = '@hashed' + GROUP_REPOSITORY_PATH_PREFIX = '@groups' SNIPPET_REPOSITORY_PATH_PREFIX = '@snippets' POOL_PATH_PREFIX = '@pools' diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index b881a43ad4d..4e14bb4e92c 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -15,6 +15,7 @@ class SystemNoteMetadata < ApplicationRecord ICON_TYPES = %w[ commit description merge confidential visible label assignee cross_reference + designs_added designs_modified designs_removed designs_discussion_added title time_tracking branch milestone discussion task moved opened closed merged duplicate locked unlocked outdated tag due_date pinned_embed cherry_pick health_status diff --git a/app/models/timelog.rb b/app/models/timelog.rb index f52dd74d4c9..c0aac6f27aa 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -16,8 +16,8 @@ class Timelog < ApplicationRecord ) end - scope :between_dates, -> (start_date, end_date) do - where('spent_at BETWEEN ? AND ?', start_date, end_date) + scope :between_times, -> (start_time, end_time) do + where('spent_at BETWEEN ? AND ?', start_time, end_time) end def issuable diff --git a/app/models/todo.rb b/app/models/todo.rb index d337ef33051..dc42551f0ab 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -110,7 +110,7 @@ class Todo < ApplicationRecord base = where.not(state: new_state).except(:order) ids = base.pluck(:id) - base.update_all(state: new_state) + base.update_all(state: new_state, updated_at: Time.now) ids end @@ -183,6 +183,10 @@ class Todo < ApplicationRecord target_type == "Commit" end + def for_design? + target_type == DesignManagement::Design.name + end + # override to return commits, which are not active record def target if for_commit? diff --git a/app/models/user.rb b/app/models/user.rb index 1b087da3a2f..b2d3978551e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -24,6 +24,7 @@ class User < ApplicationRecord include HasUniqueInternalUsers include IgnorableColumns include UpdateHighestRole + include HasUserType DEFAULT_NOTIFICATION_LEVEL = :participating @@ -57,6 +58,10 @@ class User < ApplicationRecord devise :lockable, :recoverable, :rememberable, :trackable, :validatable, :omniauthable, :confirmable, :registerable + # This module adds async behaviour to Devise emails + # and should be added after Devise modules are initialized. + include AsyncDeviseEmail + BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \ "administrator if you think this is an error." LOGIN_FORBIDDEN = "Your account does not have the required permission to login. Please contact your GitLab " \ @@ -64,9 +69,8 @@ class User < ApplicationRecord MINIMUM_INACTIVE_DAYS = 180 - enum user_type: ::UserTypeEnums.types - - ignore_column :bot_type, remove_with: '12.11', remove_after: '2020-04-22' + ignore_column :bot_type, remove_with: '13.1', remove_after: '2020-05-22' + ignore_column :ghost, remove_with: '13.2', remove_after: '2020-06-22' # Override Devise::Models::Trackable#update_tracked_fields! # to limit database writes to at most once every hour @@ -88,6 +92,9 @@ class User < ApplicationRecord # Virtual attribute for authenticating by either username or email attr_accessor :login + # Virtual attribute for impersonator + attr_accessor :impersonator + # # Relations # @@ -166,6 +173,8 @@ class User < ApplicationRecord has_many :term_agreements belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' + has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :user + has_one :status, class_name: 'UserStatus' has_one :user_preference has_one :user_detail @@ -246,15 +255,12 @@ class User < ApplicationRecord enum layout: { fixed: 0, fluid: 1 } # User's Dashboard preference - # Note: When adding an option, it MUST go on the end of the array. enum dashboard: { projects: 0, stars: 1, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8 } # User's Project preference - # Note: When adding an option, it MUST go on the end of the array. enum project_view: { readme: 0, activity: 1, files: 2 } # User's role - # Note: When adding an option, it MUST go on the end of the array. enum role: { software_developer: 0, development_team_lead: 1, devops_engineer: 2, systems_administrator: 3, security_analyst: 4, data_analyst: 5, product_manager: 6, product_designer: 7, other: 8 }, _suffix: true delegate :path, to: :namespace, allow_nil: true, prefix: true @@ -321,32 +327,26 @@ class User < ApplicationRecord scope :admins, -> { where(admin: true) } scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :external, -> { where(external: true) } + scope :confirmed, -> { where.not(confirmed_at: nil) } scope :active, -> { with_state(:active).non_internal } scope :active_without_ghosts, -> { with_state(:active).without_ghosts } - scope :without_ghosts, -> { where('ghost IS NOT TRUE') } scope :deactivated, -> { with_state(:deactivated).non_internal } scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } - scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } - scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } - scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) } - scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) } - scope :confirmed, -> { where.not(confirmed_at: nil) } scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) } scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) } scope :with_emails, -> { preload(:emails) } scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } scope :with_public_profile, -> { where(private_profile: false) } - scope :bots, -> { where(user_type: UserTypeEnums.bots.values) } - scope :bots_without_project_bot, -> { bots.where.not(user_type: UserTypeEnums.bots[:project_bot]) } - scope :with_project_bots, -> { humans.or(where.not(user_type: UserTypeEnums.bots.except(:project_bot).values)) } - scope :humans, -> { where(user_type: nil) } - scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do where('EXISTS (?)', ::PersonalAccessToken .where('personal_access_tokens.user_id = users.id') .expiring_and_not_notified(at).select(1)) end + scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } + scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } + scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) } + scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) } def active_for_authentication? super && can?(:log_in) @@ -624,7 +624,7 @@ class User < ApplicationRecord # owns records previously belonging to deleted users. def ghost email = 'ghost%s@example.com' - unique_internal(where(ghost: true, user_type: :ghost), 'ghost', email) do |u| + unique_internal(where(user_type: :ghost), 'ghost', email) do |u| u.bio = _('This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.') u.name = 'Ghost User' end @@ -639,6 +639,16 @@ class User < ApplicationRecord end end + def migration_bot + email_pattern = "noreply+gitlab-migration-bot%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :migration_bot), 'migration-bot', email_pattern) do |u| + u.bio = 'The GitLab migration bot' + u.name = 'GitLab Migration Bot' + u.confirmed_at = Time.zone.now + end + end + # Return true if there is only single non-internal user in the deployment, # ghost user is ignored. def single_user? @@ -650,43 +660,14 @@ class User < ApplicationRecord end end - def full_path - username - end - - def bot? - UserTypeEnums.bots.has_key?(user_type) - end - - # The explicit check for project_bot will be removed with Bot Categorization - # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945 - def internal? - ghost? || (bot? && !project_bot?) - end - - # We are transitioning from ghost boolean column to user_type - # so we need to read from old column for now - # @see https://gitlab.com/gitlab-org/gitlab/-/issues/210025 - def ghost? - ghost - end - - # The explicit check for project_bot will be removed with Bot Categorization - # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945 - def self.internal - where(ghost: true).or(bots_without_project_bot) - end - - # The explicit check for project_bot will be removed with Bot Categorization - # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945 - def self.non_internal - without_ghosts.with_project_bots - end - # # Instance methods # + def full_path + username + end + def to_param username end @@ -1700,16 +1681,6 @@ class User < ApplicationRecord callouts.any? end - def gitlab_employee? - strong_memoize(:gitlab_employee) do - if Feature.enabled?(:gitlab_employee_badge) && Gitlab.com? - Mail::Address.new(email).domain == "gitlab.com" && confirmed? - else - false - end - end - end - # Load the current highest access by looking directly at the user's memberships def current_highest_access_level members.non_request.maximum(:access_level) @@ -1719,8 +1690,8 @@ class User < ApplicationRecord !confirmed? && !confirmation_period_valid? end - def organization - gitlab_employee? ? 'GitLab' : super + def impersonated? + impersonator.present? end protected @@ -1779,13 +1750,6 @@ class User < ApplicationRecord ApplicationSetting.current_without_cache&.usage_stats_set_by_user_id == self.id end - # Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration - def send_devise_notification(notification, *args) - return true unless can?(:receive_notifications) - - devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend - end - def ensure_user_rights_and_limits if external? self.can_create_group = false @@ -1834,7 +1798,6 @@ class User < ApplicationRecord end def check_email_restrictions - return unless Feature.enabled?(:email_restrictions) return unless Gitlab::CurrentSettings.email_restrictions_enabled? restrictions = Gitlab::CurrentSettings.email_restrictions diff --git a/app/models/user_type_enums.rb b/app/models/user_type_enums.rb deleted file mode 100644 index cb5aac89ed3..00000000000 --- a/app/models/user_type_enums.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module UserTypeEnums - def self.types - @types ||= bots.merge(human: nil, ghost: 5) - end - - def self.bots - @bots ||= { alert_bot: 2, project_bot: 6 }.with_indifferent_access - end -end - -UserTypeEnums.prepend_if_ee('EE::UserTypeEnums') diff --git a/app/models/wiki.rb b/app/models/wiki.rb new file mode 100644 index 00000000000..54bcec32095 --- /dev/null +++ b/app/models/wiki.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +class Wiki + extend ::Gitlab::Utils::Override + include HasRepository + include Gitlab::Utils::StrongMemoize + + MARKUPS = { # rubocop:disable Style/MultilineIfModifier + 'Markdown' => :markdown, + 'RDoc' => :rdoc, + 'AsciiDoc' => :asciidoc, + 'Org' => :org + }.freeze unless defined?(MARKUPS) + + CouldNotCreateWikiError = Class.new(StandardError) + + HOMEPAGE = 'home' + SIDEBAR = '_sidebar' + + TITLE_ORDER = 'title' + CREATED_AT_ORDER = 'created_at' + DIRECTION_DESC = 'desc' + DIRECTION_ASC = 'asc' + + attr_reader :container, :user + + # Returns a string describing what went wrong after + # an operation fails. + attr_reader :error_message + + def self.for_container(container, user = nil) + "#{container.class.name}Wiki".constantize.new(container, user) + end + + def initialize(container, user = nil) + @container = container + @user = user + end + + def path + container.path + '.wiki' + end + + # Returns the Gitlab::Git::Wiki object. + def wiki + strong_memoize(:wiki) do + create_wiki_repository + Gitlab::Git::Wiki.new(repository.raw) + end + end + + def create_wiki_repository + repository.create_if_not_exists + + raise CouldNotCreateWikiError unless repository_exists? + rescue => err + Gitlab::ErrorTracking.track_exception(err, wiki: { + container_type: container.class.name, + container_id: container.id, + full_path: full_path, + disk_path: disk_path + }) + + raise CouldNotCreateWikiError + end + + def has_home_page? + !!find_page(HOMEPAGE) + end + + def empty? + list_pages(limit: 1).empty? + end + + def exists? + !empty? + end + + # Lists wiki pages of the repository. + # + # limit - max number of pages returned by the method. + # sort - criterion by which the pages are sorted. + # direction - order of the sorted pages. + # load_content - option, which specifies whether the content inside the page + # will be loaded. + # + # Returns an Array of GitLab WikiPage instances or an + # empty Array if this Wiki has no pages. + def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false) + wiki.list_pages( + limit: limit, + sort: sort, + direction_desc: direction == DIRECTION_DESC, + load_content: load_content + ).map do |page| + WikiPage.new(self, page) + end + end + + def sidebar_entries(limit: Gitlab::WikiPages::MAX_SIDEBAR_PAGES, **options) + pages = list_pages(**options.merge(limit: limit + 1)) + limited = pages.size > limit + pages = pages.first(limit) if limited + + [WikiPage.group_by_directory(pages), limited] + end + + # Finds a page within the repository based on a tile + # or slug. + # + # title - The human readable or parameterized title of + # the page. + # + # Returns an initialized WikiPage instance or nil + def find_page(title, version = nil) + page_title, page_dir = page_title_and_dir(title) + + if page = wiki.page(title: page_title, version: version, dir: page_dir) + WikiPage.new(self, page) + end + end + + def find_sidebar(version = nil) + find_page(SIDEBAR, version) + end + + def find_file(name, version = nil) + wiki.file(name, version) + end + + def create_page(title, content, format = :markdown, message = nil) + commit = commit_details(:created, message, title) + + wiki.write_page(title, format.to_sym, content, commit) + + update_container_activity + rescue Gitlab::Git::Wiki::DuplicatePageError => e + @error_message = "Duplicate page: #{e.message}" + false + end + + def update_page(page, content:, title: nil, format: :markdown, message: nil) + commit = commit_details(:updated, message, page.title) + + wiki.update_page(page.path, title || page.name, format.to_sym, content, commit) + + update_container_activity + end + + def delete_page(page, message = nil) + return unless page + + wiki.delete_page(page.path, commit_details(:deleted, message, page.title)) + + update_container_activity + end + + def page_title_and_dir(title) + return unless title + + title_array = title.split("/") + title = title_array.pop + [title, title_array.join("/")] + end + + def ensure_repository + raise CouldNotCreateWikiError unless wiki.repository_exists? + end + + def hook_attrs + { + web_url: web_url, + git_ssh_url: ssh_url_to_repo, + git_http_url: http_url_to_repo, + path_with_namespace: full_path, + default_branch: default_branch + } + end + + override :repository + def repository + @repository ||= Repository.new(full_path, container, shard: repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::WIKI) + end + + def repository_storage + raise NotImplementedError + end + + def hashed_storage? + raise NotImplementedError + end + + override :full_path + def full_path + container.full_path + '.wiki' + end + alias_method :id, :full_path + + # @deprecated use full_path when you need it for an URL route or disk_path when you want to point to the filesystem + alias_method :path_with_namespace, :full_path + + override :default_branch + def default_branch + wiki.class.default_ref + end + + def wiki_base_path + Gitlab.config.gitlab.relative_url_root + web_url(only_path: true).sub(%r{/#{Wiki::HOMEPAGE}\z}, '') + end + + private + + def commit_details(action, message = nil, title = nil) + commit_message = message.presence || default_message(action, title) + git_user = Gitlab::Git::User.from_gitlab(user) + + Gitlab::Git::Wiki::CommitDetails.new(user.id, + git_user.username, + git_user.name, + git_user.email, + commit_message) + end + + def default_message(action, title) + "#{user.username} #{action} page: #{title}" + end + + def update_container_activity + container.after_wiki_activity + end +end + +Wiki.prepend_if_ee('EE::Wiki') diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 9c887fc87f3..319cdd38d93 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -26,7 +26,7 @@ class WikiPage def eql?(other) return false unless other.present? && other.is_a?(self.class) - slug == other.slug && wiki.project == other.wiki.project + slug == other.slug && wiki.container == other.wiki.container end alias_method :==, :eql? @@ -66,9 +66,9 @@ class WikiPage validates :content, presence: true validate :validate_path_limits, if: :title_changed? - # The GitLab ProjectWiki instance. + # The GitLab Wiki instance. attr_reader :wiki - delegate :project, to: :wiki + delegate :container, to: :wiki # The raw Gitlab::Git::WikiPage instance. attr_reader :page @@ -83,7 +83,7 @@ class WikiPage # Construct a new WikiPage # - # @param [ProjectWiki] wiki + # @param [Wiki] wiki # @param [Gitlab::Git::WikiPage] page def initialize(wiki, page = nil) @wiki = wiki @@ -95,29 +95,29 @@ class WikiPage # The escaped URL path of this page. def slug - @attributes[:slug].presence || wiki.wiki.preview_slug(title, format) + attributes[:slug].presence || wiki.wiki.preview_slug(title, format) end alias_method :to_param, :slug def human_title - return 'Home' if title == 'home' + return 'Home' if title == Wiki::HOMEPAGE title end # The formatted title of this page. def title - @attributes[:title] || '' + attributes[:title] || '' end # Sets the title of this page. def title=(new_title) - @attributes[:title] = new_title + attributes[:title] = new_title end def raw_content - @attributes[:content] ||= @page&.text_data + attributes[:content] ||= page&.text_data end # The hierarchy of the directory this page is contained in. @@ -127,7 +127,7 @@ class WikiPage # The markup format for the page. def format - @attributes[:format] || :markdown + attributes[:format] || :markdown end # The commit message for this page version. @@ -151,13 +151,13 @@ class WikiPage def versions(options = {}) return [] unless persisted? - wiki.wiki.page_versions(@page.path, options) + wiki.wiki.page_versions(page.path, options) end def count_versions return [] unless persisted? - wiki.wiki.count_page_versions(@page.path) + wiki.wiki.count_page_versions(page.path) end def last_version @@ -173,7 +173,7 @@ class WikiPage def historical? return false unless last_commit_sha && version - @page.historical? && last_commit_sha != version.sha + page.historical? && last_commit_sha != version.sha end # Returns boolean True or False if this instance @@ -185,7 +185,7 @@ class WikiPage # Returns boolean True or False if this instance # has been fully created on disk or not. def persisted? - @page.present? + page.present? end # Creates a new Wiki Page. @@ -195,7 +195,7 @@ class WikiPage # :content - The raw markup content. # :format - Optional symbol representing the # content format. Can be any type - # listed in the ProjectWiki::MARKUPS + # listed in the Wiki::MARKUPS # Hash. # :message - Optional commit message to set on # the new page. @@ -215,7 +215,7 @@ class WikiPage # attrs - Hash of attributes to be updated on the page. # :content - The raw markup content to replace the existing. # :format - Optional symbol representing the content format. - # See ProjectWiki::MARKUPS Hash for available formats. + # See Wiki::MARKUPS Hash for available formats. # :message - Optional commit message to set on the new version. # :last_commit_sha - Optional last commit sha to validate the page unchanged. # :title - The Title (optionally including dir) to replace existing title @@ -232,13 +232,13 @@ class WikiPage update_attributes(attrs) if title.present? && title_changed? && wiki.find_page(title).present? - @attributes[:title] = @page.title + attributes[:title] = page.title raise PageRenameError end save do wiki.update_page( - @page, + page, content: raw_content, format: format, message: attrs[:message], @@ -251,7 +251,7 @@ class WikiPage # # Returns boolean True or False. def delete - if wiki.delete_page(@page) + if wiki.delete_page(page) true else false @@ -261,6 +261,7 @@ class WikiPage # Relative path to the partial to be used when rendering collections # of this object. def to_partial_path + # TODO: Move into shared/ with https://gitlab.com/gitlab-org/gitlab/-/issues/196054 'projects/wikis/wiki_page' end @@ -270,7 +271,7 @@ class WikiPage def title_changed? if persisted? - old_title, old_dir = wiki.page_title_and_dir(self.class.unhyphenize(@page.url_path)) + old_title, old_dir = wiki.page_title_and_dir(self.class.unhyphenize(page.url_path)) new_title, new_dir = wiki.page_title_and_dir(self.class.unhyphenize(title)) new_title != old_title || (title.include?('/') && new_dir != old_dir) @@ -287,13 +288,17 @@ class WikiPage attrs.slice!(:content, :format, :message, :title) clear_memoization(:parsed_content) if attrs.has_key?(:content) - @attributes.merge!(attrs) + attributes.merge!(attrs) end def to_ability_name 'wiki_page' end + def version_commit_timestamp + version&.commit&.committed_date + end + private def serialize_front_matter(hash) @@ -303,7 +308,7 @@ class WikiPage end def update_front_matter(attrs) - return unless Gitlab::WikiPages::FrontMatterParser.enabled?(project) + return unless Gitlab::WikiPages::FrontMatterParser.enabled?(container) return unless attrs.has_key?(:front_matter) fm_yaml = serialize_front_matter(attrs[:front_matter]) @@ -314,7 +319,7 @@ class WikiPage def parsed_content strong_memoize(:parsed_content) do - Gitlab::WikiPages::FrontMatterParser.new(raw_content, project).parse + Gitlab::WikiPages::FrontMatterParser.new(raw_content, container).parse end end @@ -325,7 +330,7 @@ class WikiPage title = deep_title_squish(title) current_dirname = File.dirname(title) - if @page.present? + if persisted? return title[1..-1] if current_dirname == '/' return File.join([directory.presence, title].compact) if current_dirname == '.' end @@ -362,9 +367,11 @@ class WikiPage end def validate_path_limits - *dirnames, title = @attributes[:title].split('/') + return unless title.present? + + *dirnames, filename = title.split('/') - if title && title.bytesize > Gitlab::WikiPages::MAX_TITLE_BYTES + if filename && filename.bytesize > Gitlab::WikiPages::MAX_TITLE_BYTES errors.add(:title, _("exceeds the limit of %{bytes} bytes") % { bytes: Gitlab::WikiPages::MAX_TITLE_BYTES }) diff --git a/app/models/wiki_page/meta.rb b/app/models/wiki_page/meta.rb index 2af7d86ebcc..474968122b1 100644 --- a/app/models/wiki_page/meta.rb +++ b/app/models/wiki_page/meta.rb @@ -5,6 +5,7 @@ class WikiPage include Gitlab::Utils::StrongMemoize CanonicalSlugConflictError = Class.new(ActiveRecord::RecordInvalid) + WikiPageInvalid = Class.new(ArgumentError) self.table_name = 'wiki_page_meta' @@ -23,46 +24,62 @@ class WikiPage alias_method :resource_parent, :project - # Return the (updated) WikiPage::Meta record for a given wiki page - # - # If none is found, then a new record is created, and its fields are set - # to reflect the wiki_page passed. - # - # @param [String] last_known_slug - # @param [WikiPage] wiki_page - # - # As with all `find_or_create` methods, this one raises errors on - # validation issues. - def self.find_or_create(last_known_slug, wiki_page) - project = wiki_page.wiki.project - known_slugs = [last_known_slug, wiki_page.slug].compact.uniq - raise 'no slugs!' if known_slugs.empty? - - transaction do - found = find_by_canonical_slug(known_slugs, project) - meta = found || create(title: wiki_page.title, project_id: project.id) - - meta.update_state(found.nil?, known_slugs, wiki_page) - - # We don't need to run validations here, since find_by_canonical_slug - # guarantees that there is no conflict in canonical_slug, and DB - # constraints on title and project_id enforce our other invariants - # This saves us a query. - meta + class << self + # Return the (updated) WikiPage::Meta record for a given wiki page + # + # If none is found, then a new record is created, and its fields are set + # to reflect the wiki_page passed. + # + # @param [String] last_known_slug + # @param [WikiPage] wiki_page + # + # This method raises errors on validation issues. + def find_or_create(last_known_slug, wiki_page) + raise WikiPageInvalid unless wiki_page.valid? + + project = wiki_page.wiki.project + known_slugs = [last_known_slug, wiki_page.slug].compact.uniq + raise 'No slugs found! This should not be possible.' if known_slugs.empty? + + transaction do + updates = wiki_page_updates(wiki_page) + found = find_by_canonical_slug(known_slugs, project) + meta = found || create!(updates.merge(project_id: project.id)) + + meta.update_state(found.nil?, known_slugs, wiki_page, updates) + + # We don't need to run validations here, since find_by_canonical_slug + # guarantees that there is no conflict in canonical_slug, and DB + # constraints on title and project_id enforce our other invariants + # This saves us a query. + meta + end end - end - def self.find_by_canonical_slug(canonical_slug, project) - meta, conflict = with_canonical_slug(canonical_slug) - .where(project_id: project.id) - .limit(2) + def find_by_canonical_slug(canonical_slug, project) + meta, conflict = with_canonical_slug(canonical_slug) + .where(project_id: project.id) + .limit(2) - if conflict.present? - meta.errors.add(:canonical_slug, 'Duplicate value found') - raise CanonicalSlugConflictError.new(meta) + if conflict.present? + meta.errors.add(:canonical_slug, 'Duplicate value found') + raise CanonicalSlugConflictError.new(meta) + end + + meta end - meta + private + + def wiki_page_updates(wiki_page) + last_commit_date = wiki_page.version_commit_timestamp || Time.now.utc + + { + title: wiki_page.title, + created_at: last_commit_date, + updated_at: last_commit_date + } + end end def canonical_slug @@ -85,24 +102,21 @@ class WikiPage @canonical_slug = slug end - def update_state(created, known_slugs, wiki_page) - update_wiki_page_attributes(wiki_page) + def update_state(created, known_slugs, wiki_page, updates) + update_wiki_page_attributes(updates) insert_slugs(known_slugs, created, wiki_page.slug) self.canonical_slug = wiki_page.slug end - def update_columns(attrs = {}) - super(attrs.reverse_merge(updated_at: Time.now.utc)) - end - - def self.update_all(attrs = {}) - super(attrs.reverse_merge(updated_at: Time.now.utc)) - end - private - def update_wiki_page_attributes(page) - update_columns(title: page.title) unless page.title == title + def update_wiki_page_attributes(updates) + # Remove all unnecessary updates: + updates.delete(:updated_at) if updated_at == updates[:updated_at] + updates.delete(:created_at) if created_at <= updates[:created_at] + updates.delete(:title) if title == updates[:title] + + update_columns(updates) unless updates.empty? end def insert_slugs(strings, is_new, canonical_slug) diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb index 75b711eab5b..428fd336a32 100644 --- a/app/models/x509_certificate.rb +++ b/app/models/x509_certificate.rb @@ -26,6 +26,8 @@ class X509Certificate < ApplicationRecord validates :x509_issuer_id, presence: true + scope :by_x509_issuer, ->(issuer) { where(x509_issuer_id: issuer.id) } + after_commit :mark_commit_signatures_unverified def self.safe_create!(attributes) @@ -33,6 +35,10 @@ class X509Certificate < ApplicationRecord .safe_find_or_create_by!(subject_key_identifier: attributes[:subject_key_identifier]) end + def self.serial_numbers(issuer) + by_x509_issuer(issuer).pluck(:serial_number) + end + def mark_commit_signatures_unverified X509CertificateRevokeWorker.perform_async(self.id) if revoked? end diff --git a/app/models/x509_commit_signature.rb b/app/models/x509_commit_signature.rb index ed7c638cecc..57d809f7cfb 100644 --- a/app/models/x509_commit_signature.rb +++ b/app/models/x509_commit_signature.rb @@ -41,4 +41,8 @@ class X509CommitSignature < ApplicationRecord Gitlab::X509::Commit.new(commit) end + + def user + commit.committer + end end diff --git a/app/policies/alert_management/alert_policy.rb b/app/policies/alert_management/alert_policy.rb new file mode 100644 index 00000000000..85fafcde2cc --- /dev/null +++ b/app/policies/alert_management/alert_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module AlertManagement + class AlertPolicy < ::BasePolicy + delegate { @subject.project } + end +end diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index ebb99270b9a..12892a69257 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -12,6 +12,14 @@ module Ci end end + condition(:unprotected_ref) do + if @subject.tag? + !ProtectedTag.protected?(@subject.project, @subject.ref) + else + !ProtectedBranch.protected?(@subject.project, @subject.ref) + end + end + condition(:owner_of_job) do @subject.triggered_by?(@user) end @@ -34,7 +42,7 @@ module Ci prevent :erase_build end - rule { can?(:admin_build) | (can?(:update_build) & owner_of_job) }.enable :erase_build + rule { can?(:admin_build) | (can?(:update_build) & owner_of_job & unprotected_ref) }.enable :erase_build rule { can?(:public_access) & branch_allows_collaboration }.policy do enable :update_build diff --git a/app/policies/ci/freeze_period_policy.rb b/app/policies/ci/freeze_period_policy.rb new file mode 100644 index 00000000000..60e53a7b2f9 --- /dev/null +++ b/app/policies/ci/freeze_period_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class FreezePeriodPolicy < BasePolicy + delegate { @subject.resource_parent } + end +end diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb index 406677d7b56..f910e04d015 100644 --- a/app/policies/concerns/policy_actor.rb +++ b/app/policies/concerns/policy_actor.rb @@ -1,8 +1,15 @@ # frozen_string_literal: true -# Include this module if we want to pass something else than the user to -# check policies. This defines several methods which the policy checker -# would call and check. +# Include this module to have an object respond to user messages without being +# a user. +# +# Use Case 1: +# Pass something else than the user to check policies. This defines several +# methods which the policy checker would call and check. +# +# Use Case 2: +# Access the API with non-user object such as deploy tokens. This defines +# several methods which the API auth flow would call. module PolicyActor extend ActiveSupport::Concern @@ -37,6 +44,30 @@ module PolicyActor def alert_bot? false end + + def deactivated? + false + end + + def confirmation_required_on_sign_in? + false + end + + def can?(action, subject = :global) + Ability.allowed?(self, action, subject) + end + + def preferred_language + nil + end + + def requires_ldap_check? + false + end + + def try_obtain_ldap_lease + nil + end end PolicyActor.prepend_if_ee('EE::PolicyActor') diff --git a/app/policies/design_management/design_at_version_policy.rb b/app/policies/design_management/design_at_version_policy.rb new file mode 100644 index 00000000000..9decbc0c4b2 --- /dev/null +++ b/app/policies/design_management/design_at_version_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module DesignManagement + class DesignAtVersionPolicy < ::BasePolicy + delegate { @subject.version } + delegate { @subject.design } + end +end diff --git a/app/policies/design_management/design_collection_policy.rb b/app/policies/design_management/design_collection_policy.rb new file mode 100644 index 00000000000..6a833da27cc --- /dev/null +++ b/app/policies/design_management/design_collection_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module DesignManagement + class DesignCollectionPolicy < DesignPolicy + # Delegates everything to the `issue` just like the `DesignPolicy` + end +end diff --git a/app/policies/design_management/design_policy.rb b/app/policies/design_management/design_policy.rb new file mode 100644 index 00000000000..57846095f80 --- /dev/null +++ b/app/policies/design_management/design_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module DesignManagement + class DesignPolicy < ::BasePolicy + # The IssuePolicy will delegate to the ProjectPolicy + delegate { @subject.issue } + end +end diff --git a/app/policies/design_management/version_policy.rb b/app/policies/design_management/version_policy.rb new file mode 100644 index 00000000000..1c59ceaea98 --- /dev/null +++ b/app/policies/design_management/version_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module DesignManagement + class VersionPolicy < ::BasePolicy + # The IssuePolicy will delegate to the ProjectPolicy + delegate { @subject.issue } + end +end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 9353b361c2a..03f5a863421 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -18,6 +18,7 @@ class GlobalPolicy < BasePolicy condition(:private_instance_statistics, score: 0) { Gitlab::CurrentSettings.instance_statistics_visibility_private? } condition(:project_bot, scope: :user) { @user&.project_bot? } + condition(:migration_bot, scope: :user) { @user&.migration_bot? } rule { admin | (~private_instance_statistics & ~anonymous) } .enable :read_instance_statistics @@ -48,11 +49,14 @@ class GlobalPolicy < BasePolicy rule { blocked | internal }.policy do prevent :log_in prevent :access_api - prevent :access_git prevent :receive_notifications prevent :use_slash_commands end + rule { blocked | (internal & ~migration_bot) }.policy do + prevent :access_git + end + rule { project_bot }.policy do prevent :log_in prevent :receive_notifications @@ -74,6 +78,10 @@ class GlobalPolicy < BasePolicy enable :create_group end + rule { can?(:create_group) }.policy do + enable :create_group_with_default_branch_protection + end + rule { can_create_fork }.policy do enable :create_fork end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 728c4b76498..136ac4cce63 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class GroupPolicy < BasePolicy - include CrudPolicyHelpers include FindGroupProjects desc "Group is public" @@ -43,23 +42,15 @@ class GroupPolicy < BasePolicy @subject.subgroup_creation_level == ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS end - desc "Group has wiki disabled" - condition(:wiki_disabled, score: 32) { !feature_available?(:wiki) } - rule { public_group }.policy do enable :read_group enable :read_package - enable :read_wiki end - rule { logged_in_viewable }.policy do - enable :read_group - enable :read_wiki - end + rule { logged_in_viewable }.enable :read_group rule { guest }.policy do enable :read_group - enable :read_wiki enable :upload_file end @@ -87,13 +78,11 @@ class GroupPolicy < BasePolicy enable :create_metrics_dashboard_annotation enable :delete_metrics_dashboard_annotation enable :update_metrics_dashboard_annotation - enable :create_wiki end rule { reporter }.policy do enable :reporter_access enable :read_container_image - enable :download_wiki_code enable :admin_label enable :admin_list enable :admin_issue @@ -112,7 +101,6 @@ class GroupPolicy < BasePolicy enable :destroy_deploy_token enable :read_deploy_token enable :create_deploy_token - enable :admin_wiki end rule { owner }.policy do @@ -123,6 +111,7 @@ class GroupPolicy < BasePolicy enable :set_note_created_at enable :set_emails_disabled + enable :update_default_branch_protection end rule { can?(:read_nested_project_resources) }.policy do @@ -158,11 +147,6 @@ class GroupPolicy < BasePolicy rule { maintainer & can?(:create_projects) }.enable :transfer_projects - rule { wiki_disabled }.policy do - prevent(*create_read_update_admin_destroy(:wiki)) - prevent(:download_wiki_code) - end - def access_level return GroupMember::NO_ACCESS if @user.nil? @@ -172,21 +156,6 @@ class GroupPolicy < BasePolicy def lookup_access_level! @subject.max_member_access_for_user(@user) end - - # TODO: Extract this into a helper shared with ProjectPolicy, once we implement group-level features. - # https://gitlab.com/gitlab-org/gitlab/-/issues/208412 - def feature_available?(feature) - return false unless feature == :wiki - - case @subject.wiki_access_level - when ProjectFeature::DISABLED - false - when ProjectFeature::PRIVATE - admin? || access_level >= ProjectFeature.required_minimum_access_level(feature) - else - true - end - end end GroupPolicy.prepend_if_ee('EE::GroupPolicy') diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 20df823c737..28baa0d8338 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -15,6 +15,9 @@ class IssuePolicy < IssuablePolicy desc "Issue is confidential" condition(:confidential, scope: :subject) { @subject.confidential? } + desc "Issue has moved" + condition(:moved) { @subject.moved? } + rule { confidential & ~can_read_confidential }.policy do prevent(*create_read_update_admin_destroy(:issue)) prevent :read_issue_iid @@ -25,6 +28,15 @@ class IssuePolicy < IssuablePolicy rule { locked }.policy do prevent :reopen_issue end -end -IssuePolicy.prepend_if_ee('::EE::IssuePolicy') + rule { ~can?(:read_issue) }.policy do + prevent :read_design + prevent :create_design + prevent :destroy_design + end + + rule { locked | moved }.policy do + prevent :create_design + prevent :destroy_design + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 7454343a357..a24c0471d6c 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -11,6 +11,7 @@ class ProjectPolicy < BasePolicy milestone snippet wiki + design note pipeline pipeline_schedule @@ -83,11 +84,26 @@ class ProjectPolicy < BasePolicy project.merge_requests_allowing_push_to_user(user).any? end + desc "Deploy token with read_package_registry scope" + condition(:read_package_registry_deploy_token) do + user.is_a?(DeployToken) && user.has_access_to?(project) && user.read_package_registry + end + + desc "Deploy token with write_package_registry scope" + condition(:write_package_registry_deploy_token) do + user.is_a?(DeployToken) && user.has_access_to?(project) && user.write_package_registry + end + with_scope :subject condition(:forking_allowed) do @subject.feature_available?(:forking, @user) end + with_scope :subject + condition(:metrics_dashboard_allowed) do + feature_available?(:metrics_dashboard) + end + with_scope :global condition(:mirror_available, score: 0) do ::Gitlab::CurrentSettings.current_application_settings.mirror_available @@ -102,6 +118,11 @@ class ProjectPolicy < BasePolicy ) end + with_scope :subject + condition(:design_management_disabled) do + !@subject.design_management_enabled? + end + # We aren't checking `:read_issue` or `:read_merge_request` in this case # because it could be possible for a user to see an issuable-iid # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be @@ -134,6 +155,7 @@ class ProjectPolicy < BasePolicy wiki builds pages + metrics_dashboard ] features.each do |f| @@ -174,6 +196,7 @@ class ProjectPolicy < BasePolicy enable :set_issue_updated_at enable :set_note_created_at enable :set_emails_disabled + enable :set_show_default_award_emojis end rule { can?(:guest_access) }.policy do @@ -218,6 +241,7 @@ class ProjectPolicy < BasePolicy enable :read_build enable :read_container_image enable :read_pipeline + enable :read_pipeline_schedule enable :read_environment enable :read_deployment enable :read_merge_request @@ -225,6 +249,7 @@ class ProjectPolicy < BasePolicy enable :update_sentry_issue enable :read_prometheus enable :read_metrics_dashboard_annotation + enable :metrics_dashboard end # We define `:public_user_access` separately because there are cases in gitlab-ee @@ -247,6 +272,21 @@ class ProjectPolicy < BasePolicy enable :fork_project end + rule { metrics_dashboard_disabled }.policy do + prevent(:metrics_dashboard) + end + + rule { can?(:metrics_dashboard) }.policy do + enable :read_prometheus + enable :read_environment + enable :read_deployment + end + + rule { ~anonymous & can?(:metrics_dashboard) }.policy do + enable :create_metrics_user_starred_dashboard + enable :read_metrics_user_starred_dashboard + end + rule { owner | admin | guest | group_member }.prevent :request_access rule { ~request_access_enabled }.prevent :request_access @@ -262,7 +302,6 @@ class ProjectPolicy < BasePolicy enable :update_commit_status enable :create_build enable :update_build - enable :read_pipeline_schedule enable :create_merge_request_from enable :create_wiki enable :push_code @@ -277,9 +316,14 @@ class ProjectPolicy < BasePolicy enable :update_deployment enable :create_release enable :update_release + enable :daily_statistics enable :create_metrics_dashboard_annotation enable :delete_metrics_dashboard_annotation enable :update_metrics_dashboard_annotation + enable :read_alert_management_alert + enable :update_alert_management_alert + enable :create_design + enable :destroy_design end rule { can?(:developer_access) & user_confirmed? }.policy do @@ -315,7 +359,6 @@ class ProjectPolicy < BasePolicy enable :create_environment_terminal enable :destroy_release enable :destroy_artifacts - enable :daily_statistics enable :admin_operations enable :read_deploy_token enable :create_deploy_token @@ -323,6 +366,18 @@ class ProjectPolicy < BasePolicy enable :destroy_deploy_token enable :read_prometheus_alerts enable :admin_terraform_state + enable :create_freeze_period + enable :read_freeze_period + enable :update_freeze_period + enable :destroy_freeze_period + end + + rule { public_project & metrics_dashboard_allowed }.policy do + enable :metrics_dashboard + end + + rule { internal_access & metrics_dashboard_allowed }.policy do + enable :metrics_dashboard end rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror @@ -374,11 +429,27 @@ class ProjectPolicy < BasePolicy rule { builds_disabled | repository_disabled }.policy do prevent(*create_read_update_admin_destroy(:build)) prevent(*create_read_update_admin_destroy(:pipeline_schedule)) - prevent(*create_read_update_admin_destroy(:environment)) prevent(*create_read_update_admin_destroy(:cluster)) prevent(*create_read_update_admin_destroy(:deployment)) end + # Enabling `read_environment` specifically for the condition of `metrics_dashboard_allowed` is + # necessary due to the route for metrics dashboard requiring an environment id. + # This will be addressed in https://gitlab.com/gitlab-org/gitlab/-/issues/213833 when + # environments and metrics are decoupled and these rules will be removed. + + rule { (builds_disabled | repository_disabled) & ~metrics_dashboard_allowed}.policy do + prevent(*create_read_update_admin_destroy(:environment)) + end + + rule { (builds_disabled | repository_disabled) & metrics_dashboard_allowed}.policy do + prevent :create_environment + prevent :update_environment + prevent :admin_environment + prevent :destroy_environment + enable :read_environment + end + # There's two separate cases when builds_disabled is true: # 1. When internal CI is disabled - builds_disabled && internal_builds_disabled # - We do not prevent the user from accessing Pipelines to allow them to access external CI @@ -395,6 +466,7 @@ class ProjectPolicy < BasePolicy prevent :fork_project prevent :read_commit_status prevent :read_pipeline + prevent :read_pipeline_schedule prevent(*create_read_update_admin_destroy(:release)) end @@ -421,6 +493,7 @@ class ProjectPolicy < BasePolicy enable :read_merge_request enable :read_note enable :read_pipeline + enable :read_pipeline_schedule enable :read_commit_status enable :read_container_image enable :download_code @@ -439,6 +512,7 @@ class ProjectPolicy < BasePolicy rule { public_builds & can?(:guest_access) }.policy do enable :read_pipeline + enable :read_pipeline_schedule end # These rules are included to allow maintainers of projects to push to certain @@ -481,6 +555,27 @@ class ProjectPolicy < BasePolicy rule { admin }.enable :change_repository_storage + rule { can?(:read_issue) }.policy do + enable :read_design + end + + # Design abilities could also be prevented in the issue policy. + rule { design_management_disabled }.policy do + prevent :read_design + prevent :create_design + prevent :destroy_design + end + + rule { read_package_registry_deploy_token }.policy do + enable :read_package + enable :read_project + end + + rule { write_package_registry_deploy_token }.policy do + enable :create_package + enable :read_project + end + private def team_member? diff --git a/app/policies/wiki_page_policy.rb b/app/policies/wiki_page_policy.rb index 468632c9085..f284fd9f5df 100644 --- a/app/policies/wiki_page_policy.rb +++ b/app/policies/wiki_page_policy.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class WikiPagePolicy < BasePolicy - delegate { @subject.wiki.project } + delegate { @subject.wiki.container } rule { can?(:read_wiki) }.enable :read_wiki_page end diff --git a/app/presenters/README.md b/app/presenters/README.md index dc4173a880e..62aec4fc8a2 100644 --- a/app/presenters/README.md +++ b/app/presenters/README.md @@ -8,7 +8,7 @@ methods from models to presenters. ### When your view is full of logic -When your view is full of logic (`if`, `else`, `select` on arrays etc.), it's +When your view is full of logic (`if`, `else`, `select` on arrays, etc.), it's time to create a presenter! ### When your model has a lot of view-related logic/data methods @@ -27,11 +27,11 @@ Presenters should be used for: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/7073/diffs. - Data and logic methods that can be pulled from models. - Simple text output methods: it's ok if the method returns a string, but not a - whole DOM element for which we'd need HAML, a view context, helpers etc. + whole DOM element for which we'd need HAML, a view context, helpers, etc. ## Why use presenters instead of model concerns? -We should strive to follow the single-responsibility principle, and view-related +We should strive to follow the single-responsibility principle and view-related logic/data methods are definitely not the responsibility of models! Another reason is as follows: @@ -52,22 +52,22 @@ we gain the following benefits: - rules are more explicit and centralized in the presenter => improves security - testing is easier and faster as presenters are Plain Old Ruby Object (PORO) - views are more readable and maintainable -- decreases number of CE -> EE merge conflicts since code is in separate files +- decreases the number of CE -> EE merge conflicts since code is in separate files - moves the conflicts from views (not always obvious) to presenters (a lot easier to resolve) ## What not to do with presenters? - Don't use helpers in presenters. Presenters are not aware of the view context. -- Don't generate complex DOM elements, forms etc. with presenters. Presenters - can return simple data as texts, and URLs using URL helpers from - `Gitlab::Routing` but nothing much more fancy. +- Don't generate complex DOM elements, forms, etc. with presenters. Presenters + can return simple data like texts, and URLs using URL helpers from + `Gitlab::Routing` but nothing much fancier. ## Implementation ### Presenter definition Every presenter should inherit from `Gitlab::View::Presenter::Simple`, which -provides a `.presents` method which allows you to define an accessor for the +provides a `.presents` the method which allows you to define an accessor for the presented object. It also includes common helpers like `Gitlab::Routing` and `Gitlab::Allowable`. diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 33b7899f912..5e35bfc79ef 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -27,14 +27,13 @@ module Ci def git_depth if git_depth_variable git_depth_variable[:value] - elsif Feature.enabled?(:ci_project_git_depth, default_enabled: true) + else project.ci_default_git_depth end.to_i end def refspecs specs = [] - specs << refspec_for_pipeline_ref if should_expose_merge_request_ref? specs << refspec_for_persistent_ref if persistent_ref_exist? if git_depth > 0 @@ -50,23 +49,10 @@ module Ci private - # We will stop exposing merge request refs when we fully depend on persistent refs - # (i.e. remove `refspec_for_pipeline_ref` when we remove `depend_on_persistent_pipeline_ref` feature flag.) - # `ci_force_exposing_merge_request_refs` is an extra feature flag that allows us to - # forcibly expose MR refs even if the `depend_on_persistent_pipeline_ref` feature flag enabled. - # This is useful when we see an unexpected behaviors/reports from users. - # See https://gitlab.com/gitlab-org/gitlab/issues/35140. - def should_expose_merge_request_ref? - return false unless merge_request_ref? - return true if Feature.enabled?(:ci_force_exposing_merge_request_refs, project) - - Feature.disabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true) - end - def create_archive(artifacts) return unless artifacts[:untracked] || artifacts[:paths] - { + archive = { artifact_type: :archive, artifact_format: :zip, name: artifacts[:name], @@ -75,6 +61,12 @@ module Ci when: artifacts[:when], expire_in: artifacts[:expire_in] } + + if artifacts.dig(:exclude).present? && ::Gitlab::Ci::Features.artifacts_exclude_enabled? + archive.merge(exclude: artifacts[:exclude]) + else + archive + end end def create_reports(reports, expire_in:) @@ -100,15 +92,18 @@ module Ci "+#{Gitlab::Git::TAG_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_TAG_PREFIX}#{ref}" end - def refspec_for_pipeline_ref - "+#{ref}:#{ref}" - end - def refspec_for_persistent_ref "+#{persistent_ref_path}:#{persistent_ref_path}" end def persistent_ref_exist? + ## + # Persistent refs for pipelines definitely exist from GitLab 12.4, + # hence, we don't need to check the ref existence before passing it to runners. + # Checking refs pressurizes gitaly node and should be avoided. + # Issue: https://gitlab.com/gitlab-com/gl-infra/production/-/issues/2143 + return true if Feature.enabled?(:ci_skip_persistent_ref_existence_check) + pipeline.persistent_ref.exist? end diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index 6b1d82e7557..5e669ff2e50 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -21,8 +21,8 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated can?(current_user, :create_cluster, clusterable) end - def index_path - polymorphic_path([clusterable, :clusters]) + def index_path(options = {}) + polymorphic_path([clusterable, :clusters], options) end def new_path(options = {}) diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index 23e688e562e..52811e152a6 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -33,14 +33,6 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated def callout_failure_message self.class.callout_failure_messages.fetch(failure_reason.to_sym) end - - def recoverable? - failed? && !unrecoverable? - end - - def unrecoverable? - script_failure? || missing_dependency_failure? || archived_failure? || scheduler_failure? || data_integrity_failure? - end end CommitStatusPresenter.prepend_if_ee('::EE::CommitStatusPresenter') diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb index 0c267fd5735..41071bc7bc7 100644 --- a/app/presenters/instance_clusterable_presenter.rb +++ b/app/presenters/instance_clusterable_presenter.rb @@ -13,8 +13,8 @@ class InstanceClusterablePresenter < ClusterablePresenter end override :index_path - def index_path - admin_clusters_path + def index_path(options = {}) + admin_clusters_path(options) end override :new_path diff --git a/app/presenters/pages_domain_presenter.rb b/app/presenters/pages_domain_presenter.rb index 6b74983d932..6ef89760bec 100644 --- a/app/presenters/pages_domain_presenter.rb +++ b/app/presenters/pages_domain_presenter.rb @@ -8,8 +8,6 @@ class PagesDomainPresenter < Gitlab::View::Presenter::Delegated end def show_auto_ssl_failed_warning? - return false unless Feature.enabled?(:pages_letsencrypt_errors, pages_domain.project) - # validations prevents auto ssl from working, so there is no need to show that warning until return false if needs_verification? diff --git a/app/presenters/projects/prometheus/alert_presenter.rb b/app/presenters/projects/prometheus/alert_presenter.rb index c03925c0871..2114e06a8c5 100644 --- a/app/presenters/projects/prometheus/alert_presenter.rb +++ b/app/presenters/projects/prometheus/alert_presenter.rb @@ -7,6 +7,7 @@ module Projects GENERIC_ALERT_SUMMARY_ANNOTATIONS = %w(monitoring_tool service hosts).freeze MARKDOWN_LINE_BREAK = " \n".freeze INCIDENT_LABEL_NAME = IncidentManagement::CreateIssueService::INCIDENT_LABEL[:title].freeze + METRIC_TIME_WINDOW = 30.minutes def full_title [environment_name, alert_title].compact.join(': ') @@ -119,9 +120,63 @@ module Projects Array(hosts.value).join(' ') end - def metric_embed_for_alert; end + def metric_embed_for_alert + url = embed_url_for_gitlab_alert || embed_url_for_self_managed_alert + + "\n[](#{url})" if url + end + + def embed_url_for_gitlab_alert + return unless gitlab_alert + + metrics_dashboard_project_prometheus_alert_url( + project, + gitlab_alert.prometheus_metric_id, + environment_id: environment.id, + **alert_embed_window_params(embed_time) + ) + end + + def embed_url_for_self_managed_alert + return unless environment && full_query && title + + metrics_dashboard_project_environment_url( + project, + environment, + embed_json: dashboard_for_self_managed_alert.to_json, + **alert_embed_window_params(embed_time) + ) + end + + def embed_time + starts_at ? Time.rfc3339(starts_at) : Time.current + end + + def alert_embed_window_params(time) + { + start: format_embed_timestamp(time - METRIC_TIME_WINDOW), + end: format_embed_timestamp(time + METRIC_TIME_WINDOW) + } + end + + def format_embed_timestamp(time) + time.utc.strftime('%FT%TZ') + end + + def dashboard_for_self_managed_alert + { + panel_groups: [{ + panels: [{ + type: 'line-graph', + title: title, + y_label: y_label, + metrics: [{ + query_range: full_query + }] + }] + }] + } + end end end end - -Projects::Prometheus::AlertPresenter.prepend_if_ee('EE::Projects::Prometheus::AlertPresenter') diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index 66211d02696..103c26289bf 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -60,11 +60,11 @@ module Projects end def to_partial_path - 'projects/deploy_keys/index' + '../../shared/deploy_keys/index' end def form_partial_path - 'projects/deploy_keys/form' + 'shared/deploy_keys/project_group_form' end private diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb index 3db89df1cc8..ea46f0a234b 100644 --- a/app/presenters/release_presenter.rb +++ b/app/presenters/release_presenter.rb @@ -43,13 +43,6 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated edit_project_release_url(project, release) end - def evidence_file_path - evidence = release.evidences.first - return unless evidence - - project_evidence_url(project, release, evidence, format: :json) - end - private def can_download_code? diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb index ba0b2b42383..faaf7568c72 100644 --- a/app/presenters/snippet_presenter.rb +++ b/app/presenters/snippet_presenter.rb @@ -12,11 +12,11 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated end def ssh_url_to_repo - snippet.ssh_url_to_repo if snippet.versioned_enabled_for?(current_user) + snippet.ssh_url_to_repo if snippet.repository_exists? end def http_url_to_repo - snippet.http_url_to_repo if snippet.versioned_enabled_for?(current_user) + snippet.http_url_to_repo if snippet.repository_exists? end def can_read_snippet? @@ -36,10 +36,10 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated end def blob - if Feature.enabled?(:version_snippets, current_user) && !snippet.repository.empty? - snippet.blobs.first - else + if snippet.empty_repo? snippet.blob + else + snippet.blobs.first end end diff --git a/app/serializers/accessibility_error_entity.rb b/app/serializers/accessibility_error_entity.rb new file mode 100644 index 00000000000..540f5384d66 --- /dev/null +++ b/app/serializers/accessibility_error_entity.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AccessibilityErrorEntity < Grape::Entity + expose :code + expose :type + expose :typeCode, as: :type_code + expose :message + expose :context + expose :selector + expose :runner + expose :runnerExtras, as: :runner_extras +end diff --git a/app/serializers/accessibility_reports_comparer_entity.rb b/app/serializers/accessibility_reports_comparer_entity.rb new file mode 100644 index 00000000000..3768607a3fc --- /dev/null +++ b/app/serializers/accessibility_reports_comparer_entity.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AccessibilityReportsComparerEntity < Grape::Entity + expose :status + + expose :new_errors, using: AccessibilityErrorEntity + expose :resolved_errors, using: AccessibilityErrorEntity + expose :existing_errors, using: AccessibilityErrorEntity + + expose :summary do + expose :total_count, as: :total + expose :resolved_count, as: :resolved + expose :errors_count, as: :errored + end +end diff --git a/app/serializers/accessibility_reports_comparer_serializer.rb b/app/serializers/accessibility_reports_comparer_serializer.rb new file mode 100644 index 00000000000..a6b8162e4ea --- /dev/null +++ b/app/serializers/accessibility_reports_comparer_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class AccessibilityReportsComparerSerializer < BaseSerializer + entity AccessibilityReportsComparerEntity +end diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb index 57e9225e2da..62828fc1428 100644 --- a/app/serializers/analytics_summary_entity.rb +++ b/app/serializers/analytics_summary_entity.rb @@ -8,8 +8,6 @@ class AnalyticsSummaryEntity < Grape::Entity private def value - return object.value if object.value.is_a? String - - object.value&.nonzero? ? object.value.to_s : '-' + object.value.to_s end end diff --git a/app/serializers/ci/basic_variable_entity.rb b/app/serializers/ci/basic_variable_entity.rb new file mode 100644 index 00000000000..dad59e8735b --- /dev/null +++ b/app/serializers/ci/basic_variable_entity.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class BasicVariableEntity < Grape::Entity + expose :id + expose :key + expose :value + expose :variable_type + + expose :protected?, as: :protected + expose :masked?, as: :masked + end +end diff --git a/app/serializers/ci/dag_job_entity.rb b/app/serializers/ci/dag_job_entity.rb new file mode 100644 index 00000000000..b4947319ed1 --- /dev/null +++ b/app/serializers/ci/dag_job_entity.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Ci + class DagJobEntity < Grape::Entity + expose :name + + expose :needs, if: -> (job, _) { job.scheduling_type_dag? } do |job| + job.needs.pluck(:name) # rubocop: disable CodeReuse/ActiveRecord + end + end +end diff --git a/app/serializers/ci/dag_job_group_entity.rb b/app/serializers/ci/dag_job_group_entity.rb new file mode 100644 index 00000000000..ac1ed89281c --- /dev/null +++ b/app/serializers/ci/dag_job_group_entity.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Ci + class DagJobGroupEntity < Grape::Entity + expose :name + expose :size + expose :jobs, with: Ci::DagJobEntity + end +end diff --git a/app/serializers/ci/dag_pipeline_entity.rb b/app/serializers/ci/dag_pipeline_entity.rb new file mode 100644 index 00000000000..b615dd2b194 --- /dev/null +++ b/app/serializers/ci/dag_pipeline_entity.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ci + class DagPipelineEntity < Grape::Entity + expose :ordered_stages_with_preloads, as: :stages, using: Ci::DagStageEntity + + private + + def ordered_stages_with_preloads + object.ordered_stages.preload(preloaded_relations) # rubocop: disable CodeReuse/ActiveRecord + end + + def preloaded_relations + [ + :project, + { latest_statuses: :needs } + ] + end + end +end diff --git a/app/serializers/ci/dag_pipeline_serializer.rb b/app/serializers/ci/dag_pipeline_serializer.rb new file mode 100644 index 00000000000..0c9e9a9db69 --- /dev/null +++ b/app/serializers/ci/dag_pipeline_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class DagPipelineSerializer < BaseSerializer + entity Ci::DagPipelineEntity + end +end diff --git a/app/serializers/ci/dag_stage_entity.rb b/app/serializers/ci/dag_stage_entity.rb new file mode 100644 index 00000000000..c7969da6c3c --- /dev/null +++ b/app/serializers/ci/dag_stage_entity.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Ci + class DagStageEntity < Grape::Entity + expose :name + + expose :groups, with: Ci::DagJobGroupEntity + end +end diff --git a/app/serializers/ci/instance_variable_serializer.rb b/app/serializers/ci/instance_variable_serializer.rb new file mode 100644 index 00000000000..b0b49aecdbd --- /dev/null +++ b/app/serializers/ci/instance_variable_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class InstanceVariableSerializer < BaseSerializer + entity BasicVariableEntity + end +end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 85a40f1f5cb..32b759b9628 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -19,4 +19,6 @@ class ClusterApplicationEntity < Grape::Entity expose :host, if: -> (e, _) { e.respond_to?(:host) } expose :port, if: -> (e, _) { e.respond_to?(:port) } expose :protocol, if: -> (e, _) { e.respond_to?(:protocol) } + expose :waf_log_enabled, if: -> (e, _) { e.respond_to?(:waf_log_enabled) } + expose :cilium_log_enabled, if: -> (e, _) { e.respond_to?(:cilium_log_enabled) } end diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb index c59f68bbc49..4f53ea30544 100644 --- a/app/serializers/cluster_entity.rb +++ b/app/serializers/cluster_entity.rb @@ -3,7 +3,16 @@ class ClusterEntity < Grape::Entity include RequestAwareEntity + expose :cluster_type + expose :enabled + expose :environment_scope + expose :name + expose :nodes expose :status_name, as: :status expose :status_reason expose :applications, using: ClusterApplicationEntity + + expose :path do |cluster| + Clusters::ClusterPresenter.new(cluster).show_path # rubocop: disable CodeReuse/Presenter + end end diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb index 4bb4d4880d4..f59b6a35a29 100644 --- a/app/serializers/cluster_serializer.rb +++ b/app/serializers/cluster_serializer.rb @@ -1,8 +1,23 @@ # frozen_string_literal: true class ClusterSerializer < BaseSerializer + include WithPagination entity ClusterEntity + def represent_list(resource) + represent(resource, { + only: [ + :cluster_type, + :enabled, + :environment_scope, + :name, + :nodes, + :path, + :status + ] + }) + end + def represent_status(resource) represent(resource, { only: [:status, :status_reason, :applications] }) end diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb index 302fe3d7c67..8c2b3a65d57 100644 --- a/app/serializers/diff_file_base_entity.rb +++ b/app/serializers/diff_file_base_entity.rb @@ -22,16 +22,16 @@ class DiffFileBaseEntity < Grape::Entity expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file| merge_request = options[:merge_request] - options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {} + next unless merge_request.merged? || merge_request.source_branch_exists? - next unless merge_request.source_project + target_project, target_branch = edit_project_branch_options(merge_request) if Feature.enabled?(:web_ide_default) - ide_edit_path(merge_request.source_project, merge_request.source_branch, diff_file.new_path) + ide_edit_path(target_project, target_branch, diff_file.new_path) else - project_edit_blob_path(merge_request.source_project, - tree_join(merge_request.source_branch, diff_file.new_path), - options) + options = merge_request.persisted? && merge_request.source_branch_exists? && !merge_request.merged? ? { from_merge_request_iid: merge_request.iid } : {} + + project_edit_blob_path(target_project, tree_join(target_branch, diff_file.new_path), options) end end @@ -61,7 +61,7 @@ class DiffFileBaseEntity < Grape::Entity next unless diff_file.blob if merge_request&.source_project && current_user - can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch) + can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch_exists? ? merge_request.source_branch : merge_request.target_branch) else false end @@ -88,6 +88,7 @@ class DiffFileBaseEntity < Grape::Entity expose :b_mode expose :viewer, using: DiffViewerEntity + expose :alternate_viewer, using: DiffViewerEntity expose :old_size do |diff_file| diff_file.old_blob&.raw_size @@ -112,4 +113,12 @@ class DiffFileBaseEntity < Grape::Entity def current_user request.current_user end + + def edit_project_branch_options(merge_request) + if merge_request.source_branch_exists? && !merge_request.merged? + [merge_request.source_project, merge_request.source_branch] + else + [merge_request.target_project, merge_request.target_branch] + end + end end diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb index 568d0f6aa8f..fb4fbe57130 100644 --- a/app/serializers/diffs_entity.rb +++ b/app/serializers/diffs_entity.rb @@ -11,6 +11,10 @@ class DiffsEntity < Grape::Entity merge_request&.source_branch end + expose :source_branch_exists do |diffs| + merge_request&.source_branch_exists? + end + expose :target_branch_name do |diffs| merge_request&.target_branch end diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb index 622106458c3..4f44723fefe 100644 --- a/app/serializers/group_variable_entity.rb +++ b/app/serializers/group_variable_entity.rb @@ -1,11 +1,4 @@ # frozen_string_literal: true -class GroupVariableEntity < Grape::Entity - expose :id - expose :key - expose :value - expose :variable_type - - expose :protected?, as: :protected - expose :masked?, as: :masked +class GroupVariableEntity < Ci::BasicVariableEntity end diff --git a/app/serializers/issuable_sidebar_basic_entity.rb b/app/serializers/issuable_sidebar_basic_entity.rb index 498cfe5930d..bbec107544e 100644 --- a/app/serializers/issuable_sidebar_basic_entity.rb +++ b/app/serializers/issuable_sidebar_basic_entity.rb @@ -21,7 +21,7 @@ class IssuableSidebarBasicEntity < Grape::Entity expose :labels, using: LabelEntity expose :current_user, if: lambda { |_issuable| current_user } do - expose :current_user, merge: true, using: API::Entities::UserBasic + expose :current_user, merge: true, using: ::API::Entities::UserBasic expose :todo, using: IssuableSidebarTodoEntity do |issuable| current_user.pending_todo_for(issuable) diff --git a/app/serializers/issuable_sidebar_extras_entity.rb b/app/serializers/issuable_sidebar_extras_entity.rb index 0e1fcc58d7a..77f2e34fa5d 100644 --- a/app/serializers/issuable_sidebar_extras_entity.rb +++ b/app/serializers/issuable_sidebar_extras_entity.rb @@ -21,5 +21,5 @@ class IssuableSidebarExtrasEntity < Grape::Entity issuable.subscribed?(request.current_user, issuable.project) end - expose :assignees, using: API::Entities::UserBasic + expose :assignees, using: ::API::Entities::UserBasic end diff --git a/app/serializers/merge_request_assignee_entity.rb b/app/serializers/merge_request_assignee_entity.rb index 6849c62e759..b7ef7449270 100644 --- a/app/serializers/merge_request_assignee_entity.rb +++ b/app/serializers/merge_request_assignee_entity.rb @@ -5,3 +5,5 @@ class MergeRequestAssigneeEntity < ::API::Entities::UserBasic options[:merge_request]&.can_be_merged_by?(assignee) end end + +MergeRequestAssigneeEntity.prepend_if_ee('EE::MergeRequestAssigneeEntity') diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index 18e8ec0e7d1..aad607f358a 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -71,6 +71,18 @@ class MergeRequestPollWidgetEntity < Grape::Entity end end + expose :accessibility_report_path do |merge_request| + if merge_request.has_accessibility_reports? + accessibility_reports_project_merge_request_path(merge_request.project, merge_request, format: :json) + end + end + + expose :terraform_reports_path do |merge_request| + if merge_request.has_terraform_reports? + terraform_reports_project_merge_request_path(merge_request.project, merge_request, format: :json) + end + end + expose :exposed_artifacts_path do |merge_request| if merge_request.has_exposed_artifacts? exposed_artifacts_project_merge_request_path(merge_request.project, merge_request, format: :json) diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index 9fd50c8c51d..508a2510dbd 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -27,3 +27,5 @@ class MergeRequestSerializer < BaseSerializer super(merge_request, opts, entity) end end + +MergeRequestSerializer.prepend_if_ee('EE::MergeRequestSerializer') diff --git a/app/serializers/note_user_entity.rb b/app/serializers/note_user_entity.rb index 8d30bbff5e4..38e71528f18 100644 --- a/app/serializers/note_user_entity.rb +++ b/app/serializers/note_user_entity.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class NoteUserEntity < UserEntity - expose :gitlab_employee?, as: :is_gitlab_employee, if: ->(user, options) { user.gitlab_employee? } - unexpose :web_url end + +NoteUserEntity.prepend_if_ee('EE::NoteUserEntity') diff --git a/app/serializers/service_event_entity.rb b/app/serializers/service_event_entity.rb new file mode 100644 index 00000000000..fd655dd1ed3 --- /dev/null +++ b/app/serializers/service_event_entity.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class ServiceEventEntity < Grape::Entity + include RequestAwareEntity + + expose :title do |event| + event + end + + expose :event_field_name, as: :name + + expose :value do |event| + service[event_field_name] + end + + expose :description do |event| + service.class.event_description(event) + end + + expose :field, if: -> (_, _) { event_field } do + expose :name do |event| + event_field[:name] + end + expose :value do |event| + service.public_send(event_field[:name]) # rubocop:disable GitlabSecurity/PublicSend + end + end + + private + + alias_method :event, :object + + def event_field_name + ServicesHelper.service_event_field_name(event) + end + + def event_field + @event_field ||= service.event_field(event) + end + + def service + request.service + end +end diff --git a/app/serializers/service_event_serializer.rb b/app/serializers/service_event_serializer.rb new file mode 100644 index 00000000000..7f5fe36e571 --- /dev/null +++ b/app/serializers/service_event_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ServiceEventSerializer < BaseSerializer + entity ServiceEventEntity +end diff --git a/app/serializers/test_suite_comparer_entity.rb b/app/serializers/test_suite_comparer_entity.rb index 78c243f75b8..a9f19564b60 100644 --- a/app/serializers/test_suite_comparer_entity.rb +++ b/app/serializers/test_suite_comparer_entity.rb @@ -46,8 +46,6 @@ class TestSuiteComparerEntity < Grape::Entity private def max_tests(*used) - return Integer::MAX unless Feature.enabled?(:ci_limit_test_reports_size, default_enabled: true) - [DEFAULT_MAX_TESTS - used.map(&:count).sum, DEFAULT_MIN_TESTS].max end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/serializers/test_suite_entity.rb b/app/serializers/test_suite_entity.rb index 0f88a496c77..53fa830718a 100644 --- a/app/serializers/test_suite_entity.rb +++ b/app/serializers/test_suite_entity.rb @@ -9,8 +9,9 @@ class TestSuiteEntity < Grape::Entity expose :failed_count expose :skipped_count expose :error_count + expose :suite_error expose :test_cases, using: TestCaseEntity do |test_suite| - test_suite.test_cases.values.flat_map(&:values) + test_suite.suite_error ? [] : test_suite.test_cases.values.flat_map(&:values) end end diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb index 017035fa117..9b0db371acb 100644 --- a/app/serializers/variable_entity.rb +++ b/app/serializers/variable_entity.rb @@ -1,12 +1,5 @@ # frozen_string_literal: true -class VariableEntity < Grape::Entity - expose :id - expose :key - expose :value - expose :variable_type - - expose :protected?, as: :protected - expose :masked?, as: :masked +class VariableEntity < Ci::BasicVariableEntity expose :environment_scope end diff --git a/app/services/alert_management/create_alert_issue_service.rb b/app/services/alert_management/create_alert_issue_service.rb new file mode 100644 index 00000000000..0197f29145d --- /dev/null +++ b/app/services/alert_management/create_alert_issue_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module AlertManagement + class CreateAlertIssueService + # @param alert [AlertManagement::Alert] + # @param user [User] + def initialize(alert, user) + @alert = alert + @user = user + end + + def execute + return error_no_permissions unless allowed? + return error_issue_already_exists if alert.issue + + result = create_issue(alert, user, alert_payload) + @issue = result[:issue] + + return error(result[:message]) if result[:status] == :error + return error(alert.errors.full_messages.to_sentence) unless update_alert_issue_id + + success + end + + private + + attr_reader :alert, :user, :issue + + delegate :project, to: :alert + + def allowed? + Feature.enabled?(:alert_management_create_alert_issue, project) && + user.can?(:create_issue, project) + end + + def create_issue(alert, user, alert_payload) + ::IncidentManagement::CreateIssueService + .new(project, alert_payload, user) + .execute(skip_settings_check: true) + end + + def alert_payload + if alert.prometheus? + alert.payload + else + Gitlab::Alerting::NotificationPayloadParser.call(alert.payload.to_h) + end + end + + def update_alert_issue_id + alert.update(issue_id: issue.id) + end + + def success + ServiceResponse.success(payload: { issue: issue }) + end + + def error(message) + ServiceResponse.error(payload: { issue: issue }, message: message) + end + + def error_issue_already_exists + error(_('An issue already exists')) + end + + def error_no_permissions + error(_('You have no permissions')) + end + end +end diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb new file mode 100644 index 00000000000..af28f1354b3 --- /dev/null +++ b/app/services/alert_management/process_prometheus_alert_service.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module AlertManagement + class ProcessPrometheusAlertService < BaseService + include Gitlab::Utils::StrongMemoize + + def execute + return bad_request unless parsed_alert.valid? + + process_alert_management_alert + + ServiceResponse.success + end + + private + + delegate :firing?, :resolved?, :gitlab_fingerprint, :ends_at, to: :parsed_alert + + def parsed_alert + strong_memoize(:parsed_alert) do + Gitlab::Alerting::Alert.new(project: project, payload: params) + end + end + + def process_alert_management_alert + process_firing_alert_management_alert if firing? + process_resolved_alert_management_alert if resolved? + end + + def process_firing_alert_management_alert + if am_alert.present? + reset_alert_management_alert_status + else + create_alert_management_alert + end + end + + def reset_alert_management_alert_status + return if am_alert.trigger + + logger.warn( + message: 'Unable to update AlertManagement::Alert status to triggered', + project_id: project.id, + alert_id: am_alert.id + ) + end + + def create_alert_management_alert + am_alert = AlertManagement::Alert.new(am_alert_params.merge(ended_at: nil)) + return if am_alert.save + + logger.warn( + message: 'Unable to create AlertManagement::Alert', + project_id: project.id, + alert_errors: am_alert.errors.messages + ) + end + + def am_alert_params + Gitlab::AlertManagement::AlertParams.from_prometheus_alert(project: project, parsed_alert: parsed_alert) + end + + def process_resolved_alert_management_alert + return if am_alert.blank? + return if am_alert.resolve(ends_at) + + logger.warn( + message: 'Unable to update AlertManagement::Alert status to resolved', + project_id: project.id, + alert_id: am_alert.id + ) + end + + def logger + @logger ||= Gitlab::AppLogger + end + + def am_alert + @am_alert ||= AlertManagement::Alert.for_fingerprint(project, gitlab_fingerprint).first + end + + def bad_request + ServiceResponse.error(message: 'Bad Request', http_status: :bad_request) + end + end +end diff --git a/app/services/alert_management/update_alert_status_service.rb b/app/services/alert_management/update_alert_status_service.rb new file mode 100644 index 00000000000..a7ebddb82e0 --- /dev/null +++ b/app/services/alert_management/update_alert_status_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module AlertManagement + class UpdateAlertStatusService + include Gitlab::Utils::StrongMemoize + + # @param alert [AlertManagement::Alert] + # @param user [User] + # @param status [Integer] Must match a value from AlertManagement::Alert::STATUSES + def initialize(alert, user, status) + @alert = alert + @user = user + @status = status + end + + def execute + return error_no_permissions unless allowed? + return error_invalid_status unless status_key + + if alert.update(status_event: status_event) + success + else + error(alert.errors.full_messages.to_sentence) + end + end + + private + + attr_reader :alert, :user, :status + + delegate :project, to: :alert + + def allowed? + user.can?(:update_alert_management_alert, project) + end + + def status_key + strong_memoize(:status_key) do + AlertManagement::Alert::STATUSES.key(status) + end + end + + def status_event + AlertManagement::Alert::STATUS_EVENTS[status_key] + end + + def success + ServiceResponse.success(payload: { alert: alert }) + end + + def error_no_permissions + error(_('You have no permissions')) + end + + def error_invalid_status + error(_('Invalid status')) + end + + def error(message) + ServiceResponse.error(payload: { alert: alert }, message: message) + end + end +end diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index d9e40c456aa..fb309aed649 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -50,8 +50,9 @@ class AuditEventService private def build_author(author) - if author.is_a?(User) - author + case author + when User + author.impersonated? ? Gitlab::Audit::ImpersonatedAuthor.new(author) : author else Gitlab::Audit::UnauthenticatedAuthor.new(name: author) end diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index 4a699fe3213..44a434f4402 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -52,7 +52,7 @@ module Auth end def self.token_expire_at - Time.now + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes + Time.current + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes end private @@ -103,17 +103,19 @@ module Auth return unless requested_project - actions = actions.select do |action| + authorized_actions = actions.select do |action| can_access?(requested_project, action) end - return unless actions.present? + log_if_actions_denied(type, requested_project, actions, authorized_actions) + + return unless authorized_actions.present? # At this point user/build is already authenticated. # - ensure_container_repository!(path, actions) + ensure_container_repository!(path, authorized_actions) - { type: type, name: path.to_s, actions: actions } + { type: type, name: path.to_s, actions: authorized_actions } end ## @@ -222,5 +224,22 @@ module Auth REGISTRY_LOGIN_ABILITIES.include?(ability) end end + + def log_if_actions_denied(type, requested_project, requested_actions, authorized_actions) + return if requested_actions == authorized_actions + + log_info = { + message: "Denied container registry permissions", + scope_type: type, + requested_project_path: requested_project.full_path, + requested_actions: requested_actions, + authorized_actions: authorized_actions, + username: current_user&.username, + user_id: current_user&.id, + project_path: project&.full_path + }.compact + + Gitlab::AuthLogger.warn(log_info) + end end end diff --git a/app/services/authorized_project_update/project_create_service.rb b/app/services/authorized_project_update/project_create_service.rb new file mode 100644 index 00000000000..c17c0a033fe --- /dev/null +++ b/app/services/authorized_project_update/project_create_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module AuthorizedProjectUpdate + class ProjectCreateService < BaseService + BATCH_SIZE = 1000 + + def initialize(project) + @project = project + end + + def execute + group = project.group + + unless group + return ServiceResponse.error(message: 'Project does not have a group') + end + + group.members_from_self_and_ancestors_with_effective_access_level + .each_batch(of: BATCH_SIZE, column: :user_id) do |members| + attributes = members.map do |member| + { user_id: member.user_id, project_id: project.id, access_level: member.access_level } + end + + ProjectAuthorization.insert_all(attributes) + end + + ServiceResponse.success + end + + private + + attr_reader :project + end +end diff --git a/app/services/base_container_service.rb b/app/services/base_container_service.rb new file mode 100644 index 00000000000..56e4b8c908c --- /dev/null +++ b/app/services/base_container_service.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Base class, scoped by container (project or group) +class BaseContainerService + include BaseServiceUtility + + attr_reader :container, :current_user, :params + + def initialize(container:, current_user: nil, params: {}) + @container, @current_user, @params = container, current_user, params.dup + end +end diff --git a/app/services/base_service.rb b/app/services/base_service.rb index bc0b968f516..b4c4b6980a8 100644 --- a/app/services/base_service.rb +++ b/app/services/base_service.rb @@ -1,7 +1,16 @@ # frozen_string_literal: true +# This is the original root class for service related classes, +# and due to historical reason takes a project as scope. +# Later separate base classes for different scopes will be created, +# and existing service will use these one by one. +# After all are migrated, we can remove this class. +# +# TODO: New services should consider inheriting from +# BaseContainerService, or create new base class: +# https://gitlab.com/gitlab-org/gitlab/-/issues/216672 class BaseService - include Gitlab::Allowable + include BaseServiceUtility attr_accessor :project, :current_user, :params @@ -9,67 +18,5 @@ class BaseService @project, @current_user, @params = project, user, params.dup end - def notification_service - NotificationService.new - end - - def event_service - EventCreateService.new - end - - def todo_service - TodoService.new - end - - def log_info(message) - Gitlab::AppLogger.info message - end - - def log_error(message) - Gitlab::AppLogger.error message - end - - def system_hook_service - SystemHooksService.new - end - delegate :repository, to: :project - - # Add an error to the specified model for restricted visibility levels - def deny_visibility_level(model, denied_visibility_level = nil) - denied_visibility_level ||= model.visibility_level - - level_name = Gitlab::VisibilityLevel.level_name(denied_visibility_level).downcase - - model.errors.add(:visibility_level, "#{level_name} has been restricted by your GitLab administrator") - end - - def visibility_level - params[:visibility].is_a?(String) ? Gitlab::VisibilityLevel.level_value(params[:visibility]) : params[:visibility_level] - end - - private - - # Return a Hash with an `error` status - # - # message - Error message to include in the Hash - # http_status - Optional HTTP status code override (default: nil) - # pass_back - Additional attributes to be included in the resulting Hash - def error(message, http_status = nil, pass_back: {}) - result = { - message: message, - status: :error - }.reverse_merge(pass_back) - - result[:http_status] = http_status if http_status - result - end - - # Return a Hash with a `success` status - # - # pass_back - Additional attributes to be included in the resulting Hash - def success(pass_back = {}) - pass_back[:status] = :success - pass_back - end end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 9637eb1b918..e08509b84db 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -12,7 +12,7 @@ module Boards def execute return fetch_issues.order_closed_date_desc if list&.closed? - fetch_issues.order_by_position_and_priority(with_cte: can_attempt_search_optimization?) + fetch_issues.order_by_position_and_priority(with_cte: params[:search].present?) end # rubocop: disable CodeReuse/ActiveRecord @@ -91,7 +91,7 @@ module Boards end def set_attempt_search_optimizations - return unless can_attempt_search_optimization? + return unless params[:search].present? if board.group_board? params[:attempt_group_search_optimizations] = true @@ -130,11 +130,6 @@ module Boards def board_group board.group_board? ? parent : parent.group end - - def can_attempt_search_optimization? - params[:search].present? && - Feature.enabled?(:board_search_optimization, board_group, default_enabled: true) - end end end end diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb index c96ea970943..07ce58b6851 100644 --- a/app/services/boards/lists/list_service.rb +++ b/app/services/boards/lists/list_service.rb @@ -3,8 +3,10 @@ module Boards module Lists class ListService < Boards::BaseService - def execute(board) - board.lists.create(list_type: :backlog) unless board.lists.backlog.exists? + def execute(board, create_default_lists: true) + if create_default_lists && !board.lists.backlog.exists? + board.lists.create(list_type: :backlog) + end board.lists.preload_associated_models end diff --git a/app/services/branches/create_service.rb b/app/services/branches/create_service.rb index c8afd97e6bf..958dd5c9965 100644 --- a/app/services/branches/create_service.rb +++ b/app/services/branches/create_service.rb @@ -14,7 +14,7 @@ module Branches if new_branch success(new_branch) else - error("Invalid reference name: #{branch_name}") + error("Invalid reference name: #{ref}") end rescue Gitlab::Git::PreReceiveError => ex error(ex.message) diff --git a/app/services/ci/compare_accessibility_reports_service.rb b/app/services/ci/compare_accessibility_reports_service.rb new file mode 100644 index 00000000000..efb38d39d98 --- /dev/null +++ b/app/services/ci/compare_accessibility_reports_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Ci + class CompareAccessibilityReportsService < CompareReportsBaseService + def comparer_class + Gitlab::Ci::Reports::AccessibilityReportsComparer + end + + def serializer_class + AccessibilityReportsComparerSerializer + end + + def get_report(pipeline) + pipeline&.accessibility_reports + end + end +end diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb index 5d7d552dc5a..f0ffe67510b 100644 --- a/app/services/ci/create_job_artifacts_service.rb +++ b/app/services/ci/create_job_artifacts_service.rb @@ -46,6 +46,11 @@ module Ci expire_in: expire_in) end + if Feature.enabled?(:keep_latest_artifact_for_ref, job.project) + artifact.locked = true + artifact_metadata&.locked = true + end + [artifact, artifact_metadata] end @@ -56,6 +61,7 @@ module Ci case artifact.file_type when 'dotenv' then parse_dotenv_artifact(job, artifact) + when 'cluster_applications' then parse_cluster_applications_artifact(job, artifact) else success end end @@ -64,6 +70,7 @@ module Ci Ci::JobArtifact.transaction do artifact.save! artifact_metadata&.save! + unlock_previous_artifacts!(artifact) # NOTE: The `artifacts_expire_at` column is already deprecated and to be removed in the near future. job.update_column(:artifacts_expire_at, artifact.expire_at) @@ -81,6 +88,12 @@ module Ci error(error.message, :bad_request) end + def unlock_previous_artifacts!(artifact) + return unless Feature.enabled?(:keep_latest_artifact_for_ref, artifact.job.project) + + Ci::JobArtifact.for_ref(artifact.job.ref, artifact.project_id).locked.update_all(locked: false) + end + def sha256_matches_existing_artifact?(job, artifact_type, artifacts_file) existing_artifact = job.job_artifacts.find_by_file_type(artifact_type) return false unless existing_artifact @@ -99,5 +112,9 @@ module Ci def parse_dotenv_artifact(job, artifact) Ci::ParseDotenvArtifactService.new(job.project, current_user).execute(artifact) end + + def parse_cluster_applications_artifact(job, artifact) + Clusters::ParseClusterApplicationsArtifactService.new(job, job.user).execute(artifact) + end end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 347630f865f..922c3556362 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -102,21 +102,12 @@ module Ci # rubocop: disable CodeReuse/ActiveRecord def auto_cancelable_pipelines - # TODO: Introduced by https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/23464 - if Feature.enabled?(:ci_support_interruptible_pipelines, project, default_enabled: true) - project.ci_pipelines - .where(ref: pipeline.ref) - .where.not(id: pipeline.same_family_pipeline_ids) - .where.not(sha: project.commit(pipeline.ref).try(:id)) - .alive_or_scheduled - .with_only_interruptible_builds - else - project.ci_pipelines - .where(ref: pipeline.ref) - .where.not(id: pipeline.same_family_pipeline_ids) - .where.not(sha: project.commit(pipeline.ref).try(:id)) - .created_or_pending - end + project.ci_pipelines + .where(ref: pipeline.ref) + .where.not(id: pipeline.same_family_pipeline_ids) + .where.not(sha: project.commit(pipeline.ref).try(:id)) + .alive_or_scheduled + .with_only_interruptible_builds end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/ci/daily_report_result_service.rb b/app/services/ci/daily_build_group_report_result_service.rb index b774a806203..6cdf3c88f8c 100644 --- a/app/services/ci/daily_report_result_service.rb +++ b/app/services/ci/daily_build_group_report_result_service.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true module Ci - class DailyReportResultService + class DailyBuildGroupReportResultService def execute(pipeline) return unless Feature.enabled?(:ci_daily_code_coverage, pipeline.project, default_enabled: true) - DailyReportResult.upsert_reports(coverage_reports(pipeline)) + DailyBuildGroupReportResult.upsert_reports(coverage_reports(pipeline)) end private @@ -14,15 +14,16 @@ module Ci base_attrs = { project_id: pipeline.project_id, ref_path: pipeline.source_ref_path, - param_type: DailyReportResult.param_types[:coverage], date: pipeline.created_at.to_date, last_pipeline_id: pipeline.id } aggregate(pipeline.builds.with_coverage).map do |group_name, group| base_attrs.merge( - title: group_name, - value: average_coverage(group) + group_name: group_name, + data: { + 'coverage' => average_coverage(group) + } ) end end diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb index 7d2f5d33fed..5deb84812ac 100644 --- a/app/services/ci/destroy_expired_job_artifacts_service.rb +++ b/app/services/ci/destroy_expired_job_artifacts_service.rb @@ -28,7 +28,13 @@ module Ci private def destroy_batch - artifacts = Ci::JobArtifact.expired(BATCH_SIZE).to_a + artifact_batch = if Feature.enabled?(:keep_latest_artifact_for_ref) + Ci::JobArtifact.expired(BATCH_SIZE).unlocked + else + Ci::JobArtifact.expired(BATCH_SIZE) + end + + artifacts = artifact_batch.to_a return false if artifacts.empty? diff --git a/app/services/ci/generate_terraform_reports_service.rb b/app/services/ci/generate_terraform_reports_service.rb new file mode 100644 index 00000000000..d768ce777d4 --- /dev/null +++ b/app/services/ci/generate_terraform_reports_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Ci + # TODO: a couple of points with this approach: + # + reuses existing architecture and reactive caching + # - it's not a report comparison and some comparing features must be turned off. + # see CompareReportsBaseService for more notes. + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + class GenerateTerraformReportsService < CompareReportsBaseService + def execute(base_pipeline, head_pipeline) + { + status: :parsed, + key: key(base_pipeline, head_pipeline), + data: head_pipeline.terraform_reports.plans + } + rescue => e + Gitlab::ErrorTracking.track_exception(e, project_id: project.id) + { + status: :error, + key: key(base_pipeline, head_pipeline), + status_reason: _('An error occurred while fetching terraform reports.') + } + end + + def latest?(base_pipeline, head_pipeline, data) + data&.fetch(:key, nil) == key(base_pipeline, head_pipeline) + end + end +end diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb index 2a1bf15b9a3..b01a9d2e3b8 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -95,7 +95,7 @@ module Ci def processable_status(processable) if processable.scheduling_type_dag? # Processable uses DAG, get status of all dependent needs - @collection.status_for_names(processable.aggregated_needs_names.to_a) + @collection.status_for_names(processable.aggregated_needs_names.to_a, dag: true) else # Processable uses Stages, get status of prior stage @collection.status_for_prior_stage_position(processable.stage_idx.to_i) diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb index 42e38a5c80f..2228328882d 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb @@ -32,14 +32,14 @@ module Ci # This methods gets composite status of all processables def status_of_all - status_for_array(all_statuses) + status_for_array(all_statuses, dag: false) end # This methods gets composite status for processables with given names - def status_for_names(names) + def status_for_names(names, dag:) name_statuses = all_statuses_by_name.slice(*names) - status_for_array(name_statuses.values) + status_for_array(name_statuses.values, dag: dag) end # This methods gets composite status for processables before given stage @@ -48,7 +48,7 @@ module Ci stage_statuses = all_statuses_grouped_by_stage_position .select { |stage_position, _| stage_position < position } - status_for_array(stage_statuses.values.flatten) + status_for_array(stage_statuses.values.flatten, dag: false) end end @@ -65,7 +65,7 @@ module Ci strong_memoize("status_for_stage_position_#{current_position}") do stage_statuses = all_statuses_grouped_by_stage_position[current_position].to_a - status_for_array(stage_statuses.flatten) + status_for_array(stage_statuses.flatten, dag: false) end end @@ -76,7 +76,14 @@ module Ci private - def status_for_array(statuses) + def status_for_array(statuses, dag:) + # TODO: This is hack to support + # the same exact behaviour for Atomic and Legacy processing + # that DAG is blocked from executing if dependent is not "complete" + if dag && statuses.any? { |status| HasStatus::COMPLETED_STATUSES.exclude?(status[:status]) } + return 'pending' + end + result = Gitlab::Ci::Status::Composite .new(statuses) .status diff --git a/app/services/ci/pipeline_schedule_service.rb b/app/services/ci/pipeline_schedule_service.rb index 6028643489d..596c3b80bda 100644 --- a/app/services/ci/pipeline_schedule_service.rb +++ b/app/services/ci/pipeline_schedule_service.rb @@ -6,19 +6,7 @@ module Ci # Ensure `next_run_at` is set properly before creating a pipeline. # Otherwise, multiple pipelines could be created in a short interval. schedule.schedule_next_run! - - if Feature.enabled?(:ci_pipeline_schedule_async) - RunPipelineScheduleWorker.perform_async(schedule.id, schedule.owner&.id) - else - begin - RunPipelineScheduleWorker.new.perform(schedule.id, schedule.owner&.id) - ensure - ## - # This is the temporary solution for avoiding the memory bloat. - # See more https://gitlab.com/gitlab-org/gitlab-foss/issues/61955 - GC.start if Feature.enabled?(:ci_pipeline_schedule_force_gc, default_enabled: true) - end - end + RunPipelineScheduleWorker.perform_async(schedule.id, schedule.owner&.id) end end end diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index d1efa19eb0d..3f23e81dcdd 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -10,7 +10,6 @@ module Ci def execute(trigger_build_ids = nil, initial_process: false) update_retried - ensure_scheduling_type_for_processables if Feature.enabled?(:ci_atomic_processing, pipeline.project) Ci::PipelineProcessing::AtomicProcessingService @@ -44,17 +43,5 @@ module Ci .update_all(retried: true) if latest_statuses.any? end # rubocop: enable CodeReuse/ActiveRecord - - # Set scheduling type of processables if they were created before scheduling_type - # data was deployed (https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22246). - # Given that this service runs multiple times during the pipeline - # life cycle we need to ensure we populate the data once. - # See more: https://gitlab.com/gitlab-org/gitlab/issues/205426 - def ensure_scheduling_type_for_processables - lease = Gitlab::ExclusiveLease.new("set-scheduling-types:#{pipeline.id}", timeout: 1.hour.to_i) - return unless lease.try_obtain - - pipeline.processables.populate_scheduling_type! - end end end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index fb59797a8df..17b9e56636b 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -85,8 +85,6 @@ module Ci # to make sure that this is properly handled by runner. Result.new(nil, false) rescue => ex - raise ex unless Feature.enabled?(:ci_doom_build, default_enabled: true) - scheduler_failure!(build) track_exception_for_build(ex, build) @@ -203,7 +201,7 @@ module Ci labels[:shard] = shard.gsub(METRICS_SHARD_TAG_PREFIX, '') if shard end - job_queue_duration_seconds.observe(labels, Time.now - job.queued_at) unless job.queued_at.nil? + job_queue_duration_seconds.observe(labels, Time.current - job.queued_at) unless job.queued_at.nil? attempt_counter.increment end diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index a65fe2ecb3a..23507a31c72 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -9,6 +9,8 @@ module Ci resource_group scheduling_type].freeze def execute(build) + build.ensure_scheduling_type! + reprocess!(build).tap do |new_build| build.pipeline.mark_as_processable_after_stage(build.stage_idx) @@ -31,6 +33,9 @@ module Ci end.to_h attributes[:user] = current_user + + # TODO: we can probably remove this logic + # see: https://gitlab.com/gitlab-org/gitlab/-/issues/217930 attributes[:scheduling_type] ||= build.find_legacy_scheduling_type Ci::Build.transaction do diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index 9bb236ac44c..4229be6c7d7 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -11,6 +11,8 @@ module Ci needs = Set.new + pipeline.ensure_scheduling_type! + pipeline.retryable_builds.preload_needs.find_each do |build| next unless can?(current_user, :update_build, build) diff --git a/app/services/ci/update_instance_variables_service.rb b/app/services/ci/update_instance_variables_service.rb new file mode 100644 index 00000000000..ee513647d08 --- /dev/null +++ b/app/services/ci/update_instance_variables_service.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# This class is a simplified version of assign_nested_attributes_for_collection_association from ActiveRecord +# https://github.com/rails/rails/blob/v6.0.2.1/activerecord/lib/active_record/nested_attributes.rb#L466 + +module Ci + class UpdateInstanceVariablesService + UNASSIGNABLE_KEYS = %w(id _destroy).freeze + + def initialize(params) + @params = params[:variables_attributes] + end + + def execute + instantiate_records + persist_records + end + + def errors + @records.to_a.flat_map { |r| r.errors.full_messages } + end + + private + + attr_reader :params + + def existing_records_by_id + @existing_records_by_id ||= Ci::InstanceVariable + .all + .index_by { |var| var.id.to_s } + end + + def instantiate_records + @records = params.map do |attributes| + find_or_initialize_record(attributes).tap do |record| + record.assign_attributes(attributes.except(*UNASSIGNABLE_KEYS)) + record.mark_for_destruction if has_destroy_flag?(attributes) + end + end + end + + def find_or_initialize_record(attributes) + id = attributes[:id].to_s + + if id.blank? + Ci::InstanceVariable.new + else + existing_records_by_id.fetch(id) { raise ActiveRecord::RecordNotFound } + end + end + + def persist_records + Ci::InstanceVariable.transaction do + success = @records.map do |record| + if record.marked_for_destruction? + record.destroy + else + record.save + end + end.all? + + raise ActiveRecord::Rollback unless success + + success + end + end + + def has_destroy_flag?(hash) + Gitlab::Utils.to_boolean(hash['_destroy']) + end + end +end diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb index 86b48b5228d..39a2d6bf758 100644 --- a/app/services/clusters/applications/base_service.rb +++ b/app/services/clusters/applications/base_service.rb @@ -5,6 +5,8 @@ module Clusters class BaseService InvalidApplicationError = Class.new(StandardError) + FLUENTD_KNOWN_ATTRS = %i[host protocol port waf_log_enabled cilium_log_enabled].freeze + attr_reader :cluster, :current_user, :params def initialize(cluster, user, params = {}) @@ -35,17 +37,7 @@ module Clusters application.modsecurity_mode = params[:modsecurity_mode] || 0 end - if application.has_attribute?(:host) - application.host = params[:host] - end - - if application.has_attribute?(:protocol) - application.protocol = params[:protocol] - end - - if application.has_attribute?(:port) - application.port = params[:port] - end + apply_fluentd_related_attributes(application) if application.respond_to?(:oauth_application) application.oauth_application = create_oauth_application(application, request) @@ -111,6 +103,12 @@ module Clusters ::Applications::CreateService.new(current_user, oauth_application_params).execute(request) end + + def apply_fluentd_related_attributes(application) + FLUENTD_KNOWN_ATTRS.each do |attr| + application[attr] = params[attr] if application.has_attribute?(attr) + end + end end end end diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index 7d064abfaa3..249abd3ff9d 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -33,7 +33,7 @@ module Clusters end def timed_out? - Time.now.utc - app.updated_at.utc > ClusterWaitForAppInstallationWorker::TIMEOUT + Time.current.utc - app.updated_at.utc > ClusterWaitForAppInstallationWorker::TIMEOUT end def remove_installation_pod diff --git a/app/services/clusters/applications/check_uninstall_progress_service.rb b/app/services/clusters/applications/check_uninstall_progress_service.rb index fe9c488bdfd..cd213c3ebbf 100644 --- a/app/services/clusters/applications/check_uninstall_progress_service.rb +++ b/app/services/clusters/applications/check_uninstall_progress_service.rb @@ -31,7 +31,7 @@ module Clusters end def timed_out? - Time.now.utc - app.updated_at.utc > WaitForUninstallAppWorker::TIMEOUT + Time.current.utc - app.updated_at.utc > WaitForUninstallAppWorker::TIMEOUT end def remove_uninstallation_pod diff --git a/app/services/clusters/applications/check_upgrade_progress_service.rb b/app/services/clusters/applications/check_upgrade_progress_service.rb index 8502ea69f27..bc161218618 100644 --- a/app/services/clusters/applications/check_upgrade_progress_service.rb +++ b/app/services/clusters/applications/check_upgrade_progress_service.rb @@ -46,7 +46,7 @@ module Clusters end def timed_out? - Time.now.utc - app.updated_at.to_time.utc > ::ClusterWaitForAppUpdateWorker::TIMEOUT + Time.current.utc - app.updated_at.to_time.utc > ::ClusterWaitForAppUpdateWorker::TIMEOUT end def remove_pod diff --git a/app/services/clusters/applications/ingress_modsecurity_usage_service.rb b/app/services/clusters/applications/ingress_modsecurity_usage_service.rb deleted file mode 100644 index 4aac8bb3cbd..00000000000 --- a/app/services/clusters/applications/ingress_modsecurity_usage_service.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -# rubocop: disable CodeReuse/ActiveRecord -module Clusters - module Applications - ## - # This service measures usage of the Modsecurity Web Application Firewall across the entire - # instance's deployed environments. - # - # The default configuration is`AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE=DetectionOnly` so we - # measure non-default values via definition of either ci_variables or ci_pipeline_variables. - # Since both these values are encrypted, we must decrypt and count them in memory. - # - # NOTE: this service is an approximation as it does not yet take into account `environment_scope` or `ci_group_variables`. - ## - class IngressModsecurityUsageService - ADO_MODSEC_KEY = "AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE" - - def initialize(blocking_count: 0, disabled_count: 0) - @blocking_count = blocking_count - @disabled_count = disabled_count - end - - def execute - conditions = -> { merge(::Environment.available).merge(::Deployment.success).where(key: ADO_MODSEC_KEY) } - - ci_pipeline_var_enabled = - ::Ci::PipelineVariable - .joins(pipeline: { environments: :last_visible_deployment }) - .merge(conditions) - .order('deployments.environment_id, deployments.id DESC') - - ci_var_enabled = - ::Ci::Variable - .joins(project: { environments: :last_visible_deployment }) - .merge(conditions) - .merge( - # Give priority to pipeline variables by excluding from dataset - ::Ci::Variable.joins(project: :environments).where.not( - environments: { id: ci_pipeline_var_enabled.select('DISTINCT ON (deployments.environment_id) deployments.environment_id') } - ) - ).select('DISTINCT ON (deployments.environment_id) ci_variables.*') - - sum_modsec_config_counts( - ci_pipeline_var_enabled.select('DISTINCT ON (deployments.environment_id) ci_pipeline_variables.*') - ) - sum_modsec_config_counts(ci_var_enabled) - - { - ingress_modsecurity_blocking: @blocking_count, - ingress_modsecurity_disabled: @disabled_count - } - end - - private - - # These are encrypted so we must decrypt and count in memory - def sum_modsec_config_counts(dataset) - dataset.each do |var| - case var.value - when "On" then @blocking_count += 1 - when "Off" then @disabled_count += 1 - # `else` could be default or any unsupported user input - end - end - end - end - end -end diff --git a/app/services/clusters/applications/schedule_update_service.rb b/app/services/clusters/applications/schedule_update_service.rb index b7639c771a8..41718df9a98 100644 --- a/app/services/clusters/applications/schedule_update_service.rb +++ b/app/services/clusters/applications/schedule_update_service.rb @@ -16,9 +16,9 @@ module Clusters return unless application if recently_scheduled? - worker_class.perform_in(BACKOFF_DELAY, application.name, application.id, project.id, Time.now) + worker_class.perform_in(BACKOFF_DELAY, application.name, application.id, project.id, Time.current) else - worker_class.perform_async(application.name, application.id, project.id, Time.now) + worker_class.perform_async(application.name, application.id, project.id, Time.current) end end @@ -31,7 +31,7 @@ module Clusters def recently_scheduled? return false unless application.last_update_started_at - application.last_update_started_at.utc >= Time.now.utc - BACKOFF_DELAY + application.last_update_started_at.utc >= Time.current.utc - BACKOFF_DELAY end end end diff --git a/app/services/clusters/gcp/verify_provision_status_service.rb b/app/services/clusters/gcp/verify_provision_status_service.rb index b24246f5c4b..ddb2832aae6 100644 --- a/app/services/clusters/gcp/verify_provision_status_service.rb +++ b/app/services/clusters/gcp/verify_provision_status_service.rb @@ -35,7 +35,7 @@ module Clusters end def elapsed_time_from_creation(operation) - Time.now.utc - operation.start_time.to_time.utc + Time.current.utc - operation.start_time.to_time.utc end def finalize_creation diff --git a/app/services/clusters/kubernetes/configure_istio_ingress_service.rb b/app/services/clusters/kubernetes/configure_istio_ingress_service.rb index a81014d99ff..53c3c686f07 100644 --- a/app/services/clusters/kubernetes/configure_istio_ingress_service.rb +++ b/app/services/clusters/kubernetes/configure_istio_ingress_service.rb @@ -54,8 +54,8 @@ module Clusters cert = OpenSSL::X509::Certificate.new cert.version = 2 cert.serial = 0 - cert.not_before = Time.now - cert.not_after = Time.now + 1000.years + cert.not_before = Time.current + cert.not_after = Time.current + 1000.years cert.public_key = key.public_key cert.subject = name diff --git a/app/services/clusters/management/create_project_service.rb b/app/services/clusters/management/create_project_service.rb index 0a33582be98..5a0176edd12 100644 --- a/app/services/clusters/management/create_project_service.rb +++ b/app/services/clusters/management/create_project_service.rb @@ -15,11 +15,8 @@ module Clusters def execute return unless management_project_required? - ActiveRecord::Base.transaction do - project = create_management_project! - - update_cluster!(project) - end + project = create_management_project! + update_cluster!(project) end private diff --git a/app/services/clusters/parse_cluster_applications_artifact_service.rb b/app/services/clusters/parse_cluster_applications_artifact_service.rb new file mode 100644 index 00000000000..b8e1c80cfe7 --- /dev/null +++ b/app/services/clusters/parse_cluster_applications_artifact_service.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Clusters + class ParseClusterApplicationsArtifactService < ::BaseService + include Gitlab::Utils::StrongMemoize + + MAX_ACCEPTABLE_ARTIFACT_SIZE = 5.kilobytes + RELEASE_NAMES = %w[prometheus].freeze + + def initialize(job, current_user) + @job = job + + super(job.project, current_user) + end + + def execute(artifact) + return success unless Feature.enabled?(:cluster_applications_artifact, project) + + raise ArgumentError, 'Artifact is not cluster_applications file type' unless artifact&.cluster_applications? + + unless artifact.file.size < MAX_ACCEPTABLE_ARTIFACT_SIZE + return error(too_big_error_message, :bad_request) + end + + unless cluster + return error(s_('ClusterIntegration|No deployment cluster found for this job')) + end + + parse!(artifact) + + success + rescue Gitlab::Kubernetes::Helm::Parsers::ListV2::ParserError, ActiveRecord::RecordInvalid => error + Gitlab::ErrorTracking.track_exception(error, job_id: artifact.job_id) + error(error.message, :bad_request) + end + + private + + attr_reader :job + + def cluster + strong_memoize(:cluster) do + deployment_cluster = job.deployment&.cluster + + deployment_cluster if Ability.allowed?(current_user, :admin_cluster, deployment_cluster) + end + end + + def parse!(artifact) + releases = [] + + artifact.each_blob do |blob| + releases.concat(Gitlab::Kubernetes::Helm::Parsers::ListV2.new(blob).releases) + end + + update_cluster_application_statuses!(releases) + end + + def update_cluster_application_statuses!(releases) + release_by_name = releases.index_by { |release| release['Name'] } + + Clusters::Cluster.transaction do + RELEASE_NAMES.each do |release_name| + application = find_or_build_application(release_name) + + release = release_by_name[release_name] + + if release + case release['Status'] + when 'DEPLOYED' + application.make_externally_installed! + when 'FAILED' + application.make_errored!(s_('ClusterIntegration|Helm release failed to install')) + end + else + # missing, so by definition, we consider this uninstalled + application.make_externally_uninstalled! if application.persisted? + end + end + end + end + + def find_or_build_application(application_name) + application_class = Clusters::Cluster::APPLICATIONS[application_name] + + cluster.find_or_build_application(application_class) + end + + def too_big_error_message + human_size = ActiveSupport::NumberHelper.number_to_human_size(MAX_ACCEPTABLE_ARTIFACT_SIZE) + + s_('ClusterIntegration|Cluster_applications artifact too big. Maximum allowable size: %{human_size}') % { human_size: human_size } + end + end +end diff --git a/app/services/concerns/base_service_utility.rb b/app/services/concerns/base_service_utility.rb new file mode 100644 index 00000000000..70b223a0289 --- /dev/null +++ b/app/services/concerns/base_service_utility.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module BaseServiceUtility + extend ActiveSupport::Concern + include Gitlab::Allowable + + ### Convenience service methods + + def notification_service + NotificationService.new + end + + def event_service + EventCreateService.new + end + + def todo_service + TodoService.new + end + + def system_hook_service + SystemHooksService.new + end + + # Logging + + def log_info(message) + Gitlab::AppLogger.info message + end + + def log_error(message) + Gitlab::AppLogger.error message + end + + # Add an error to the specified model for restricted visibility levels + def deny_visibility_level(model, denied_visibility_level = nil) + denied_visibility_level ||= model.visibility_level + + level_name = Gitlab::VisibilityLevel.level_name(denied_visibility_level).downcase + + model.errors.add(:visibility_level, "#{level_name} has been restricted by your GitLab administrator") + end + + def visibility_level + params[:visibility].is_a?(String) ? Gitlab::VisibilityLevel.level_value(params[:visibility]) : params[:visibility_level] + end + + private + + # Return a Hash with an `error` status + # + # message - Error message to include in the Hash + # http_status - Optional HTTP status code override (default: nil) + # pass_back - Additional attributes to be included in the resulting Hash + def error(message, http_status = nil, pass_back: {}) + result = { + message: message, + status: :error + }.reverse_merge(pass_back) + + result[:http_status] = http_status if http_status + result + end + + # Return a Hash with a `success` status + # + # pass_back - Additional attributes to be included in the resulting Hash + def success(pass_back = {}) + pass_back[:status] = :success + pass_back + end +end diff --git a/app/services/concerns/git/logger.rb b/app/services/concerns/git/logger.rb deleted file mode 100644 index 7c036212e66..00000000000 --- a/app/services/concerns/git/logger.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module Git - module Logger - def log_error(message, save_message_on_model: false) - Gitlab::GitLogger.error("#{self.class.name} error (#{merge_request.to_reference(full: true)}): #{message}") - merge_request.update(merge_error: message) if save_message_on_model - end - end -end diff --git a/app/services/concerns/measurable.rb b/app/services/concerns/measurable.rb new file mode 100644 index 00000000000..5a74f15506e --- /dev/null +++ b/app/services/concerns/measurable.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# In order to measure and log execution of our service, we just need to 'prepend Measurable' module +# Example: +# ``` +# class DummyService +# prepend Measurable +# +# def execute +# # ... +# end +# end + +# DummyService.prepend(Measurable) +# ``` +# +# In case when we are prepending a module from the `EE` namespace with EE features +# we need to prepend Measurable after prepending `EE` module. +# This way Measurable will be at the bottom of the ancestor chain, +# in order to measure execution of `EE` features as well +# ``` +# class DummyService +# def execute +# # ... +# end +# end +# +# DummyService.prepend_if_ee('EE::DummyService') +# DummyService.prepend(Measurable) +# ``` +# +module Measurable + extend ::Gitlab::Utils::Override + + override :execute + def execute(*args) + measuring? ? ::Gitlab::Utils::Measuring.new(base_log_data).with_measuring { super(*args) } : super(*args) + end + + protected + + # You can set extra attributes for performance measurement log. + def extra_attributes_for_measurement + defined?(super) ? super : {} + end + + private + + def measuring? + Feature.enabled?("gitlab_service_measuring_#{service_class}") + end + + # These attributes are always present in log. + def base_log_data + extra_attributes_for_measurement.merge({ class: self.class.name }) + end + + def service_class + self.class.name.underscore.tr('/', '_') + end +end diff --git a/app/services/concerns/spam_check_methods.rb b/app/services/concerns/spam_check_methods.rb index 695bdf92b49..53e9e001463 100644 --- a/app/services/concerns/spam_check_methods.rb +++ b/app/services/concerns/spam_check_methods.rb @@ -23,14 +23,14 @@ module SpamCheckMethods # attribute values. # rubocop:disable Gitlab/ModuleWithInstanceVariables def spam_check(spammable, user) - Spam::SpamCheckService.new( + Spam::SpamActionService.new( spammable: spammable, request: @request ).execute( api: @api, recaptcha_verified: @recaptcha_verified, spam_log_id: @spam_log_id, - user_id: user.id) + user: user) end # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb index 122f8ac89ed..e765d2484ea 100644 --- a/app/services/deployments/older_deployments_drop_service.rb +++ b/app/services/deployments/older_deployments_drop_service.rb @@ -12,7 +12,9 @@ module Deployments return unless @deployment&.running? older_deployments.find_each do |older_deployment| - older_deployment.deployable&.drop!(:forward_deployment_failure) + Gitlab::OptimisticLocking.retry_lock(older_deployment.deployable) do |deployable| + deployable.drop(:forward_deployment_failure) + end rescue => e Gitlab::ErrorTracking.track_exception(e, subject_id: @deployment.id, deployment_id: older_deployment.id) end diff --git a/app/services/design_management/delete_designs_service.rb b/app/services/design_management/delete_designs_service.rb new file mode 100644 index 00000000000..e69f07db5bf --- /dev/null +++ b/app/services/design_management/delete_designs_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module DesignManagement + class DeleteDesignsService < DesignService + include RunsDesignActions + include OnSuccessCallbacks + + def initialize(project, user, params = {}) + super + + @designs = params.fetch(:designs) + end + + def execute + return error('Forbidden!') unless can_delete_designs? + + version = delete_designs! + + success(version: version) + end + + def commit_message + n = designs.size + + <<~MSG + Removed #{n} #{'designs'.pluralize(n)} + + #{formatted_file_list} + MSG + end + + private + + attr_reader :designs + + def delete_designs! + DesignManagement::Version.with_lock(project.id, repository) do + run_actions(build_actions) + end + end + + def can_delete_designs? + Ability.allowed?(current_user, :destroy_design, issue) + end + + def build_actions + designs.map { |d| design_action(d) } + end + + def design_action(design) + on_success { counter.count(:delete) } + + DesignManagement::DesignAction.new(design, :delete) + end + + def counter + ::Gitlab::UsageDataCounters::DesignsCounter + end + + def formatted_file_list + designs.map { |design| "- #{design.full_path}" }.join("\n") + end + end +end + +DesignManagement::DeleteDesignsService.prepend_if_ee('EE::DesignManagement::DeleteDesignsService') diff --git a/app/services/design_management/design_service.rb b/app/services/design_management/design_service.rb new file mode 100644 index 00000000000..54e53609646 --- /dev/null +++ b/app/services/design_management/design_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DesignManagement + class DesignService < ::BaseService + def initialize(project, user, params = {}) + super + + @issue = params.fetch(:issue) + end + + # Accessors common to all subclasses: + + attr_reader :issue + + def target_branch + repository.root_ref || "master" + end + + def collection + issue.design_collection + end + + def repository + collection.repository + end + + def project + issue.project + end + end +end diff --git a/app/services/design_management/design_user_notes_count_service.rb b/app/services/design_management/design_user_notes_count_service.rb new file mode 100644 index 00000000000..e49914ea6d3 --- /dev/null +++ b/app/services/design_management/design_user_notes_count_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module DesignManagement + # Service class for counting and caching the number of unresolved + # notes of a Design + class DesignUserNotesCountService < ::BaseCountService + # The version of the cache format. This should be bumped whenever the + # underlying logic changes. This removes the need for explicitly flushing + # all caches. + VERSION = 1 + + def initialize(design) + @design = design + end + + def relation_for_count + design.notes.user + end + + def raw? + # Since we're storing simple integers we don't need all of the + # additional Marshal data Rails includes by default. + true + end + + def cache_key + ['designs', 'notes_count', VERSION, design.id] + end + + private + + attr_reader :design + end +end diff --git a/app/services/design_management/generate_image_versions_service.rb b/app/services/design_management/generate_image_versions_service.rb new file mode 100644 index 00000000000..213aac164ff --- /dev/null +++ b/app/services/design_management/generate_image_versions_service.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module DesignManagement + # This service generates smaller image versions for `DesignManagement::Design` + # records within a given `DesignManagement::Version`. + class GenerateImageVersionsService < DesignService + # We limit processing to only designs with file sizes that don't + # exceed `MAX_DESIGN_SIZE`. + # + # Note, we may be able to remove checking this limit, if when we come to + # implement a file size limit for designs, there are no designs that + # exceed 40MB on GitLab.com + # + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22860#note_281780387 + MAX_DESIGN_SIZE = 40.megabytes.freeze + + def initialize(version) + super(version.project, version.author, issue: version.issue) + + @version = version + end + + def execute + # rubocop: disable CodeReuse/ActiveRecord + version.actions.includes(:design).each do |action| + generate_image(action) + end + # rubocop: enable CodeReuse/ActiveRecord + + success(version: version) + end + + private + + attr_reader :version + + def generate_image(action) + raw_file = get_raw_file(action) + + unless raw_file + log_error("No design file found for Action: #{action.id}") + return + end + + # Skip attempting to process images that would be rejected by CarrierWave. + return unless DesignManagement::DesignV432x230Uploader::MIME_TYPE_WHITELIST.include?(raw_file.content_type) + + # Store and process the file + action.image_v432x230.store!(raw_file) + action.save! + rescue CarrierWave::UploadError => e + Gitlab::ErrorTracking.track_exception(e, project_id: project.id, design_id: action.design_id, version_id: action.version_id) + log_error(e.message) + end + + # Returns the `CarrierWave::SanitizedFile` of the original design file + def get_raw_file(action) + raw_files_by_path[action.design.full_path] + end + + # Returns the `Carrierwave:SanitizedFile` instances for all of the original + # design files, mapping to { design.filename => `Carrierwave::SanitizedFile` }. + # + # As design files are stored in Git LFS, the only way to retrieve their original + # files is to first fetch the LFS pointer file data from the Git design repository. + # The LFS pointer file data contains an "OID" that lets us retrieve `LfsObject` + # records, which have an Uploader (`LfsObjectUploader`) for the original design file. + def raw_files_by_path + @raw_files_by_path ||= begin + LfsObject.for_oids(blobs_by_oid.keys).each_with_object({}) do |lfs_object, h| + blob = blobs_by_oid[lfs_object.oid] + file = lfs_object.file.file + # The `CarrierWave::SanitizedFile` is loaded without knowing the `content_type` + # of the file, due to the file not having an extension. + # + # Set the content_type from the `Blob`. + file.content_type = blob.content_type + h[blob.path] = file + end + end + end + + # Returns the `Blob`s that correspond to the design files in the repository. + # + # All design `Blob`s are LFS Pointer files, and are therefore small amounts + # of data to load. + # + # `Blob`s whose size are above a certain threshold: `MAX_DESIGN_SIZE` + # are filtered out. + def blobs_by_oid + @blobs ||= begin + items = version.designs.map { |design| [version.sha, design.full_path] } + blobs = repository.blobs_at(items) + blobs.reject! { |blob| blob.lfs_size > MAX_DESIGN_SIZE } + blobs.index_by(&:lfs_oid) + end + end + end +end diff --git a/app/services/design_management/on_success_callbacks.rb b/app/services/design_management/on_success_callbacks.rb new file mode 100644 index 00000000000..be55890a02d --- /dev/null +++ b/app/services/design_management/on_success_callbacks.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module DesignManagement + module OnSuccessCallbacks + def on_success(&block) + success_callbacks.push(block) + end + + def success(*_) + while cb = success_callbacks.pop + cb.call + end + + super + end + + private + + def success_callbacks + @success_callbacks ||= [] + end + end +end diff --git a/app/services/design_management/runs_design_actions.rb b/app/services/design_management/runs_design_actions.rb new file mode 100644 index 00000000000..4bd6bb45658 --- /dev/null +++ b/app/services/design_management/runs_design_actions.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module DesignManagement + module RunsDesignActions + NoActions = Class.new(StandardError) + + # this concern requires the following methods to be implemented: + # current_user, target_branch, repository, commit_message + # + # Before calling `run_actions`, you should ensure the repository exists, by + # calling `repository.create_if_not_exists`. + # + # @raise [NoActions] if actions are empty + def run_actions(actions) + raise NoActions if actions.empty? + + sha = repository.multi_action(current_user, + branch_name: target_branch, + message: commit_message, + actions: actions.map(&:gitaly_action)) + + ::DesignManagement::Version + .create_for_designs(actions, sha, current_user) + .tap { |version| post_process(version) } + end + + private + + def post_process(version) + version.run_after_commit_or_now do + ::DesignManagement::NewVersionWorker.perform_async(id) + end + end + end +end diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb new file mode 100644 index 00000000000..a09c19bc885 --- /dev/null +++ b/app/services/design_management/save_designs_service.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module DesignManagement + class SaveDesignsService < DesignService + include RunsDesignActions + include OnSuccessCallbacks + + MAX_FILES = 10 + + def initialize(project, user, params = {}) + super + + @files = params.fetch(:files) + end + + def execute + return error("Not allowed!") unless can_create_designs? + return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES + + uploaded_designs, version = upload_designs! + skipped_designs = designs - uploaded_designs + + success({ designs: uploaded_designs, version: version, skipped_designs: skipped_designs }) + rescue ::ActiveRecord::RecordInvalid => e + error(e.message) + end + + private + + attr_reader :files + + def upload_designs! + ::DesignManagement::Version.with_lock(project.id, repository) do + actions = build_actions + + [actions.map(&:design), actions.presence && run_actions(actions)] + end + end + + # Returns `Design` instances that correspond with `files`. + # New `Design`s will be created where a file name does not match + # an existing `Design` + def designs + @designs ||= files.map do |file| + collection.find_or_create_design!(filename: file.original_filename) + end + end + + def build_actions + files.zip(designs).flat_map do |(file, design)| + Array.wrap(build_design_action(file, design)) + end + end + + def build_design_action(file, design) + content = file_content(file, design.full_path) + return if design_unchanged?(design, content) + + action = new_file?(design) ? :create : :update + on_success { ::Gitlab::UsageDataCounters::DesignsCounter.count(action) } + + DesignManagement::DesignAction.new(design, action, content) + end + + # Returns true if the design file is the same as its latest version + def design_unchanged?(design, content) + content == existing_blobs[design]&.data + end + + def commit_message + <<~MSG + Updated #{files.size} #{'designs'.pluralize(files.size)} + + #{formatted_file_list} + MSG + end + + def formatted_file_list + filenames.map { |name| "- #{name}" }.join("\n") + end + + def filenames + @filenames ||= files.map(&:original_filename) + end + + def can_create_designs? + Ability.allowed?(current_user, :create_design, issue) + end + + def new_file?(design) + !existing_blobs[design] + end + + def file_content(file, full_path) + transformer = ::Lfs::FileTransformer.new(project, repository, target_branch) + transformer.new_file(full_path, file.to_io).content + end + + # Returns the latest blobs for the designs as a Hash of `{ Design => Blob }` + def existing_blobs + @existing_blobs ||= begin + items = designs.map { |d| ['HEAD', d.full_path] } + + repository.blobs_at(items).each_with_object({}) do |blob, h| + design = designs.find { |d| d.full_path == blob.path } + + h[design] = blob + end + end + end + end +end + +DesignManagement::SaveDesignsService.prepend_if_ee('EE::DesignManagement::SaveDesignsService') diff --git a/app/services/emails/base_service.rb b/app/services/emails/base_service.rb index 99324638300..c94505b2068 100644 --- a/app/services/emails/base_service.rb +++ b/app/services/emails/base_service.rb @@ -11,3 +11,5 @@ module Emails end end end + +Emails::BaseService.prepend_if_ee('::EE::Emails::BaseService') diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index 0b044e1679a..522f36cda46 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -85,18 +85,40 @@ class EventCreateService # Create a new wiki page event # # @param [WikiPage::Meta] wiki_page_meta The event target - # @param [User] current_user The event author + # @param [User] author The event author # @param [Integer] action One of the Event::WIKI_ACTIONS - def wiki_event(wiki_page_meta, current_user, action) + # + # @return a tuple of event and either :found or :created + def wiki_event(wiki_page_meta, author, action) return unless Feature.enabled?(:wiki_events) raise IllegalActionError, action unless Event::WIKI_ACTIONS.include?(action) - create_record_event(wiki_page_meta, current_user, action) + if duplicate = existing_wiki_event(wiki_page_meta, action) + return duplicate + end + + event = create_record_event(wiki_page_meta, author, action) + # Ensure that the event is linked in time to the metadata, for non-deletes + unless action == Event::DESTROYED + time_stamp = wiki_page_meta.updated_at + event.update_columns(updated_at: time_stamp, created_at: time_stamp) + end + + event end private + def existing_wiki_event(wiki_page_meta, action) + if action == Event::DESTROYED + most_recent = Event.for_wiki_meta(wiki_page_meta).recent.first + return most_recent if most_recent.present? && most_recent.action == action + else + Event.for_wiki_meta(wiki_page_meta).created_at(wiki_page_meta.updated_at).first + end + end + def create_record_event(record, current_user, status) create_event(record.resource_parent, current_user, status, target_id: record.id, target_type: record.class.name) end diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index e1cc1f8c834..92e7702727c 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -112,7 +112,7 @@ module Git end def enqueue_update_signatures - unsigned = unsigned_x509_shas(commits) & unsigned_gpg_shas(commits) + unsigned = unsigned_x509_shas(limited_commits) & unsigned_gpg_shas(limited_commits) return if unsigned.empty? signable = Gitlab::Git::Commit.shas_with_signatures(project.repository, unsigned) diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb index d4267d4a3c5..8bdbc28f3e8 100644 --- a/app/services/git/wiki_push_service.rb +++ b/app/services/git/wiki_push_service.rb @@ -2,8 +2,63 @@ module Git class WikiPushService < ::BaseService + # Maximum number of change events we will process on any single push + MAX_CHANGES = 100 + def execute - # This is used in EE + process_changes + end + + private + + def process_changes + return unless can_process_wiki_events? + + push_changes.take(MAX_CHANGES).each do |change| # rubocop:disable CodeReuse/ActiveRecord + next unless change.page.present? + + response = create_event_for(change) + log_error(response.message) if response.error? + end + end + + def can_process_wiki_events? + Feature.enabled?(:wiki_events) && Feature.enabled?(:wiki_events_on_git_push, project) + end + + def push_changes + default_branch_changes.flat_map do |change| + raw_changes(change).map { |raw| Git::WikiPushService::Change.new(wiki, change, raw) } + end + end + + def raw_changes(change) + wiki.repository.raw.raw_changes_between(change[:oldrev], change[:newrev]) + end + + def wiki + project.wiki + end + + def create_event_for(change) + event_service.execute(change.last_known_slug, change.page, change.event_action) + end + + def event_service + @event_service ||= WikiPages::EventCreateService.new(current_user) + end + + def on_default_branch?(change) + project.wiki.default_branch == ::Gitlab::Git.branch_name(change[:ref]) + end + + # See: [Gitlab::GitPostReceive#changes] + def changes + params[:changes] || [] + end + + def default_branch_changes + @default_branch_changes ||= changes.select { |change| on_default_branch?(change) } end end end diff --git a/app/services/git/wiki_push_service/change.rb b/app/services/git/wiki_push_service/change.rb new file mode 100644 index 00000000000..8685850165a --- /dev/null +++ b/app/services/git/wiki_push_service/change.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Git + class WikiPushService + class Change + include Gitlab::Utils::StrongMemoize + + # @param [ProjectWiki] wiki + # @param [Hash] change - must have keys `:oldrev` and `:newrev` + # @param [Gitlab::Git::RawDiffChange] raw_change + def initialize(project_wiki, change, raw_change) + @wiki, @raw_change, @change = project_wiki, raw_change, change + end + + def page + strong_memoize(:page) { wiki.find_page(slug, revision) } + end + + # See [Gitlab::Git::RawDiffChange#extract_operation] for the + # definition of the full range of operation values. + def event_action + case raw_change.operation + when :added + Event::CREATED + when :deleted + Event::DESTROYED + else + Event::UPDATED + end + end + + def last_known_slug + strip_extension(raw_change.old_path || raw_change.new_path) + end + + private + + attr_reader :raw_change, :change, :wiki + + def filename + return raw_change.old_path if deleted? + + raw_change.new_path + end + + def slug + strip_extension(filename) + end + + def revision + return change[:oldrev] if deleted? + + change[:newrev] + end + + def deleted? + raw_change.operation == :deleted + end + + def strip_extension(filename) + return unless filename + + File.basename(filename, File.extname(filename)) + end + end + end +end diff --git a/app/services/grafana/proxy_service.rb b/app/services/grafana/proxy_service.rb index 74fcdc750b0..ac4c3cc091c 100644 --- a/app/services/grafana/proxy_service.rb +++ b/app/services/grafana/proxy_service.rb @@ -12,6 +12,7 @@ module Grafana self.reactive_cache_key = ->(service) { service.cache_key } self.reactive_cache_lease_timeout = 30.seconds self.reactive_cache_refresh_interval = 30.seconds + self.reactive_cache_work_type = :external_dependency self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } attr_accessor :project, :datasource_id, :proxy_path, :query_params diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 8cc31200689..eb1b8d4fcc0 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -38,6 +38,10 @@ module Groups # overridden in EE end + def remove_unallowed_params + params.delete(:default_branch_protection) unless can?(current_user, :create_group_with_default_branch_protection) + end + def create_chat_team? Gitlab.config.mattermost.enabled && @chat_team && group.chat_team.nil? end diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb index f8715b57d6e..0f2e3bb65f9 100644 --- a/app/services/groups/import_export/export_service.rb +++ b/app/services/groups/import_export/export_service.rb @@ -52,11 +52,11 @@ module Groups end def savers - [tree_exporter, file_saver] + [version_saver, tree_exporter, file_saver] end def tree_exporter - Gitlab::ImportExport::Group::LegacyTreeSaver.new( + tree_exporter_class.new( group: @group, current_user: @current_user, shared: @shared, @@ -64,6 +64,18 @@ module Groups ) end + def tree_exporter_class + if ::Feature.enabled?(:group_export_ndjson, @group&.parent, default_enabled: true) + Gitlab::ImportExport::Group::TreeSaver + else + Gitlab::ImportExport::Group::LegacyTreeSaver + end + end + + def version_saver + Gitlab::ImportExport::VersionSaver.new(shared: shared) + end + def file_saver Gitlab::ImportExport::Saver.new(exportable: @group, shared: @shared) end @@ -84,6 +96,8 @@ module Groups group_name: @group.name, message: 'Group Import/Export: Export succeeded' ) + + notification_service.group_was_exported(@group, @current_user) end def notify_error @@ -93,6 +107,12 @@ module Groups error: @shared.errors.join(', '), message: 'Group Import/Export: Export failed' ) + + notification_service.group_was_not_exported(@group, @current_user, @shared.errors) + end + + def notification_service + @notification_service ||= NotificationService.new end end end diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb index f62b9d3c8a6..6f692c98c38 100644 --- a/app/services/groups/import_export/import_service.rb +++ b/app/services/groups/import_export/import_service.rb @@ -27,18 +27,34 @@ module Groups private def import_file - @import_file ||= Gitlab::ImportExport::FileImporter.import(importable: @group, - archive_file: nil, - shared: @shared) + @import_file ||= Gitlab::ImportExport::FileImporter.import( + importable: @group, + archive_file: nil, + shared: @shared + ) end def restorer - @restorer ||= Gitlab::ImportExport::Group::LegacyTreeRestorer.new( - user: @current_user, - shared: @shared, - group: @group, - group_hash: nil - ) + @restorer ||= + if ndjson? + Gitlab::ImportExport::Group::TreeRestorer.new( + user: @current_user, + shared: @shared, + group: @group + ) + else + Gitlab::ImportExport::Group::LegacyTreeRestorer.new( + user: @current_user, + shared: @shared, + group: @group, + group_hash: nil + ) + end + end + + def ndjson? + ::Feature.enabled?(:group_import_ndjson, @group&.parent, default_enabled: true) && + File.exist?(File.join(@shared.export_path, 'tree/groups/_all.ndjson')) end def remove_import_file diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 8635b82461b..948540619ae 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -66,6 +66,7 @@ module Groups # overridden in EE def remove_unallowed_params params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, group) + params.delete(:default_branch_protection) unless can?(current_user, :update_default_branch_protection, group) end def valid_share_with_group_lock_change? diff --git a/app/services/incident_management/create_issue_service.rb b/app/services/incident_management/create_issue_service.rb index 43077e03e6d..4b59dc64cec 100644 --- a/app/services/incident_management/create_issue_service.rb +++ b/app/services/incident_management/create_issue_service.rb @@ -13,12 +13,12 @@ module IncidentManagement DESCRIPTION }.freeze - def initialize(project, params) - super(project, User.alert_bot, params) + def initialize(project, params, user = User.alert_bot) + super(project, user, params) end - def execute - return error_with('setting disabled') unless incident_management_setting.create_issue? + def execute(skip_settings_check: false) + return error_with('setting disabled') unless skip_settings_check || incident_management_setting.create_issue? return error_with('invalid alert') unless alert.valid? issue = create_issue diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb index 55f5629baac..a78e191c85f 100644 --- a/app/services/issuable/clone/attributes_rewriter.rb +++ b/app/services/issuable/clone/attributes_rewriter.rb @@ -20,6 +20,7 @@ module Issuable copy_resource_label_events copy_resource_weight_events copy_resource_milestone_events + copy_resource_state_events end private @@ -47,8 +48,6 @@ module Issuable end def copy_resource_label_events - entity_key = new_entity.class.name.underscore.foreign_key - copy_events(ResourceLabelEvent.table_name, original_entity.resource_label_events) do |event| event.attributes .except('id', 'reference', 'reference_html') @@ -67,22 +66,39 @@ module Issuable end def copy_resource_milestone_events - entity_key = new_entity.class.name.underscore.foreign_key + return unless milestone_events_supported? copy_events(ResourceMilestoneEvent.table_name, original_entity.resource_milestone_events) do |event| - matching_destination_milestone = matching_milestone(event.milestone.title) - - if matching_destination_milestone.present? - event.attributes - .except('id') - .merge(entity_key => new_entity.id, - 'milestone_id' => matching_destination_milestone.id, - 'action' => ResourceMilestoneEvent.actions[event.action], - 'state' => ResourceMilestoneEvent.states[event.state]) + if event.remove? + event_attributes_with_milestone(event, nil) + else + matching_destination_milestone = matching_milestone(event.milestone_title) + + event_attributes_with_milestone(event, matching_destination_milestone) if matching_destination_milestone.present? end end end + def copy_resource_state_events + return unless state_events_supported? + + copy_events(ResourceStateEvent.table_name, original_entity.resource_state_events) do |event| + event.attributes + .except('id') + .merge(entity_key => new_entity.id, + 'state' => ResourceStateEvent.states[event.state]) + end + end + + def event_attributes_with_milestone(event, milestone) + event.attributes + .except('id') + .merge(entity_key => new_entity.id, + 'milestone_id' => milestone&.id, + 'action' => ResourceMilestoneEvent.actions[event.action], + 'state' => ResourceMilestoneEvent.states[event.state]) + end + def copy_events(table_name, events_to_copy) events_to_copy.find_in_batches do |batch| events = batch.map do |event| @@ -94,7 +110,20 @@ module Issuable end def entity_key - new_entity.class.name.parameterize('_').foreign_key + new_entity.class.name.underscore.foreign_key + end + + def milestone_events_supported? + both_respond_to?(:resource_milestone_events) + end + + def state_events_supported? + both_respond_to?(:resource_state_events) + end + + def both_respond_to?(method) + original_entity.respond_to?(method) && + new_entity.respond_to?(method) end end end diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb index 54576e82030..0d1640924e5 100644 --- a/app/services/issuable/clone/base_service.rb +++ b/app/services/issuable/clone/base_service.rb @@ -47,7 +47,7 @@ module Issuable end def new_parent - new_entity.project ? new_entity.project : new_entity.group + new_entity.project || new_entity.group end def group diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index 67cf212691f..195616857dc 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -4,7 +4,7 @@ module Issuable class CommonSystemNotesService < ::BaseService attr_reader :issuable - def execute(issuable, old_labels: [], is_update: true) + def execute(issuable, old_labels: [], old_milestone: nil, is_update: true) @issuable = issuable # We disable touch so that created system notes do not update @@ -22,17 +22,13 @@ module Issuable end create_due_date_note if issuable.previous_changes.include?('due_date') - create_milestone_note if has_milestone_changes? + create_milestone_note(old_milestone) if issuable.previous_changes.include?('milestone_id') create_labels_note(old_labels) if old_labels && issuable.labels != old_labels end end private - def has_milestone_changes? - issuable.previous_changes.include?('milestone_id') - end - def handle_time_tracking_note if issuable.previous_changes.include?('time_estimate') create_time_estimate_note @@ -98,15 +94,19 @@ module Issuable SystemNoteService.change_time_spent(issuable, issuable.project, issuable.time_spent_user) end - def create_milestone_note + def create_milestone_note(old_milestone) if milestone_changes_tracking_enabled? - # Creates a synthetic note - ResourceEvents::ChangeMilestoneService.new(issuable, current_user).execute + create_milestone_change_event(old_milestone) else SystemNoteService.change_milestone(issuable, issuable.project, current_user, issuable.milestone) end end + def create_milestone_change_event(old_milestone) + ResourceEvents::ChangeMilestoneService.new(issuable, current_user, old_milestone: old_milestone) + .execute + end + def milestone_changes_tracking_enabled? ::Feature.enabled?(:track_resource_milestone_change_events, issuable.project) end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 506f4309a1e..18062bd60da 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -22,7 +22,9 @@ class IssuableBaseService < BaseService params.delete(:milestone_id) params.delete(:labels) params.delete(:add_label_ids) + params.delete(:add_labels) params.delete(:remove_label_ids) + params.delete(:remove_labels) params.delete(:label_ids) params.delete(:assignee_ids) params.delete(:assignee_id) @@ -91,6 +93,8 @@ class IssuableBaseService < BaseService elsif params[label_key] params[label_id_key] = labels_service.find_or_create_by_titles(label_key, find_only: find_only).map(&:id) end + + params.delete(label_key) if params[label_key].nil? end def filter_labels_in_param(key) @@ -217,7 +221,7 @@ class IssuableBaseService < BaseService issuable.assign_attributes(params) if has_title_or_description_changed?(issuable) - issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user) + issuable.assign_attributes(last_edited_at: Time.current, last_edited_by: current_user) end before_update(issuable) @@ -237,7 +241,8 @@ class IssuableBaseService < BaseService end if issuable_saved - Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels]) + Issuable::CommonSystemNotesService.new(project, current_user).execute( + issuable, old_labels: old_associations[:labels], old_milestone: old_associations[:milestone]) handle_changes(issuable, old_associations: old_associations) @@ -265,7 +270,7 @@ class IssuableBaseService < BaseService if issuable.changed? || params.present? issuable.assign_attributes(params.merge(updated_by: current_user, - last_edited_at: Time.now, + last_edited_at: Time.current, last_edited_by: current_user)) before_update(issuable, skip_spam_check: true) @@ -360,7 +365,8 @@ class IssuableBaseService < BaseService { labels: issuable.labels.to_a, mentioned_users: issuable.mentioned_users(current_user).to_a, - assignees: issuable.assignees.to_a + assignees: issuable.assignees.to_a, + milestone: issuable.try(:milestone) } associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent) associations[:description] = issuable.description diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index daef468987e..e62315de5f9 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -65,15 +65,19 @@ module Issues private def whitelisted_issue_params + base_params = [:title, :description, :confidential] + admin_params = [:milestone_id] + if can?(current_user, :admin_issue, project) - params.slice(:title, :description, :milestone_id) + params.slice(*(base_params + admin_params)) else - params.slice(:title, :description) + params.slice(*base_params) end end def build_issue_params - issue_params_with_info_from_discussions.merge(whitelisted_issue_params) + { author: current_user }.merge(issue_params_with_info_from_discussions) + .merge(whitelisted_issue_params) end end end diff --git a/app/services/issues/related_branches_service.rb b/app/services/issues/related_branches_service.rb index 76af482b7ac..46076218857 100644 --- a/app/services/issues/related_branches_service.rb +++ b/app/services/issues/related_branches_service.rb @@ -5,11 +5,29 @@ module Issues class RelatedBranchesService < Issues::BaseService def execute(issue) - branches_with_iid_of(issue) - branches_with_merge_request_for(issue) + branch_names = branches_with_iid_of(issue) - branches_with_merge_request_for(issue) + branch_names.map { |branch_name| branch_data(branch_name) } end private + def branch_data(branch_name) + { + name: branch_name, + pipeline_status: pipeline_status(branch_name) + } + end + + def pipeline_status(branch_name) + branch = project.repository.find_branch(branch_name) + target = branch&.dereferenced_target + + return unless target + + pipeline = project.pipeline_for(branch_name, target.sha) + pipeline.detailed_status(current_user) if can?(current_user, :read_pipeline, pipeline) + end + def branches_with_merge_request_for(issue) Issues::ReferencedMergeRequestsService .new(project, current_user) diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 78ebbd7bff2..ee1a22634af 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -21,6 +21,10 @@ module Issues spam_check(issue, current_user) unless skip_spam_check end + def after_update(issue) + IssuesChannel.broadcast_to(issue, event: 'updated') if Feature.enabled?(:broadcast_issue_updates, issue.project) + end + def handle_changes(issue, options) old_associations = options.fetch(:old_associations, {}) old_labels = old_associations.fetch(:labels, []) diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb index de4e490281f..59fd463022f 100644 --- a/app/services/jira_import/start_import_service.rb +++ b/app/services/jira_import/start_import_service.rb @@ -56,7 +56,7 @@ module JiraImport import_start_time = Time.zone.now jira_imports_for_project = project.jira_imports.by_jira_project_key(jira_project_key).size + 1 title = "jira-import::#{jira_project_key}-#{jira_imports_for_project}" - description = "Label for issues that were imported from jira on #{import_start_time.strftime('%Y-%m-%d %H:%M:%S')}" + description = "Label for issues that were imported from Jira on #{import_start_time.strftime('%Y-%m-%d %H:%M:%S')}" color = "#{Label.color_for(title)}" { title: title, description: description, color: color } end diff --git a/app/services/lfs/file_transformer.rb b/app/services/lfs/file_transformer.rb index 88f59b820a4..69d33e1c873 100644 --- a/app/services/lfs/file_transformer.rb +++ b/app/services/lfs/file_transformer.rb @@ -5,8 +5,7 @@ module Lfs # return a transformed result with `content` and `encoding` to commit. # # The `repository` passed to the initializer can be a Repository or - # a DesignManagement::Repository (an EE-specific class that inherits - # from Repository). + # class that inherits from Repository. # # The `repository_type` property will be one of the types named in # `Gitlab::GlRepository.types`, and is recorded on the `LfsObjectsProject` diff --git a/app/services/members/request_access_service.rb b/app/services/members/request_access_service.rb index b9b0550e290..4dfedc6cd4e 100644 --- a/app/services/members/request_access_service.rb +++ b/app/services/members/request_access_service.rb @@ -8,7 +8,7 @@ module Members source.members.create( access_level: Gitlab::Access::DEVELOPER, user: current_user, - requested_at: Time.now.utc) + requested_at: Time.current.utc) end private diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 00bf69739ad..7f7bfa29af7 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -39,6 +39,8 @@ module MergeRequests # Don't try to print expensive instance variables. def inspect + return "#<#{self.class}>" unless respond_to?(:merge_request) + "#<#{self.class} #{merge_request.to_reference(full: true)}>" end @@ -89,8 +91,7 @@ module MergeRequests end def can_use_merge_request_ref?(merge_request) - Feature.enabled?(:ci_use_merge_request_ref, project, default_enabled: true) && - !merge_request.for_fork? + !merge_request.for_fork? end def abort_auto_merge(merge_request, reason) @@ -115,6 +116,32 @@ module MergeRequests yield merge_request end end + + def log_error(exception:, message:, save_message_on_model: false) + reference = merge_request.to_reference(full: true) + data = { + class: self.class.name, + message: message, + merge_request_id: merge_request.id, + merge_request: reference, + save_message_on_model: save_message_on_model + } + + if exception + Gitlab::ErrorTracking.with_context(current_user) do + Gitlab::ErrorTracking.track_exception(exception, data) + end + + data[:"exception.message"] = exception.message + end + + # TODO: Deprecate Gitlab::GitLogger since ErrorTracking should suffice: + # https://gitlab.com/gitlab-org/gitlab/-/issues/216379 + data[:message] = "#{self.class.name} error (#{reference}): #{message}" + Gitlab::GitLogger.error(data) + + merge_request.update(merge_error: message) if save_message_on_model + end end end diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index bc1e97088af..87808a21a15 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -2,8 +2,6 @@ module MergeRequests class RebaseService < MergeRequests::BaseService - include Git::Logger - REBASE_ERROR = 'Rebase failed. Please rebase locally' attr_reader :merge_request @@ -22,7 +20,7 @@ module MergeRequests def rebase # Ensure Gitaly isn't already running a rebase if source_project.repository.rebase_in_progress?(merge_request.id) - log_error('Rebase task canceled: Another rebase is already in progress', save_message_on_model: true) + log_error(exception: nil, message: 'Rebase task canceled: Another rebase is already in progress', save_message_on_model: true) return false end @@ -30,8 +28,8 @@ module MergeRequests true rescue => e - log_error(REBASE_ERROR, save_message_on_model: true) - log_error(e.message) + log_error(exception: e, message: REBASE_ERROR, save_message_on_model: true) + false ensure merge_request.update_column(:rebase_jid, nil) diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index c6e1651fa26..56a91fa0305 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -115,6 +115,10 @@ module MergeRequests filter_merge_requests(merge_requests).each do |merge_request| if branch_and_project_match?(merge_request) || @push.force_push? merge_request.reload_diff(current_user) + # Clear existing merge error if the push were directed at the + # source branch. Clearing the error when the target branch + # changes will hide the error from the user. + merge_request.merge_error = nil elsif merge_request.merge_request_diff.includes_any_commits?(push_commit_ids) merge_request.reload_diff(current_user) end diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb index d25997c925e..4b04d42b48e 100644 --- a/app/services/merge_requests/squash_service.rb +++ b/app/services/merge_requests/squash_service.rb @@ -2,7 +2,7 @@ module MergeRequests class SquashService < MergeRequests::BaseService - include Git::Logger + SquashInProgressError = Class.new(RuntimeError) def execute # If performing a squash would result in no change, then @@ -11,11 +11,13 @@ module MergeRequests return success(squash_sha: merge_request.diff_head_sha) end - if merge_request.squash_in_progress? + if squash_in_progress? return error(s_('MergeRequests|Squash task canceled: another squash is already in progress.')) end squash! || error(s_('MergeRequests|Failed to squash. Should be done manually.')) + rescue SquashInProgressError + error(s_('MergeRequests|An error occurred while checking whether another squash is in progress.')) end private @@ -25,11 +27,19 @@ module MergeRequests success(squash_sha: squash_sha) rescue => e - log_error("Failed to squash merge request #{merge_request.to_reference(full: true)}:") - log_error(e.message) + log_error(exception: e, message: 'Failed to squash merge request') + false end + def squash_in_progress? + merge_request.squash_in_progress? + rescue => e + log_error(exception: e, message: 'Failed to check squash in progress') + + raise SquashInProgressError, e.message + end + def repository target_project.repository end diff --git a/app/services/metrics/dashboard/base_service.rb b/app/services/metrics/dashboard/base_service.rb index c112d75a9b5..514793694ba 100644 --- a/app/services/metrics/dashboard/base_service.rb +++ b/app/services/metrics/dashboard/base_service.rb @@ -42,7 +42,7 @@ module Metrics def allowed? return false unless params[:environment] - Ability.allowed?(current_user, :read_environment, project) + project&.feature_available?(:metrics_dashboard, current_user) end # Returns a new dashboard Hash, supplemented with DB info diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb index d58b80162f5..d9ce2c5e905 100644 --- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb +++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb @@ -18,6 +18,7 @@ module Metrics self.reactive_cache_lease_timeout = 30.seconds self.reactive_cache_refresh_interval = 30.minutes self.reactive_cache_lifetime = 30.minutes + self.reactive_cache_work_type = :external_dependency self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } class << self @@ -112,7 +113,7 @@ module Metrics end def parse_json(json) - JSON.parse(json, symbolize_names: true) + Gitlab::Json.parse(json, symbolize_names: true) rescue JSON::ParserError raise DashboardProcessingError.new('Grafana response contains invalid json') end diff --git a/app/services/metrics/dashboard/transient_embed_service.rb b/app/services/metrics/dashboard/transient_embed_service.rb index ce81f337e47..cb6ca215447 100644 --- a/app/services/metrics/dashboard/transient_embed_service.rb +++ b/app/services/metrics/dashboard/transient_embed_service.rb @@ -23,7 +23,9 @@ module Metrics override :get_raw_dashboard def get_raw_dashboard - JSON.parse(params[:embed_json]) + Gitlab::Json.parse(params[:embed_json]) + rescue JSON::ParserError => e + invalid_embed_json!(e.message) end override :sequence @@ -35,6 +37,10 @@ module Metrics def identifiers Digest::SHA256.hexdigest(params[:embed_json]) end + + def invalid_embed_json!(message) + raise DashboardProcessingError.new("Parsing error for param :embed_json. #{message}") + end end end end diff --git a/app/services/metrics/users_starred_dashboards/create_service.rb b/app/services/metrics/users_starred_dashboards/create_service.rb new file mode 100644 index 00000000000..7784ed4eb4e --- /dev/null +++ b/app/services/metrics/users_starred_dashboards/create_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Create Metrics::UsersStarredDashboard entry for given user based on matched dashboard_path, project +module Metrics + module UsersStarredDashboards + class CreateService < ::BaseService + include Stepable + + steps :authorize_create_action, + :parse_dashboard_path, + :create + + def initialize(user, project, dashboard_path) + @user, @project, @dashboard_path = user, project, dashboard_path + end + + def execute + keys = %i[status message starred_dashboard] + status, message, dashboards = execute_steps.values_at(*keys) + + if status != :success + ServiceResponse.error(message: message) + else + ServiceResponse.success(payload: dashboards) + end + end + + private + + attr_reader :user, :project, :dashboard_path + + def authorize_create_action(_options) + if Ability.allowed?(user, :create_metrics_user_starred_dashboard, project) + success(user: user, project: project) + else + error(s_('Metrics::UsersStarredDashboards|You are not authorized to add star to this dashboard')) + end + end + + def parse_dashboard_path(options) + if dashboard_path_exists? + options[:dashboard_path] = dashboard_path + success(options) + else + error(s_('Metrics::UsersStarredDashboards|Dashboard with requested path can not be found')) + end + end + + def create(options) + starred_dashboard = build_starred_dashboard_from(options) + + if starred_dashboard.save + success(starred_dashboard: starred_dashboard) + else + error(starred_dashboard.errors.messages) + end + end + + def build_starred_dashboard_from(options) + Metrics::UsersStarredDashboard.new( + user: options.fetch(:user), + project: options.fetch(:project), + dashboard_path: options.fetch(:dashboard_path) + ) + end + + def dashboard_path_exists? + Gitlab::Metrics::Dashboard::Finder + .find_all_paths(project) + .any? { |dashboard| dashboard[:path] == dashboard_path } + end + end + end +end diff --git a/app/services/metrics/users_starred_dashboards/delete_service.rb b/app/services/metrics/users_starred_dashboards/delete_service.rb new file mode 100644 index 00000000000..579715bd49f --- /dev/null +++ b/app/services/metrics/users_starred_dashboards/delete_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Delete all matching Metrics::UsersStarredDashboard entries for given user based on matched dashboard_path, project +module Metrics + module UsersStarredDashboards + class DeleteService < ::BaseService + def initialize(user, project, dashboard_path = nil) + @user, @project, @dashboard_path = user, project, dashboard_path + end + + def execute + ServiceResponse.success(payload: { deleted_rows: starred_dashboards.delete_all }) + end + + private + + attr_reader :user, :project, :dashboard_path + + def starred_dashboards + # since deleted records are scoped to their owner there is no need to + # check if that user can delete them, also if user lost access to + # project it shouldn't block that user from removing them + dashboards = user.metrics_users_starred_dashboards + + if dashboard_path.present? + dashboards.for_project_dashboard(project, dashboard_path) + else + dashboards.for_project(project) + end + end + end + end +end diff --git a/app/services/namespaces/check_storage_size_service.rb b/app/services/namespaces/check_storage_size_service.rb new file mode 100644 index 00000000000..b3cf17681ee --- /dev/null +++ b/app/services/namespaces/check_storage_size_service.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Namespaces + class CheckStorageSizeService + include ActiveSupport::NumberHelper + include Gitlab::Allowable + include Gitlab::Utils::StrongMemoize + + def initialize(namespace, user) + @root_namespace = namespace.root_ancestor + @root_storage_size = Namespace::RootStorageSize.new(root_namespace) + @user = user + end + + def execute + return ServiceResponse.success unless Feature.enabled?(:namespace_storage_limit, root_namespace) + return ServiceResponse.success if alert_level == :none + + if root_storage_size.above_size_limit? + ServiceResponse.error(message: above_size_limit_message, payload: payload) + else + ServiceResponse.success(payload: payload) + end + end + + private + + attr_reader :root_namespace, :root_storage_size, :user + + USAGE_THRESHOLDS = { + none: 0.0, + info: 0.5, + warning: 0.75, + alert: 0.95, + error: 1.0 + }.freeze + + def payload + return {} unless can?(user, :admin_namespace, root_namespace) + + { + explanation_message: explanation_message, + usage_message: usage_message, + alert_level: alert_level + } + end + + def explanation_message + root_storage_size.above_size_limit? ? above_size_limit_message : below_size_limit_message + end + + def usage_message + s_("You reached %{usage_in_percent} of %{namespace_name}'s capacity (%{used_storage} of %{storage_limit})" % current_usage_params) + end + + def alert_level + strong_memoize(:alert_level) do + usage_ratio = root_storage_size.usage_ratio + current_level = USAGE_THRESHOLDS.each_key.first + + USAGE_THRESHOLDS.each do |level, threshold| + current_level = level if usage_ratio >= threshold + end + + current_level + end + end + + def below_size_limit_message + s_("If you reach 100%% storage capacity, you will not be able to: %{base_message}" % { base_message: base_message } ) + end + + def above_size_limit_message + s_("%{namespace_name} is now read-only. You cannot: %{base_message}" % { namespace_name: root_namespace.name, base_message: base_message }) + end + + def base_message + s_("push to your repository, create pipelines, create issues or add comments. To reduce storage capacity, delete unused repositories, artifacts, wikis, issues, and pipelines.") + end + + def current_usage_params + { + usage_in_percent: number_to_percentage(root_storage_size.usage_ratio * 100, precision: 0), + namespace_name: root_namespace.name, + used_storage: formatted(root_storage_size.current_size), + storage_limit: formatted(root_storage_size.limit) + } + end + + def formatted(number) + number_to_human_size(number, delimiter: ',', precision: 2) + end + end +end diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 53b3b57f4af..bc86118a150 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -16,10 +16,18 @@ module Notes return if @note.for_personal_snippet? @note.create_cross_references! + ::SystemNoteService.design_discussion_added(@note) if create_design_discussion_system_note? + execute_note_hooks end end + private + + def create_design_discussion_system_note? + @note && @note.for_design? && @note.start_of_discussion? + end + def hook_data Gitlab::DataBuilder::Note.build(@note, @note.author) end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 91e19d190bd..4c1db03fab8 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -66,6 +66,14 @@ class NotificationService mailer.access_token_about_to_expire_email(user).deliver_later end + # Notify a user when a previously unknown IP or device is used to + # sign in to their account + def unknown_sign_in(user, ip) + return unless user.can?(:receive_notifications) + + mailer.unknown_sign_in_email(user, ip).deliver_later + end + # When create an issue we should send an email to: # # * issue assignee if their notification level is not Disabled @@ -537,6 +545,18 @@ class NotificationService end end + def group_was_exported(group, current_user) + return true unless notifiable?(current_user, :mention, group: group) + + mailer.group_was_exported_email(current_user, group).deliver_later + end + + def group_was_not_exported(group, current_user, errors) + return true unless notifiable?(current_user, :mention, group: group) + + mailer.group_was_not_exported_email(current_user, group, errors).deliver_later + end + protected def new_resource_email(target, method) diff --git a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb index 1c03641469e..e14241158a6 100644 --- a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb +++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb @@ -51,8 +51,6 @@ module PagesDomains def save_order_error(acme_order, api_order) log_error(api_order) - return unless Feature.enabled?(:pages_letsencrypt_errors, pages_domain.project) - pages_domain.assign_attributes(auto_ssl_failed: true) pages_domain.save!(validate: false) diff --git a/app/services/pod_logs/base_service.rb b/app/services/pod_logs/base_service.rb index 2451ab8e0ce..8936f9b67a5 100644 --- a/app/services/pod_logs/base_service.rb +++ b/app/services/pod_logs/base_service.rb @@ -58,6 +58,9 @@ module PodLogs result[:pod_name] = params['pod_name'].presence result[:container_name] = params['container_name'].presence + return error(_('Invalid pod_name')) if result[:pod_name] && !result[:pod_name].is_a?(String) + return error(_('Invalid container_name')) if result[:container_name] && !result[:container_name].is_a?(String) + success(result) end diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb index aac0fa424ca..f79562c8ab3 100644 --- a/app/services/pod_logs/elasticsearch_service.rb +++ b/app/services/pod_logs/elasticsearch_service.rb @@ -11,6 +11,7 @@ module PodLogs :pod_logs, :filter_return_keys + self.reactive_cache_work_type = :external_dependency self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) } private @@ -52,12 +53,16 @@ module PodLogs def check_search(result) result[:search] = params['search'] if params.key?('search') + return error(_('Invalid search parameter')) if result[:search] && !result[:search].is_a?(String) + success(result) end def check_cursor(result) result[:cursor] = params['cursor'] if params.key?('cursor') + return error(_('Invalid cursor parameter')) if result[:cursor] && !result[:cursor].is_a?(String) + success(result) end @@ -65,6 +70,8 @@ module PodLogs client = cluster&.application_elastic_stack&.elasticsearch_client return error(_('Unable to connect to Elasticsearch')) unless client + chart_above_v2 = cluster.application_elastic_stack.chart_above_v2? + response = ::Gitlab::Elasticsearch::Logs::Lines.new(client).pod_logs( namespace, pod_name: result[:pod_name], @@ -72,7 +79,8 @@ module PodLogs search: result[:search], start_time: result[:start_time], end_time: result[:end_time], - cursor: result[:cursor] + cursor: result[:cursor], + chart_above_v2: chart_above_v2 ) result.merge!(response) diff --git a/app/services/pod_logs/kubernetes_service.rb b/app/services/pod_logs/kubernetes_service.rb index 0a8072a9037..b573ceae1aa 100644 --- a/app/services/pod_logs/kubernetes_service.rb +++ b/app/services/pod_logs/kubernetes_service.rb @@ -17,6 +17,7 @@ module PodLogs :split_logs, :filter_return_keys + self.reactive_cache_work_type = :external_dependency self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) } private @@ -46,6 +47,10 @@ module PodLogs ' chars' % { max_length: K8S_NAME_MAX_LENGTH })) end + unless result[:pod_name] =~ Gitlab::Regex.kubernetes_dns_subdomain_regex + return error(_('pod_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character')) + end + unless result[:pods].include?(result[:pod_name]) return error(_('Pod does not exist')) end @@ -69,6 +74,10 @@ module PodLogs ' %{max_length} chars' % { max_length: K8S_NAME_MAX_LENGTH })) end + unless result[:container_name] =~ Gitlab::Regex.kubernetes_dns_subdomain_regex + return error(_('container_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character')) + end + unless container_names.include?(result[:container_name]) return error(_('Container does not exist')) end diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb index f12e45d701a..65e6ebc17d2 100644 --- a/app/services/post_receive_service.rb +++ b/app/services/post_receive_service.rb @@ -29,6 +29,8 @@ class PostReceiveService response.add_alert_message(message) end + response.add_alert_message(storage_size_limit_alert) + broadcast_message = BroadcastMessage.current_banner_messages&.last&.message response.add_alert_message(broadcast_message) @@ -74,4 +76,19 @@ class PostReceiveService ::MergeRequests::GetUrlsService.new(project).execute(params[:changes]) end + + private + + def storage_size_limit_alert + return unless repository&.repo_type&.project? + + payload = Namespaces::CheckStorageSizeService.new(project.namespace, user).execute.payload + return unless payload.present? + + alert_level = "##### #{payload[:alert_level].to_s.upcase} #####" + + [alert_level, payload[:usage_message], payload[:explanation_message]].join("\n") + end end + +PostReceiveService.prepend_if_ee('EE::PostReceiveService') diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb index 1ce1ef7a1cd..76c89e85f17 100644 --- a/app/services/projects/alerting/notify_service.rb +++ b/app/services/projects/alerting/notify_service.rb @@ -10,7 +10,10 @@ module Projects return forbidden unless alerts_service_activated? return unauthorized unless valid_token?(token) - process_incident_issues if process_issues? + alert = create_alert + return bad_request unless alert.persisted? + + process_incident_issues(alert) if process_issues? send_alert_email if send_email? ServiceResponse.success @@ -22,13 +25,21 @@ module Projects delegate :alerts_service, :alerts_service_activated?, to: :project + def am_alert_params + Gitlab::AlertManagement::AlertParams.from_generic_alert(project: project, payload: params.to_h) + end + + def create_alert + AlertManagement::Alert.create(am_alert_params) + end + def send_email? incident_management_setting.send_email? end - def process_incident_issues + def process_incident_issues(alert) IncidentManagement::ProcessAlertWorker - .perform_async(project.id, parsed_payload) + .perform_async(project.id, parsed_payload, alert.id) end def send_alert_email diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index fc09d14ba4d..b53a9c1561e 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -33,7 +33,7 @@ module Projects end def order_by_date(tags) - now = DateTime.now + now = DateTime.current tags.sort_by { |tag| tag.created_at || now }.reverse end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 429ae905e3d..3233d1799b8 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -108,8 +108,22 @@ module Projects # users in the background def setup_authorizations if @project.group - @project.group.refresh_members_authorized_projects(blocking: false) current_user.refresh_authorized_projects + + if Feature.enabled?(:specialized_project_authorization_workers) + AuthorizedProjectUpdate::ProjectCreateWorker.perform_async(@project.id) + # AuthorizedProjectsWorker uses an exclusive lease per user but + # specialized workers might have synchronization issues. Until we + # compare the inconsistency rates of both approaches, we still run + # AuthorizedProjectsWorker but with some delay and lower urgency as a + # safety net. + @project.group.refresh_members_authorized_projects( + blocking: false, + priority: UserProjectAccessChangedService::LOW_PRIORITY + ) + else + @project.group.refresh_members_authorized_projects(blocking: false) + end else @project.add_maintainer(@project.namespace.owner, current_user: current_user) end @@ -202,8 +216,19 @@ module Projects end end + def extra_attributes_for_measurement + { + current_user: current_user&.name, + project_full_path: "#{project_namespace&.full_path}/#{@params[:path]}" + } + end + private + def project_namespace + @project_namespace ||= Namespace.find_by_id(@params[:namespace_id]) || current_user.namespace + end + def create_from_template? @params[:template_name].present? || @params[:template_project_id].present? end @@ -224,4 +249,9 @@ module Projects end end +# rubocop: disable Cop/InjectEnterpriseEditionModule Projects::CreateService.prepend_if_ee('EE::Projects::CreateService') +# rubocop: enable Cop/InjectEnterpriseEditionModule + +# Measurable should be at the bottom of the ancestor chain, so it will measure execution of EE::Projects::CreateService as well +Projects::CreateService.prepend(Measurable) diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb index 234ebbc6651..2e192942b9c 100644 --- a/app/services/projects/gitlab_projects_import_service.rb +++ b/app/services/projects/gitlab_projects_import_service.rb @@ -29,17 +29,21 @@ module Projects end def project_with_same_full_path? - Project.find_by_full_path("#{current_namespace.full_path}/#{params[:path]}").present? + Project.find_by_full_path(project_path).present? end # rubocop: disable CodeReuse/ActiveRecord def current_namespace strong_memoize(:current_namespace) do - Namespace.find_by(id: params[:namespace_id]) + Namespace.find_by(id: params[:namespace_id]) || current_user.namespace end end # rubocop: enable CodeReuse/ActiveRecord + def project_path + "#{current_namespace.full_path}/#{params[:path]}" + end + def overwrite? strong_memoize(:overwrite) do params.delete(:overwrite) diff --git a/app/services/projects/hashed_storage/base_attachment_service.rb b/app/services/projects/hashed_storage/base_attachment_service.rb index f8852c206e3..a2a7895ba17 100644 --- a/app/services/projects/hashed_storage/base_attachment_service.rb +++ b/app/services/projects/hashed_storage/base_attachment_service.rb @@ -70,7 +70,7 @@ module Projects # # @param [String] new_path def discard_path!(new_path) - discarded_path = "#{new_path}-#{Time.now.utc.to_i}" + discarded_path = "#{new_path}-#{Time.current.utc.to_i}" logger.info("Moving existing empty attachments folder from '#{new_path}' to '#{discarded_path}', (PROJECT_ID=#{project.id})") FileUtils.mv(new_path, discarded_path) diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb index d81aa4de9f1..065bf8725be 100644 --- a/app/services/projects/hashed_storage/base_repository_service.rb +++ b/app/services/projects/hashed_storage/base_repository_service.rb @@ -8,13 +8,15 @@ module Projects class BaseRepositoryService < BaseService include Gitlab::ShellAdapter - attr_reader :old_disk_path, :new_disk_path, :old_storage_version, :logger, :move_wiki + attr_reader :old_disk_path, :new_disk_path, :old_storage_version, + :logger, :move_wiki, :move_design def initialize(project:, old_disk_path:, logger: nil) @project = project @logger = logger || Gitlab::AppLogger @old_disk_path = old_disk_path @move_wiki = has_wiki? + @move_design = has_design? end protected @@ -23,6 +25,10 @@ module Projects gitlab_shell.repository_exists?(project.repository_storage, "#{old_wiki_disk_path}.git") end + def has_design? + gitlab_shell.repository_exists?(project.repository_storage, "#{old_design_disk_path}.git") + end + def move_repository(from_name, to_name) from_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{from_name}.git") to_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{to_name}.git") @@ -58,12 +64,18 @@ module Projects project.clear_memoization(:wiki) end + if move_design + result &&= move_repository(old_design_disk_path, new_design_disk_path) + project.clear_memoization(:design_repository) + end + result end def rollback_folder_move move_repository(new_disk_path, old_disk_path) move_repository(new_wiki_disk_path, old_wiki_disk_path) + move_repository(new_design_disk_path, old_design_disk_path) if move_design end def try_to_set_repository_read_only! @@ -87,8 +99,18 @@ module Projects def new_wiki_disk_path @new_wiki_disk_path ||= "#{new_disk_path}#{wiki_path_suffix}" end + + def design_path_suffix + @design_path_suffix ||= ::Gitlab::GlRepository::DESIGN.path_suffix + end + + def old_design_disk_path + @old_design_disk_path ||= "#{old_disk_path}#{design_path_suffix}" + end + + def new_design_disk_path + @new_design_disk_path ||= "#{new_disk_path}#{design_path_suffix}" + end end end end - -Projects::HashedStorage::BaseRepositoryService.prepend_if_ee('EE::Projects::HashedStorage::BaseRepositoryService') diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index 8893bf18e1f..86cb4f35206 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -3,19 +3,35 @@ module Projects module ImportExport class ExportService < BaseService - def execute(after_export_strategy = nil, options = {}) + prepend Measurable + + def initialize(*args) + super + + @shared = project.import_export_shared + end + + def execute(after_export_strategy = nil) unless project.template_source? || can?(current_user, :admin_project, project) raise ::Gitlab::ImportExport::Error.permission_error(current_user, project) end - @shared = project.import_export_shared - save_all! execute_after_export_action(after_export_strategy) ensure cleanup end + protected + + def extra_attributes_for_measurement + { + current_user: current_user&.name, + project_full_path: project&.full_path, + file_path: shared.export_path + } + end + private attr_accessor :shared @@ -42,7 +58,10 @@ module Projects end def exporters - [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver, lfs_saver, snippets_repo_saver] + [ + version_saver, avatar_saver, project_tree_saver, uploads_saver, + repo_saver, wiki_repo_saver, lfs_saver, snippets_repo_saver, design_repo_saver + ] end def version_saver @@ -81,6 +100,10 @@ module Projects Gitlab::ImportExport::SnippetsRepoSaver.new(current_user: current_user, project: project, shared: shared) end + def design_repo_saver + Gitlab::ImportExport::DesignRepoSaver.new(project: project, shared: shared) + end + def cleanup FileUtils.rm_rf(shared.archive_path) if shared&.archive_path end @@ -103,5 +126,3 @@ module Projects end end end - -Projects::ImportExport::ExportService.prepend_if_ee('EE::Projects::ImportExport::ExportService') diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 4b294a97516..449c4c3de6b 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -3,6 +3,7 @@ module Projects class ImportService < BaseService Error = Class.new(StandardError) + PermissionError = Class.new(StandardError) # Returns true if this importer is supposed to perform its work in the # background. @@ -21,6 +22,8 @@ module Projects import_data + after_execute_hook + success rescue Gitlab::UrlBlocker::BlockedUrlError => e Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type) @@ -34,8 +37,23 @@ module Projects error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message }) end + protected + + def extra_attributes_for_measurement + { + current_user: current_user&.name, + project_full_path: project&.full_path, + import_type: project&.import_type, + file_path: project&.import_source + } + end + private + def after_execute_hook + # Defined in EE::Projects::ImportService + end + def add_repository_to_project if project.external_import? && !unknown_url? begin @@ -130,3 +148,10 @@ module Projects end end end + +# rubocop: disable Cop/InjectEnterpriseEditionModule +Projects::ImportService.prepend_if_ee('EE::Projects::ImportService') +# rubocop: enable Cop/InjectEnterpriseEditionModule + +# Measurable should be at the bottom of the ancestor chain, so it will measure execution of EE::Projects::ImportService as well +Projects::ImportService.prepend(Measurable) diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb index 48a21bf94ba..efd410088ab 100644 --- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb @@ -69,7 +69,7 @@ module Projects # application/vnd.git-lfs+json # (https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md#requests), # HTTParty does not know this is actually JSON. - data = JSON.parse(response.body) + data = Gitlab::Json.parse(response.body) raise DownloadLinksError, "LFS Batch API did return any objects" unless data.is_a?(Hash) && data.key?('objects') diff --git a/app/services/projects/lsif_data_service.rb b/app/services/projects/lsif_data_service.rb index 142a5a910d4..5e7055b3309 100644 --- a/app/services/projects/lsif_data_service.rb +++ b/app/services/projects/lsif_data_service.rb @@ -42,7 +42,7 @@ module Projects file.open do |stream| Zlib::GzipReader.wrap(stream) do |gz_stream| - data = JSON.parse(gz_stream.read) + data = Gitlab::Json.parse(gz_stream.read) end end diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index 6ebc061c2e3..2583a6cae9f 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -12,6 +12,7 @@ module Projects return unprocessable_entity unless valid_version? return unauthorized unless valid_alert_manager_token?(token) + process_prometheus_alerts persist_events send_alert_email if send_email? process_incident_issues if process_issues? @@ -115,6 +116,14 @@ module Projects end end + def process_prometheus_alerts + alerts.each do |alert| + AlertManagement::ProcessPrometheusAlertService + .new(project, nil, alert.to_h) + .execute + end + end + def persist_events CreateEventsService.new(project, nil, params).execute end diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb index 6013b00b8c6..0483c951f1e 100644 --- a/app/services/projects/propagate_service_template.rb +++ b/app/services/projects/propagate_service_template.rb @@ -4,8 +4,10 @@ module Projects class PropagateServiceTemplate BATCH_SIZE = 100 - def self.propagate(*args) - new(*args).propagate + delegate :data_fields_present?, to: :template + + def self.propagate(template) + new(template).propagate end def initialize(template) @@ -13,15 +15,15 @@ module Projects end def propagate - return unless @template.active? - - Rails.logger.info("Propagating services for template #{@template.id}") # rubocop:disable Gitlab/RailsLogger + return unless template.active? propagate_projects_with_template end private + attr_reader :template + def propagate_projects_with_template loop do batch = Project.uncached { project_ids_batch } @@ -38,7 +40,14 @@ module Projects end Project.transaction do - bulk_insert_services(service_hash.keys << 'project_id', service_list) + results = bulk_insert(Service, service_hash.keys << 'project_id', service_list) + + if data_fields_present? + data_list = results.map { |row| data_hash.values << row['id'] } + + bulk_insert(template.data_fields.class, data_hash.keys << 'service_id', data_list) + end + run_callbacks(batch) end end @@ -52,36 +61,27 @@ module Projects SELECT true FROM services WHERE services.project_id = projects.id - AND services.type = '#{@template.type}' + AND services.type = #{ActiveRecord::Base.connection.quote(template.type)} ) AND projects.pending_delete = false AND projects.archived = false LIMIT #{BATCH_SIZE} - SQL + SQL ) end - def bulk_insert_services(columns, values_array) - ActiveRecord::Base.connection.execute( - <<-SQL.strip_heredoc - INSERT INTO services (#{columns.join(', ')}) - VALUES #{values_array.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} - SQL - ) + def bulk_insert(klass, columns, values_array) + items_to_insert = values_array.map { |array| Hash[columns.zip(array)] } + + klass.insert_all(items_to_insert, returning: [:id]) end def service_hash - @service_hash ||= - begin - template_hash = @template.as_json(methods: :type).except('id', 'template', 'project_id') - - template_hash.each_with_object({}) do |(key, value), service_hash| - value = value.is_a?(Hash) ? value.to_json : value + @service_hash ||= template.as_json(methods: :type, except: %w[id template project_id]) + end - service_hash[ActiveRecord::Base.connection.quote_column_name(key)] = - ActiveRecord::Base.connection.quote(value) - end - end + def data_hash + @data_hash ||= template.data_fields.as_json(only: template.data_fields.class.column_names).except('id', 'service_id') end # rubocop: disable CodeReuse/ActiveRecord @@ -97,11 +97,11 @@ module Projects # rubocop: enable CodeReuse/ActiveRecord def active_external_issue_tracker? - @template.issue_tracker? && !@template.default + template.issue_tracker? && !template.default end def active_external_wiki? - @template.type == 'ExternalWikiService' + template.type == 'ExternalWikiService' end end end diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 309eab59463..60e5b7e2639 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -135,7 +135,8 @@ module Projects return if project.hashed_storage?(:repository) move_repo_folder(@new_path, @old_path) - move_repo_folder("#{@new_path}.wiki", "#{@old_path}.wiki") + move_repo_folder(new_wiki_repo_path, old_wiki_repo_path) + move_repo_folder(new_design_repo_path, old_design_repo_path) end def move_repo_folder(from_name, to_name) @@ -157,8 +158,9 @@ module Projects # Disk path is changed; we need to ensure we reload it project.reload_repository! - # Move wiki repo also if present - move_repo_folder("#{@old_path}.wiki", "#{@new_path}.wiki") + # Move wiki and design repos also if present + move_repo_folder(old_wiki_repo_path, new_wiki_repo_path) + move_repo_folder(old_design_repo_path, new_design_repo_path) end def move_project_uploads(project) @@ -170,6 +172,22 @@ module Projects @new_namespace.full_path ) end + + def old_wiki_repo_path + "#{old_path}#{::Gitlab::GlRepository::WIKI.path_suffix}" + end + + def new_wiki_repo_path + "#{new_path}#{::Gitlab::GlRepository::WIKI.path_suffix}" + end + + def old_design_repo_path + "#{old_path}#{::Gitlab::GlRepository::DESIGN.path_suffix}" + end + + def new_design_repo_path + "#{new_path}#{::Gitlab::GlRepository::DESIGN.path_suffix}" + end end end diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index 13a467a3ef9..e554bed6819 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -29,14 +29,16 @@ module Projects remote_mirror.ensure_remote! repository.fetch_remote(remote_mirror.remote_name, ssh_auth: remote_mirror, no_tags: true) - opts = {} - if remote_mirror.only_protected_branches? - opts[:only_branches_matching] = project.protected_branches.select(:name).map(&:name) - end + response = remote_mirror.update_repository - remote_mirror.update_repository(opts) + if response.divergent_refs.any? + message = "Some refs have diverged and have not been updated on the remote:" + message += "\n\n#{response.divergent_refs.join("\n")}" - remote_mirror.update_finish! + remote_mirror.mark_as_failed!(message) + else + remote_mirror.update_finish! + end end def retry_or_fail(mirror, message, tries) diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb index 2e5de9411d1..0632df6f6d7 100644 --- a/app/services/projects/update_repository_storage_service.rb +++ b/app/services/projects/update_repository_storage_service.rb @@ -1,37 +1,49 @@ # frozen_string_literal: true module Projects - class UpdateRepositoryStorageService < BaseService - include Gitlab::ShellAdapter - + class UpdateRepositoryStorageService Error = Class.new(StandardError) SameFilesystemError = Class.new(Error) - def initialize(project) - @project = project + attr_reader :repository_storage_move + delegate :project, :destination_storage_name, to: :repository_storage_move + delegate :repository, to: :project + + def initialize(repository_storage_move) + @repository_storage_move = repository_storage_move end - def execute(new_repository_storage_key) - raise SameFilesystemError if same_filesystem?(project.repository.storage, new_repository_storage_key) + def execute + repository_storage_move.start! - mirror_repositories(new_repository_storage_key) + raise SameFilesystemError if same_filesystem?(repository.storage, destination_storage_name) - mark_old_paths_for_archive + mirror_repositories - project.update(repository_storage: new_repository_storage_key, repository_read_only: false) - project.leave_pool_repository - project.track_project_repository + project.transaction do + mark_old_paths_for_archive + + repository_storage_move.finish! + project.update!(repository_storage: destination_storage_name, repository_read_only: false) + project.leave_pool_repository + project.track_project_repository + end enqueue_housekeeping - success + ServiceResponse.success - rescue Error, ArgumentError, Gitlab::Git::BaseError => e - project.update(repository_read_only: false) + rescue StandardError => e + project.transaction do + repository_storage_move.do_fail! + project.update!(repository_read_only: false) + end Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path) - error(s_("UpdateRepositoryStorage|Error moving repository storage for %{project_full_path} - %{message}") % { project_full_path: project.full_path, message: e.message }) + ServiceResponse.error( + message: s_("UpdateRepositoryStorage|Error moving repository storage for %{project_full_path} - %{message}") % { project_full_path: project.full_path, message: e.message } + ) end private @@ -40,15 +52,19 @@ module Projects Gitlab::GitalyClient.filesystem_id(old_storage) == Gitlab::GitalyClient.filesystem_id(new_storage) end - def mirror_repositories(new_repository_storage_key) - mirror_repository(new_repository_storage_key) + def mirror_repositories + mirror_repository if project.wiki.repository_exists? - mirror_repository(new_repository_storage_key, type: Gitlab::GlRepository::WIKI) + mirror_repository(type: Gitlab::GlRepository::WIKI) + end + + if project.design_repository.exists? + mirror_repository(type: ::Gitlab::GlRepository::DESIGN) end end - def mirror_repository(new_storage_key, type: Gitlab::GlRepository::PROJECT) + def mirror_repository(type: Gitlab::GlRepository::PROJECT) unless wait_for_pushes(type) raise Error, s_('UpdateRepositoryStorage|Timeout waiting for %{type} repository pushes') % { type: type.name } end @@ -60,7 +76,7 @@ module Projects # Initialize a git repository on the target path new_repository = Gitlab::Git::Repository.new( - new_storage_key, + destination_storage_name, raw_repository.relative_path, raw_repository.gl_repository, full_path @@ -94,11 +110,18 @@ module Projects wiki.disk_path, "#{new_project_path}.wiki") end + + if design_repository.exists? + GitlabShellWorker.perform_async(:mv_repository, + old_repository_storage, + design_repository.disk_path, + "#{new_project_path}.design") + end end end def moved_path(path) - "#{path}+#{project.id}+moved+#{Time.now.to_i}" + "#{path}+#{project.id}+moved+#{Time.current.to_i}" end # The underlying FetchInternalRemote call uses a `git fetch` to move data @@ -128,5 +151,3 @@ module Projects end end end - -Projects::UpdateRepositoryStorageService.prepend_if_ee('EE::Projects::UpdateRepositoryStorageService') diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb index 99c739a630b..085cfc76196 100644 --- a/app/services/prometheus/proxy_service.rb +++ b/app/services/prometheus/proxy_service.rb @@ -17,6 +17,7 @@ module Prometheus # is expected to change *and* be fetched again by the frontend self.reactive_cache_refresh_interval = 90.seconds self.reactive_cache_lifetime = 1.minute + self.reactive_cache_work_type = :external_dependency self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } attr_accessor :proxyable, :method, :path, :params diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb index 240586c8419..aa3a09ba05c 100644 --- a/app/services/prometheus/proxy_variable_substitution_service.rb +++ b/app/services/prometheus/proxy_variable_substitution_service.rb @@ -4,11 +4,20 @@ module Prometheus class ProxyVariableSubstitutionService < BaseService include Stepable + VARIABLE_INTERPOLATION_REGEX = / + {{ # Variable needs to be wrapped in these chars. + \s* # Allow whitespace before and after the variable name. + (?<variable> # Named capture. + \w+ # Match one or more word characters. + ) + \s* + }} + /x.freeze + steps :validate_variables, :add_params_to_result, :substitute_params, - :substitute_ruby_variables, - :substitute_liquid_variables + :substitute_variables def initialize(environment, params = {}) @environment, @params = environment, params.deep_dup @@ -46,37 +55,28 @@ module Prometheus success(result) end - def substitute_liquid_variables(result) + def substitute_variables(result) return success(result) unless query(result) - result[:params][:query] = - TemplateEngines::LiquidService.new(query(result)).render(full_context) + result[:params][:query] = gsub(query(result), full_context) success(result) - rescue TemplateEngines::LiquidService::RenderError => e - error(e.message) end - def substitute_ruby_variables(result) - return success(result) unless query(result) - - # The % operator doesn't replace variables if the hash contains string - # keys. - result[:params][:query] = query(result) % predefined_context.symbolize_keys - - success(result) - rescue TypeError, ArgumentError => exception - log_error(exception.message) - Gitlab::ErrorTracking.track_exception(exception, { - template_string: query(result), - variables: predefined_context - }) - - error(_('Malformed string')) + def gsub(string, context) + # Search for variables of the form `{{variable}}` in the string and replace + # them with their value. + string.gsub(VARIABLE_INTERPOLATION_REGEX) do |match| + # Replace with the value of the variable, or if there is no such variable, + # replace the invalid variable with itself. So, + # `up{instance="{{invalid_variable}}"}` will remain + # `up{instance="{{invalid_variable}}"}` after substitution. + context.fetch($~[:variable], match) + end end def predefined_context - @predefined_context ||= Gitlab::Prometheus::QueryVariables.call(@environment) + Gitlab::Prometheus::QueryVariables.call(@environment).stringify_keys end def full_context diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index 9a0a876454f..81ca9d6d123 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -47,11 +47,17 @@ module Releases release.save! + notify_create_release(release) + success(tag: tag, release: release) rescue => e error(e.message, 400) end + def notify_create_release(release) + NotificationService.new.async.send_new_release_notifications(release) + end + def build_release(tag) project.releases.build( name: name, diff --git a/app/services/resources/create_access_token_service.rb b/app/services/resource_access_tokens/create_service.rb index fd3c8d78e58..c8e86e68383 100644 --- a/app/services/resources/create_access_token_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -1,13 +1,11 @@ # frozen_string_literal: true -module Resources - class CreateAccessTokenService < BaseService - attr_accessor :resource_type, :resource - - def initialize(resource_type, resource, user, params = {}) - @resource_type = resource_type +module ResourceAccessTokens + class CreateService < BaseService + def initialize(current_user, resource, params = {}) + @resource_type = resource.class.name.downcase @resource = resource - @current_user = user + @current_user = current_user @params = params.dup end @@ -33,6 +31,8 @@ module Resources private + attr_reader :resource_type, :resource + def feature_enabled? ::Feature.enabled?(:resource_access_token, resource) end @@ -85,7 +85,7 @@ module Resources def personal_access_token_params { - name: "#{resource_type}_bot", + name: params[:name] || "#{resource_type}_bot", impersonation: false, scopes: params[:scopes] || default_scopes, expires_at: params[:expires_at] || nil @@ -93,7 +93,7 @@ module Resources end def default_scopes - Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user] + Gitlab::Auth.resource_bot_scopes end def provision_access(resource, user) diff --git a/app/services/resource_access_tokens/revoke_service.rb b/app/services/resource_access_tokens/revoke_service.rb new file mode 100644 index 00000000000..eea6bff572b --- /dev/null +++ b/app/services/resource_access_tokens/revoke_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module ResourceAccessTokens + class RevokeService < BaseService + include Gitlab::Utils::StrongMemoize + + RevokeAccessTokenError = Class.new(RuntimeError) + + def initialize(current_user, resource, access_token) + @current_user = current_user + @access_token = access_token + @bot_user = access_token.user + @resource = resource + end + + def execute + return error("Failed to find bot user") unless find_member + + PersonalAccessToken.transaction do + access_token.revoke! + + raise RevokeAccessTokenError, "Failed to remove #{bot_user.name} member from: #{resource.name}" unless remove_member + + raise RevokeAccessTokenError, "Migration to ghost user failed" unless migrate_to_ghost_user + end + + success("Revoked access token: #{access_token.name}") + rescue ActiveRecord::ActiveRecordError, RevokeAccessTokenError => error + log_error("Failed to revoke access token for #{bot_user.name}: #{error.message}") + error(error.message) + end + + private + + attr_reader :current_user, :access_token, :bot_user, :resource + + def remove_member + ::Members::DestroyService.new(current_user).execute(find_member) + end + + def migrate_to_ghost_user + ::Users::MigrateToGhostUserService.new(bot_user).execute + end + + def find_member + strong_memoize(:member) do + if resource.is_a?(Project) + resource.project_member(bot_user) + elsif resource.is_a?(Group) + resource.group_member(bot_user) + else + false + end + end + end + + def error(message) + ServiceResponse.error(message: message) + end + + def success(message) + ServiceResponse.success(message: message) + end + end +end diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb index 1b85ca811a1..db8bf6e4b74 100644 --- a/app/services/resource_events/base_synthetic_notes_builder_service.rb +++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb @@ -26,7 +26,7 @@ module ResourceEvents def since_fetch_at(events) return events unless params[:last_fetched_at].present? - last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i) + last_fetched_at = Time.zone.at(params.fetch(:last_fetched_at).to_i) events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP) end diff --git a/app/services/resource_events/change_milestone_service.rb b/app/services/resource_events/change_milestone_service.rb index ea196822f74..82c3e2acad5 100644 --- a/app/services/resource_events/change_milestone_service.rb +++ b/app/services/resource_events/change_milestone_service.rb @@ -2,13 +2,14 @@ module ResourceEvents class ChangeMilestoneService - attr_reader :resource, :user, :event_created_at, :milestone + attr_reader :resource, :user, :event_created_at, :milestone, :old_milestone - def initialize(resource, user, created_at: Time.now) + def initialize(resource, user, created_at: Time.current, old_milestone:) @resource = resource @user = user @event_created_at = created_at @milestone = resource&.milestone + @old_milestone = old_milestone end def execute @@ -26,7 +27,7 @@ module ResourceEvents { user_id: user.id, created_at: event_created_at, - milestone_id: milestone&.id, + milestone_id: action == :add ? milestone&.id : old_milestone&.id, state: ResourceMilestoneEvent.states[resource.state], action: ResourceMilestoneEvent.actions[action], key => resource.id diff --git a/app/services/search/snippet_service.rb b/app/services/search/snippet_service.rb index e686d3bf7c2..30401b28571 100644 --- a/app/services/search/snippet_service.rb +++ b/app/services/search/snippet_service.rb @@ -7,7 +7,7 @@ module Search end def scope - @scope ||= %w[snippet_titles].delete(params[:scope]) { 'snippet_blobs' } + @scope ||= 'snippet_titles' end end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index c96599f9958..bf21eba28f7 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -6,6 +6,9 @@ class SearchService SEARCH_TERM_LIMIT = 64 SEARCH_CHAR_LIMIT = 4096 + DEFAULT_PER_PAGE = Gitlab::SearchResults::DEFAULT_PER_PAGE + MAX_PER_PAGE = 200 + def initialize(current_user, params = {}) @current_user = current_user @params = params.dup @@ -60,11 +63,19 @@ class SearchService end def search_objects - @search_objects ||= redact_unauthorized_results(search_results.objects(scope, params[:page])) + @search_objects ||= redact_unauthorized_results(search_results.objects(scope, page: params[:page], per_page: per_page)) end private + def per_page + per_page_param = params[:per_page].to_i + + return DEFAULT_PER_PAGE unless per_page_param.positive? + + [MAX_PER_PAGE, per_page_param].min + end + def visible_result?(object) return true unless object.respond_to?(:to_ability_name) && DeclarativePolicy.has_policy?(object) @@ -75,13 +86,13 @@ class SearchService results = results_collection.to_a permitted_results = results.select { |object| visible_result?(object) } - filtered_results = (results - permitted_results).each_with_object({}) do |object, memo| + redacted_results = (results - permitted_results).each_with_object({}) do |object, memo| memo[object.id] = { ability: :"read_#{object.to_ability_name}", id: object.id, class_name: object.class.name } end - log_redacted_search_results(filtered_results.values) if filtered_results.any? + log_redacted_search_results(redacted_results.values) if redacted_results.any? - return results_collection.id_not_in(filtered_results.keys) if results_collection.is_a?(ActiveRecord::Relation) + return results_collection.id_not_in(redacted_results.keys) if results_collection.is_a?(ActiveRecord::Relation) Kaminari.paginate_array( permitted_results, diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb index 2b450db0b83..81d12997335 100644 --- a/app/services/snippets/base_service.rb +++ b/app/services/snippets/base_service.rb @@ -2,8 +2,32 @@ module Snippets class BaseService < ::BaseService + include SpamCheckMethods + + CreateRepositoryError = Class.new(StandardError) + + attr_reader :uploaded_files + + def initialize(project, user = nil, params = {}) + super + + @uploaded_files = Array(@params.delete(:files).presence) + + filter_spam_check_params + end + private + def visibility_allowed?(snippet, visibility_level) + Gitlab::VisibilityLevel.allowed_for?(current_user, visibility_level) + end + + def error_forbidden_visibility(snippet) + deny_visibility_level(snippet) + + snippet_error_response(snippet, 403) + end + def snippet_error_response(snippet, http_status) ServiceResponse.error( message: snippet.errors.full_messages.to_sentence, @@ -11,5 +35,22 @@ module Snippets payload: { snippet: snippet } ) end + + def add_snippet_repository_error(snippet:, error:) + message = repository_error_message(error) + + snippet.errors.add(:repository, message) + end + + def repository_error_message(error) + message = self.is_a?(Snippets::CreateService) ? _("Error creating the snippet") : _("Error updating the snippet") + + # We only want to include additional error detail in the message + # if the error is not a CommitError because we cannot guarantee the message + # will be user-friendly + message += " - #{error.message}" unless error.instance_of?(SnippetRepository::CommitError) + + message + end end end diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index 155013db344..ed6da3a0ad0 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -2,23 +2,11 @@ module Snippets class CreateService < Snippets::BaseService - include SpamCheckMethods - - CreateRepositoryError = Class.new(StandardError) - def execute - filter_spam_check_params - - @snippet = if project - project.snippets.build(params) - else - PersonalSnippet.new(params) - end - - unless Gitlab::VisibilityLevel.allowed_for?(current_user, @snippet.visibility_level) - deny_visibility_level(@snippet) + @snippet = build_from_params - return snippet_error_response(@snippet, 403) + unless visibility_allowed?(@snippet, @snippet.visibility_level) + return error_forbidden_visibility(@snippet) end @snippet.author = current_user @@ -29,6 +17,8 @@ module Snippets UserAgentDetailService.new(@snippet, @request).create Gitlab::UsageDataCounters::SnippetCounter.count(:create) + move_temporary_files + ServiceResponse.success(payload: { snippet: @snippet } ) else snippet_error_response(@snippet, 400) @@ -37,10 +27,18 @@ module Snippets private + def build_from_params + if project + project.snippets.build(params) + else + PersonalSnippet.new(params) + end + end + def save_and_commit snippet_saved = @snippet.save - if snippet_saved && Feature.enabled?(:version_snippets, current_user) + if snippet_saved create_repository create_commit end @@ -60,7 +58,7 @@ module Snippets @snippet = @snippet.dup end - @snippet.errors.add(:base, e.message) + add_snippet_repository_error(snippet: @snippet, error: e) false end @@ -83,5 +81,13 @@ module Snippets def snippet_files [{ file_path: params[:file_name], content: params[:content] }] end + + def move_temporary_files + return unless @snippet.is_a?(PersonalSnippet) + + uploaded_files.each do |file| + FileMover.new(file, from_model: current_user, to_model: @snippet).execute + end + end end end diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index e56b20c6057..2dc9266dbd0 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -2,24 +2,15 @@ module Snippets class UpdateService < Snippets::BaseService - include SpamCheckMethods + COMMITTABLE_ATTRIBUTES = %w(file_name content).freeze UpdateError = Class.new(StandardError) - CreateRepositoryError = Class.new(StandardError) def execute(snippet) - # check that user is allowed to set specified visibility_level - new_visibility = visibility_level - - if new_visibility && new_visibility.to_i != snippet.visibility_level - unless Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility) - deny_visibility_level(snippet, new_visibility) - - return snippet_error_response(snippet, 403) - end + if visibility_changed?(snippet) && !visibility_allowed?(snippet, visibility_level) + return error_forbidden_visibility(snippet) end - filter_spam_check_params snippet.assign_attributes(params) spam_check(snippet, current_user) @@ -34,30 +25,32 @@ module Snippets private + def visibility_changed?(snippet) + visibility_level && visibility_level.to_i != snippet.visibility_level + end + def save_and_commit(snippet) return false unless snippet.save - # In order to avoid non migrated snippets scenarios, - # if the snippet does not have a repository we created it - # We don't need to check if the repository exists - # because `create_repository` already handles it - if Feature.enabled?(:version_snippets, current_user) - create_repository_for(snippet) - end + # If the updated attributes does not need to update + # the repository we can just return + return true unless committable_attributes? - # If the snippet repository exists we commit always - # the changes - create_commit(snippet) if snippet.repository_exists? + create_repository_for(snippet) + create_commit(snippet) true rescue => e - # Restore old attributes + # Restore old attributes but re-assign changes so they're not lost unless snippet.previous_changes.empty? snippet.previous_changes.each { |attr, value| snippet[attr] = value[0] } snippet.save + + snippet.assign_attributes(params) end - snippet.errors.add(:repository, 'Error updating the snippet') + add_snippet_repository_error(snippet: snippet, error: e) + log_error(e.message) # If the commit action failed we remove it because @@ -92,7 +85,7 @@ module Snippets end def snippet_files(snippet) - [{ previous_path: snippet.blobs.first&.path, + [{ previous_path: snippet.file_name_on_repo, file_path: params[:file_name], content: params[:content] }] end @@ -104,5 +97,9 @@ module Snippets def repository_empty?(snippet) snippet.repository._uncached_exists? && !snippet.repository._uncached_has_visible_content? end + + def committable_attributes? + (params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present? + end end end diff --git a/app/services/spam/akismet_service.rb b/app/services/spam/akismet_service.rb index 7d16743b3ed..ab35fb8700f 100644 --- a/app/services/spam/akismet_service.rb +++ b/app/services/spam/akismet_service.rb @@ -17,7 +17,7 @@ module Spam params = { type: 'comment', text: text, - created_at: DateTime.now, + created_at: DateTime.current, author: owner_name, author_email: owner_email, referrer: options[:referrer] diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb new file mode 100644 index 00000000000..f0a4aff4443 --- /dev/null +++ b/app/services/spam/spam_action_service.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Spam + class SpamActionService + include SpamConstants + + attr_accessor :target, :request, :options + attr_reader :spam_log + + def initialize(spammable:, request:) + @target = spammable + @request = request + @options = {} + + if @request + @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s + @options[:user_agent] = @request.env['HTTP_USER_AGENT'] + @options[:referrer] = @request.env['HTTP_REFERRER'] + else + @options[:ip_address] = @target.ip_address + @options[:user_agent] = @target.user_agent + end + end + + def execute(api: false, recaptcha_verified:, spam_log_id:, user:) + if recaptcha_verified + # If it's a request which is already verified through reCAPTCHA, + # update the spam log accordingly. + SpamLog.verify_recaptcha!(user_id: user.id, id: spam_log_id) + else + return if allowlisted?(user) + return unless request + return unless check_for_spam? + + perform_spam_service_check(api) + end + end + + delegate :check_for_spam?, to: :target + + private + + def allowlisted?(user) + user.respond_to?(:gitlab_employee) && user.gitlab_employee? + end + + def perform_spam_service_check(api) + # since we can check for spam, and recaptcha is not verified, + # ask the SpamVerdictService what to do with the target. + spam_verdict_service.execute.tap do |result| + case result + when REQUIRE_RECAPTCHA + create_spam_log(api) + + break if target.allow_possible_spam? + + target.needs_recaptcha! + when DISALLOW + # TODO: remove `unless target.allow_possible_spam?` once this flag has been passed to `SpamVerdictService` + # https://gitlab.com/gitlab-org/gitlab/-/issues/214739 + target.spam! unless target.allow_possible_spam? + create_spam_log(api) + when ALLOW + target.clear_spam_flags! + end + end + end + + def create_spam_log(api) + @spam_log = SpamLog.create!( + { + user_id: target.author_id, + title: target.spam_title, + description: target.spam_description, + source_ip: options[:ip_address], + user_agent: options[:user_agent], + noteable_type: target.class.to_s, + via_api: api + } + ) + + target.spam_log = spam_log + end + + def spam_verdict_service + SpamVerdictService.new(target: target, + request: @request, + options: options) + end + end +end diff --git a/app/services/spam/spam_check_service.rb b/app/services/spam/spam_check_service.rb deleted file mode 100644 index 3269f9d687a..00000000000 --- a/app/services/spam/spam_check_service.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -module Spam - class SpamCheckService - include AkismetMethods - - attr_accessor :target, :request, :options - attr_reader :spam_log - - def initialize(spammable:, request:) - @target = spammable - @request = request - @options = {} - - if @request - @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s - @options[:user_agent] = @request.env['HTTP_USER_AGENT'] - @options[:referrer] = @request.env['HTTP_REFERRER'] - else - @options[:ip_address] = @target.ip_address - @options[:user_agent] = @target.user_agent - end - end - - def execute(api: false, recaptcha_verified:, spam_log_id:, user_id:) - if recaptcha_verified - # If it's a request which is already verified through recaptcha, - # update the spam log accordingly. - SpamLog.verify_recaptcha!(user_id: user_id, id: spam_log_id) - else - # Otherwise, it goes to Akismet for spam check. - # If so, it assigns spammable object as "spam" and creates a SpamLog record. - possible_spam = check(api) - target.spam = possible_spam unless target.allow_possible_spam? - target.spam_log = spam_log - end - end - - private - - def check(api) - return unless request - return unless check_for_spam? - return unless akismet.spam? - - create_spam_log(api) - true - end - - def check_for_spam? - target.check_for_spam? - end - - def create_spam_log(api) - @spam_log = SpamLog.create!( - { - user_id: target.author_id, - title: target.spam_title, - description: target.spam_description, - source_ip: options[:ip_address], - user_agent: options[:user_agent], - noteable_type: target.class.to_s, - via_api: api - } - ) - end - end -end diff --git a/app/services/spam/spam_constants.rb b/app/services/spam/spam_constants.rb new file mode 100644 index 00000000000..085bac684c4 --- /dev/null +++ b/app/services/spam/spam_constants.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Spam + module SpamConstants + REQUIRE_RECAPTCHA = :recaptcha + DISALLOW = :disallow + ALLOW = :allow + end +end diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb new file mode 100644 index 00000000000..2b4d5f4a984 --- /dev/null +++ b/app/services/spam/spam_verdict_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Spam + class SpamVerdictService + include AkismetMethods + include SpamConstants + + def initialize(target:, request:, options:) + @target = target + @request = request + @options = options + end + + def execute + if akismet.spam? + Gitlab::Recaptcha.enabled? ? REQUIRE_RECAPTCHA : DISALLOW + else + ALLOW + end + end + + private + + attr_reader :target, :request, :options + end +end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 1b9f5971f73..6bf04c55415 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -245,6 +245,34 @@ module SystemNoteService def auto_resolve_prometheus_alert(noteable, project, author) ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).auto_resolve_prometheus_alert end + + # Parameters: + # - version [DesignManagement::Version] + # + # Example Note text: + # + # "added [1 designs](link-to-version)" + # "changed [2 designs](link-to-version)" + # + # Returns [Array<Note>]: the created Note objects + def design_version_added(version) + ::SystemNotes::DesignManagementService.new(noteable: version.issue, project: version.issue.project, author: version.author).design_version_added(version) + end + + # Called when a new discussion is created on a design + # + # discussion_note - DiscussionNote + # + # Example Note text: + # + # "started a discussion on screen.png" + # + # Returns the created Note object + def design_discussion_added(discussion_note) + design = discussion_note.noteable + + ::SystemNotes::DesignManagementService.new(noteable: design.issue, project: design.project, author: discussion_note.author).design_discussion_added(discussion_note) + end end SystemNoteService.prepend_if_ee('EE::SystemNoteService') diff --git a/app/services/system_notes/design_management_service.rb b/app/services/system_notes/design_management_service.rb new file mode 100644 index 00000000000..a773877e25b --- /dev/null +++ b/app/services/system_notes/design_management_service.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module SystemNotes + class DesignManagementService < ::SystemNotes::BaseService + include ActionView::RecordIdentifier + + # Parameters: + # - version [DesignManagement::Version] + # + # Example Note text: + # + # "added [1 designs](link-to-version)" + # "changed [2 designs](link-to-version)" + # + # Returns [Array<Note>]: the created Note objects + def design_version_added(version) + events = DesignManagement::Action.events + link_href = designs_path(version: version.id) + + version.designs_by_event.map do |(event_name, designs)| + note_data = self.class.design_event_note_data(events[event_name]) + icon_name = note_data[:icon] + n = designs.size + + body = "%s [%d %s](%s)" % [note_data[:past_tense], n, 'design'.pluralize(n), link_href] + + create_note(NoteSummary.new(noteable, project, author, body, action: icon_name)) + end + end + + # Called when a new discussion is created on a design + # + # discussion_note - DiscussionNote + # + # Example Note text: + # + # "started a discussion on screen.png" + # + # Returns the created Note object + def design_discussion_added(discussion_note) + design = discussion_note.noteable + + body = _('started a discussion on %{design_link}') % { + design_link: '[%s](%s)' % [ + design.filename, + designs_path(vueroute: design.filename, anchor: dom_id(discussion_note)) + ] + } + + action = :designs_discussion_added + + create_note(NoteSummary.new(noteable, project, author, body, action: action)) + end + + # Take one of the `DesignManagement::Action.events` and + # return: + # * an English past-tense verb. + # * the name of an icon used in renderin a system note + # + # We do not currently internationalize our system notes, + # instead we just produce English-language descriptions. + # See: https://gitlab.com/gitlab-org/gitlab/issues/30408 + # See: https://gitlab.com/gitlab-org/gitlab/issues/14056 + def self.design_event_note_data(event) + case event + when DesignManagement::Action.events[:creation] + { icon: 'designs_added', past_tense: 'added' } + when DesignManagement::Action.events[:modification] + { icon: 'designs_modified', past_tense: 'updated' } + when DesignManagement::Action.events[:deletion] + { icon: 'designs_removed', past_tense: 'removed' } + else + raise "Unknown event: #{event}" + end + end + + private + + def designs_path(params = {}) + url_helpers.designs_project_issue_path(project, noteable, params) + end + end +end diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb index 4f6ae07be7d..3a01192487d 100644 --- a/app/services/tags/destroy_service.rb +++ b/app/services/tags/destroy_service.rb @@ -18,11 +18,6 @@ module Tags .new(project, current_user, tag: tag_name) .execute - push_data = build_push_data(tag) - EventCreateService.new.push(project, current_user, push_data) - project.execute_hooks(push_data.dup, :tag_push_hooks) - project.execute_services(push_data.dup, :tag_push_hooks) - success('Tag was removed') else error('Failed to remove tag') @@ -38,14 +33,5 @@ module Tags def success(message) super().merge(message: message) end - - def build_push_data(tag) - Gitlab::DataBuilder::Push.build( - project: project, - user: current_user, - oldrev: tag.dereferenced_target.sha, - newrev: Gitlab::Git::BLANK_SHA, - ref: "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}") - end end end diff --git a/app/services/template_engines/liquid_service.rb b/app/services/template_engines/liquid_service.rb deleted file mode 100644 index 809ebd0316b..00000000000 --- a/app/services/template_engines/liquid_service.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module TemplateEngines - class LiquidService < BaseService - RenderError = Class.new(StandardError) - - DEFAULT_RENDER_SCORE_LIMIT = 1_000 - - def initialize(string) - @template = Liquid::Template.parse(string) - end - - def render(context, render_score_limit: DEFAULT_RENDER_SCORE_LIMIT) - set_limits(render_score_limit) - - @template.render!(context.stringify_keys) - rescue Liquid::MemoryError => e - handle_exception(e, string: @string, context: context) - - raise RenderError, _('Memory limit exceeded while rendering template') - rescue Liquid::Error => e - handle_exception(e, string: @string, context: context) - - raise RenderError, _('Error rendering query') - end - - private - - def set_limits(render_score_limit) - @template.resource_limits.render_score_limit = render_score_limit - - # We can also set assign_score_limit and render_length_limit if required. - - # render_score_limit limits the number of nodes (string, variable, block, tags) - # that are allowed in the template. - # render_length_limit seems to limit the sum of the bytesize of all node blocks. - # assign_score_limit seems to limit the sum of the bytesize of all capture blocks. - end - - def handle_exception(exception, extra = {}) - log_error(exception.message) - Gitlab::ErrorTracking.track_exception(exception, { - template_string: extra[:string], - variables: extra[:context] - }) - end - end -end diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb index 5bb6f6a1dee..d180a3a2432 100644 --- a/app/services/terraform/remote_state_handler.rb +++ b/app/services/terraform/remote_state_handler.rb @@ -42,7 +42,7 @@ module Terraform state.lock_xid = params[:lock_id] state.locked_by_user = current_user - state.locked_at = Time.now + state.locked_at = Time.current state.save! end diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb index 21d0861ac3f..66f1ccfab70 100644 --- a/app/services/user_project_access_changed_service.rb +++ b/app/services/user_project_access_changed_service.rb @@ -1,17 +1,26 @@ # frozen_string_literal: true class UserProjectAccessChangedService + DELAY = 1.hour + + HIGH_PRIORITY = :high + LOW_PRIORITY = :low + def initialize(user_ids) @user_ids = Array.wrap(user_ids) end - def execute(blocking: true) + def execute(blocking: true, priority: HIGH_PRIORITY) bulk_args = @user_ids.map { |id| [id] } if blocking AuthorizedProjectsWorker.bulk_perform_and_wait(bulk_args) else - AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext + if priority == HIGH_PRIORITY + AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext + else + AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker.bulk_perform_in(DELAY, bulk_args) # rubocop:disable Scalability/BulkPerformWithContext + end end end end diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb index e7186fdfb63..5ca9ed67e56 100644 --- a/app/services/users/migrate_to_ghost_user_service.rb +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -52,6 +52,7 @@ module Users migrate_notes migrate_abuse_reports migrate_award_emoji + migrate_snippets end # rubocop: disable CodeReuse/ActiveRecord @@ -79,6 +80,11 @@ module Users def migrate_award_emoji user.award_emoji.update_all(user_id: ghost_user.id) end + + def migrate_snippets + snippets = user.snippets.only_project_snippets + snippets.update_all(author_id: ghost_user.id) + end end end diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb index b53c3145caf..a9e219547d7 100644 --- a/app/services/verify_pages_domain_service.rb +++ b/app/services/verify_pages_domain_service.rb @@ -37,7 +37,7 @@ class VerifyPagesDomainService < BaseService # Prevent any pre-existing grace period from being truncated reverify = [domain.enabled_until, VERIFICATION_PERIOD.from_now].compact.max - domain.assign_attributes(verified_at: Time.now, enabled_until: reverify, remove_at: nil) + domain.assign_attributes(verified_at: Time.current, enabled_until: reverify, remove_at: nil) domain.save!(validate: false) if was_disabled @@ -73,7 +73,7 @@ class VerifyPagesDomainService < BaseService # A domain is only expired until `disable!` has been called def expired? - domain.enabled_until && domain.enabled_until < Time.now + domain.enabled_until && domain.enabled_until < Time.current end def dns_record_present? diff --git a/app/services/wiki_pages/base_service.rb b/app/services/wiki_pages/base_service.rb index 2e774973ca5..a0256ea5e69 100644 --- a/app/services/wiki_pages/base_service.rb +++ b/app/services/wiki_pages/base_service.rb @@ -6,13 +6,13 @@ module WikiPages # - external_action: the action we report to external clients with webhooks # - usage_counter_action: the action that we count in out internal counters # - event_action: what we record as the value of `Event#action` - class BaseService < ::BaseService + class BaseService < ::BaseContainerService private def execute_hooks(page) page_data = payload(page) - @project.execute_hooks(page_data, :wiki_page_hooks) - @project.execute_services(page_data, :wiki_page_hooks) + container.execute_hooks(page_data, :wiki_page_hooks) + container.execute_services(page_data, :wiki_page_hooks) increment_usage create_wiki_event(page) end @@ -46,12 +46,9 @@ module WikiPages def create_wiki_event(page) return unless ::Feature.enabled?(:wiki_events) - slug = slug_for_page(page) + response = WikiPages::EventCreateService.new(current_user).execute(slug_for_page(page), page, event_action) - Event.transaction do - wiki_page_meta = WikiPage::Meta.find_or_create(slug, page) - EventCreateService.new.wiki_event(wiki_page_meta, current_user, event_action) - end + log_error(response.message) if response.error? end def slug_for_page(page) diff --git a/app/services/wiki_pages/create_service.rb b/app/services/wiki_pages/create_service.rb index 811f460e042..4ef19676d82 100644 --- a/app/services/wiki_pages/create_service.rb +++ b/app/services/wiki_pages/create_service.rb @@ -3,8 +3,8 @@ module WikiPages class CreateService < WikiPages::BaseService def execute - project_wiki = ProjectWiki.new(@project, current_user) - page = WikiPage.new(project_wiki) + wiki = Wiki.for_container(container, current_user) + page = WikiPage.new(wiki) if page.create(@params) execute_hooks(page) diff --git a/app/services/wiki_pages/event_create_service.rb b/app/services/wiki_pages/event_create_service.rb new file mode 100644 index 00000000000..18a45d057a9 --- /dev/null +++ b/app/services/wiki_pages/event_create_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module WikiPages + class EventCreateService + # @param [User] author The event author + def initialize(author) + raise ArgumentError, 'author must not be nil' unless author + + @author = author + end + + def execute(slug, page, action) + return ServiceResponse.success(message: 'No event created as `wiki_events` feature is disabled') unless ::Feature.enabled?(:wiki_events) + + event = Event.transaction do + wiki_page_meta = WikiPage::Meta.find_or_create(slug, page) + + ::EventCreateService.new.wiki_event(wiki_page_meta, author, action) + end + + ServiceResponse.success(payload: { event: event }) + rescue ::EventCreateService::IllegalActionError, ::ActiveRecord::ActiveRecordError => e + ServiceResponse.error(message: e.message, payload: { error: e }) + end + + private + + attr_reader :author + end +end diff --git a/app/services/wikis/create_attachment_service.rb b/app/services/wikis/create_attachment_service.rb index 6ef6cbc3c12..82179459345 100644 --- a/app/services/wikis/create_attachment_service.rb +++ b/app/services/wikis/create_attachment_service.rb @@ -5,12 +5,15 @@ module Wikis ATTACHMENT_PATH = 'uploads' MAX_FILENAME_LENGTH = 255 - delegate :wiki, to: :project + attr_reader :container + + delegate :wiki, to: :container delegate :repository, to: :wiki - def initialize(*args) - super + def initialize(container:, current_user: nil, params: {}) + super(nil, current_user, params) + @container = container @file_name = clean_file_name(params[:file_name]) @file_path = File.join(ATTACHMENT_PATH, SecureRandom.hex, @file_name) if @file_name @commit_message ||= "Upload attachment #{@file_name}" @@ -51,7 +54,7 @@ module Wikis end def validate_permissions! - unless can?(current_user, :create_wiki, project) + unless can?(current_user, :create_wiki, container) raise_error('You are not allowed to push to the wiki') end end diff --git a/app/uploaders/design_management/design_v432x230_uploader.rb b/app/uploaders/design_management/design_v432x230_uploader.rb new file mode 100644 index 00000000000..ba48f381bbd --- /dev/null +++ b/app/uploaders/design_management/design_v432x230_uploader.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module DesignManagement + # This Uploader is used to generate and serve the smaller versions of + # the design files. + # + # The original (full-sized) design files are stored in Git LFS, and so + # have a different uploader, `LfsObjectUploader`. + class DesignV432x230Uploader < GitlabUploader + include CarrierWave::MiniMagick + include RecordsUploads::Concern + include ObjectStorage::Concern + prepend ObjectStorage::Extension::RecordsUploads + + # We choose not to resize `image/ico` as we assume there will be no + # benefit in generating an 432x230 sized icon. + # + # We currently cannot resize `image/tiff`. + # See https://gitlab.com/gitlab-org/gitlab/issues/207740 + # + # We currently choose not to resize `image/svg+xml` for security reasons. + # See https://gitlab.com/gitlab-org/gitlab/issues/207740#note_302766171 + MIME_TYPE_WHITELIST = %w(image/png image/jpeg image/bmp image/gif).freeze + + process resize_to_fit: [432, 230] + + # Allow CarrierWave to reject files without correct mimetypes. + def content_type_whitelist + MIME_TYPE_WHITELIST + end + + # Override `GitlabUploader` and always return false, otherwise local + # `LfsObject` files would be deleted. + # https://github.com/carrierwaveuploader/carrierwave/blob/f84672a/lib/carrierwave/uploader/cache.rb#L131-L135 + def move_to_cache + false + end + + private + + def dynamic_segment + File.join(model.class.underscore, mounted_as.to_s, model.id.to_s) + end + end +end diff --git a/app/validators/cron_freeze_period_timezone_validator.rb b/app/validators/cron_freeze_period_timezone_validator.rb new file mode 100644 index 00000000000..143a0262136 --- /dev/null +++ b/app/validators/cron_freeze_period_timezone_validator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# CronTimezoneValidator +# +# Custom validator for CronTimezone. +class CronFreezePeriodTimezoneValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + freeze_start_parser = Gitlab::Ci::CronParser.new(record.freeze_start, record.cron_timezone) + freeze_end_parser = Gitlab::Ci::CronParser.new(record.freeze_end, record.cron_timezone) + + record.errors.add(attribute, " is invalid syntax") unless freeze_start_parser.cron_timezone_valid? && freeze_end_parser.cron_timezone_valid? + end +end diff --git a/app/validators/cron_validator.rb b/app/validators/cron_validator.rb index bd48a7a6efb..6f42bdb5f9b 100644 --- a/app/validators/cron_validator.rb +++ b/app/validators/cron_validator.rb @@ -1,11 +1,16 @@ # frozen_string_literal: true -# CronValidator -# -# Custom validator for Cron. class CronValidator < ActiveModel::EachValidator + ATTRIBUTE_WHITELIST = %i[cron freeze_start freeze_end].freeze + + NonWhitelistedAttributeError = Class.new(StandardError) + def validate_each(record, attribute, value) - cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone) - record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid? + if ATTRIBUTE_WHITELIST.include?(attribute) + cron_parser = Gitlab::Ci::CronParser.new(record.public_send(attribute), record.cron_timezone) # rubocop:disable GitlabSecurity/PublicSend + record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid? + else + raise NonWhitelistedAttributeError.new "Non-whitelisted attribute" + end end end diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml index a5f34d0dab2..5bb05bcba26 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/appearances/_form.html.haml @@ -1,3 +1,5 @@ +- parsed_with_gfm = "Content parsed with #{link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank')}.".html_safe + = form_for @appearance, url: admin_appearances_path, html: { class: 'prepend-top-default' } do |f| = form_errors(@appearance) @@ -57,7 +59,7 @@ = f.label :description, class: 'col-form-label label-bold' = f.text_area :description, class: "form-control", rows: 10 .hint - Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}. + = parsed_with_gfm .form-group = f.label :logo, class: 'col-form-label label-bold pt-0' %p @@ -83,15 +85,30 @@ %p = f.text_area :new_project_guidelines, class: "form-control", rows: 10 .hint - Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}. + = parsed_with_gfm + + %hr + .row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 Profile image guideline + + .col-lg-8 + .form-group + = f.label :profile_image_guidelines, class: 'col-form-label label-bold' + %p + = f.text_area :profile_image_guidelines, class: "form-control", rows: 10 + .hint + = parsed_with_gfm .prepend-top-default.append-bottom-default = f.submit 'Update appearance settings', class: 'btn btn-success' - - if @appearance.persisted? - Preview last save: - = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' + - if @appearance.persisted? || @appearance.updated_at + .mt-4 + - if @appearance.persisted? + Preview last save: + = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' + = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - - if @appearance.updated_at - %span.float-right - Last edit #{time_ago_with_tooltip(@appearance.updated_at)} + - if @appearance.updated_at + %span.float-right + Last edit #{time_ago_with_tooltip(@appearance.updated_at)} diff --git a/app/views/admin/application_settings/_influx.html.haml b/app/views/admin/application_settings/_influx.html.haml deleted file mode 100644 index 300b01c6777..00000000000 --- a/app/views/admin/application_settings/_influx.html.haml +++ /dev/null @@ -1,60 +0,0 @@ -= form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-influx-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) - - %fieldset - %p - Set up InfluxDB to measure a wide variety of statistics like the time spent - in running SQL queries. These settings require a - = link_to 'restart', help_page_path('administration/restart_gitlab') - to take effect. - = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/index') - .form-group - .form-check - = f.check_box :metrics_enabled, class: 'form-check-input' - = f.label :metrics_enabled, class: 'form-check-label' do - Enable InfluxDB Metrics - .form-group - = f.label :metrics_host, 'InfluxDB host', class: 'label-bold' - = f.text_field :metrics_host, class: 'form-control', placeholder: 'influxdb.example.com' - .form-group - = f.label :metrics_port, 'InfluxDB port', class: 'label-bold' - = f.text_field :metrics_port, class: 'form-control', placeholder: '8089' - .form-text.text-muted - The UDP port to use for connecting to InfluxDB. InfluxDB requires that - your server configuration specifies a database to store data in when - sending messages to this port, without it metrics data will not be - saved. - .form-group - = f.label :metrics_pool_size, 'Connection pool size', class: 'label-bold' - = f.number_field :metrics_pool_size, class: 'form-control' - .form-text.text-muted - The amount of InfluxDB connections to open. Connections are opened - lazily. Users using multi-threaded application servers should ensure - enough connections are available (at minimum the amount of application - server threads). - .form-group - = f.label :metrics_timeout, 'Connection timeout', class: 'label-bold' - = f.number_field :metrics_timeout, class: 'form-control' - .form-text.text-muted - The amount of seconds after which an InfluxDB connection will time - out. - .form-group - = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'label-bold' - = f.number_field :metrics_method_call_threshold, class: 'form-control' - .form-text.text-muted - A method call is only tracked when it takes longer to complete than - the given amount of milliseconds. - .form-group - = f.label :metrics_sample_interval, 'Sampler Interval (sec)', class: 'label-bold' - = f.number_field :metrics_sample_interval, class: 'form-control' - .form-text.text-muted - The sampling interval in seconds. Sampled data includes memory usage, - retained Ruby objects, file descriptors and so on. - .form-group - = f.label :metrics_packet_size, 'Metrics per packet', class: 'label-bold' - = f.number_field :metrics_packet_size, class: 'form-control' - .form-text.text-muted - The amount of points to store in a single UDP packet. More points - results in fewer but larger UDP packets being sent. - - = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml index 4c0ff3a18e8..b2ec25cdf8d 100644 --- a/app/views/admin/application_settings/_prometheus.html.haml +++ b/app/views/admin/application_settings/_prometheus.html.haml @@ -23,5 +23,11 @@ %code prometheus_multiproc_dir does not exist or is not pointing to a valid directory. = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory') + .form-group + = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'label-bold' + = f.number_field :metrics_method_call_threshold, class: 'form-control' + .form-text.text-muted + A method call is only tracked when it takes longer to complete than + the given amount of milliseconds. = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml index 6e5fa6eb62c..8ec9b3c528a 100644 --- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml +++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml @@ -10,7 +10,7 @@ = _('Allow repository mirroring to be configured by project maintainers') %span.form-text.text-muted = _('If disabled, only admins will be able to configure repository mirroring.') - = link_to icon('question-circle'), help_page_path('workflow/repository_mirroring') + = link_to icon('question-circle'), help_page_path('user/project/repository/repository_mirroring.md') = render_if_exists 'admin/application_settings/mirror_settings', form: f diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index c3ae39ddd48..6fabafe3fc1 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -6,10 +6,10 @@ %h4= _("Hashed repository storage paths") .form-group .form-check - = f.check_box :hashed_storage_enabled, class: 'form-check-input qa-hashed-storage-checkbox' + = f.check_box :hashed_storage_enabled, class: 'form-check-input qa-hashed-storage-checkbox', disabled: @application_setting.hashed_storage_enabled? = f.label :hashed_storage_enabled, _("Use hashed storage"), class: 'label-bold form-check-label' .form-text.text-muted - = _("Use hashed storage paths for newly created and renamed repositories. Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents repositories from having to be moved or renamed when the Repository URL changes and may improve disk I/O performance.") + = _("Use hashed storage paths for newly created and renamed repositories. Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents repositories from having to be moved or renamed when the Repository URL changes and may improve disk I/O performance. (Always enabled since 13.0)") .sub-section %h4= _("Storage nodes for new repositories") .form-group diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml index dc6d68e54ec..d8495c82af1 100644 --- a/app/views/admin/application_settings/_signup.html.haml +++ b/app/views/admin/application_settings/_signup.html.haml @@ -49,20 +49,19 @@ = f.label :domain_blacklist, 'Blacklisted domains for sign-ups', class: 'label-bold' = f.text_area :domain_blacklist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 .form-text.text-muted Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com - - if Feature.enabled?(:email_restrictions) - .form-group - = f.label :email_restrictions_enabled, _('Email restrictions'), class: 'label-bold' - .form-check - = f.check_box :email_restrictions_enabled, class: 'form-check-input' - = f.label :email_restrictions_enabled, class: 'form-check-label' do - = _('Enable email restrictions for sign ups') - .form-group - = f.label :email_restrictions, _('Email restrictions for sign-ups'), class: 'label-bold' - = f.text_area :email_restrictions, class: 'form-control', rows: 4 - .form-text.text-muted - - supported_syntax_link_url = 'https://github.com/google/re2/wiki/Syntax' - - supported_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: supported_syntax_link_url } - = _('Restricts sign-ups for email addresses that match the given regex. See the %{supported_syntax_link_start}supported syntax%{supported_syntax_link_end} for more information.').html_safe % { supported_syntax_link_start: supported_syntax_link_start, supported_syntax_link_end: '</a>'.html_safe } + .form-group + = f.label :email_restrictions_enabled, _('Email restrictions'), class: 'label-bold' + .form-check + = f.check_box :email_restrictions_enabled, class: 'form-check-input' + = f.label :email_restrictions_enabled, class: 'form-check-label' do + = _('Enable email restrictions for sign ups') + .form-group + = f.label :email_restrictions, _('Email restrictions for sign-ups'), class: 'label-bold' + = f.text_area :email_restrictions, class: 'form-control', rows: 4 + .form-text.text-muted + - supported_syntax_link_url = 'https://github.com/google/re2/wiki/Syntax' + - supported_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: supported_syntax_link_url } + = _('Restricts sign-ups for email addresses that match the given regex. See the %{supported_syntax_link_start}supported syntax%{supported_syntax_link_end} for more information.').html_safe % { supported_syntax_link_start: supported_syntax_link_start, supported_syntax_link_end: '</a>'.html_safe } .form-group = f.label :after_sign_up_text, class: 'label-bold' diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index a4acbe6c885..3c4fc75dbee 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -3,6 +3,7 @@ %fieldset = render 'shared/default_branch_protection', f: f, selected_level: @application_setting.default_branch_protection + = render_if_exists 'admin/application_settings/group_owners_can_manage_default_branch_protection_setting', form: f .form-group = f.label s_('ProjectCreationLevel|Default project creation protection'), class: 'label-bold' diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index bebda385886..fd3f04fefd1 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -98,9 +98,9 @@ .form-check = f.check_box :web_ide_clientside_preview_enabled, class: 'form-check-input' = f.label :web_ide_clientside_preview_enabled, class: 'form-check-label' do - = s_('IDE|Client side evaluation') + = s_('IDE|Live Preview') %span.form-text.text-muted - = s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox client side evaluation.') + = s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox Live Preview.') = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml index 2b01160a230..a8eff26b94c 100644 --- a/app/views/admin/application_settings/integrations.html.haml +++ b/app/views/admin/application_settings/integrations.html.haml @@ -18,7 +18,7 @@ %p = s_('AdminSettings|Integrations configured here will automatically apply to all projects on this instance.') = link_to _('Learn more'), '#' - = render 'shared/integrations/integrations', integrations: @integrations + = render 'shared/integrations/index', integrations: @integrations - else = render_if_exists 'admin/application_settings/elasticsearch_form' diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index 6a703d0b70c..befe10ea510 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -2,17 +2,6 @@ - page_title _("Metrics and profiling") - @content_class = "limit-container-width" unless fluid_layout -%section.settings.as-influx.no-animate#js-influx-settings{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4 - = _('Metrics - Influx') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } - = expanded_by_default? ? _('Collapse') : _('Expand') - %p - = _('Enable and configure InfluxDB metrics.') - .settings-content - = render 'influx' - %section.settings.as-prometheus.no-animate#js-prometheus-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml index a7da14d16ff..8342507d8a6 100644 --- a/app/views/admin/impersonation_tokens/index.html.haml +++ b/app/views/admin/impersonation_tokens/index.html.haml @@ -1,15 +1,29 @@ -- add_to_breadcrumbs "Users", admin_users_path +- add_to_breadcrumbs 'Users', admin_users_path - breadcrumb_title @user.name -- page_title "Impersonation Tokens", @user.name, "Users" +- page_title _('Impersonation Tokens'), @user.name, _('Users') +- type = _('impersonation token') +- type_plural = _('impersonation tokens') + = render 'admin/users/head' .row.prepend-top-default .col-lg-12 - if @new_impersonation_token - = render "shared/personal_access_tokens_created_container", new_token_value: @new_impersonation_token, - container_title: 'Your New Impersonation Token', - clipboard_button_title: _('Copy impersonation token') + = render 'shared/access_tokens/created_container', + type: type, + new_token_value: @new_impersonation_token - = render "shared/personal_access_tokens_form", path: admin_user_impersonation_tokens_path, impersonation: true, token: @impersonation_token, scopes: @scopes + = render 'shared/access_tokens/form', + type: type, + title: _('Add an impersonation token'), + path: admin_user_impersonation_tokens_path, + impersonation: true, + token: @impersonation_token, + scopes: @scopes - = render "shared/personal_access_tokens_table", impersonation: true, active_tokens: @active_impersonation_tokens, inactive_tokens: @inactive_impersonation_tokens + = render 'shared/access_tokens/table', + type: type, + type_plural: type_plural, + impersonation: true, + active_tokens: @active_impersonation_tokens, + revoke_route_helper: ->(token) { revoke_admin_user_impersonation_token_path(token.user, token) } diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml deleted file mode 100644 index eb93f645ea6..00000000000 --- a/app/views/admin/logs/show.html.haml +++ /dev/null @@ -1,24 +0,0 @@ -- page_title "Logs" - -%ul.nav-links.log-tabs.nav.nav-tabs - - @loggers.each do |klass| - %li.nav-item - = link_to klass.file_name, "##{klass.file_name_noext}", data: { toggle: 'tab' }, class: "#{active_when(klass == @loggers.first)} nav-link" -.row-content-block - To prevent performance issues admin logs output the last 2000 lines -.tab-content - - @loggers.each do |klass| - .tab-pane{ class: active_when(klass == @loggers.first), id: klass.file_name_noext } - .file-holder#README - .js-file-title.file-title - %i.fa.fa-file - = klass.file_name - .float-right - = link_to '#', class: 'log-bottom' do - %i.fa.fa-arrow-down - Scroll down - .file-content.logs - %ol - - klass.read_latest.each do |line| - %li - %p= line diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 7274099806d..8abc4c37e70 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -14,11 +14,9 @@ .col-md-12 .card .card-header.alert.alert-danger - Last repository check - = "(#{time_ago_with_tooltip(@project.last_repository_check_at)})" - failed. See - = link_to 'repocheck.log', admin_logs_path - for error messages. + - last_check_message = _("Last repository check (%{last_check_timestamp}) failed. See the 'repocheck.log' file for error messages.") + - last_check_message = last_check_message % { last_check_timestamp: time_ago_with_tooltip(@project.last_repository_check_at) } + = last_check_message.html_safe .row .col-md-6 .card @@ -135,24 +133,18 @@ .card.repository-check .card-header - Repository check + = _("Repository check") .card-body = form_for @project, url: repository_check_admin_project_path(@project), method: :post do |f| .form-group - if @project.last_repository_check_at.nil? - This repository has never been checked. + = _("This repository has never been checked.") + - elsif @project.last_repository_check_failed? + - failed_message = _("This repository was last checked %{last_check_timestamp}. The check %{strong_start}failed.%{strong_end} See the 'repocheck.log' file for error messages.") + - failed_message = failed_message % { last_check_timestamp: @project.last_repository_check_at.to_s(:medium), strong_start: "<strong class='cred'>", strong_end: "</strong>" } + = failed_message.html_safe - else - This repository was last checked - = @project.last_repository_check_at.to_s(:medium) + '.' - The check - - if @project.last_repository_check_failed? - = succeed '.' do - %strong.cred failed - See - = link_to 'repocheck.log', admin_logs_path - for error messages. - - else - passed. + = _("This repository was last checked %{last_check_timestamp}. The check passed.") % { last_check_timestamp: @project.last_repository_check_at.to_s(:medium) } = link_to icon('question-circle'), help_page_path('administration/repository_checks') diff --git a/app/views/admin/services/_deprecated_message.html.haml b/app/views/admin/services/_deprecated_message.html.haml deleted file mode 100644 index fea9506a4bb..00000000000 --- a/app/views/admin/services/_deprecated_message.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -.flash-container.flash-container-page - .flash-alert.deprecated-service - %span= @service.deprecation_message diff --git a/app/views/admin/services/edit.html.haml b/app/views/admin/services/edit.html.haml index 79f5ab0d77d..00ed5464a44 100644 --- a/app/views/admin/services/edit.html.haml +++ b/app/views/admin/services/edit.html.haml @@ -2,6 +2,4 @@ - breadcrumb_title @service.title - page_title @service.title, "Service Templates" -= render 'deprecated_message' if @service.deprecation_message - = render 'form' diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index a8d678d2b61..5be1c90d6aa 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -1,4 +1,4 @@ -= form_tag(admin_session_path, method: :post, html: { class: 'new_user gl-show-field-errors', 'aria-live': 'assertive'}) do += form_tag(admin_session_path, method: :post, class: 'new_user gl-show-field-errors', 'aria-live': 'assertive') do .form-group = label_tag :user_password, _('Password'), class: 'label-bold' = password_field_tag 'user[password]', nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml new file mode 100644 index 00000000000..cb6c0a76e56 --- /dev/null +++ b/app/views/admin/sessions/_signin_box.html.haml @@ -0,0 +1,19 @@ +- if any_form_based_providers_enabled? + - if crowd_enabled? + .login-box.tab-pane{ id: "crowd", role: 'tabpanel', class: active_when(form_based_auth_provider_has_active_class?(:crowd)) } + .login-body + = render 'devise/sessions/new_crowd' + + = render_if_exists 'devise/sessions/new_kerberos_tab' + + - ldap_servers.each_with_index do |server, i| + .login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain)) } + .login-body + = render 'devise/sessions/new_ldap', server: server, hide_remember_me: true, submit_message: _('Enter Admin Mode') + + = render_if_exists 'devise/sessions/new_smartcard' + +- if allow_admin_mode_password_authentication_for_web? + .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel', class: active_when(!any_form_based_providers_enabled?) } + .login-body + = render 'admin/sessions/new_base' diff --git a/app/views/admin/sessions/_tabs_normal.html.haml b/app/views/admin/sessions/_tabs_normal.html.haml deleted file mode 100644 index 2e279013720..00000000000 --- a/app/views/admin/sessions/_tabs_normal.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%ul.nav-links.new-session-tabs.nav-tabs.nav{ role: 'tablist' } - %li.nav-item{ role: 'presentation' } - %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= tab_title diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml index 0a7f20b861e..4ce1629bb53 100644 --- a/app/views/admin/sessions/new.html.haml +++ b/app/views/admin/sessions/new.html.haml @@ -5,18 +5,19 @@ .col-md-5.new-session-forms-container .login-page #signin-container - = render 'admin/sessions/tabs_normal', tab_title: _('Enter Admin Mode') + - if any_form_based_providers_enabled? + = render 'devise/shared/tabs_ldap', show_password_form: allow_admin_mode_password_authentication_for_web?, render_signup_link: false + - else + = render 'devise/shared/tabs_normal', tab_title: _('Enter Admin Mode'), render_signup_link: false .tab-content - - if !current_user.require_password_creation_for_web? - .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' } - .login-body - = render 'admin/sessions/new_base' + - if allow_admin_mode_password_authentication_for_web? || ldap_sign_in_enabled? || crowd_enabled? + = render 'admin/sessions/signin_box' - - if omniauth_enabled? && button_based_providers_enabled? - .clearfix - = render 'devise/shared/omniauth_box', hide_remember_me: true + -# Show a message if none of the mechanisms above are enabled + - if !allow_admin_mode_password_authentication_for_web? && !ldap_sign_in_enabled? && !omniauth_enabled? + .prepend-top-default.center + = _('No authentication methods configured.') - -# Show a message if none of the mechanisms above are enabled - - if current_user.require_password_creation_for_web? && !omniauth_enabled? - .prepend-top-default.center - = _('No authentication methods configured.') + - if omniauth_enabled? && button_based_providers_enabled? + .clearfix + = render 'devise/shared/omniauth_box', hide_remember_me: true diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml index 3a0cbe3facb..57a3452cf35 100644 --- a/app/views/admin/sessions/two_factor.html.haml +++ b/app/views/admin/sessions/two_factor.html.haml @@ -5,7 +5,7 @@ .col-md-5.new-session-forms-container .login-page #signin-container - = render 'admin/sessions/tabs_normal', tab_title: _('Enter Admin Mode') + = render 'devise/shared/tabs_normal', tab_title: _('Enter Admin Mode'), render_signup_link: false .tab-content .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' } .login-body diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml index 369b0f7e62c..d9d646c77d9 100644 --- a/app/views/ci/status/_dropdown_graph_badge.html.haml +++ b/app/views/ci/status/_dropdown_graph_badge.html.haml @@ -8,12 +8,12 @@ - if status.has_details? = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item d-flex', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do %span{ class: klass }= sprite_icon(status.icon) - %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name + %span.ci-build-text.text-truncate.mw-70p.gl-pl-1-deprecated-no-really-do-not-use-me= subject.name - else .menu-item.mini-pipeline-graph-dropdown-item.d-flex{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } } %span{ class: klass }= sprite_icon(status.icon) - %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name + %span.ci-build-text.text-truncate.mw-70p.gl-pl-1-deprecated-no-really-do-not-use-me= subject.name - if status.has_action? = link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 4d8df4cc12a..26051261715 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -8,7 +8,7 @@ - if Feature.enabled?(:new_variables_ui, @project || @group, default_enabled: true) - is_group = !@group.nil? - #js-ci-project-variables{ data: { endpoint: save_endpoint, project_id: @project&.id || '', group: is_group.to_s, maskable_regex: ci_variable_maskable_regex} } + #js-ci-project-variables{ data: { endpoint: save_endpoint, project_id: @project&.id || '', group: is_group.to_s, maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} } - else .row diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml index 9b6c0c20080..f11117ea5c4 100644 --- a/app/views/clusters/clusters/_cluster.html.haml +++ b/app/views/clusters/clusters/_cluster.html.haml @@ -2,7 +2,8 @@ .card-body.gl-responsive-table-row .table-section.section-60 .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster") - .table-mobile-content + .table-mobile-content.gl-display-flex.gl-align-items-center.gl-justify-content-end.gl-justify-content-md-start + .gl-w-6.gl-h-6.gl-mr-3.gl-display-flex.gl-align-items-center= provider_icon(cluster.provider_type) = cluster.item_link(clusterable, html_options: { data: { qa_selector: 'cluster', qa_cluster_name: cluster.name } }) - if cluster.status_name == :creating .spinner.ml-2.align-middle.has-tooltip{ title: s_("ClusterIntegration|Cluster being created") } diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index 28002dbff92..86194842664 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -19,7 +19,7 @@ = link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence') - if Feature.enabled?(:clusters_list_redesign) - #js-clusters-list-app{ data: { endpoint: 'todo/add/endpoint' } } + #js-clusters-list-app{ data: { endpoint: clusterable.index_path(format: :json) } } - else .clusters-table.js-clusters-list .gl-responsive-table-row.table-row-header{ role: "row" } diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 7fc76880480..1cc68d927bd 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -17,6 +17,7 @@ install_knative_path: clusterable.install_applications_cluster_path(@cluster, :knative), update_knative_path: clusterable.update_applications_cluster_path(@cluster, :knative), install_elastic_stack_path: clusterable.install_applications_cluster_path(@cluster, :elastic_stack), + install_fluentd_path: clusterable.install_applications_cluster_path(@cluster, :fluentd), cluster_environments_path: cluster_environments_path, toggle_status: @cluster.enabled? ? 'true': 'false', has_rbac: has_rbac_enabled?(@cluster) ? 'true': 'false', diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml index 05214346496..2f0cc76f2e0 100644 --- a/app/views/dashboard/snippets/index.html.haml +++ b/app/views/dashboard/snippets/index.html.haml @@ -6,8 +6,6 @@ = render 'dashboard/snippets_head' - if current_user.snippets.exists? = render partial: 'snippets/snippets_scope_menu', locals: { include_private: true, counts: @snippet_counts } - -- if current_user.snippets.exists? = render partial: 'shared/snippets/list', locals: { link_project: true } - else = render 'shared/empty_states/snippets', button_path: button_path diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 232dffa28b4..9fb5e27b692 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -8,7 +8,7 @@ = _("GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.") .col-lg-5.order-12 .text-center.mb-3 - %h2.font-weight-bold.gl-font-size-20= _('Register for GitLab') + %h2.font-weight-bold.gl-font-size-20-deprecated-no-really-do-not-use-me= _('Register for GitLab') = render 'devise/shared/experimental_separate_sign_up_flow_box' = render 'devise/shared/sign_in_link' - else diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index 31c4bb0e33e..3fc99b6a47d 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -1,4 +1,6 @@ - server = local_assigns.fetch(:server) +- hide_remember_me = local_assigns.fetch(:hide_remember_me, false) +- submit_message = local_assigns.fetch(:submit_message, _('Sign in')) = form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do .form-group @@ -7,9 +9,11 @@ .form-group = label_tag :password = password_field_tag :password, nil, { class: "form-control bottom", title: "This field is required.", data: { qa_selector: 'password_field' }, required: true } - - if devise_mapping.rememberable? + - if !hide_remember_me && devise_mapping.rememberable? .remember-me %label{ for: "remember_me" } = check_box_tag :remember_me, '1', false, id: 'remember_me' %span Remember me - = submit_tag "Sign in", class: "btn-success btn", data: { qa_selector: 'sign_in_button' } + + .submit-container.move-submit-down + = submit_tag submit_message, class: "btn-success btn", data: { qa_selector: 'sign_in_button' } diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index cca0f756e76..5c3e4ccbfe5 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -1,3 +1,5 @@ +- hide_remember_me = local_assigns.fetch(:hide_remember_me, false) + .omniauth-container.prepend-top-15 %label.label-bold.d-block Sign in with @@ -10,7 +12,7 @@ = provider_image_tag(provider) %span = label_for_provider(provider) - - unless defined?(hide_remember_me) && hide_remember_me + - unless hide_remember_me %fieldset.remember-me %label = check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox' diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml index 6ddb7e1ac48..c0b005bac77 100644 --- a/app/views/devise/shared/_signin_box.html.haml +++ b/app/views/devise/shared/_signin_box.html.haml @@ -6,7 +6,7 @@ = render_if_exists 'devise/sessions/new_kerberos_tab' - - @ldap_servers.each_with_index do |server, i| + - ldap_servers.each_with_index do |server, i| .login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain)) } .login-body = render 'devise/sessions/new_ldap', server: server diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml index b8f0cd2a91a..eb14ad6006f 100644 --- a/app/views/devise/shared/_tabs_ldap.html.haml +++ b/app/views/devise/shared/_tabs_ldap.html.haml @@ -1,17 +1,20 @@ +- show_password_form = local_assigns.fetch(:show_password_form, password_authentication_enabled_for_web?) +- render_signup_link = local_assigns.fetch(:render_signup_link, true) + %ul.nav-links.new-session-tabs.nav-tabs.nav{ class: ('custom-provider-tabs' if any_form_based_providers_enabled?) } - if crowd_enabled? %li.nav-item = link_to "Crowd", "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab' = render_if_exists "devise/shared/kerberos_tab" - - @ldap_servers.each_with_index do |server, i| + - ldap_servers.each_with_index do |server, i| %li.nav-item = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', qa_selector: 'ldap_tab' } = render_if_exists 'devise/shared/tab_smartcard' - - if password_authentication_enabled_for_web? + - if show_password_form %li.nav-item - = link_to 'Standard', '#login-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'standard_tab' } - - if allow_signup? + = link_to _('Standard'), '#login-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'standard_tab' } + - if render_signup_link && allow_signup? %li.nav-item = link_to 'Register', '#register-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'register_tab' } diff --git a/app/views/devise/shared/_tabs_normal.html.haml b/app/views/devise/shared/_tabs_normal.html.haml index b6a1b8805ee..a2d5a8be625 100644 --- a/app/views/devise/shared/_tabs_normal.html.haml +++ b/app/views/devise/shared/_tabs_normal.html.haml @@ -1,6 +1,9 @@ +- tab_title = local_assigns.fetch(:tab_title, _('Sign in')) +- render_signup_link = local_assigns.fetch(:render_signup_link, true) + %ul.nav-links.new-session-tabs.nav-tabs.nav{ role: 'tablist' } %li.nav-item{ role: 'presentation' } - %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' } Sign in - - if allow_signup? + %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= tab_title + - if render_signup_link && allow_signup? %li.nav-item{ role: 'presentation' } %a.nav-link{ href: '#register-pane', data: { track_label: 'sign_in_register', track_property: '', track_event: 'click_button', track_value: '', toggle: 'tab', qa_selector: 'register_tab' }, role: 'tab' } Register diff --git a/app/views/groups/_flash_messages.html.haml b/app/views/groups/_flash_messages.html.haml new file mode 100644 index 00000000000..fa1a9d2cca4 --- /dev/null +++ b/app/views/groups/_flash_messages.html.haml @@ -0,0 +1,2 @@ += content_for :flash_message do + = render_if_exists 'shared/shared_runners_minutes_limit', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)] diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 6772ee94d46..d083288edc8 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -1,4 +1,5 @@ - can_create_subgroups = can?(current_user, :create_subgroup, @group) +- can_create_projects = can?(current_user, :create_projects, @group) - emails_disabled = @group.emails_disabled? .group-home-panel @@ -23,32 +24,33 @@ - if current_user .group-buttons = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn', emails_disabled: emails_disabled - - if can? current_user, :create_projects, @group - - new_project_label = _("New project") - - new_subgroup_label = _("New subgroup") - - if can_create_subgroups - .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.prepend-top-default.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } } - %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } } - %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } } - = sprite_icon("chevron-down", css_class: "icon dropdown-btn-icon") - %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } } - %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } } + - new_project_label = _("New project") + - new_subgroup_label = _("New subgroup") + - if can_create_projects and can_create_subgroups + .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.prepend-top-default.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } } + %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } } + %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } } + = sprite_icon("chevron-down", css_class: "icon dropdown-btn-icon") + %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } } + %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } } + .menu-item + .icon-container + = icon("check", class: "list-item-checkmark") + .description + %strong= new_project_label + %span= s_("GroupsTree|Create a project in this group.") + %li.divider.droplap-item-ignore + %li.qa-new-subgroup-option{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } } .menu-item .icon-container = icon("check", class: "list-item-checkmark") .description - %strong= new_project_label - %span= s_("GroupsTree|Create a project in this group.") - %li.divider.droplap-item-ignore - %li.qa-new-subgroup-option{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } } - .menu-item - .icon-container - = icon("check", class: "list-item-checkmark") - .description - %strong= new_subgroup_label - %span= s_("GroupsTree|Create a subgroup in this group.") - - else - = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success prepend-top-default" + %strong= new_subgroup_label + %span= s_("GroupsTree|Create a subgroup in this group.") + - elsif can_create_projects + = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success prepend-top-default" + - elsif can_create_subgroups + = link_to new_subgroup_label, new_group_path(parent_id: @group.id), class: "btn btn-success prepend-top-default" - if @group.description.present? .group-home-desc.mt-1 diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index fe5a00e3be9..2e58517fdc7 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -45,11 +45,11 @@ %section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } - = _('Path, transfer, remove') + = _('Advanced') %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _('Perform advanced options such as changing path, transferring, or removing the group.') + = _('Perform advanced options such as changing path, transferring, exporting, or removing the group.') .settings-content = render 'groups/settings/advanced' diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 048edb80d99..1f2fb747c7d 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -9,20 +9,16 @@ = _("Group members") %hr - if can_manage_members - - if Feature.enabled?(:share_group_with_group, default_enabled: true) - %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } + %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } + %li.nav-tab{ role: 'presentation' } + %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") %li.nav-tab{ role: 'presentation' } - %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") - %li.nav-tab{ role: 'presentation' } - %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group") - .tab-content.gitlab-tab-content - .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } - = render_invite_member_for_group(@group, @group_member.access_level) - - if Feature.enabled?(:share_group_with_group, default_enabled: true) - .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } - = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access' - - else - = render_invite_member_for_group(@group, @group_member.access_level) + %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group") + .tab-content.gitlab-tab-content + .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } + = render_invite_member_for_group(@group, @group_member.access_level) + .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } + = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access' = render 'shared/members/requests', membership_source: @group, requesters: @requesters diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml index 2734ab538a0..0df82898644 100644 --- a/app/views/groups/settings/_advanced.html.haml +++ b/app/views/groups/settings/_advanced.html.haml @@ -1,3 +1,5 @@ += render 'groups/settings/export', group: @group + .sub-section %h4.warning-title= s_('GroupSettings|Change group path') = form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| diff --git a/app/views/groups/settings/_default_branch_protection.html.haml b/app/views/groups/settings/_default_branch_protection.html.haml new file mode 100644 index 00000000000..e0e901cbc4a --- /dev/null +++ b/app/views/groups/settings/_default_branch_protection.html.haml @@ -0,0 +1,3 @@ +- return unless can_update_default_branch_protection?(group) + += render 'shared/default_branch_protection', f: f, selected_level: group.default_branch_protection diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml new file mode 100644 index 00000000000..ef7bf562c69 --- /dev/null +++ b/app/views/groups/settings/_export.html.haml @@ -0,0 +1,28 @@ +- return unless Feature.enabled?(:group_import_export, @group, default_enabled: true) + +- group = local_assigns.fetch(:group) + +.sub-section + %h4= s_('GroupSettings|Export group') + %p= _('Export this group with all related data to a new GitLab instance. Once complete, you can import the data file from the "New Group" page.') + + .bs-callout.bs-callout-info + %p.append-bottom-0 + %p= _('The following items will be exported:') + %ul + - group_export_descriptions.each do |description| + %li= description + %p= _('The following items will NOT be exported:') + %ul + %li= _('Projects') + %li= _('Runner tokens') + %li= _('SAML discovery tokens') + %p= _('Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.') + - if group.export_file_exists? + = link_to _('Regenerate export'), export_group_path(group), + method: :post, class: 'btn btn-default', data: { qa_selector: 'regenerate_export_group_link' } + = link_to _('Download export'), download_export_group_path(group), + rel: 'nofollow', method: :get, class: 'btn btn-default', data: { qa_selector: 'download_export_link' } + - else + = link_to _('Export group'), export_group_path(group), + method: :post, class: 'btn btn-default', data: { qa_selector: 'export_group_link' } diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 1ddaa855e62..e886c99a656 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -33,7 +33,7 @@ = render_if_exists 'groups/settings/ip_restriction', f: f, group: @group = render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group = render 'groups/settings/lfs', f: f - = render 'shared/default_branch_protection', f: f, selected_level: @group.default_branch_protection + = render 'groups/settings/default_branch_protection', f: f, group: @group = render 'groups/settings/project_creation_level', f: f, group: @group = render 'groups/settings/subgroup_creation_level', f: f, group: @group = render 'groups/settings/two_factor_auth', f: f diff --git a/app/views/groups/settings/integrations/index.html.haml b/app/views/groups/settings/integrations/index.html.haml index 78825cc72b0..96bd6d69a96 100644 --- a/app/views/groups/settings/integrations/index.html.haml +++ b/app/views/groups/settings/integrations/index.html.haml @@ -6,4 +6,4 @@ %p = s_('GroupSettings|Integrations configured here will automatically apply to all projects in this group.') = link_to _('Learn more'), '#' -= render 'shared/integrations/integrations', integrations: @integrations += render 'shared/integrations/index', integrations: @integrations diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml index 1f1d7779267..ff0c9de4fef 100644 --- a/app/views/groups/settings/repository/show.html.haml +++ b/app/views/groups/settings/repository/show.html.haml @@ -1,6 +1,6 @@ - breadcrumb_title _('Repository Settings') - page_title _('Repository') -- deploy_token_description = s_('DeployTokens|Group deploy tokens allow read-only access to the repositories and registry images within the group.') +- deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.') = render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index a9c19502a7c..032766327ca 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,9 +1,15 @@ - breadcrumb_title _("Details") - @content_class = "limit-container-width" unless fluid_layout += content_for :flash_message do + - if Feature.enabled?(:subscribable_banner_subscription) + = render_if_exists "layouts/header/ee_subscribable_banner", subscription: true + = content_for :meta_tags do = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity") += render partial: 'flash_messages' + %div{ class: [("limit-container-width" unless fluid_layout)] } = render_if_exists 'trials/banner', namespace: @group diff --git a/app/views/groups/sidebar/_packages.html.haml b/app/views/groups/sidebar/_packages.html.haml index 16b902a18b9..67e759a4d63 100644 --- a/app/views/groups/sidebar/_packages.html.haml +++ b/app/views/groups/sidebar/_packages.html.haml @@ -4,12 +4,12 @@ .nav-icon-container = sprite_icon('package') %span.nav-item-name - = _('Packages') + = _('Packages & Registries') %ul.sidebar-sub-level-items = nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do = link_to group_container_registries_path(@group), title: _('Container Registry') do %strong.fly-out-top-item-name - = _('Packages') + = _('Packages & Registries') %li.divider.fly-out-top-item = nav_link(controller: 'groups/container_registries') do = link_to group_container_registries_path(@group), title: _('Container Registry') do diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 4b9304cfdb9..bd5424c30c6 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -65,25 +65,23 @@ %tbody %tr %th - %th= _('Web IDE') + %th= _('Editing') %tr %td.shortcut - if browser.platform.mac? - %kbd ⌘ p + %kbd ⌘ shift p - else - %kbd ctrl p - %td= _('Go to file') + %kbd ctrl shift p + %td= _('Toggle Markdown preview') %tr %td.shortcut - - if browser.platform.mac? - %kbd ⌘ enter - - else - %kbd ctrl enter - %td= _('Commit (when editing commit message)') + %kbd + %i.fa.fa-arrow-up + %td= _('Edit your most recent comment in a thread (from an empty textarea)') %tbody %tr %th - %th= _('Wiki pages') + %th= _('Wiki') %tr %td.shortcut %kbd e @@ -91,19 +89,49 @@ %tbody %tr %th - %th= _('Editing') + %th= _('Repository Graph') %tr %td.shortcut - - if browser.platform.mac? - %kbd ⌘ shift p - - else - %kbd ctrl shift p - %td= _('Toggle Markdown preview') + %kbd + %i.fa.fa-arrow-left + \/ + %kbd h + %td= _('Scroll left') + %tr + %td.shortcut + %kbd + %i.fa.fa-arrow-right + \/ + %kbd l + %td= _('Scroll right') %tr %td.shortcut %kbd %i.fa.fa-arrow-up - %td= _('Edit your most recent comment in a thread (from an empty textarea)') + \/ + %kbd k + %td= _('Scroll up') + %tr + %td.shortcut + %kbd + %i.fa.fa-arrow-down + \/ + %kbd j + %td= _('Scroll down') + %tr + %td.shortcut + %kbd + shift + %i.fa.fa-arrow-up + \/ k + %td= _('Scroll to top') + %tr + %td.shortcut + %kbd + shift + %i.fa.fa-arrow-down + \/ j + %td= _('Scroll to bottom') .col-lg-4 %table.shortcut-mappings.text-2 %tbody @@ -229,15 +257,7 @@ %tbody %tr %th - %th= _('Issues / Merge Requests') - %tr - %td.shortcut - %kbd a - %td= _('Change assignee') - %tr - %td.shortcut - %kbd m - %td= _('Change milestone') + %th= _('Epics, Issues, and Merge Requests') %tr %td.shortcut %kbd r @@ -250,92 +270,76 @@ %td.shortcut %kbd l %td= _('Change label') + %tbody + %tr + %th + %th= _('Issues and Merge Requests') + %tr + %td.shortcut + %kbd a + %td= _('Change assignee') + %tr + %td.shortcut + %kbd m + %td= _('Change milestone') + %tbody + %tr + %th + %th= _('Merge Requests') %tr %td.shortcut %kbd ] \/ %kbd j - %td= _('Next file in diff (MRs only)') + %td= _('Next file in diff') %tr %td.shortcut %kbd [ \/ %kbd k - %td= _('Previous file in diff (MRs only)') + %td= _('Previous file in diff') %tr %td.shortcut - if browser.platform.mac? %kbd ⌘ p - else %kbd ctrl p - %td= _('Go to file (MRs only)') + %td= _('Go to file') %tr %td.shortcut %kbd n - %td= _('Next unresolved discussion (MRs only)') + %td= _('Next unresolved discussion') %tr %td.shortcut %kbd p - %td= _('Previous unresolved discussion (MRs only)') + %td= _('Previous unresolved discussion') %tbody %tr %th - %th= _('Epics (Ultimate / Gold license only)') + %th= _('Merge Request Commits') %tr %td.shortcut - %kbd r - %td= _('Comment/Reply (quoting selected text)') - %tr - %td.shortcut - %kbd e - %td= _('Edit epic description') + %kbd c + %td= _('Next commit') %tr %td.shortcut - %kbd l - %td= _('Change label') + %kbd x + %td= _('Previous commit') %tbody %tr %th - %th= _('Repository Graph') - %tr - %td.shortcut - %kbd - %i.fa.fa-arrow-left - \/ - %kbd h - %td= _('Scroll left') - %tr - %td.shortcut - %kbd - %i.fa.fa-arrow-right - \/ - %kbd l - %td= _('Scroll right') - %tr - %td.shortcut - %kbd - %i.fa.fa-arrow-up - \/ - %kbd k - %td= _('Scroll up') - %tr - %td.shortcut - %kbd - %i.fa.fa-arrow-down - \/ - %kbd j - %td= _('Scroll down') + %th= _('Web IDE') %tr %td.shortcut - %kbd - shift - %i.fa.fa-arrow-up - \/ k - %td= _('Scroll to top') + - if browser.platform.mac? + %kbd ⌘ p + - else + %kbd ctrl p + %td= _('Go to file') %tr %td.shortcut - %kbd - shift - %i.fa.fa-arrow-down - \/ j - %td= _('Scroll to bottom') + - if browser.platform.mac? + %kbd ⌘ enter + - else + %kbd ctrl enter + %td= _('Commit (when editing commit message)') diff --git a/app/views/import/google_code/new_user_map.html.haml b/app/views/import/google_code/new_user_map.html.haml index f523b993aa7..732ba95a63f 100644 --- a/app/views/import/google_code/new_user_map.html.haml +++ b/app/views/import/google_code/new_user_map.html.haml @@ -30,7 +30,7 @@ .form-group.row .col-sm-12 - = text_area_tag :user_map, JSON.pretty_generate(@user_map), class: 'form-control', rows: 15 + = text_area_tag :user_map, Gitlab::Json.pretty_generate(@user_map), class: 'form-control', rows: 15 .form-actions = submit_tag _('Continue to the next step'), class: "btn btn-success" diff --git a/app/views/kaminari/gitlab/_page.html.haml b/app/views/kaminari/gitlab/_page.html.haml index 33e00256100..b000a490e3e 100644 --- a/app/views/kaminari/gitlab/_page.html.haml +++ b/app/views/kaminari/gitlab/_page.html.haml @@ -11,4 +11,4 @@ ('js-first-button' if page.first?), ('js-last-button' if page.last?), ('d-none d-md-block' if !page.current?) ] } - = link_to page, url, { remote: remote, rel: page.next? ? 'next' : page.prev? ? 'prev' : nil, class: 'page-link' } + = link_to page, url, { remote: remote, rel: page.next? ? 'next' : page.prev? ? 'prev' : nil, class: ['page-link', active_when(page.current?)] } diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 8c272a73d40..99c4fc0d1b6 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -68,6 +68,7 @@ = csrf_meta_tags = csp_meta_tag + = action_cable_meta_tag - unless browser.safari? %meta{ name: 'referrer', content: 'origin-when-cross-origin' } diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 49345b7b215..3885fa311ba 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -5,6 +5,7 @@ .mobile-overlay .alert-wrapper = render 'shared/outdated_browser' + = render_if_exists 'layouts/header/users_over_license_banner' - if Feature.enabled?(:subscribable_banner_license, default_enabled: true) = render_if_exists "layouts/header/ee_subscribable_banner" = render "layouts/broadcast" diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 6a261bbbc46..bbcb525ea4f 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -22,10 +22,10 @@ = brand_text - else %h3.mt-sm-0 - = _('Open source software to collaborate on code') + = _('A complete DevOps platform') %p - = _('Manage Git repositories with fine-grained access controls that keep your code secure. Perform code reviews and enhance collaboration with merge requests. Each project can also have an issue tracker and a wiki.') + = _('GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.') - if Gitlab::CurrentSettings.sign_in_text.present? = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text) diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 410b120396d..7d9924719a2 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -27,6 +27,7 @@ %li = link_to s_("CurrentUser|Settings"), profile_path, data: { qa_selector: 'settings_link' } = render_if_exists 'layouts/header/buy_ci_minutes', project: @project, namespace: @group + = render_if_exists 'layouts/header/upgrade' - if current_user_menu?(:help) %li.divider.d-md-none diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index a003d6f8903..2b3f5d266b0 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -1,5 +1,6 @@ %ul - if current_user_menu?(:help) + = render_if_exists 'layouts/header/whats_new_dropdown_item' %li = link_to _("Help"), help_path %li diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 52964dd6739..28e52dc85db 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -56,7 +56,7 @@ = _('Monitoring') %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_monitoring_submenu_content' } } - = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles), html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: %w(system_info background_jobs health_check requests_profiles), html_options: { class: "fly-out-top-item" } ) do = link_to admin_system_info_path do %strong.fly-out-top-item-name = _('Monitoring') @@ -69,10 +69,6 @@ = link_to admin_background_jobs_path, title: _('Background Jobs') do %span = _('Background Jobs') - = nav_link(controller: :logs) do - = link_to admin_logs_path, title: _('Logs') do - %span - = _('Logs') = nav_link(controller: :health_check) do = link_to admin_health_check_path, title: _('Health Check') do %span @@ -271,11 +267,6 @@ = link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_item' } do %span = _('Network') - - if template_exists?('admin/geo/settings/show') - = nav_link do - = link_to geo_admin_application_settings_path, title: _('Geo') do - %span - = _('Geo') = nav_link(path: 'application_settings#preferences') do = link_to preferences_admin_application_settings_path, title: _('Preferences'), data: { qa_selector: 'admin_settings_preferences_link' } do %span diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index f63a7b3a664..92b6174795b 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -102,6 +102,8 @@ = render_if_exists "layouts/nav/ee/security_link" # EE-specific + = render_if_exists "layouts/nav/ee/push_rules_link" # EE-specific + - if group_sidebar_link?(:kubernetes) = nav_link(controller: [:clusters]) do = link_to group_clusters_path(@group) do diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index 15f1067f0d9..95d66786984 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -152,10 +152,6 @@ = link_to audit_log_profile_path do %strong.fly-out-top-item-name = _('Authentication Log') - - - if Feature.enabled?(:user_usage_quota) = render_if_exists 'layouts/nav/sidebar/profile_usage_quotas_link' - - else - = render_if_exists 'layouts/nav/sidebar/profile_pipeline_quota_link' = render 'shared/sidebar_toggle_button' diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index c11d1256d21..a67860e8e2e 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -203,7 +203,7 @@ - if project_nav_tab? :operations = nav_link(controller: sidebar_operations_paths) do - = link_to sidebar_operations_link_path, class: 'shortcuts-operations qa-link-operations' do + = link_to sidebar_operations_link_path, class: 'shortcuts-operations', data: { qa_selector: 'operations_link' } do .nav-icon-container = sprite_icon('cloud-gear') %span.nav-item-name @@ -222,6 +222,13 @@ %span = _('Metrics') + - if project_nav_tab?(:alert_management) + = nav_link(controller: :alert_management) do + = link_to project_alert_management_index_path(@project), title: _('Alerts'), class: 'shortcuts-tracking qa-operations-tracking-link' do + %span + = _('Alerts') + + - if project_nav_tab? :environments = render_if_exists "layouts/nav/sidebar/tracing_link" = nav_link(controller: :environments, action: [:index, :folder, :show, :new, :edit, :create, :update, :stop, :terminal]) do @@ -356,6 +363,11 @@ = link_to project_hooks_path(@project), title: _('Webhooks'), data: { qa_selector: 'webhooks_settings_link' } do %span = _('Webhooks') + - if project_access_token_available?(@project) + = nav_link(controller: [:access_tokens]) do + = link_to project_settings_access_tokens_path(@project), title: _('Access Tokens'), data: { qa_selector: 'access_tokens_settings_link' } do + %span + = _('Access Tokens') = nav_link(controller: :repository) do = link_to project_settings_repository_path(@project), title: _('Repository') do %span @@ -367,7 +379,7 @@ = _('CI / CD') - if !@project.archived? && settings_operations_available? = nav_link(controller: [:operations]) do - = link_to project_settings_operations_path(@project), title: _('Operations') do + = link_to project_settings_operations_path(@project), title: _('Operations'), data: { qa_selector: 'operations_settings_link' } do = _('Operations') - if @project.pages_available? = nav_link(controller: :pages) do diff --git a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml b/app/views/layouts/nav/sidebar/_project_packages_link.html.haml index 0fdfc6cd2ab..0931ccdf637 100644 --- a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml +++ b/app/views/layouts/nav/sidebar/_project_packages_link.html.haml @@ -4,12 +4,12 @@ .nav-icon-container = sprite_icon('package') %span.nav-item-name - = _('Packages') + = _('Packages & Registries') %ul.sidebar-sub-level-items = nav_link(controller: :repositories, html_options: { class: "fly-out-top-item" } ) do = link_to project_container_registry_index_path(@project) do %strong.fly-out-top-item-name - = _('Packages') + = _('Packages & Registries') %li.divider.fly-out-top-item = nav_link controller: :repositories do = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry', title: _('Container Registry') do diff --git a/app/views/notify/group_was_exported_email.html.haml b/app/views/notify/group_was_exported_email.html.haml new file mode 100644 index 00000000000..a2f34537662 --- /dev/null +++ b/app/views/notify/group_was_exported_email.html.haml @@ -0,0 +1,9 @@ +%p + = _('Group %{group_name} was exported successfully.') % { group_name: @group.name } + +%p + = _('The group export can be downloaded from:') + = link_to download_export_group_url(@group), rel: 'nofollow', download: '' do + #{@group.full_name} export +%p + = _('The download link will expire in 24 hours.') diff --git a/app/views/notify/group_was_exported_email.text.erb b/app/views/notify/group_was_exported_email.text.erb new file mode 100644 index 00000000000..02571459af0 --- /dev/null +++ b/app/views/notify/group_was_exported_email.text.erb @@ -0,0 +1,6 @@ +<%= _('Group %{group_name} was exported successfully.') % { group_name: @group.name } %> + +<%= _('The group export can be downloaded from:') %> +<%= download_export_group_url(@group) %> + +<%= _('The download link will expire in 24 hours.') %> diff --git a/app/views/notify/group_was_not_exported_email.html.haml b/app/views/notify/group_was_not_exported_email.html.haml new file mode 100644 index 00000000000..58fc34d41a3 --- /dev/null +++ b/app/views/notify/group_was_not_exported_email.html.haml @@ -0,0 +1,10 @@ +%p + = _("Group %{group_name} couldn't be exported.") % { group_name: @group.name } + +%p + = _('The errors we encountered were:') + + %ul + - @errors.each do |error| + %li + #{error} diff --git a/app/views/notify/group_was_not_exported_email.text.erb b/app/views/notify/group_was_not_exported_email.text.erb new file mode 100644 index 00000000000..92bd79b7b85 --- /dev/null +++ b/app/views/notify/group_was_not_exported_email.text.erb @@ -0,0 +1,7 @@ +<%= _("Group %{group_name} couldn't be exported.") % { group_name: @group.name } %> + +<%= _('The errors we encountered were:') %> + +<% @errors.each do |error| -%> + - <%= error %> +<% end -%> diff --git a/app/views/notify/issues_csv_email.html.haml b/app/views/notify/issues_csv_email.html.haml index b777ca1e57d..77502a45f02 100644 --- a/app/views/notify/issues_csv_email.html.haml +++ b/app/views/notify/issues_csv_email.html.haml @@ -1,9 +1,6 @@ --# haml-lint:disable NoPlainNodes %p{ style: 'font-size:18px; text-align:center; line-height:30px;' } - Your CSV export of #{ pluralize(@written_count, 'issue') } from project - %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;" } - = @project.full_name - has been added to this email as an attachment. + - project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;") + = _('Your CSV export of %{issues_count} from project %{project_link} has been added to this email as an attachment.').html_safe % { issues_count: pluralize(@written_count, 'issue'), project_link: project_link } - if @truncated %p - This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. #{ @written_count } of #{ @issues_count } issues have been included. Consider re-exporting with a narrower selection of issues. + = _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of 15MB. %{written_count} of %{issues_count} issues have been included. Consider re-exporting with a narrower selection of issues.') % { written_count: @written_count, issues_count: @issues_count } diff --git a/app/views/notify/issues_csv_email.text.erb b/app/views/notify/issues_csv_email.text.erb index 5d4128e3ae9..a1d2a4691bc 100644 --- a/app/views/notify/issues_csv_email.text.erb +++ b/app/views/notify/issues_csv_email.text.erb @@ -1,5 +1,5 @@ -Your CSV export of <%= pluralize(@written_count, 'issue') %> from project <%= @project.full_name %> (<%= project_url(@project) %>) has been added to this email as an attachment. +<%= _('Your CSV export of %{written_count} from project %{project_name} (%{project_url}) has been added to this email as an attachment.') % { written_count: pluralize(@written_count, 'issue'), project_name: @project.full_name, project_url: project_url(@project) } %> <% if @truncated %> -This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. <%= @written_count %> of <%= @issues_count %> issues have been included. Consider re-exporting with a narrower selection of issues. + <%= _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of 15MB. %{written_count} of %{issues_count} issues have been included. Consider re-exporting with a narrower selection of issues.') % { written_count: @written_count, issues_count: @issues_count} %> <% end %> diff --git a/app/views/notify/note_design_email.html.haml b/app/views/notify/note_design_email.html.haml new file mode 100644 index 00000000000..5e69f01a486 --- /dev/null +++ b/app/views/notify/note_design_email.html.haml @@ -0,0 +1 @@ += render 'note_email' diff --git a/app/views/notify/note_design_email.text.erb b/app/views/notify/note_design_email.text.erb new file mode 100644 index 00000000000..413d9e6e9ac --- /dev/null +++ b/app/views/notify/note_design_email.text.erb @@ -0,0 +1 @@ +<%= render 'note_email' %> diff --git a/app/views/notify/unknown_sign_in_email.html.haml b/app/views/notify/unknown_sign_in_email.html.haml new file mode 100644 index 00000000000..a4123fada1b --- /dev/null +++ b/app/views/notify/unknown_sign_in_email.html.haml @@ -0,0 +1,14 @@ +%p + = _('Hi %{username}!') % { username: sanitize_name(@user.name) } +%p + = _('A sign-in to your account has been made from the following IP address: %{ip}.') % { ip: @ip } +%p + - password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://docs.gitlab.com/ee/user/profile/#changing-your-password' } + = _('If you recently signed in and recognize the IP address, you may disregard this email.') + = _('If you did not recently sign in, you should immediately %{password_link_start}change your password%{password_link_end}.').html_safe % { password_link_start: password_link_start, password_link_end: '</a>'.html_safe } + = _('Passwords should be unique and not used for any other sites or services.') + +- unless @user.two_factor_enabled? + %p + - mfa_link_start = '<a href="https://docs.gitlab.com/ee/user/profile/account/two_factor_authentication.html" target="_blank">'.html_safe + = _('To further protect your account, consider configuring a %{mfa_link_start}two-factor authentication%{mfa_link_end} method.').html_safe % { mfa_link_start: mfa_link_start, mfa_link_end: '</a>'.html_safe } diff --git a/app/views/notify/unknown_sign_in_email.text.haml b/app/views/notify/unknown_sign_in_email.text.haml new file mode 100644 index 00000000000..f3efc4c4fcd --- /dev/null +++ b/app/views/notify/unknown_sign_in_email.text.haml @@ -0,0 +1,10 @@ += _('Hi %{username}!') % { username: sanitize_name(@user.name) } + += _('A sign-in to your account has been made from the following IP address: %{ip}') % { ip: @ip } + += _('If you recently signed in and recognize the IP address, you may disregard this email.') += _('If you did not recently sign in, you should immediately change your password: %{password_link}.') % { password_link: 'https://docs.gitlab.com/ee/user/profile/#changing-your-password' } += _('Passwords should be unique and not used for any other sites or services.') + +- unless @user.two_factor_enabled? + = _('To further protect your account, consider configuring a two-factor authentication method: %{mfa_link}.') % { mfa_link: 'https://docs.gitlab.com/ee/user/profile/account/two_factor_authentication.html' } diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 34e81285328..7709aa8f4b9 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -14,7 +14,7 @@ .col.form-group = f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold' - = f.date_field :expires_at, class: "form-control input-lg qa-key-expiry-field", min: Date.tomorrow + = f.date_field :expires_at, class: "form-control input-lg", min: Date.tomorrow, data: { qa_selector: 'key_expiry_date_field' } .js-add-ssh-key-validation-warning.hide .bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' } diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index d9e94908b80..81b22d964a5 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -1,6 +1,8 @@ - breadcrumb_title s_('AccessTokens|Access Tokens') - page_title s_('AccessTokens|Personal Access Tokens') -- @content_class = "limit-container-width" unless fluid_layout +- type = _('personal access token') +- type_plural = _('personal access tokens') +- @content_class = 'limit-container-width' unless fluid_layout .row.prepend-top-default .col-lg-4.profile-settings-sidebar @@ -14,11 +16,21 @@ .col-lg-8 - if @new_personal_access_token - = render "shared/personal_access_tokens_created_container", new_token_value: @new_personal_access_token + = render 'shared/access_tokens/created_container', + type: type, + new_token_value: @new_personal_access_token - = render "shared/personal_access_tokens_form", path: profile_personal_access_tokens_path, impersonation: false, token: @personal_access_token, scopes: @scopes + = render 'shared/access_tokens/form', + type: type, + path: profile_personal_access_tokens_path, + token: @personal_access_token, + scopes: @scopes - = render "shared/personal_access_tokens_table", impersonation: false, active_tokens: @active_personal_access_tokens, inactive_tokens: @inactive_personal_access_tokens + = render 'shared/access_tokens/table', + type: type, + type_plural: type_plural, + active_tokens: @active_personal_access_tokens, + revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) } %hr .row.prepend-top-default @@ -30,7 +42,7 @@ %p = s_('AccessTokens|It cannot be used to access any other data.') .col-lg-8.feed-token-reset - = label_tag :feed_token, s_('AccessTokens|Feed token'), class: "label-bold" + = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold' = text_field_tag :feed_token, current_user.feed_token, class: 'form-control js-select-on-focus', readonly: true %p.form-text.text-muted - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.') } @@ -48,7 +60,7 @@ %p = s_('AccessTokens|It cannot be used to access any other data.') .col-lg-8.incoming-email-token-reset - = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: "label-bold" + = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: 'label-bold' = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control js-select-on-focus', readonly: true %p.form-text.text-muted - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.') } diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index da2b8c40191..43fc9150e99 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -20,6 +20,9 @@ = s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } - else = s_("Profiles|You can upload your avatar here") + - if current_appearance&.profile_image_guidelines? + .md + = brand_profile_image_guidelines .col-lg-8 .clearfix.avatar-image.append-bottom-default = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do @@ -101,7 +104,7 @@ - else = f.text_field :location, label: s_('Profiles|Location'), class: 'input-lg', placeholder: s_("Profiles|City, country") = f.text_field :job_title, class: 'input-md' - = f.text_field :organization, readonly: @user.gitlab_employee?, label: s_('Profiles|Organization'), class: 'input-md', help: s_("Profiles|Who you represent or work for") + = f.text_field :organization, label: s_('Profiles|Organization'), class: 'input-md', help: s_("Profiles|Who you represent or work for") = f.text_area :bio, label: s_('Profiles|Bio'), rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters") %hr %h5= s_("Private profile") diff --git a/app/views/projects/alert_management/details.html.haml b/app/views/projects/alert_management/details.html.haml new file mode 100644 index 00000000000..5230d5e3476 --- /dev/null +++ b/app/views/projects/alert_management/details.html.haml @@ -0,0 +1,4 @@ +- add_to_breadcrumbs s_('AlertManagement|Alerts'), project_alert_management_index_path(@project) +- page_title s_('AlertManagement|Alert detail') + +#js-alert_details{ data: alert_management_detail_data(@project, @alert_id) } diff --git a/app/views/projects/alert_management/index.html.haml b/app/views/projects/alert_management/index.html.haml new file mode 100644 index 00000000000..415820ac3ad --- /dev/null +++ b/app/views/projects/alert_management/index.html.haml @@ -0,0 +1,3 @@ +- page_title _('Alerts') + +#js-alert_management{ data: alert_management_data(@current_user, @project) } diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 76a9d3df5d7..2a1545e7db7 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -4,13 +4,13 @@ .file-actions< = render 'projects/blob/viewer_switcher', blob: blob unless blame - = edit_blob_button - = ide_edit_button + = edit_blob_button(@project, @ref, @path, blob: blob) + = ide_edit_button(@project, @ref, @path, blob: blob) .btn-group.ml-2{ role: "group" }> = render_if_exists 'projects/blob/header_file_locks_link' - if current_user - = replace_blob_link - = delete_blob_link + = replace_blob_link(@project, @ref, @path, blob: blob) + = delete_blob_link(@project, @ref, @path, blob: blob) .btn-group.ml-2{ role: "group" } = copy_blob_source_button(blob) unless blame = open_raw_blob_button(blob) diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml index 2be95bc5541..ba8029ac32a 100644 --- a/app/views/projects/blob/_template_selectors.html.haml +++ b/app/views/projects/blob/_template_selectors.html.haml @@ -1,4 +1,4 @@ -.template-selectors-menu.gl-pl-2 +.template-selectors-menu.gl-pl-2-deprecated-no-really-do-not-use-me .template-selector-dropdowns-wrap .template-type-selector.js-template-type-selector-wrap.hidden - toggle_text = should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : 'Select a template type' diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index cae8bbf8c01..445752d0a15 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -12,14 +12,13 @@ %h5.m-0.dropdown-bold-header= _('Download source code') .dropdown-menu-content = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil - - if Feature.enabled?(:git_archive_path, default_enabled: true) - - if vue_file_list_enabled? - #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } - - elsif directory? - %section.border-top.pt-1.mt-1 - %h5.m-0.dropdown-bold-header= _('Download this directory') - .dropdown-menu-content - = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path + - if vue_file_list_enabled? + #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } + - elsif directory? + %section.border-top.pt-1.mt-1 + %h5.m-0.dropdown-bold-header= _('Download this directory') + .dropdown-menu-content + = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path - if pipeline && pipeline.latest_builds_with_artifacts.any? %section.border-top.pt-1.mt-1 %h5.m-0.dropdown-bold-header= _('Download artifacts') diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml index aa7c90bad66..fb31ac44118 100644 --- a/app/views/projects/commit/_signature.html.haml +++ b/app/views/projects/commit/_signature.html.haml @@ -1,3 +1,3 @@ - if signature - - uri = "projects/commit/#{"x509/" if signature.instance_of?(X509CommitSignature)}" + - uri = "projects/commit/#{"x509/" if x509_signature?(signature)}" = render partial: "#{uri}#{signature.verification_status}_signature_badge", locals: { signature: signature } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 8ecaa1329fd..8004a5facd7 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -17,13 +17,13 @@ - content = capture do - if show_user .clearfix - - uri_signature_badge_user = "projects/commit/#{"x509/" if signature.instance_of?(X509CommitSignature)}signature_badge_user" + - uri_signature_badge_user = "projects/commit/#{"x509/" if x509_signature?(signature)}signature_badge_user" = render partial: "#{uri_signature_badge_user}", locals: { signature: signature } - - if signature.instance_of?(X509CommitSignature) + - if x509_signature?(signature) = render partial: "projects/commit/x509/certificate_details", locals: { signature: signature } - = link_to(_('Learn more about x509 signed commits'), help_page_path('user/project/repository/x509_signed_commits/index.md'), class: 'gpg-popover-help-link') + = link_to(_('Learn more about X.509 signed commits'), help_page_path('user/project/repository/x509_signed_commits/index.md'), class: 'gpg-popover-help-link') - else = _('GPG Key ID:') %span.monospace= signature.gpg_key_primary_keyid diff --git a/app/views/projects/commit/x509/_signature_badge_user.html.haml b/app/views/projects/commit/x509/_signature_badge_user.html.haml index b64ccba2a18..f3d39b21ec2 100644 --- a/app/views/projects/commit/x509/_signature_badge_user.html.haml +++ b/app/views/projects/commit/x509/_signature_badge_user.html.haml @@ -1,5 +1,5 @@ -- user = signature.commit.committer - user_email = signature.x509_certificate.email +- user = signature.user - if user = link_to user_path(user), class: 'gpg-popover-user-link' do diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 8b659034fe6..b42eef32a76 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -1,6 +1,8 @@ -#----------------------------------------------------------------- WARNING: Please keep changes up-to-date with the following files: - `assets/javascripts/diffs/components/commit_item.vue` + + EXCEPTION WARNING - see above `.vue` file for de-sync drift -#----------------------------------------------------------------- - view_details = local_assigns.fetch(:view_details, false) - merge_request = local_assigns.fetch(:merge_request, nil) diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index da20fee227a..b6c30c680e4 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -5,6 +5,9 @@ %banner{ "v-if" => "!isOverviewDialogDismissed", "documentation-link": help_page_path('user/analytics/value_stream_analytics.md'), "v-on:dismiss-overview-dialog" => "dismissOverviewDialog()" } + .mb-3 + %h3 + = _("Value Stream Analytics") %gl-loading-icon{ "v-show" => "isLoading", "size" => "lg" } .wrapper{ "v-show" => "!isLoading && !hasError" } .card @@ -54,7 +57,7 @@ %nav.stage-nav %ul %stage-nav-item{ "v-for" => "stage in state.stages", ":key" => '`ca-stage-title-${stage.title}`', '@select' => 'selectStage(stage)', ":title" => "stage.title", ":is-user-allowed" => "stage.isUserAllowed", ":value" => "stage.value", ":is-active" => "stage.active" } - .section.stage-events + .section.stage-events.overflow-auto %gl-loading-icon{ "v-show" => "isLoadingStage", "size" => "lg" } %template{ "v-if" => "currentStage && !currentStage.isUserAllowed" } = render partial: "no_access" diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index 19fe7ba4360..7257dacf680 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -1,5 +1,9 @@ - page_title _("Repository Analytics") +.mb-3 + %h3 + = _("Repository Analytics") + .repo-charts %h4.sub-header = _("Programming languages used in this repository") @@ -9,6 +13,23 @@ #js-languages-chart{ data: { chart_data: @languages.to_json.html_safe } } +- if defined?(@daily_coverage_options) + .repo-charts.my-5 + .sub-header-block.border-top + .d-flex.justify-content-between.align-items-center + %h4.sub-header.m-0 + - start_date = capture do + #{@daily_coverage_options[:base_params][:start_date].strftime('%b %d')} + - end_date = capture do + #{@daily_coverage_options[:base_params][:end_date].strftime('%b %d')} + = (_("Code coverage statistics for master %{start_date} - %{end_date}") % {start_date: start_date, end_date: end_date}) + - download_path = capture do + #{@daily_coverage_options[:download_path]} + %a.btn.btn-sm{ href: "#{download_path}?#{@daily_coverage_options[:base_params].to_query}" } + %small + = _("Download raw data (.csv)") + #js-code-coverage-chart{ data: { daily_coverage_options: @daily_coverage_options.to_json.html_safe } } + .repo-charts .sub-header-block.border-top diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index a952db0eea3..495a4ac50bf 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -1,6 +1,6 @@ - page_title _('Contributors') -.sub-header-block.bg-gray-light.gl-p-3 +.sub-header-block.bg-gray-light.gl-p-3-deprecated-no-really-do-not-use-me .tree-ref-holder.inline.vertical-align-middle = render 'shared/ref_switcher', destination: 'graphs' = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn' diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml index 4106bcc2e5a..cddd97cbc84 100644 --- a/app/views/projects/import/jira/show.html.haml +++ b/app/views/projects/import/jira/show.html.haml @@ -1,6 +1,7 @@ - if Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true) .js-jira-import-root{ data: { project_path: @project.full_path, issues_path: project_issues_path(@project), + jira_integration_path: edit_project_service_path(@project, :jira), is_jira_configured: @project.jira_service.present?.to_s, jira_projects: @jira_projects.to_json, in_progress_illustration: image_path('illustrations/export-import.svg'), diff --git a/app/views/projects/issues/_design_management.html.haml b/app/views/projects/issues/_design_management.html.haml new file mode 100644 index 00000000000..96f1dc0155c --- /dev/null +++ b/app/views/projects/issues/_design_management.html.haml @@ -0,0 +1,15 @@ +- if @project.design_management_enabled? + .js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } } +- else + .mt-4 + .row.empty-state + .col-12 + .text-content + %h4.center + = _('The one place for your designs') + %p.center + - requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements') + - requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url } + - support_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: support_url } + - link_end = '</a>'.html_safe + = s_("DesignManagement|To enable design management, you'll need to %{requirements_link_start}meet the requirements%{requirements_link_end}. If you need help, reach out to our %{support_link_start}support team%{support_link_end} for assistance.").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end, support_link_start: support_link_start, support_link_end: link_end } diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 54002b9ca2e..1bf0c8eb031 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -6,12 +6,13 @@ = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected-issuable" .issuable-info-container .issuable-main-info - .issue-title.title + .issue-title.title.d-flex.align-items-center %span.issue-title-text.js-onboarding-issue-item{ dir: "auto" } - if issue.confidential? %span.has-tooltip{ title: _('Confidential') } = confidential_icon(issue) = link_to issue.title, issue_path(issue) + = render_if_exists 'projects/issues/subepic_flag', issue: issue - if issue.tasks? %span.task-status.d-none.d-sm-inline-block @@ -24,7 +25,7 @@ · opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} by #{link_to_member(@project, issue.author, avatar: false)} - = gitlab_team_member_badge(issue.author) + = render_if_exists 'shared/issuable/gitlab_team_member_badge', {author: issue.author} - if issue.milestone %span.issuable-milestone.d-none.d-sm-inline-block diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 69b030ed76a..0604e89be6e 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -4,11 +4,9 @@ %ul.unstyled-list.related-merge-requests - @related_branches.each do |branch| %li - - target = @project.repository.find_branch(branch).dereferenced_target - - pipeline = @project.pipeline_for(branch, target.sha) if target - - if can?(current_user, :read_pipeline, pipeline) + - if branch[:pipeline_status].present? %span.related-branch-ci-status - = render 'ci/status/icon', status: pipeline.detailed_status(current_user) + = render 'ci/status/icon', status: branch[:pipeline_status] %span.related-branch-info %strong - = link_to branch, project_compare_path(@project, from: @project.default_branch, to: branch), class: "ref-name" + = link_to branch[:name], branch[:link], class: "ref-name" diff --git a/app/views/projects/issues/_tabs.html.haml b/app/views/projects/issues/_tabs.html.haml new file mode 100644 index 00000000000..d998a01623f --- /dev/null +++ b/app/views/projects/issues/_tabs.html.haml @@ -0,0 +1,14 @@ +%ul.nav-tabs.nav.nav-links{ role: 'tablist' } + %li + = link_to '#discussion-tab', class: 'active js-issue-tabs', id: 'discussion', role: 'tab', 'aria-controls': 'js-discussion', 'aria-selected': 'true', data: { toggle: 'tab', target: '#discussion-tab', qa_selector: 'discussion_tab_link' } do + = _('Discussion') + %span.badge.badge-pill.js-discussions-count + %li + = link_to '#designs-tab', class: 'js-issue-tabs', id: 'designs', role: 'tab', 'aria-controls': 'js-designs', 'aria-selected': 'false', data: { toggle: 'tab', target: '#designs-tab', qa_selector: 'designs_tab_link' } do + = _('Designs') + %span.badge.badge-pill.js-designs-count +.tab-content + #discussion-tab.tab-pane.show.active{ role: 'tabpanel', 'aria-labelledby': 'discussion', data: { qa_selector: 'discussion_tab_content' } } + = render 'projects/issues/discussion' + #designs-tab.tab-pane{ role: 'tabpanel', 'aria-labelledby': 'designs', data: { qa_selector: 'designs_tab_content' } } + = render 'projects/issues/design_management' diff --git a/app/views/projects/issues/export_csv/_modal.html.haml b/app/views/projects/issues/export_csv/_modal.html.haml index af3a087ca59..9fdeb901b56 100644 --- a/app/views/projects/issues/export_csv/_modal.html.haml +++ b/app/views/projects/issues/export_csv/_modal.html.haml @@ -1,4 +1,3 @@ --# haml-lint:disable NoPlainNodes - if current_user .issues-export-modal.modal .modal-dialog diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 2633a3899f7..0aef4e39466 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -6,6 +6,12 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues") +- if @project.jira_issues_import_feature_flag_enabled? + .js-projects-issues-root{ data: { can_edit: can?(current_user, :admin_project, @project).to_s, + is_jira_configured: @project.jira_service.present?.to_s, + issues_path: project_issues_path(@project), + project_path: @project.full_path } } + - if project_issues(@project).exists? .top-area = render 'shared/issuable/nav', type: :issues diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 4fc67884584..c8ffa2e3720 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -10,6 +10,8 @@ - can_report_spam = @issue.submittable_as_spam_by?(current_user) - can_create_issue = show_new_issue_link?(@project) += render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user + .detail-page-header .detail-page-header-body .issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(@issue, status_box: :closed) } @@ -50,7 +52,7 @@ %li.divider %li= link_to 'New issue', new_project_issue_path(@project), id: 'new_issue_link' - = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue, can_reopen: can_reopen_issue + = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue, can_reopen: can_reopen_issue, warn_before_close: defined?(@issue.blocked?) && @issue.blocked? - if can_report_spam = link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'd-none d-sm-none d-md-block btn btn-grouped btn-spam', title: 'Submit as spam' @@ -84,13 +86,13 @@ .content-block.emoji-block.emoji-block-sticky .row - .col-md-12.col-lg-6.js-noteable-awards + .col-md-12.col-lg-4.js-noteable-awards = render 'award_emoji/awards_block', awardable: @issue, inline: true - .col-md-12.col-lg-6.new-branch-col + .col-md-12.col-lg-8.new-branch-col #js-vue-sort-issue-discussions #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } } = render 'new_branch' if show_new_branch_button? - = render_if_exists 'projects/issues/discussion' + = render 'projects/issues/tabs' = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 0373e37818d..760d81136c6 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -22,13 +22,13 @@ .content-list.manage-labels-list.js-prioritized-labels{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } } #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" } = render 'shared/empty_states/priority_labels' - - if @prioritized_labels.present? + - if @prioritized_labels.any? = render partial: 'shared/label', collection: @prioritized_labels, as: :label, locals: { force_priority: true, subject: @project } - elsif search.present? .nothing-here-block = _('No prioritized labels with such name or description') - - if @labels.present? + - if @labels.any? .other-labels %h5{ class: ('hide' if hide) }= _('Other Labels') .content-list.manage-labels-list.js-other-labels diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml index 9cdbbe7204b..a2da0e707d3 100644 --- a/app/views/projects/merge_requests/_how_to_merge.html.haml +++ b/app/views/projects/merge_requests/_how_to_merge.html.haml @@ -53,4 +53,4 @@ %strong Tip: = succeed '.' do You can also checkout merge requests locally by - = link_to 'following these guidelines', help_page_path('user/project/merge_requests/index.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer' + = link_to 'following these guidelines', help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index f7f5388a54a..a753ee50c43 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -20,7 +20,7 @@ · opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')} by #{link_to_member(@project, merge_request.author, avatar: false)} - = gitlab_team_member_badge(merge_request.author) + = render_if_exists 'shared/issuable/gitlab_team_member_badge', {author: merge_request.author} - if merge_request.milestone %span.issuable-milestone.d-none.d-sm-inline-block diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 1853d40c2e4..6aba5c98d52 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -8,6 +8,7 @@ window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}'; window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}'; + window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}'; window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}'; window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}'; window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}'; diff --git a/app/views/projects/merge_requests/creations/update_branches.html.haml b/app/views/projects/merge_requests/creations/update_branches.html.haml deleted file mode 100644 index 64482973a89..00000000000 --- a/app/views/projects/merge_requests/creations/update_branches.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -= render 'projects/merge_requests/dropdowns/branch', -branches: @target_branches, -selected: nil diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 4004c4f4b07..38e4fbf73e0 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -10,7 +10,7 @@ = expanded ? _('Collapse') : _('Expand') %p = _('Set up your project to automatically push and/or pull changes to/from another repository. Branches, tags, and commits will be synced automatically.') - = link_to _('Read more'), help_page_path('workflow/repository_mirroring'), target: '_blank' + = link_to _('Read more'), help_page_path('user/project/repository/repository_mirroring.md'), target: '_blank' .settings-content - if mirror_settings_enabled diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml index 8482424a184..9b5b31bfc15 100644 --- a/app/views/projects/mirrors/_mirror_repos_push.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml @@ -1,15 +1,13 @@ - protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|') -- keep_divergent_refs = Feature.enabled?(:keep_divergent_refs, @project) = f.fields_for :remote_mirrors, @project.remote_mirrors.build do |rm_f| = rm_f.hidden_field :enabled, value: '1' = rm_f.hidden_field :url, class: 'js-mirror-url-hidden', required: true, pattern: "(#{protocols}):\/\/.+" = rm_f.hidden_field :only_protected_branches, class: 'js-mirror-protected-hidden' - - if keep_divergent_refs - = rm_f.hidden_field :keep_divergent_refs, class: 'js-mirror-keep-divergent-refs-hidden' + = rm_f.hidden_field :keep_divergent_refs, class: 'js-mirror-keep-divergent-refs-hidden' = render partial: 'projects/mirrors/ssh_host_keys', locals: { f: rm_f } = render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f } - - if keep_divergent_refs - .form-check.append-bottom-10 - = check_box_tag :keep_divergent_refs, '1', false, class: 'js-mirror-keep-divergent-refs form-check-input' - = label_tag :keep_divergent_refs, 'Keep divergent refs', class: 'form-check-label' + .form-check.append-bottom-10 + = check_box_tag :keep_divergent_refs, '1', false, class: 'js-mirror-keep-divergent-refs form-check-input' + = label_tag :keep_divergent_refs, _('Keep divergent refs'), class: 'form-check-label' + = link_to icon('question-circle'), help_page_path('user/project/repository/repository_mirroring', anchor: 'keep-divergent-refs-core'), target: '_blank' diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 3ff4ab354b9..c18af6a267b 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -18,7 +18,7 @@ = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.') = render_if_exists 'projects/new_ci_cd_banner_external_repo' %p - - pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/getting_started_part_two", anchor: "fork-a-project-to-get-started-from"), target: '_blank' + - pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/index", anchor: "getting-started"), target: '_blank' = _('Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}.').html_safe % { pages_getting_started_guide: pages_getting_started_guide } .md = brand_new_project_guidelines diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 37ca020cfb6..e39f543d42e 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,4 +1,5 @@ -- test_reports_enabled = Feature.enabled?(:junit_pipeline_view) +- test_reports_enabled = Feature.enabled?(:junit_pipeline_view, @project) +- dag_pipeline_tab_enabled = Feature.enabled?(:dag_pipeline_tab) .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs @@ -9,6 +10,10 @@ = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do = _('Jobs') %span.badge.badge-pill.js-builds-counter= pipeline.total_size + - if dag_pipeline_tab_enabled + %li.js-dag-tab-link + = link_to dag_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-dag', action: 'dag', toggle: 'tab' }, class: 'dag-tab' do + = _('DAG') - if @pipeline.failed_builds.present? %li.js-failures-tab-link = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do @@ -75,6 +80,9 @@ %code.bash.js-build-output = build_summary(build) + - if dag_pipeline_tab_enabled + #js-tab-dag.tab-pane + #js-tab-tests.tab-pane #js-pipeline-tests-detail = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index f64f07487fd..64789c7c263 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -3,6 +3,7 @@ = render_if_exists "shared/shared_runners_minutes_limit_flash_message" #pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json), + project_id: @project.id, "help-page-path" => help_page_path('ci/quick_start/README'), "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'), "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'), diff --git a/app/views/projects/services/_deprecated_message.html.haml b/app/views/projects/services/_deprecated_message.html.haml deleted file mode 100644 index fea9506a4bb..00000000000 --- a/app/views/projects/services/_deprecated_message.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -.flash-container.flash-container-page - .flash-alert.deprecated-service - %span= @service.deprecation_message diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 0dbd6a48ec5..3f91bdc4266 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -1,19 +1,19 @@ .row.prepend-top-default.append-bottom-default - .col-lg-3 + .col-lg-4 %h4.prepend-top-0 = @service.title - [true, false].each do |value| - - hide_class = 'd-none' if @service.activated? != value + - hide_class = 'd-none' if @service.operating? != value %span.js-service-active-status{ class: hide_class, data: { value: value.to_s } } = boolean_to_icon value - if @service.respond_to?(:detailed_description) %p= @service.detailed_description - .col-lg-9 + .col-lg-8 = form_for(@service, as: :service, url: scoped_integration_path(@service), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form| = render 'shared/service_settings', form: form, service: @service .footer-block.row-content-block - = service_save_button(@service) + = service_save_button = link_to _('Cancel'), project_settings_integrations_path(@project), class: 'btn btn-cancel' diff --git a/app/views/projects/services/_index.html.haml b/app/views/projects/services/_index.html.haml deleted file mode 100644 index dca324ac846..00000000000 --- a/app/views/projects/services/_index.html.haml +++ /dev/null @@ -1,30 +0,0 @@ -.row.prepend-top-default - .col-lg-4 - %h4.prepend-top-0 - = _('Integrations') - %p= _('Integrations allow you to integrate GitLab with other applications') - .col-lg-8 - %table.table - %colgroup - %col - %col - %col - %col{ width: "120" } - %thead - %tr - %th - %th= _('Integration') - %th.d-none.d-sm-block= _("Description") - %th= s_("ProjectService|Last edit") - - @services.sort_by(&:title).each do |service| - %tr - %td{ "aria-label" => (service.activated? ? s_("ProjectService|%{service_title}: status on") : s_("ProjectService|%{service_title}: status off")) % { service_title: service.title } } - = boolean_to_icon service.activated? - %td - = link_to edit_project_service_path(@project, service.to_param), { data: { qa_selector: "#{service.title.downcase.gsub(/[\s\(\)]/,'_')}_link" } } do - %strong= service.title - %td.d-none.d-sm-block - = service.description - %td.light - - if service.updated_at.present? - = time_ago_with_tooltip service.updated_at diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml index 4195dce7780..1aaea50c8d5 100644 --- a/app/views/projects/services/edit.html.haml +++ b/app/views/projects/services/edit.html.haml @@ -1,8 +1,7 @@ - breadcrumb_title @service.title - add_to_breadcrumbs _('Integration Settings'), project_settings_integrations_path(@project) - page_title @service.title, _('Integrations') - -= render 'deprecated_message' if @service.deprecation_message +- @content_class = 'limit-container-width' unless fluid_layout = render 'form' - if @web_hook_logs diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml index 21f9d1125e0..210d0f37d65 100644 --- a/app/views/projects/services/prometheus/_custom_metrics.html.haml +++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml @@ -6,14 +6,14 @@ = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus', anchor: 'adding-custom-metrics'), target: '_blank', rel: "noopener noreferrer" .col-lg-9 - .card.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{@service.active}" } } + .card.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { qa_selector: 'custom_metrics_container', active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{@service.active}" } } .card-header %strong = s_('PrometheusService|Custom metrics') -# haml-lint:disable NoPlainNodes %span.badge.badge-pill.js-custom-monitored-count 0 -# haml-lint:enable NoPlainNodes - = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn btn-success js-new-metric-button hidden' + = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn btn-success js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' } .card-body .flash-container.hidden .flash-warning diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 93ea17a3a3d..0cf78d4f681 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -57,7 +57,7 @@ .form-group = label_tag nil, _('Customize icon'), class: 'col-12 col-form-label label-bold' .col-12 - = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36, class: 'mr-3') + = image_tag(asset_url('slash-command-logo.png', skip_pipeline: true), width: 36, height: 36, class: 'mr-3') = link_to(_('Download image'), asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank', rel: 'noopener noreferrer') .form-group diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index 0f60fc18026..5eeebe4160f 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -16,7 +16,7 @@ .row .form-group.col-md-9 - = f.label :tag_list, _('Topics'), class: 'label-bold' + = f.label :tag_list, _('Topics (optional)'), class: 'label-bold' = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control" %p.form-text.text-muted= _('Separate topics with commas.') diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml new file mode 100644 index 00000000000..07784dce677 --- /dev/null +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -0,0 +1,34 @@ +- breadcrumb_title s_('AccessTokens|Access Tokens') +- page_title _('Project Access Tokens') +- type = _('project access token') +- type_plural = _('project access tokens') +- @content_class = 'limit-container-width' unless fluid_layout + +.row.prepend-top-default + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 + = page_title + %p + = _('You can generate an access token scoped to this project for each application to use the GitLab API.') + %p + = _('You can also use project access tokens to authenticate against Git over HTTP.') + + .col-lg-8 + - if @new_project_access_token + = render 'shared/access_tokens/created_container', + type: type, + new_token_value: @new_project_access_token + + = render 'shared/access_tokens/form', + type: type, + path: project_settings_access_tokens_path(@project), + token: @project_access_token, + scopes: @scopes, + prefix: :project_access_token + + = render 'shared/access_tokens/table', + active_tokens: @active_project_access_tokens, + type: type, + type_plural: type_plural, + revoke_route_helper: ->(token) { revoke_namespace_project_settings_access_token_path(id: token) }, + no_active_tokens_message: _('This project has no active access tokens.') diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 6702786fdb3..8b84acb67c1 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -30,7 +30,7 @@ .card-footer.js-extra-settings{ class: auto_devops_enabled || 'hidden' } - if @project.all_clusters.empty? %p.settings-message.text-center - = s_('CICD|You must add a %{kubernetes_cluster_link_start}Kubernetes cluster integration%{link_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_link_start: kubernetes_cluster_link_start, link_end: link_end } + = s_('CICD|Add a %{kubernetes_cluster_link_start}Kubernetes cluster integration%{link_end} with a domain or create an AUTO_DEVOPS_PLATFORM_TARGET CI variable.').html_safe % { kubernetes_cluster_link_start: kubernetes_cluster_link_start, link_end: link_end } - elsif !has_base_domain %p.settings-message.text-center = s_('CICD|You must add a %{base_domain_link_start}base domain%{link_end} to your %{kubernetes_cluster_link_start}Kubernetes cluster%{link_end} in order for your deployment strategy to work.').html_safe % { base_domain_link_start: base_domain_link_start, kubernetes_cluster_link_start: kubernetes_cluster_link_start, link_end: link_end } diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 4040b1094aa..b50f712922f 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -103,7 +103,7 @@ .input-group %span.input-group-prepend .input-group-text / - = f.text_field :build_coverage_regex, class: 'form-control', placeholder: 'Regular expression' + = f.text_field :build_coverage_regex, class: 'form-control', placeholder: 'Regular expression', data: { qa_selector: 'build_coverage_regex_field' } %span.input-group-append .input-group-text / %p.form-text.text-muted @@ -143,7 +143,7 @@ go test -cover (Go) %code coverage: \d+.\d+% of statements - = f.submit _('Save changes'), class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'save_general_pipelines_changes_button' } %hr diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 1358077f2b2..4e14426a069 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -5,7 +5,7 @@ - expanded = expanded_by_default? - general_expanded = @project.errors.empty? ? expanded : true -%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) } +%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded), data: { qa_selector: 'general_pipelines_settings_content' } } .settings-header %h4 = _("General pipelines") diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml index f603f23a2c7..4372763fcf7 100644 --- a/app/views/projects/settings/integrations/show.html.haml +++ b/app/views/projects/settings/integrations/show.html.haml @@ -12,4 +12,6 @@ .gl-alert-actions = link_to _('Go to Webhooks'), project_hooks_path(@project), class: 'btn gl-alert-action btn-info new-gl-button' -= render 'projects/services/index' +%h4= s_('Integrations') +%p= s_('Integrations allow you to integrate GitLab with other applications') += render 'shared/integrations/index', integrations: @services diff --git a/app/views/projects/settings/operations/_incidents.html.haml b/app/views/projects/settings/operations/_incidents.html.haml index a96a41b78c2..92fffa42b73 100644 --- a/app/views/projects/settings/operations/_incidents.html.haml +++ b/app/views/projects/settings/operations/_incidents.html.haml @@ -2,7 +2,7 @@ - setting = project_incident_management_setting - templates = setting.available_issue_templates.map { |t| [t.name, t.key] } -%section.settings.no-animate.qa-incident-management-settings +%section.settings.no-animate.qa-incident-management-settings{ data: { qa_selector: 'incidents_settings_content' } } .settings-header %h3{ :class => "h4" }= _('Incidents') %button.btn.js-settings-toggle{ type: 'button' } @@ -17,16 +17,16 @@ .form-group = f.fields_for :incident_management_setting_attributes, setting do |form| .form-group - = form.check_box :create_issue + = form.check_box :create_issue, data: { qa_selector: 'create_issue_checkbox' } = form.label :create_issue, _('Create an issue. Issues are created for each alert triggered.'), class: 'form-check-label' .form-group.col-sm-8 = form.label :issue_template_key, class: 'label-bold' do = _('Issue template (optional)') = link_to icon('question-circle'), help_page_path('user/project/description_templates', anchor: 'creating-issue-templates'), target: '_blank', rel: 'noopener noreferrer' .select-wrapper - = form.select :issue_template_key, templates, {include_blank: 'No template selected'}, class: "form-control select-control" + = form.select :issue_template_key, templates, {include_blank: 'No template selected'}, class: "form-control select-control", data: { qa_selector: 'incident_templates_dropdown' } = icon('chevron-down') .form-group = form.check_box :send_email = form.label :send_email, _('Send a separate email notification to Developers.'), class: 'form-check-label' - = f.submit _('Save changes'), class: 'btn btn-success' + = f.submit _('Save changes'), class: 'btn btn-success', data: { qa_selector: 'save_changes_button' } diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 193053c8c97..24fc137fd29 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -1,7 +1,7 @@ - breadcrumb_title _("Repository Settings") - page_title _("Repository") - @content_class = "limit-container-width" unless fluid_layout -- deploy_token_description = s_('DeployTokens|Deploy tokens allow access to your repository and registry images.') +- deploy_token_description = s_('DeployTokens|Deploy tokens allow access to packages, your repository, and registry images.') = render "projects/default_branch/show" = render_if_exists "projects/push_rules/index" diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index ccf109968fc..7cf5de8947c 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -3,7 +3,7 @@ - breadcrumb_title @snippet.to_reference - page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") -- if Feature.enabled?(:snippets_vue) +- if Feature.enabled?(:snippets_vue, default_enabled: true) #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} } - else = render 'shared/snippets/header' diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 75805192a61..da693a15ec2 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -30,6 +30,9 @@ = markdown_field(release, :description) .row-fixed-content.controls.flex-row + - if tag.has_signature? + = render partial: 'projects/commit/signature', object: tag.signature + = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name] - if can?(current_user, :admin_tag, @project) diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 8086d47479d..6f53a687fb9 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -39,6 +39,8 @@ = s_("TagsPage|Can't find HEAD commit for this tag") .nav-controls + - if @tag.has_signature? + = render partial: 'projects/commit/signature', object: @tag.signature - if can?(current_user, :admin_tag, @project) = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-edit controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do = icon("pencil") diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml index 0f2938686cc..2b8da83b126 100644 --- a/app/views/projects/wikis/_sidebar.html.haml +++ b/app/views/projects/wikis/_sidebar.html.haml @@ -17,6 +17,6 @@ %ul.wiki-pages = render @sidebar_wiki_entries, context: 'sidebar' .block.w-100 - - if @sidebar_wiki_entries&.length.to_i >= 15 + - if @sidebar_limited = link_to project_wikis_pages_path(@project), class: 'btn btn-block' do = s_("Wiki|View All Pages") diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml index 6972eda9bb7..72c9f45779a 100644 --- a/app/views/projects/wikis/git_access.html.haml +++ b/app/views/projects/wikis/git_access.html.haml @@ -18,11 +18,6 @@ %pre.dark :preserve gem install gollum - %p - = (s_("WikiClone|It is recommended to install %{markdown} so that GFM features render locally:") % { markdown: "<code>github-markdown</code>" }).html_safe - %pre.dark - :preserve - gem install github-markdown %h3= s_("WikiClone|Clone your wiki") %pre.dark diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index 6ad155eb715..db7769fa743 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -24,7 +24,7 @@ = users - elsif @show_snippets - = search_filter_link 'snippet_titles', _("Titles and Filenames"), search: { snippets: true, group_id: nil, project_id: nil } + = search_filter_link 'snippet_titles', _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil } - else = search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' } = search_filter_link 'issues', _("Issues") diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml index 01e42224428..218de30d707 100644 --- a/app/views/search/results/_blob_data.html.haml +++ b/app/views/search/results/_blob_data.html.haml @@ -7,4 +7,4 @@ = search_blob_title(project, path) - if blob.data .file-content.code.term{ data: { qa_selector: 'file_text_content' } } - = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline + = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml deleted file mode 100644 index fa77566dddb..00000000000 --- a/app/views/search/results/_snippet_blob.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -- snippet_blob = chunk_snippet(snippet_blob, @search_term) -- snippet = snippet_blob[:snippet_object] -- snippet_chunks = snippet_blob[:snippet_chunks] -- snippet_path = gitlab_snippet_path(snippet) - -.search-result-row.snippet-row - = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: '' - .title - = link_to gitlab_snippet_path(snippet) do - = snippet.title - .snippet-info - = snippet.to_reference - · - authored - = time_ago_with_tooltip(snippet.created_at) - by - = link_to user_snippets_path(snippet.author) do - = snippet.author_name - - .file-holder.my-2 - .js-file-title.file-title-flex-parent - = link_to snippet_path do - %i.fa.fa-file - %strong= snippet.file_name - - if markup?(snippet.file_name) - .file-content.md - - snippet_chunks.each do |chunk| - - unless chunk[:data].empty? - = markup(snippet.file_name, chunk[:data]) - - else - .file-content.code - .nothing-here-block= _("Empty file") - - else - .file-content.code.js-syntax-highlight - .line-numbers - - snippet_chunks.each do |chunk| - - unless chunk[:data].empty? - - Gitlab::Git::Util.count_lines(chunk[:data]).times do |index| - - offset = defined?(chunk[:start_line]) ? chunk[:start_line] : 1 - - i = index + offset - = link_to snippet_path+"#L#{i}", id: "L#{i}", rel: "#L#{i}", class: "diff-line-num" do - %i.fa.fa-link - = i - .blob-content - - snippet_chunks.each do |chunk| - - unless chunk[:data].empty? - = highlight(snippet.file_name, chunk[:data]) - - else - .file-content.code - .nothing-here-block= _("Empty file") diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml index 81e746c55a3..a28d9effbdd 100644 --- a/app/views/search/results/_snippet_title.html.haml +++ b/app/views/search/results/_snippet_title.html.haml @@ -1,5 +1,5 @@ .search-result-row - %h4.snippet-title.term + %h4 = link_to gitlab_snippet_path(snippet_title) do = truncate(snippet_title.title, length: 60) = snippet_badge(snippet_title) diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index 128508e954e..bf1683be32d 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -11,5 +11,5 @@ = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn btn-md new-gl-button js-close-callout' %button.gl-banner-close.close.js-close-callout{ type: 'button', - 'aria-label' => 'Dismiss Auto DevOps box' } + 'aria-label' => s_('AutoDevOps|Dismiss Auto DevOps box') } = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml index bc4db672938..b809696cccb 100644 --- a/app/views/shared/_broadcast_message.html.haml +++ b/app/views/shared/_broadcast_message.html.haml @@ -6,5 +6,5 @@ = render_broadcast_message(message) .flex-grow-1.text-right{ style: 'flex-basis: 0' } - if (message.notification? || message.dismissable?) && opts[:preview].blank? - %button.broadcast-message-dismiss.js-dismiss-current-broadcast-notification.btn.btn-link.pl-2.pr-2{ 'aria-label' => _('Close'), :type => 'button', data: { id: message.id } } + %button.broadcast-message-dismiss.js-dismiss-current-broadcast-notification.btn.btn-link.pl-2.pr-2{ 'aria-label' => _('Close'), :type => 'button', data: { id: message.id, expire_date: message.ends_at.iso8601 } } %i.fa.fa-times diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 3e805189055..9ec8d3c18cd 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -18,7 +18,7 @@ = http_clone_button(project) = render_if_exists 'shared/kerberos_clone_button', project: project - = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' } + = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Project clone URL') } .input-group-append = clipboard_button(target: '#project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index 68c14c307ac..d65b7492690 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -14,12 +14,11 @@ id: "commit_message-#{nonce}" - if local_assigns[:hint] %p.hint - Try to keep the first line under 52 characters - and the others under 72. + = _('Try to keep the first line under 52 characters and the others under 72.') - if descriptions.present? .hint.js-with-description-hint = link_to "#", class: "js-with-description-link" do - Include description in commit message + = _('Include description in commit message') .hint.js-without-description-hint.hide = link_to "#", class: "js-without-description-link" do - Don't include description in commit message + = _("Don't include description in commit message") diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml index c6629cd33a5..25c841d2344 100644 --- a/app/views/shared/_delete_label_modal.html.haml +++ b/app/views/shared/_delete_label_modal.html.haml @@ -2,20 +2,19 @@ .modal-dialog .modal-content .modal-header - %h3.page-title Delete label: #{label.name} ? + %h3.page-title= _('Delete label: %{label_name} ?') % { label_name: label.name } %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } %span{ "aria-hidden": true } × .modal-body %p - %strong= label.name - %span will be permanently deleted from #{label.subject_name}. This cannot be undone. + = _('<strong>%{label_name}</strong> <span>will be permanently deleted from %{subject_name}. This cannot be undone.</span>').html_safe % { label_name: label.name, subject_name: label.subject_name } .modal-footer - %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } Cancel + %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' }= _('Cancel') - = link_to 'Delete label', + = link_to _('Delete label'), label.destroy_path, - title: 'Delete', + title: _('Delete'), method: :delete, class: 'btn btn-remove' diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml index a7ad6d6f2c4..4f416c483f2 100644 --- a/app/views/shared/_field.html.haml +++ b/app/views/shared/_field.html.haml @@ -7,23 +7,22 @@ - choices = field[:choices] - default_choice = field[:default_choice] - help = field[:help] -- disabled = disable_fields_service?(@service) .form-group.row - if type == "password" && value.present? - = form.label name, "Enter new #{title.downcase}", class: "col-form-label col-sm-2" + = form.label name, _("Enter new %{field_title}") % { field_title: title.downcase }, class: "col-form-label col-sm-2" - else = form.label name, title, class: "col-form-label col-sm-2" .col-sm-10 - if type == 'text' - = form.text_field name, class: "form-control", placeholder: placeholder, required: required, disabled: disabled, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" } + = form.text_field name, class: "form-control", placeholder: placeholder, required: required, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" } - elsif type == 'textarea' - = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required, disabled: disabled + = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required - elsif type == 'checkbox' - = form.check_box name, disabled: disabled + = form.check_box name - elsif type == 'select' - = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control", disabled: disabled} + = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control"} - elsif type == 'password' - = form.password_field name, autocomplete: "new-password", placeholder: placeholder, class: "form-control", required: value.blank? && required, disabled: disabled, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" } + = form.password_field name, autocomplete: "new-password", placeholder: placeholder, class: "form-control", required: value.blank? && required, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" } - if help %span.form-text.text-muted= help diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index 9a65981ed58..019b2ef89a4 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -6,7 +6,7 @@ .form-group.group-name-holder.col-sm-12 = f.label :name, class: 'label-bold' do = _("Group name") - = f.text_field :name, placeholder: 'My Awesome Group', class: 'form-control input-lg', + = f.text_field :name, placeholder: _('My Awesome Group'), class: 'form-control input-lg', required: true, title: _('Please fill in a descriptive name for your group.'), autofocus: true @@ -22,7 +22,7 @@ - if parent %strong= parent.full_path + '/' = f.hidden_field :parent_id - = f.text_field :path, placeholder: 'my-awesome-group', class: 'form-control js-validate-group-path', + = f.text_field :path, placeholder: _('my-awesome-group'), class: 'form-control js-validate-group-path', autofocus: local_assigns[:autofocus] || false, required: true, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, title: _('Please choose a group URL with no special characters.'), diff --git a/app/views/shared/_group_tips.html.haml b/app/views/shared/_group_tips.html.haml index 46e4340511a..2d7f8e36139 100644 --- a/app/views/shared/_group_tips.html.haml +++ b/app/views/shared/_group_tips.html.haml @@ -1,5 +1,5 @@ %ul - %li A group is a collection of several projects - %li Members of a group may only view projects they have permission to access - %li Group project URLs are prefixed with the group namespace - %li Existing projects may be moved into a group + %li= _('A group is a collection of several projects') + %li= _('Members of a group may only view projects they have permission to access') + %li= _('Group project URLs are prefixed with the group namespace') + %li= _('Existing projects may be moved into a group') diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index b05d903fabe..cd303dd7a3d 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -13,7 +13,7 @@ %ul.label-links - if show_label_issues_link %li.label-link-item.inline - = link_to_label(label) { 'Issues' } + = link_to_label(label) { _('Issues') } - if show_label_merge_requests_link · %li.label-link-item.inline diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml index 099e3ac8462..48a97a18ca9 100644 --- a/app/views/shared/_milestone_expired.html.haml +++ b/app/views/shared/_milestone_expired.html.haml @@ -1,6 +1,6 @@ - if milestone.expired? and not milestone.closed? - .status-box.status-box-expired.append-bottom-5 Expired + .status-box.status-box-expired.append-bottom-5= _('Expired') - if milestone.upcoming? - .status-box.status-box-mr-merged.append-bottom-5 Upcoming + .status-box.status-box-mr-merged.append-bottom-5= _('Upcoming') - if milestone.closed? - .status-box.status-box-closed.append-bottom-5 Closed + .status-box.status-box-closed.append-bottom-5= _('Closed') diff --git a/app/views/shared/_milestones_filter.html.haml b/app/views/shared/_milestones_filter.html.haml index 6c1ac20d544..eb50960202a 100644 --- a/app/views/shared/_milestones_filter.html.haml +++ b/app/views/shared/_milestones_filter.html.haml @@ -1,13 +1,13 @@ %ul.nav-links.mobile-separator.nav.nav-tabs %li{ class: milestone_class_for_state(params[:state], 'opened', true) }> = link_to milestones_filter_path(state: 'opened') do - Open + = _('Open') %span.badge.badge-pill= counts[:opened] %li{ class: milestone_class_for_state(params[:state], 'closed') }> = link_to milestones_filter_path(state: 'closed', sort: 'due_date_desc') do - Closed + = _('Closed') %span.badge.badge-pill= counts[:closed] %li{ class: milestone_class_for_state(params[:state], 'all') }> = link_to milestones_filter_path(state: 'all', sort: 'due_date_desc') do - All + = _('All') %span.badge.badge-pill= counts[:all] diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml index a1f21c2a83e..172f3d85472 100644 --- a/app/views/shared/_mini_pipeline_graph.html.haml +++ b/app/views/shared/_mini_pipeline_graph.html.haml @@ -14,4 +14,4 @@ %li.js-builds-dropdown-loading.hidden .loading-container.text-center - %span.spinner{ 'aria-label': 'Loading' } + %span.spinner{ 'aria-label': _('Loading') } diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml index fbfd4d0e9a9..2b04e3e1c98 100644 --- a/app/views/shared/_no_ssh.html.haml +++ b/app/views/shared/_no_ssh.html.haml @@ -1,7 +1,7 @@ - if show_no_ssh_key_message? %div{ class: 'no-ssh-key-message gl-alert gl-alert-warning', role: 'alert' } = sprite_icon('warning', size: 16, css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title') - %button{ class: 'gl-alert-dismiss hide-no-ssh-message', type: 'button', 'aria-label': 'Dismiss' } + %button{ class: 'gl-alert-dismiss hide-no-ssh-message', type: 'button', 'aria-label': _('Dismiss') } = sprite_icon('close', size: 16, css_class: 'gl-icon s16') .gl-alert-body = s_("MissingSSHKeyWarningLink|You won't be able to pull or push project code via SSH until you add an SSH key to your profile").html_safe diff --git a/app/views/shared/_personal_access_tokens_created_container.html.haml b/app/views/shared/_personal_access_tokens_created_container.html.haml deleted file mode 100644 index df4577e2862..00000000000 --- a/app/views/shared/_personal_access_tokens_created_container.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -- container_title = local_assigns.fetch(:container_title, _('Your New Personal Access Token')) -- clipboard_button_title = local_assigns.fetch(:clipboard_button_title, _('Copy personal access token')) - -.created-personal-access-token-container - %h5.prepend-top-0 - = container_title - .form-group - .input-group - = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: "qa-created-personal-access-token form-control js-select-on-focus", 'aria-describedby' => "created-token-help-block" - %span.input-group-append - = clipboard_button(text: new_token_value, title: clipboard_button_title, placement: "left", class: "input-group-text btn-default btn-clipboard") - %span#created-token-help-block.form-text.text-muted.text-danger - = _("Make sure you save it - you won't be able to access it again.") - -%hr diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml deleted file mode 100644 index 71f3447ebc7..00000000000 --- a/app/views/shared/_personal_access_tokens_form.html.haml +++ /dev/null @@ -1,31 +0,0 @@ -- type = impersonation ? s_('Profiles|impersonation') : s_('Profiles|personal access') - -%h5.prepend-top-0 - = _('Add a %{type} token') % { type: type } -%p.profile-settings-content - = _("Pick a name for the application, and we'll give you a unique %{type} token.") % { type: type } - -= form_for token, url: path, method: :post, html: { class: 'js-requires-input' } do |f| - - = form_errors(token) - - .row - .form-group.col-md-6 - = f.label :name, _('Name'), class: 'label-bold' - = f.text_field :name, class: "form-control", required: true, data: { qa_selector: 'personal_access_token_name_field' } - - .row - .form-group.col-md-6 - = f.label :expires_at, _('Expires at'), class: 'label-bold' - .input-icon-wrapper - - = render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime' - - = f.text_field :expires_at, class: "datepicker form-control", placeholder: 'YYYY-MM-DD', data: { qa_selector: 'expiry_date_field' } - - .form-group - = f.label :scopes, _('Scopes'), class: 'label-bold' - = render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: token, scopes: scopes - - .prepend-top-default - = f.submit _('Create %{type} token') % { type: type }, class: "btn btn-success", data: { qa_selector: 'create_token_button' } diff --git a/app/views/shared/_project_limit.html.haml b/app/views/shared/_project_limit.html.haml index 2c52eccccb6..88f213612fc 100644 --- a/app/views/shared/_project_limit.html.haml +++ b/app/views/shared/_project_limit.html.haml @@ -1,8 +1,8 @@ - if cookies[:hide_project_limit_message].blank? && !current_user.hide_project_limit && !current_user.can_create_project? && current_user.projects_limit > 0 .project-limit-message.alert.alert-warning.d-none.d-sm-block - You won't be able to create new projects because you have reached your project limit. + = _("You won't be able to create new projects because you have reached your project limit.") .float-right - = link_to "Don't show again", profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link' + = link_to _("Don't show again"), profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link' | - = link_to 'Remind later', '#', class: 'hide-project-limit-message alert-link' + = link_to _('Remind later'), '#', class: 'hide-project-limit-message alert-link' diff --git a/app/views/shared/_recaptcha_form.html.haml b/app/views/shared/_recaptcha_form.html.haml index 10f358402c1..245a86721eb 100644 --- a/app/views/shared/_recaptcha_form.html.haml +++ b/app/views/shared/_recaptcha_form.html.haml @@ -17,4 +17,4 @@ - if has_submit .row-content-block.footer-block - = f.submit "Submit #{humanized_resource_name}", class: 'btn btn-success' + = f.submit _("Submit %{humanized_resource_name}") % { humanized_resource_name: humanized_resource_name }, class: 'btn btn-success' diff --git a/app/views/shared/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml index 8b2a3bee407..ee2b2a17e21 100644 --- a/app/views/shared/_ref_dropdown.html.haml +++ b/app/views/shared/_ref_dropdown.html.haml @@ -1,7 +1,7 @@ - dropdown_class = local_assigns.fetch(:dropdown_class, '') .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: dropdown_class } - = dropdown_title "Select Git revision" - = dropdown_filter "Filter by Git revision" + = dropdown_title _('Select Git revision') + = dropdown_filter _('Filter by Git revision') = dropdown_content = dropdown_loading diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 3da4b77b5eb..a9203459914 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -1,4 +1,5 @@ = form_errors(@service) +- trigger_events = Feature.enabled?(:integration_form_refactor) ? ServiceEventSerializer.new(service: @service).represent(@service.configurable_events).to_json : [] - if lookup_context.template_exists?('help', "projects/services/#{@service.to_param}", true) = render "projects/services/#{@service.to_param}/help", subject: @service @@ -8,9 +9,10 @@ = markdown @service.help .service-settings - .js-vue-integration-settings{ data: { show_active: @service.show_active_box?.to_s, activated: (@service.active || @service.new_record?).to_s, disabled: disable_fields_service?(@service).to_s } } + .js-vue-integration-settings{ data: { show_active: @service.show_active_box?.to_s, activated: (@service.active || @service.new_record?).to_s, type: @service.to_param, merge_request_events: @service.merge_requests_events.to_s, +commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on_event_enabled.to_s, comment_detail: @service.comment_detail, trigger_events: trigger_events } } - - if @service.configurable_events.present? + - if @service.configurable_events.present? && !@service.is_a?(JiraService) && Feature.disabled?(:integration_form_refactor) .form-group.row %label.col-form-label.col-sm-2= _('Trigger') @@ -31,32 +33,5 @@ %p.text-muted = @service.class.event_description(event) - - if @service.configurable_event_actions.present? - .form-group.row - %label.col-form-label.col-sm-2= _('Event Actions') - - .col-sm-10 - - @service.configurable_event_actions.each do |action| - .form-group - .form-check - = form.check_box service_event_action_field_name(action), class: 'form-check-input' - = form.label service_event_action_field_name(action), class: 'form-check-label' do - %strong - = event_action_description(action) - - %p.text-muted - = event_action_description(action) - - @service.global_fields.each do |field| - - type = field[:type] - - - if type == 'fieldset' - - fields = field[:fields] - - legend = field[:legend] - - %fieldset - %legend= legend - - fields.each do |subfield| - = render 'shared/field', form: form, field: subfield - - else - = render 'shared/field', form: form, field: field + = render 'shared/field', form: form, field: field diff --git a/app/views/shared/access_tokens/_created_container.html.haml b/app/views/shared/access_tokens/_created_container.html.haml new file mode 100644 index 00000000000..f11ef1e01de --- /dev/null +++ b/app/views/shared/access_tokens/_created_container.html.haml @@ -0,0 +1,12 @@ +.created-personal-access-token-container + %h5.prepend-top-0 + = _('Your new %{type}') % { type: type } + .form-group + .input-group + = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: 'qa-created-access-token form-control js-select-on-focus', 'aria-describedby' => 'created-token-help-block' + %span.input-group-append + = clipboard_button(text: new_token_value, title: _('Copy %{type}') % { type: type }, placement: 'left', class: 'input-group-text btn-default btn-clipboard') + %span#created-token-help-block.form-text.text-muted.text-danger + = _("Make sure you save it - you won't be able to access it again.") + +%hr diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml new file mode 100644 index 00000000000..cb7f907308f --- /dev/null +++ b/app/views/shared/access_tokens/_form.html.haml @@ -0,0 +1,34 @@ +- title = local_assigns.fetch(:title, _('Add a %{type}') % { type: type }) +- prefix = local_assigns.fetch(:prefix, :personal_access_token) + +%h5.prepend-top-0 + = title +%p.profile-settings-content + = _("Enter the name of your application, and we'll return a unique %{type}.") % { type: type } + += form_for token, as: prefix, url: path, method: :post, html: { class: 'js-requires-input' } do |f| + + = form_errors(token) + + .row + .form-group.col-md-6 + = f.label :name, _('Name'), class: 'label-bold' + = f.text_field :name, class: 'form-control', required: true, data: { qa_selector: 'access_token_name_field' } + + .row + .form-group.col-md-6 + = f.label :expires_at, _('Expires at'), class: 'label-bold' + .input-icon-wrapper + + = render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime' + + .js-access-tokens-expires-at + %expires-at-field + = f.text_field :expires_at, class: 'datepicker form-control gl-datepicker-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', inputmode: 'none', data: { qa_selector: 'expiry_date_field' } + + .form-group + = f.label :scopes, _('Scopes'), class: 'label-bold' + = render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes + + .prepend-top-default + = f.submit _('Create %{type}') % { type: type }, class: 'btn btn-success', data: { qa_selector: 'create_token_button' } diff --git a/app/views/shared/_personal_access_tokens_table.html.haml b/app/views/shared/access_tokens/_table.html.haml index 823117f37ca..5518c31cb06 100644 --- a/app/views/shared/_personal_access_tokens_table.html.haml +++ b/app/views/shared/access_tokens/_table.html.haml @@ -1,8 +1,10 @@ -- type = impersonation ? s_('Profiles|Impersonation') : s_('Profiles|Personal Access') +- no_active_tokens_message = local_assigns.fetch(:no_active_tokens_message, _('This user has no active %{type}.') % { type: type_plural }) +- impersonation = local_assigns.fetch(:impersonation, false) + %hr %h5 - = _('Active %{type} Tokens (%{token_length})') % { type: type, token_length: active_tokens.length } + = _('Active %{type} (%{token_length})') % { type: type_plural, token_length: active_tokens.length } - if impersonation %p.profile-settings-content = _("To see all the user's personal access tokens you must impersonate them first.") @@ -25,12 +27,11 @@ %td - if token.expires? %span{ class: ('text-warning' if token.expires_soon?) } - In #{distance_of_time_in_words_to_now(token.expires_at)} + = _('In %{time_to_now}') % { time_to_now: distance_of_time_in_words_to_now(token.expires_at) } - else %span.token-never-expires-label= _('Never') - %td= token.scopes.present? ? token.scopes.join(", ") : _('<no scopes selected>') - - path = impersonation ? revoke_admin_user_impersonation_token_path(token.user, token) : revoke_profile_personal_access_token_path(token) - %td= link_to _('Revoke'), path, method: :put, class: "btn btn-danger float-right qa-revoke-button", data: { confirm: _('Are you sure you want to revoke this %{type} Token? This action cannot be undone.') % { type: type } } + %td= token.scopes.present? ? token.scopes.join(', ') : _('<no scopes selected>') + %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: 'btn btn-danger float-right qa-revoke-button', data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type } } - else .settings-message.text-center - = _('This user has no active %{type} Tokens.') % { type: type } + = no_active_tokens_message diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index ffb406ac35b..2a5b72d478a 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -38,8 +38,7 @@ ":description" => "list.label.description", "tooltipPlacement" => "bottom", ":size" => '(!list.isExpanded ? "sm" : "")', - ":scoped" => "showScopedLabels(list.label)", - ":scoped-labels-documentation-link" => "helpLink" } + ":scoped" => "showScopedLabels(list.label)" } - if can?(current_user, :admin_list, current_board_parent) %board-delete{ "inline-template" => true, diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index a1088dc5222..58ffa3942ef 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -12,8 +12,7 @@ ":background-color" => "label.color", ":title" => "label.title", ":description" => "label.description", - ":scoped" => "showScopedLabels(label)", - ":scoped-labels-documentation-link" => "helpLink" } + ":scoped" => "showScopedLabels(label)" } - if can_admin_issue? .selectbox diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml index 6b3b824f72f..f28e745f4c5 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/shared/deploy_keys/_index.html.haml @@ -1,15 +1,14 @@ - expanded = expanded_by_default? %section.qa-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_keys_settings' } } .settings-header - %h4 - Deploy Keys + %h4= _('Deploy Keys') %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p - Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one. + = _('Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one.') .settings-content %h5.prepend-top-0 - Create a new deploy key for this project + = _('Create a new deploy key for this project') = render @deploy_keys.form_partial_path %hr #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } } diff --git a/app/views/projects/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml index 568930595a2..8edd1d9deb8 100644 --- a/app/views/projects/deploy_keys/_form.html.haml +++ b/app/views/shared/deploy_keys/_project_group_form.html.haml @@ -8,17 +8,17 @@ = f.text_area :key, class: "form-control", rows: 5, required: true .form-group.row %p.light.append-bottom-0 - Paste a machine public key here. Read more about how to generate it + = _('Paste a machine public key here. Read more about how to generate it') = link_to "here", help_page_path("ssh/README") = f.fields_for :deploy_keys_projects do |deploy_keys_project_form| .form-group.row = deploy_keys_project_form.label :can_push do = deploy_keys_project_form.check_box :can_push - %strong Write access allowed + %strong= _('Write access allowed') .form-group.row %p.light.append-bottom-0 - Allow this key to push to repository as well? (Default only allows pull access.) + = _('Allow this key to push to repository as well? (Default only allows pull access.)') .form-group.row = f.submit "Add key", class: "btn-success btn" diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index 5751ed9cb7a..512644518fa 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -35,5 +35,15 @@ = label_tag ("deploy_token_write_registry"), 'write_registry', class: 'label-bold form-check-label' .text-secondary= s_('DeployTokens|Allows write access to the registry images') + %fieldset.form-group.form-check + = f.check_box :read_package_registry, class: 'form-check-input' + = label_tag ("deploy_token_read_package_registry"), 'read_package_registry', class: 'label-bold form-check-label' + .text-secondary= s_('DeployTokens|Allows read access to the package registry') + + %fieldset.form-group.form-check + = f.check_box :write_package_registry, class: 'form-check-input' + = label_tag ("deploy_token_write_package_registry"), 'write_package_registry', class: 'label-bold form-check-label' + .text-secondary= s_('DeployTokens|Allows write access to the package registry') + .prepend-top-default = f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success qa-create-deploy-token' diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml index 74eb6c94116..0e1f41bbbf6 100644 --- a/app/views/shared/file_hooks/_index.html.haml +++ b/app/views/shared/file_hooks/_index.html.haml @@ -19,6 +19,10 @@ %li .monospace = File.basename(file) + - if File.dirname(file).ends_with?('plugins') + .text-warning + = _('Plugins directory is deprecated and will be removed in 14.0. Please move this file into /file_hooks directory.') + - else .card.bg-light.text-center .nothing-here-block= _('No file hooks found.') diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml index f3b56df0c96..6b056e93460 100644 --- a/app/views/shared/hook_logs/_content.html.haml +++ b/app/views/shared/hook_logs/_content.html.haml @@ -31,7 +31,7 @@ %h5 Request body: %pre :escaped - #{JSON.pretty_generate(hook_log.request_data)} + #{Gitlab::Json.pretty_generate(hook_log.request_data)} %h5 Response headers: %pre - hook_log.response_headers.each do |k,v| diff --git a/app/views/shared/integrations/_form.html.haml b/app/views/shared/integrations/_form.html.haml index 0ddab1368c2..4ec7f286c7a 100644 --- a/app/views/shared/integrations/_form.html.haml +++ b/app/views/shared/integrations/_form.html.haml @@ -10,5 +10,5 @@ - if integration.editable? .footer-block.row-content-block - = service_save_button(integration) + = service_save_button = link_to _('Cancel'), scoped_integration_path(integration), class: 'btn btn-cancel' diff --git a/app/views/shared/integrations/_integrations.html.haml b/app/views/shared/integrations/_index.html.haml index b2359aca016..2dbd612ea38 100644 --- a/app/views/shared/integrations/_integrations.html.haml +++ b/app/views/shared/integrations/_index.html.haml @@ -3,7 +3,7 @@ %col %col %col.d-none.d-sm-table-column - %col{ width: 120 } + %col{ width: 130 } %thead{ role: 'rowgroup' } %tr{ role: 'row' } %th{ role: 'columnheader', scope: 'col', 'aria-colindex': 1 } @@ -13,13 +13,14 @@ %tbody{ role: 'rowgroup' } - integrations.each do |integration| + - activated_label = (integration.activated? ? s_("ProjectService|%{service_title}: status on") : s_("ProjectService|%{service_title}: status off")) % { service_title: integration.title } %tr{ role: 'row' } - %td{ role: 'cell', 'aria-colindex': 1 } - = boolean_to_icon integration.activated? + %td{ role: 'cell', 'aria-colindex': 1, 'aria-label': activated_label } + = boolean_to_icon integration.operating? %td{ role: 'cell', 'aria-colindex': 2 } - = link_to scoped_edit_integration_path(integration) do + = link_to scoped_edit_integration_path(integration), { data: { qa_selector: "#{integration.to_param}_link" } } do %strong= integration.title - %td.d-none.d-sm-block{ role: 'cell', 'aria-colindex': 3 } + %td.d-none.d-sm-table-cell{ role: 'cell', 'aria-colindex': 3 } = integration.description %td{ role: 'cell', 'aria-colindex': 4 } - if integration.updated_at.present? diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index a05a13814ac..4bc6c1dee37 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -5,45 +5,49 @@ = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do .block.issuable-sidebar-header .filter-item.inline.update-issues-btn.float-left - = button_tag "Update all", class: "btn update-selected-issues btn-info", disabled: true - = button_tag "Cancel", class: "btn btn-default js-bulk-update-menu-hide float-right" + = button_tag _('Update all'), class: "btn update-selected-issues btn-info", disabled: true + = button_tag _('Cancel'), class: "btn btn-default js-bulk-update-menu-hide float-right" .block .title - Status + = _('Status') .filter-item - = dropdown_tag("Select status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do + = dropdown_tag(_("Select status"), options: { toggle_class: "js-issue-status", title: _("Change status"), dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: _("Status") } } ) do %ul %li - %a{ href: "#", data: { id: "reopen" } } Open + %a{ href: "#", data: { id: "reopen" } } + = _('Open') %li - %a{ href: "#", data: { id: "close" } } Closed + %a{ href: "#", data: { id: "close" } } + = _('Closed') .block .title - Assignee + = _('Assignee') .filter-item - field_name = "update[assignee_ids][]" - = dropdown_tag("Select assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", - placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) + = dropdown_tag(_("Select assignee"), options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: _("Assign to"), filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + placeholder: _("Search authors"), data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) .block .title - Milestone + = _('Milestone') .filter-item - = dropdown_tag("Select milestone", options: { title: "Assign milestone", toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: project_milestones_path(@project, :json), use_id: true, default_label: "Milestone" } }) + = dropdown_tag(_("Select milestone"), options: { title: _("Assign milestone"), toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: _("Search milestones"), data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: project_milestones_path(@project, :json), use_id: true, default_label: _("Milestone") } }) .block .title - Labels + = _('Labels') .filter-item.labels-filter - = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: "Apply a label", show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: "Labels" }, label_name: "Select labels", no_default_styles: true + = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: _("Apply a label"), show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: _("Labels") }, label_name: _("Select labels"), no_default_styles: true .block .title - Subscriptions + = _('Subscriptions') .filter-item - = dropdown_tag("Select subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do + = dropdown_tag(_("Select subscription"), options: { toggle_class: "js-subscription-event", title: _("Change subscription"), dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: _("Subscription") } } ) do %ul %li - %a{ href: "#", data: { id: "subscribe" } } Subscribe + %a{ href: "#", data: { id: "subscribe" } } + = _('Subscribe') %li - %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe + %a{ href: "#", data: { id: "unsubscribe" } } + = _('Unsubscribe') = hidden_field_tag "update[issuable_ids]", [] = hidden_field_tag :state_event, params[:state_event] diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml index 2eb96a7bc9b..5f7cfdc9d03 100644 --- a/app/views/shared/issuable/_close_reopen_button.html.haml +++ b/app/views/shared/issuable/_close_reopen_button.html.haml @@ -2,17 +2,20 @@ - display_issuable_type = issuable_display_type(issuable) - button_method = issuable_close_reopen_button_method(issuable) - are_close_and_open_buttons_hidden = issuable_button_hidden?(issuable, true) && issuable_button_hidden?(issuable, false) +- add_blocked_class = false +- if defined? warn_before_close + - add_blocked_class = warn_before_close - if is_current_user - if can_update - = link_to "Close #{display_issuable_type}", close_issuable_path(issuable), method: button_method, - class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}", data: { qa_selector: 'close_issue_button' } + = link_to _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, close_issuable_path(issuable), method: button_method, + class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, data: { qa_selector: 'close_issue_button' } - if can_reopen - = link_to "Reopen #{display_issuable_type}", reopen_issuable_path(issuable), method: button_method, - class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}", data: { qa_selector: 'reopen_issue_button' } + = link_to _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, reopen_issuable_path(issuable), method: button_method, + class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, data: { qa_selector: 'reopen_issue_button' } - else - if can_update && !are_close_and_open_buttons_hidden - = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable + = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class - else - = link_to 'Report abuse', new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)), - class: 'd-none d-sm-none d-md-block btn btn-grouped btn-close-color', title: 'Report abuse' + = link_to _('Report abuse'), new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)), + class: 'd-none d-sm-none d-md-block btn btn-grouped btn-close-color', title: _('Report abuse') diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml index 0d59c9304b4..9d718083d2d 100644 --- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml +++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml @@ -5,45 +5,46 @@ - button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button" - toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle" - button_method = issuable_close_reopen_button_method(issuable) +- add_blocked_class = false +- if defined? warn_before_close + - add_blocked_class = !issuable.closed? && warn_before_close .float-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown = link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_path(issuable), - method: button_method, class: "#{button_class} btn-#{button_action}", title: "#{display_button_action} #{display_issuable_type}" + method: button_method, class: "#{button_class} btn-#{button_action} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: "#{display_button_action} #{display_issuable_type}", data: { qa_selector: 'close_issue_button' } = button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color", - data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => 'Toggle dropdown' do + data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => _('Toggle dropdown') do = icon('caret-down', class: 'toggle-icon icon') %ul#issuable-close-menu.js-issuable-close-menu.dropdown-menu{ data: { dropdown: true } } %li.close-item{ class: "#{issuable_button_visibility(issuable, true) || 'droplab-item-selected'}", - data: { text: "Close #{display_issuable_type}", url: close_issuable_path(issuable), + data: { text: _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: close_issuable_path(issuable), button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color", method: button_method } } %button.btn.btn-transparent = icon('check', class: 'icon') .description %strong.title - Close + = _('Close') = display_issuable_type %li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}", - data: { text: "Reopen #{display_issuable_type}", url: reopen_issuable_path(issuable), + data: { text: _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: reopen_issuable_path(issuable), button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color", method: button_method } } %button.btn.btn-transparent = icon('check', class: 'icon') .description %strong.title - Reopen + = _('Reopen') = display_issuable_type %li.divider.droplab-item-ignore - %li.report-item{ data: { text: 'Report abuse', url: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)), + %li.report-item{ data: { text: _('Report abuse'), url: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)), button_class: "#{button_class} btn-close-color", toggle_class: "#{toggle_class} btn-close-color", method: '' } } %button.btn.btn-transparent = icon('check', class: 'icon') .description - %strong.title Report abuse + %strong.title= _('Report abuse') %p.text - Report - = display_issuable_type.pluralize - that are abusive, inappropriate or spam. + = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize } diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index bca5db16bd3..535af522c1a 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -8,10 +8,11 @@ - data_options = local_assigns.fetch(:data_options, {}) - classes = local_assigns.fetch(:classes, []) - selected = local_assigns.fetch(:selected, nil) -- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label") -- dropdown_data = label_dropdown_data(edit_context, labels: labels_filter_path_with_defaults(only_group_labels: edit_context.is_a?(Group)), default_label: "Labels") +- dropdown_title = local_assigns.fetch(:dropdown_title, _('Filter by label')) +- dropdown_data = label_dropdown_data(edit_context, labels: labels_filter_path_with_defaults(only_group_labels: edit_context.is_a?(Group)), default_label: _('Labels')) + - dropdown_data.merge!(data_options) -- label_name = local_assigns.fetch(:label_name, "Labels") +- label_name = local_assigns.fetch(:label_name, _('Labels')) - no_default_styles = local_assigns.fetch(:no_default_styles, false) - classes << 'js-extra-options' if extra_options - classes << 'js-filter-submit' if filter_submit diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml index a0fb5229fc3..43e80c9db27 100644 --- a/app/views/shared/issuable/_label_page_default.html.haml +++ b/app/views/shared/issuable/_label_page_default.html.haml @@ -3,7 +3,7 @@ - show_title = local_assigns.fetch(:show_title, true) - show_create = local_assigns.fetch(:show_create, true) - show_footer = local_assigns.fetch(:show_footer, true) -- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search') +- filter_placeholder = local_assigns.fetch(:filter_placeholder, _('Search')) - show_boards_content = local_assigns.fetch(:show_boards_content, false) - subject = @project || @group .dropdown-page-one diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml index 93408e0bfc0..c715cd8f736 100644 --- a/app/views/shared/issuable/_nav.html.haml +++ b/app/views/shared/issuable/_nav.html.haml @@ -4,20 +4,20 @@ %ul.nav-links.issues-state-filters.mobile-separator.nav.nav-tabs %li{ class: active_when(params[:state] == 'opened') }> - = link_to page_filter_path(state: 'opened'), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", data: { state: 'opened' } do + = link_to page_filter_path(state: 'opened'), id: 'state-opened', title: _("Filter by %{page_context_word} that are currently opened.") % { page_context_word: page_context_word }, data: { state: 'opened' } do #{issuables_state_counter_text(type, :opened, display_count)} - if type == :merge_requests %li{ class: active_when(params[:state] == 'merged') }> - = link_to page_filter_path(state: 'merged'), id: 'state-merged', title: 'Filter by merge requests that are currently merged.', data: { state: 'merged' } do + = link_to page_filter_path(state: 'merged'), id: 'state-merged', title: _('Filter by merge requests that are currently merged.'), data: { state: 'merged' } do #{issuables_state_counter_text(type, :merged, display_count)} %li{ class: active_when(params[:state] == 'closed') }> - = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.', data: { state: 'closed' } do + = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: _('Filter by merge requests that are currently closed and unmerged.'), data: { state: 'closed' } do #{issuables_state_counter_text(type, :closed, display_count)} - else %li{ class: active_when(params[:state] == 'closed') }> - = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: 'Filter by issues that are currently closed.', data: { state: 'closed', qa_selector: 'closed_issues_link' } do + = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: _('Filter by issues that are currently closed.'), data: { state: 'closed', qa_selector: 'closed_issues_link' } do #{issuables_state_counter_text(type, :closed, display_count)} = render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all, display_count) diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index d9ca0b8869f..34be9291f1f 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -1,6 +1,7 @@ - type = local_assigns.fetch(:type) - board = local_assigns.fetch(:board, nil) - show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true) +- placeholder = local_assigns[:placeholder] || _('Search or filter results...') - is_not_boards_modal_or_productivity_analytics = type != :boards_modal && type != :productivity_analytics - block_css_class = is_not_boards_modal_or_productivity_analytics ? 'row-content-block second-block' : '' - user_can_admin_list = board && can?(current_user, :admin_list, board.resource_parent) @@ -29,7 +30,7 @@ .scroll-container %ul.tokens-container.list-unstyled %li.input-token - %input.form-control.filtered-search{ search_filter_input_options(type) } + %input.form-control.filtered-search{ search_filter_input_options(type, placeholder) } #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } } @@ -73,6 +74,7 @@ user: User.new(username: '{{username}}', name: '{{name}}'), avatar: { lazy: true, url: '{{avatar_url}}' } = render_if_exists 'shared/issuable/approver_dropdown' + = render_if_exists 'shared/issuable/approved_by_dropdown' #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'None' } } diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index e20573ed3a7..a1c56cdb64f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -15,7 +15,7 @@ - if signed_in %span.issuable-header-text.hide-collapsed.float-left = _('To Do') - %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } } + %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } } = sidebar_gutter_toggle_icon - if signed_in = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar @@ -65,7 +65,7 @@ .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) } = icon('calendar', 'aria-hidden': 'true') %span.js-due-date-sidebar-value - = issuable_sidebar[:due_date].try(:to_s, :medium) || 'None' + = issuable_sidebar[:due_date].try(:to_s, :medium) || _('None') .title.hide-collapsed = _('Due date') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index b5a27f2f17d..4192ecd2238 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -1,7 +1,7 @@ - issuable_type = issuable_sidebar[:type] - signed_in = !!issuable_sidebar.dig(:current_user, :id) -#js-vue-sidebar-assignees{ data: { field: "#{issuable_type}", signed_in: signed_in } } +#js-vue-sidebar-assignees{ data: { field: issuable_type, signed_in: signed_in } } .title.hide-collapsed = _('Assignee') .spinner.spinner-sm.align-bottom diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index d8253924e0a..3794a3b3845 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -6,7 +6,7 @@ - source_title, target_title = format_mr_branch_names(@merge_request) -.form-group.row.d-flex.gl-pl-3.gl-pr-3.branch-selector +.form-group.row.d-flex.gl-pl-3-deprecated-no-really-do-not-use-me.gl-pr-3-deprecated-no-really-do-not-use-me.branch-selector .align-self-center %span = _('From <code>%{source_title}</code> into').html_safe % { source_title: source_title } diff --git a/app/views/shared/members/_badge.html.haml b/app/views/shared/members/_badge.html.haml new file mode 100644 index 00000000000..e304207f3e9 --- /dev/null +++ b/app/views/shared/members/_badge.html.haml @@ -0,0 +1,4 @@ +- type ||= 'info' + +%span.px-1.py-1 + %span{ class: "badge badge-#{type}" }= yield diff --git a/app/views/shared/members/_blocked_badge.html.haml b/app/views/shared/members/_blocked_badge.html.haml new file mode 100644 index 00000000000..95335ebe74d --- /dev/null +++ b/app/views/shared/members/_blocked_badge.html.haml @@ -0,0 +1,3 @@ +- if user.blocked? + = render 'shared/members/badge', type: 'danger' do + = _("Blocked") diff --git a/app/views/shared/members/_its_you_badge.html.haml b/app/views/shared/members/_its_you_badge.html.haml new file mode 100644 index 00000000000..b53ffd8032d --- /dev/null +++ b/app/views/shared/members/_its_you_badge.html.haml @@ -0,0 +1,3 @@ +- if user == current_user + = render 'shared/members/badge', type: 'success' do + = _("It's you") diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index d74030c566f..f7d90a588c7 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -13,24 +13,23 @@ - if user = image_tag avatar_icon_for_user(user, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '' .user-info - = link_to user.name, user_path(user), class: 'member js-user-link', data: { user_id: user.id } - = user_status(user) - %span.cgray= user.to_reference + %span.mr-1 + = link_to user.name, user_path(user), class: 'member js-user-link', data: { user_id: user.id } + = user_status(user) + %span.cgray= user.to_reference - = render_if_exists 'shared/members/ee/sso_badge', member: member + .mx-n1.d-inline-flex.flex-wrap + = render_if_exists 'shared/members/ee/sso_badge', member: member - - if user == current_user - %span.badge.badge-success.prepend-left-5= _("It's you") + = render_if_exists 'shared/members/ee/gma_badge', member: member - = render_if_exists 'shared/members/ee/license_badge', user: user, group: @group + = render 'shared/members/its_you_badge', user: user, current_user: current_user - - if user.blocked? - %label.badge.badge-danger - %strong= _("Blocked") + = render_if_exists 'shared/members/ee/license_badge', user: user, group: @group - - if user.two_factor_enabled? - %label.badge.badge-info - = _("2FA") + = render 'shared/members/blocked_badge', user: user + + = render 'shared/members/two_factor_auth_badge', user: user - if source.instance_of?(Group) && source != @group · @@ -68,7 +67,7 @@ class: 'btn btn-default align-self-center mr-sm-2', title: _('Resend invite') - - if user != current_user && member.can_update? + - if user != current_user && member.can_update? && !user&.project_bot? = form_for member, remote: true, html: { class: "js-edit-member-form form-group #{'d-sm-flex' unless force_mobile_view}" } do |f| = f.hidden_field :access_level .member-form-control.dropdown{ class: [("mr-sm-2 d-sm-inline-block" unless force_mobile_view)] } @@ -118,7 +117,7 @@ method: :delete, data: { confirm: leave_confirmation_message(member.source) }, class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}" - - else + - elsif !user&.project_bot? = link_to member, method: :delete, data: { confirm: remove_member_message(member), qa_selector: 'delete_member_button' }, diff --git a/app/views/shared/members/_two_factor_auth_badge.html.haml b/app/views/shared/members/_two_factor_auth_badge.html.haml new file mode 100644 index 00000000000..34850c135d6 --- /dev/null +++ b/app/views/shared/members/_two_factor_auth_badge.html.haml @@ -0,0 +1,3 @@ +- if user.two_factor_enabled? + = render 'shared/members/badge', type: 'info' do + = _("2FA") diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index 0adfe2f0c04..f8bf3e7ad6a 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -25,5 +25,5 @@ %span.assignee-icon - assignees.each do |assignee| = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }), - class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do + class: 'has-tooltip', title: _("Assigned to %{assignee_name}") % { assignee_name: assignee.name }, data: { container: 'body' } do - image_tag(avatar_icon_for_user(assignee, 16), class: "avatar s16", alt: '') diff --git a/app/views/shared/milestones/_issues_tab.html.haml b/app/views/shared/milestones/_issues_tab.html.haml index d7e4f2ed5a0..6684f6d752a 100644 --- a/app/views/shared/milestones/_issues_tab.html.haml +++ b/app/views/shared/milestones/_issues_tab.html.haml @@ -8,8 +8,8 @@ .row.prepend-top-default .col-md-4 - = render 'shared/milestones/issuables', args.merge(title: 'Unstarted Issues (open and unassigned)', issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true) + = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Unstarted Issues (open and unassigned)'), issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true) .col-md-4 - = render 'shared/milestones/issuables', args.merge(title: 'Ongoing Issues (open and assigned)', issuables: issues.opened.assigned, id: 'ongoing', show_counter: true) + = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Ongoing Issues (open and assigned)'), issuables: issues.opened.assigned, id: 'ongoing', show_counter: true) .col-md-4 - = render 'shared/milestones/issuables', args.merge(title: 'Completed Issues (closed)', issuables: issues.closed, id: 'closed', show_counter: true) + = render 'shared/milestones/issuables', args.merge(title: s_('Milestones|Completed Issues (closed)'), issuables: issues.closed, id: 'closed', show_counter: true) diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index 6d79b0d31b2..3b4d29ca7b0 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -9,6 +9,6 @@ .float-right.d-none.d-lg-block.d-xl-block = link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue' + - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), _('open issue') = link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue' + - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), _('closed issue') diff --git a/app/views/shared/milestones/_merge_requests_tab.haml b/app/views/shared/milestones/_merge_requests_tab.haml index 9c193f901e2..4dba2473efc 100644 --- a/app/views/shared/milestones/_merge_requests_tab.haml +++ b/app/views/shared/milestones/_merge_requests_tab.haml @@ -3,10 +3,10 @@ .row.prepend-top-default .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Work in progress (open and unassigned)', issuables: merge_requests.opened.unassigned, id: 'unassigned', show_counter: true) + = render 'shared/milestones/issuables', args.merge(title: _('Work in progress (open and unassigned)'), issuables: merge_requests.opened.unassigned, id: 'unassigned', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Waiting for merge (open and assigned)', issuables: merge_requests.opened.assigned, id: 'ongoing', show_counter: true) + = render 'shared/milestones/issuables', args.merge(title: _('Waiting for merge (open and assigned)'), issuables: merge_requests.opened.assigned, id: 'ongoing', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Rejected (closed)', issuables: merge_requests.closed, id: 'closed', show_counter: true) + = render 'shared/milestones/issuables', args.merge(title: _('Rejected (closed)'), issuables: merge_requests.closed, id: 'closed', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Merged', issuables: merge_requests.merged, id: 'merged', primary: true, show_counter: true) + = render 'shared/milestones/issuables', args.merge(title: _('Merged'), issuables: merge_requests.merged, id: 'merged', primary: true, show_counter: true) diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 451c2c2ba10..9f61082d605 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -1,6 +1,6 @@ - dashboard = local_assigns[:dashboard] - custom_dom_id = dom_id(milestone.try(:milestone) ? milestone.milestone : milestone) -- milestone_type = milestone.group_milestone? ? 'Group Milestone' : 'Project Milestone' +- milestone_type = milestone.group_milestone? ? s_('Milestones|Group Milestone') : s_('Milestones|Project Milestone') %li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id } .row @@ -42,17 +42,17 @@ .col-sm-4.milestone-progress = milestone_progress_bar(milestone) - = link_to pluralize(milestone.total_issues_count, 'Issue'), issues_path + = link_to pluralize(milestone.total_issues_count, _('Issue')), issues_path - if milestone.merge_requests_enabled? · - = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, 'Merge Request'), merge_requests_path + = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, _('Merge Request')), merge_requests_path .float-lg-right.light #{milestone.percent_complete}% complete .col-sm-2 .milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end - if @project - if can_admin_project_milestones? and milestone.active? - if can_admin_group_milestones? - %button.js-promote-project-milestone-button.btn.btn-blank.btn-sm.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'), + %button.js-promote-project-milestone-button.btn.btn-blank.btn-sm.btn-grouped.has-tooltip{ title: s_('Milestones|Promote to Group Milestone'), disabled: true, type: 'button', data: { url: promote_project_milestone_path(milestone.project, milestone), @@ -63,15 +63,15 @@ toggle: 'modal' } } = sprite_icon('level-up', size: 14) - = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-sm btn-close btn-grouped" + = link_to s_('Milestones|Close Milestone'), project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-sm btn-close btn-grouped" - unless milestone.active? - = link_to 'Reopen Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen" + = link_to s_('Milestones|Reopen Milestone'), project_milestone_path(@project, milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen" - if @group - if can?(current_user, :admin_milestone, @group) - if milestone.closed? - = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen" + = link_to s_('Milestones|Reopen Milestone'), group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen" - else - = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close" + = link_to s_('Milestones|Close Milestone'), group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close" - if dashboard .label-badge.label-badge-gray = milestone_type diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 8d911d4247e..5f53e6316af 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -21,10 +21,10 @@ %table.table %thead %tr - %th Project - %th Open issues - %th State - %th Due date + %th= _('Project') + %th= _('Open issues') + %th= _('State') + %th= _('Due date') %tr %td - project_name = group ? milestone.project.name : milestone.project.full_name @@ -33,8 +33,8 @@ = milestone.milestone.issues_visible_to_user(current_user).opened.count %td - if milestone.closed? - Closed + = _('Closed') - else - Open + = _('Open') %td = milestone.expires_at diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index d91bc6e57c9..327745e4f4d 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -5,7 +5,7 @@ - else - preview_url = preview_markdown_path(@project) -= form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| += form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form discussion-reply-holder", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| = hidden_field_tag :view, diff_view = hidden_field_tag :line_type = hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha) @@ -24,7 +24,7 @@ -# DiffNote = f.hidden_field :position - .discussion-form-container + .discussion-form-container.discussion-with-resolve-btn.flex-column.p-0 = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: f, attr: :note, diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 50bc4fb35df..df09c4338a1 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -40,9 +40,10 @@ - if note.system %span.system-note-message = markdown_field(note, :note) - %span.system-note-separator - · - %a.system-note-separator{ href: "##{dom_id(note)}" }= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') + - if note.created_at + %span.system-note-separator + · + %a.system-note-separator{ href: "##{dom_id(note)}" }= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') - unless note.system? .note-actions - if note.for_personal_snippet? diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 396b6e56ea9..4695692fb53 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -1,7 +1,3 @@ -- if Feature.disabled?(:monaco_snippets) - - content_for :page_specific_javascripts do - = page_specific_javascript_tag('lib/ace.js') - - if Feature.enabled?(:snippets_edit_vue) #js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access") } } - else @@ -24,7 +20,7 @@ = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' } .js-expanded{ class: ('d-none' if !is_expanded) } = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'description_field' + = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'snippet_description_field' = render 'shared/notes/hints' .form-group.file-editor @@ -48,9 +44,9 @@ .form-actions - if @snippet.new_record? - = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button" + = f.submit 'Create snippet', class: "btn-success btn", data: { qa_selector: 'submit_button' } - else - = f.submit 'Save changes', class: "btn-success btn" + = f.submit 'Save changes', class: "btn-success btn", data: { qa_selector: 'submit_button' } - if @snippet.project_id = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel" diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 1243bdab6dd..e663d57ae6a 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -21,7 +21,7 @@ = markdown_field(@snippet, :title) - if @snippet.description.present? - .description{ data: { qa_selector: 'snippet_description' } } + .description{ data: { qa_selector: 'snippet_description_field' } } .md = markdown_field(@snippet, :description) %textarea.hidden.js-task-list-field @@ -34,7 +34,7 @@ .embed-snippet .input-group .input-group-prepend - %button.btn.btn-svg.embed-toggle.input-group-text.qa-embed-type{ 'data-toggle': 'dropdown', type: 'button' } + %button.btn.btn-svg.embed-toggle.input-group-text{ 'data-toggle': 'dropdown', type: 'button' } %span.js-embed-action= _("Embed") = sprite_icon('angle-down', size: 12, css_class: 'caret-down') %ul.dropdown-menu.dropdown-menu-selectable.embed-toggle-list diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 3fea2c1e3fc..128ddbb8e8b 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -1,6 +1,5 @@ - link_project = local_assigns.fetch(:link_project, false) - notes_count = @noteable_meta_data[snippet.id].user_notes_count -- file_name = snippet_file_name(snippet) %li.snippet-row.py-3 = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: '' @@ -8,10 +7,6 @@ .title = link_to gitlab_snippet_path(snippet) do = snippet.title - - if file_name.present? - %span.snippet-filename.d-none.d-sm-inline-block.ml-2 - = sprite_icon('doc-code', size: 16, css_class: 'file-icon align-text-bottom') - = file_name %ul.controls %li diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml index a5d3e1c8de0..82e32597c94 100644 --- a/app/views/shared/tokens/_scopes_form.html.haml +++ b/app/views/shared/tokens/_scopes_form.html.haml @@ -6,4 +6,4 @@ %fieldset.form-group.form-check = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: "form-check-input qa-#{scope}-radio" = label_tag ("#{prefix}_scopes_#{scope}"), scope, class: 'label-bold form-check-label' - .text-secondary= t scope, scope: [:doorkeeper, :scope_desc] + .text-secondary= t scope, scope: scope_description(prefix) diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 741e38e3d84..819f02b78fe 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -4,7 +4,7 @@ - breadcrumb_title @snippet.to_reference - page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") -- if Feature.enabled?(:snippets_vue) +- if Feature.enabled?(:snippets_vue, default_enabled: true) #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} } - else = render 'shared/snippets/header' diff --git a/app/views/users/_deletion_guidance.html.haml b/app/views/users/_deletion_guidance.html.haml index 0545cab538c..7169aebea74 100644 --- a/app/views/users/_deletion_guidance.html.haml +++ b/app/views/users/_deletion_guidance.html.haml @@ -3,8 +3,9 @@ %ul %li %p - Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the - = link_to 'user account deletion documentation.', help_page_path("user/profile/account/delete_account", anchor: "associated-records") + - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path("user/profile/account/delete_account", anchor: "associated-records") } + = _('Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the %{link_start}user account deletion documentation.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - personal_projects_count = user.personal_projects.count - unless personal_projects_count.zero? - %li #{pluralize(personal_projects_count, 'personal project')} will be removed and cannot be restored + %li + = n_('personal project will be removed and cannot be restored', '%d personal projects will be removed and cannot be restored', personal_projects_count) diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml index 7516dfe1602..a5197a9950b 100644 --- a/app/views/users/calendar_activities.html.haml +++ b/app/views/users/calendar_activities.html.haml @@ -22,14 +22,14 @@ - elsif event.target = link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title - at + = s_('UserProfile|at') %strong - if event.project = link_to_project(event.project) - else = event.resource_parent_name - else - made a private contribution + = s_('UserProfile|made a private contribution') - else %p = _('No contributions were found') diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 57d41bfaec2..1f9a53d64d9 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -3,6 +3,20 @@ # # Do not edit it manually! --- +- :name: authorized_project_update:authorized_project_update_project_create + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true +- :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true - :name: auto_devops:auto_devops_disable :feature_category: :auto_devops :has_external_dependencies: @@ -18,35 +32,35 @@ :weight: 3 :idempotent: - :name: chaos:chaos_cpu_spin - :feature_category: :chaos_engineering + :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 :idempotent: - :name: chaos:chaos_db_spin - :feature_category: :chaos_engineering + :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 :idempotent: - :name: chaos:chaos_kill - :feature_category: :chaos_engineering + :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 :idempotent: - :name: chaos:chaos_leak_mem - :feature_category: :chaos_engineering + :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 :idempotent: - :name: chaos:chaos_sleep - :feature_category: :chaos_engineering + :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -269,6 +283,13 @@ :resource_boundary: :unknown :weight: 1 :idempotent: +- :name: cronjob:x509_issuer_crl_check + :feature_category: :source_code_management + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true - :name: deployment:deployments_finished :feature_category: :continuous_delivery :has_external_dependencies: @@ -290,13 +311,6 @@ :resource_boundary: :cpu :weight: 3 :idempotent: -- :name: gcp_cluster:cluster_configure - :feature_category: :kubernetes_management - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: - :name: gcp_cluster:cluster_configure_istio :feature_category: :kubernetes_management :has_external_dependencies: true @@ -318,13 +332,6 @@ :resource_boundary: :unknown :weight: 1 :idempotent: -- :name: gcp_cluster:cluster_project_configure - :feature_category: :kubernetes_management - :has_external_dependencies: true - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: - :name: gcp_cluster:cluster_provision :feature_category: :kubernetes_management :has_external_dependencies: true @@ -689,7 +696,7 @@ :resource_boundary: :unknown :weight: 1 :idempotent: -- :name: pipeline_background:ci_daily_report_results +- :name: pipeline_background:ci_daily_build_group_report_results :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -849,14 +856,14 @@ :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: true - :name: pipeline_processing:update_head_pipeline_for_merge_request :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 5 - :idempotent: + :idempotent: true - :name: repository_check:repository_check_batch :feature_category: :source_code_management :has_external_dependencies: @@ -961,7 +968,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: true - :name: create_evidence :feature_category: :release_evidence :has_external_dependencies: @@ -1011,6 +1018,13 @@ :resource_boundary: :unknown :weight: 1 :idempotent: +- :name: design_management_new_version + :feature_category: :design_management + :has_external_dependencies: + :urgency: :low + :resource_boundary: :memory + :weight: 1 + :idempotent: - :name: detect_repository_languages :feature_category: :source_code_management :has_external_dependencies: @@ -1053,6 +1067,13 @@ :resource_boundary: :cpu :weight: 1 :idempotent: +- :name: external_service_reactive_caching + :feature_category: :not_owned + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: - :name: file_hook :feature_category: :integrations :has_external_dependencies: @@ -1143,7 +1164,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: true - :name: migrate_external_diffs :feature_category: :source_code_management :has_external_dependencies: @@ -1220,7 +1241,7 @@ :urgency: :high :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: true - :name: project_cache :feature_category: :source_code_management :has_external_dependencies: @@ -1280,7 +1301,7 @@ - :name: reactive_caching :feature_category: :not_owned :has_external_dependencies: - :urgency: :high + :urgency: :low :resource_boundary: :cpu :weight: 1 :idempotent: diff --git a/app/workers/authorized_project_update/project_create_worker.rb b/app/workers/authorized_project_update/project_create_worker.rb new file mode 100644 index 00000000000..651849b57ec --- /dev/null +++ b/app/workers/authorized_project_update/project_create_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module AuthorizedProjectUpdate + class ProjectCreateWorker + include ApplicationWorker + + feature_category :authentication_and_authorization + urgency :low + queue_namespace :authorized_project_update + + idempotent! + + def perform(project_id) + project = Project.find(project_id) + + AuthorizedProjectUpdate::ProjectCreateService.new(project).execute + end + end +end diff --git a/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb new file mode 100644 index 00000000000..19038cb8900 --- /dev/null +++ b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module AuthorizedProjectUpdate + class UserRefreshWithLowUrgencyWorker < ::AuthorizedProjectsWorker + feature_category :authentication_and_authorization + urgency :low + queue_namespace :authorized_project_update + + idempotent! + end +end diff --git a/app/workers/ci/daily_report_results_worker.rb b/app/workers/ci/daily_build_group_report_results_worker.rb index 314fd44f86c..a6d3c485e24 100644 --- a/app/workers/ci/daily_report_results_worker.rb +++ b/app/workers/ci/daily_build_group_report_results_worker.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class DailyReportResultsWorker + class DailyBuildGroupReportResultsWorker include ApplicationWorker include PipelineBackgroundQueue @@ -9,7 +9,7 @@ module Ci def perform(pipeline_id) Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| - Ci::DailyReportResultService.new.execute(pipeline) + Ci::DailyBuildGroupReportResultService.new.execute(pipeline) end end end diff --git a/app/workers/cluster_configure_worker.rb b/app/workers/cluster_configure_worker.rb deleted file mode 100644 index f9364ab7144..00000000000 --- a/app/workers/cluster_configure_worker.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -class ClusterConfigureWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - include ClusterQueue - - def perform(cluster_id) - # Scheduled for removal in https://gitlab.com/gitlab-org/gitlab-foss/issues/59319 - end -end diff --git a/app/workers/cluster_project_configure_worker.rb b/app/workers/cluster_project_configure_worker.rb deleted file mode 100644 index b68df01dc7a..00000000000 --- a/app/workers/cluster_project_configure_worker.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class ClusterProjectConfigureWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - include ClusterQueue - - worker_has_external_dependencies! - - def perform(project_id) - # Scheduled for removal in https://gitlab.com/gitlab-org/gitlab-foss/issues/59319 - end -end diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index c0062780688..7ab9a0c2a02 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -11,6 +11,8 @@ module ApplicationWorker include WorkerAttributes include WorkerContext + LOGGING_EXTRA_KEY = 'extra' + included do set_queue @@ -24,6 +26,21 @@ module ApplicationWorker payload.stringify_keys.merge(context) end + + def log_extra_metadata_on_done(key, value) + @done_log_extra_metadata ||= {} + @done_log_extra_metadata[key] = value + end + + def logging_extras + return {} unless @done_log_extra_metadata + + # Prefix keys with class name to avoid conflicts in Elasticsearch types. + # Also prefix with "extra." so that we know to log these new fields. + @done_log_extra_metadata.transform_keys do |k| + "#{LOGGING_EXTRA_KEY}.#{self.class.name.gsub("::", "_").underscore}.#{k}" + end + end end class_methods do diff --git a/app/workers/concerns/chaos_queue.rb b/app/workers/concerns/chaos_queue.rb index c5db10491f2..a9c557f0175 100644 --- a/app/workers/concerns/chaos_queue.rb +++ b/app/workers/concerns/chaos_queue.rb @@ -5,6 +5,6 @@ module ChaosQueue included do queue_namespace :chaos - feature_category :chaos_engineering + feature_category_not_owned! end end diff --git a/app/workers/concerns/reactive_cacheable_worker.rb b/app/workers/concerns/reactive_cacheable_worker.rb new file mode 100644 index 00000000000..e73707c2b43 --- /dev/null +++ b/app/workers/concerns/reactive_cacheable_worker.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ReactiveCacheableWorker + extend ActiveSupport::Concern + + included do + include ApplicationWorker + + feature_category_not_owned! + + def self.context_for_arguments(arguments) + class_name, *_other_args = arguments + Gitlab::ApplicationContext.new(related_class: class_name.to_s) + end + end + + def perform(class_name, id, *args) + klass = begin + class_name.constantize + rescue NameError + nil + end + + return unless klass + + klass + .reactive_cache_worker_finder + .call(id, *args) + .try(:exclusively_update_reactive_cache!, *args) + rescue ReactiveCaching::ExceededReactiveCacheLimit => e + Gitlab::ErrorTracking.track_exception(e) + end +end diff --git a/app/workers/create_commit_signature_worker.rb b/app/workers/create_commit_signature_worker.rb index 9cbc75f8944..a88d2bf7d15 100644 --- a/app/workers/create_commit_signature_worker.rb +++ b/app/workers/create_commit_signature_worker.rb @@ -1,11 +1,13 @@ # frozen_string_literal: true -class CreateCommitSignatureWorker # rubocop:disable Scalability/IdempotentWorker +class CreateCommitSignatureWorker include ApplicationWorker feature_category :source_code_management weight 2 + idempotent! + # rubocop: disable CodeReuse/ActiveRecord def perform(commit_shas, project_id) # Older versions of Git::BranchPushService may push a single commit ID on diff --git a/app/workers/design_management/new_version_worker.rb b/app/workers/design_management/new_version_worker.rb new file mode 100644 index 00000000000..3634dcbcebd --- /dev/null +++ b/app/workers/design_management/new_version_worker.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DesignManagement + class NewVersionWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + feature_category :design_management + # Declare this worker as memory bound due to + # `GenerateImageVersionsService` resizing designs + worker_resource_boundary :memory + + def perform(version_id) + version = DesignManagement::Version.find(version_id) + + add_system_note(version) + generate_image_versions(version) + rescue ActiveRecord::RecordNotFound => e + Sidekiq.logger.warn(e) + end + + private + + def add_system_note(version) + SystemNoteService.design_version_added(version) + end + + def generate_image_versions(version) + DesignManagement::GenerateImageVersionsService.new(version).execute + end + end +end diff --git a/app/workers/external_service_reactive_caching_worker.rb b/app/workers/external_service_reactive_caching_worker.rb new file mode 100644 index 00000000000..e3104b44a7f --- /dev/null +++ b/app/workers/external_service_reactive_caching_worker.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ExternalServiceReactiveCachingWorker # rubocop:disable Scalability/IdempotentWorker + include ReactiveCacheableWorker + + worker_has_external_dependencies! +end diff --git a/app/workers/gitlab/jira_import/import_issue_worker.rb b/app/workers/gitlab/jira_import/import_issue_worker.rb index 7ace0a35fd9..78de5cf1307 100644 --- a/app/workers/gitlab/jira_import/import_issue_worker.rb +++ b/app/workers/gitlab/jira_import/import_issue_worker.rb @@ -28,19 +28,35 @@ module Gitlab private def create_issue(issue_attributes, project_id) + label_ids = issue_attributes.delete('label_ids') + assignee_ids = issue_attributes.delete('assignee_ids') issue_id = insert_and_return_id(issue_attributes, Issue) - label_issue(project_id, issue_id) + label_issue(project_id, issue_id, label_ids) + assign_issue(project_id, issue_id, assignee_ids) issue_id end - def label_issue(project_id, issue_id) - label_id = JiraImport.get_import_label_id(project_id) - return unless label_id + def label_issue(project_id, issue_id, label_ids) + label_link_attrs = label_ids.to_a.map do |label_id| + build_label_attrs(issue_id, label_id.to_i) + end - label_link_attrs = build_label_attrs(issue_id, label_id.to_i) - insert_and_return_id(label_link_attrs, LabelLink) + import_label_id = JiraImport.get_import_label_id(project_id) + return unless import_label_id + + label_link_attrs << build_label_attrs(issue_id, import_label_id.to_i) + + Gitlab::Database.bulk_insert(LabelLink.table_name, label_link_attrs) + end + + def assign_issue(project_id, issue_id, assignee_ids) + return if assignee_ids.blank? + + assignee_attrs = assignee_ids.map { |user_id| { issue_id: issue_id, user_id: user_id } } + + Gitlab::Database.bulk_insert(IssueAssignee.table_name, assignee_attrs) end def build_label_attrs(issue_id, label_id) diff --git a/app/workers/group_import_worker.rb b/app/workers/group_import_worker.rb index b6fc5afc28c..d8f236013bf 100644 --- a/app/workers/group_import_worker.rb +++ b/app/workers/group_import_worker.rb @@ -2,14 +2,23 @@ class GroupImportWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - include ExceptionBacktrace + sidekiq_options retry: false feature_category :importers def perform(user_id, group_id) current_user = User.find(user_id) group = Group.find(group_id) + group_import = group.build_import_state(jid: self.jid) + + group_import.start! ::Groups::ImportExport::ImportService.new(group: group, user: current_user).execute + + group_import.finish! + rescue StandardError => e + group_import&.fail_op(e.message) + + raise e end end diff --git a/app/workers/incident_management/process_alert_worker.rb b/app/workers/incident_management/process_alert_worker.rb index 8d4294cc231..2ce9fe359b5 100644 --- a/app/workers/incident_management/process_alert_worker.rb +++ b/app/workers/incident_management/process_alert_worker.rb @@ -7,11 +7,14 @@ module IncidentManagement queue_namespace :incident_management feature_category :incident_management - def perform(project_id, alert) + def perform(project_id, alert_payload, am_alert_id = nil) project = find_project(project_id) return unless project - create_issue(project, alert) + new_issue = create_issue(project, alert_payload) + return unless am_alert_id && new_issue.persisted? + + link_issue_with_alert(am_alert_id, new_issue.id) end private @@ -20,10 +23,24 @@ module IncidentManagement Project.find_by_id(project_id) end - def create_issue(project, alert) + def create_issue(project, alert_payload) IncidentManagement::CreateIssueService - .new(project, alert) + .new(project, alert_payload) .execute end + + def link_issue_with_alert(alert_id, issue_id) + alert = AlertManagement::Alert.find_by_id(alert_id) + return unless alert + + return if alert.update(issue_id: issue_id) + + Gitlab::AppLogger.warn( + message: 'Cannot link an Issue with Alert', + issue_id: issue_id, + alert_id: alert_id, + alert_errors: alert.errors.messages + ) + end end end diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb index 73bc050d7be..7622f40a949 100644 --- a/app/workers/irker_worker.rb +++ b/app/workers/irker_worker.rb @@ -53,7 +53,7 @@ class IrkerWorker # rubocop:disable Scalability/IdempotentWorker def sendtoirker(privmsg) to_send = { to: @channels, privmsg: privmsg } - @socket.puts JSON.dump(to_send) + @socket.puts Gitlab::Json.dump(to_send) end def close_connection diff --git a/app/workers/merge_request_mergeability_check_worker.rb b/app/workers/merge_request_mergeability_check_worker.rb index a26c1a886f6..1a84efb4e52 100644 --- a/app/workers/merge_request_mergeability_check_worker.rb +++ b/app/workers/merge_request_mergeability_check_worker.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true -class MergeRequestMergeabilityCheckWorker # rubocop:disable Scalability/IdempotentWorker +class MergeRequestMergeabilityCheckWorker include ApplicationWorker feature_category :source_code_management + idempotent! def perform(merge_request_id) merge_request = MergeRequest.find_by_id(merge_request_id) diff --git a/app/workers/new_release_worker.rb b/app/workers/new_release_worker.rb index 3c19e5f3d2b..fa4703d10f2 100644 --- a/app/workers/new_release_worker.rb +++ b/app/workers/new_release_worker.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +# TODO: Worker can be removed in 13.2: +# https://gitlab.com/gitlab-org/gitlab/-/issues/218231 class NewReleaseWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker diff --git a/app/workers/pages_domain_ssl_renewal_cron_worker.rb b/app/workers/pages_domain_ssl_renewal_cron_worker.rb index 43fb35c5298..fe6d516d3cf 100644 --- a/app/workers/pages_domain_ssl_renewal_cron_worker.rb +++ b/app/workers/pages_domain_ssl_renewal_cron_worker.rb @@ -10,11 +10,6 @@ class PagesDomainSslRenewalCronWorker # rubocop:disable Scalability/IdempotentWo return unless ::Gitlab::LetsEncrypt.enabled? PagesDomain.need_auto_ssl_renewal.with_logging_info.find_each do |domain| - # Ideally that should be handled in PagesDomain.need_auto_ssl_renewal scope - # but it's hard to make scope work with feature flags - # once we remove feature flag we can modify scope to implement this behaviour - next if Feature.enabled?(:pages_letsencrypt_errors, domain.project) && domain.auto_ssl_failed - with_context(project: domain.project) do PagesDomainSslRenewalWorker.perform_async(domain.id) end diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 9960e812a2f..bdfabea8938 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -7,13 +7,15 @@ # result of this the workload of this worker should be kept to a bare minimum. # Consider using an extra worker if you need to add any extra (and potentially # slow) processing of commits. -class ProcessCommitWorker # rubocop:disable Scalability/IdempotentWorker +class ProcessCommitWorker include ApplicationWorker feature_category :source_code_management urgency :high weight 3 + idempotent! + # project_id - The ID of the project this commit belongs to. # user_id - The ID of the user that pushed the commit. # commit_hash - Hash containing commit details to use for constructing a diff --git a/app/workers/project_update_repository_storage_worker.rb b/app/workers/project_update_repository_storage_worker.rb index ecee33e6421..5c1a8062f12 100644 --- a/app/workers/project_update_repository_storage_worker.rb +++ b/app/workers/project_update_repository_storage_worker.rb @@ -5,9 +5,19 @@ class ProjectUpdateRepositoryStorageWorker # rubocop:disable Scalability/Idempot feature_category :gitaly - def perform(project_id, new_repository_storage_key) - project = Project.find(project_id) + def perform(project_id, new_repository_storage_key, repository_storage_move_id = nil) + repository_storage_move = + if repository_storage_move_id + ProjectRepositoryStorageMove.find(repository_storage_move_id) + else + # maintain compatibility with workers queued before release + project = Project.find(project_id) + project.repository_storage_moves.create!( + source_storage_name: project.repository_storage, + destination_storage_name: new_repository_storage_key + ) + end - ::Projects::UpdateRepositoryStorageService.new(project).execute(new_repository_storage_key) + ::Projects::UpdateRepositoryStorageService.new(repository_storage_move).execute end end diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb index 513033281e5..a0829c31280 100644 --- a/app/workers/reactive_caching_worker.rb +++ b/app/workers/reactive_caching_worker.rb @@ -1,36 +1,8 @@ # frozen_string_literal: true class ReactiveCachingWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker + include ReactiveCacheableWorker - feature_category_not_owned! - - # TODO: The reactive caching worker should be split into - # two different workers, one for high urgency jobs without external dependencies - # and another worker without high urgency, but with external dependencies - # https://gitlab.com/gitlab-com/gl-infra/scalability/issues/34 - # This worker should also have `worker_has_external_dependencies!` enabled - urgency :high + urgency :low worker_resource_boundary :cpu - - def self.context_for_arguments(arguments) - class_name, *_other_args = arguments - Gitlab::ApplicationContext.new(related_class: class_name.to_s) - end - - def perform(class_name, id, *args) - klass = begin - class_name.constantize - rescue NameError - nil - end - return unless klass - - klass - .reactive_cache_worker_finder - .call(id, *args) - .try(:exclusively_update_reactive_cache!, *args) - rescue ReactiveCaching::ExceededReactiveCacheLimit => e - Gitlab::ErrorTracking.track_exception(e) - end end diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb index aface8288e3..20db19536c3 100644 --- a/app/workers/stage_update_worker.rb +++ b/app/workers/stage_update_worker.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true -class StageUpdateWorker # rubocop:disable Scalability/IdempotentWorker +class StageUpdateWorker include ApplicationWorker include PipelineQueue queue_namespace :pipeline_processing urgency :high + idempotent! + def perform(stage_id) Ci::Stage.find_by_id(stage_id)&.update_legacy_status end diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb index 69698ba81bd..63d11d33283 100644 --- a/app/workers/update_head_pipeline_for_merge_request_worker.rb +++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UpdateHeadPipelineForMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker +class UpdateHeadPipelineForMergeRequestWorker include ApplicationWorker include PipelineQueue @@ -9,6 +9,8 @@ class UpdateHeadPipelineForMergeRequestWorker # rubocop:disable Scalability/Idem urgency :high worker_resource_boundary :cpu + idempotent! + def perform(merge_request_id) MergeRequest.find_by_id(merge_request_id).try do |merge_request| merge_request.update_head_pipeline diff --git a/app/workers/x509_issuer_crl_check_worker.rb b/app/workers/x509_issuer_crl_check_worker.rb new file mode 100644 index 00000000000..5fc92da803c --- /dev/null +++ b/app/workers/x509_issuer_crl_check_worker.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class X509IssuerCrlCheckWorker + include ApplicationWorker + include CronjobQueue + + feature_category :source_code_management + urgency :low + + idempotent! + worker_has_external_dependencies! + + attr_accessor :logger + + def perform + @logger = Gitlab::GitLogger.build + + X509Issuer.all.find_each do |issuer| + with_context(related_class: X509IssuerCrlCheckWorker) do + update_certificates(issuer) + end + end + end + + private + + def update_certificates(issuer) + crl = download_crl(issuer) + return unless crl + + serials = X509Certificate.serial_numbers(issuer) + return if serials.empty? + + revoked_serials = serials & crl.revoked.map(&:serial).map(&:to_i) + + revoked_serials.each_slice(1000) do |batch| + certs = issuer.x509_certificates.where(serial_number: batch, certificate_status: :good) # rubocop: disable CodeReuse/ActiveRecord + + certs.find_each do |cert| + logger.info(message: "Certificate revoked", + id: cert.id, + email: cert.email, + subject: cert.subject, + serial_number: cert.serial_number, + issuer: cert.x509_issuer.id, + issuer_subject: cert.x509_issuer.subject, + issuer_crl_url: cert.x509_issuer.crl_url) + end + + certs.update_all(certificate_status: :revoked) + end + end + + def download_crl(issuer) + response = Gitlab::HTTP.try_get(issuer.crl_url) + + if response&.code == 200 + OpenSSL::X509::CRL.new(response.body) + else + logger.warn(message: "Failed to download certificate revocation list", + issuer: issuer.id, + issuer_subject: issuer.subject, + issuer_crl_url: issuer.crl_url) + + nil + end + + rescue OpenSSL::X509::CRLError + logger.warn(message: "Failed to parse certificate revocation list", + issuer: issuer.id, + issuer_subject: issuer.subject, + issuer_crl_url: issuer.crl_url) + + nil + end +end |