diff options
Diffstat (limited to 'app')
14 files changed, 359 insertions, 152 deletions
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue index b3b1551497a..3da338bf13f 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -336,7 +336,7 @@ export default { :sidebar-collapsed="sidebarCollapsed" @alert-refresh="alertRefresh" @toggle-sidebar="toggleSidebar" - @alert-sidebar-error="handleAlertSidebarError" + @alert-error="handleAlertSidebarError" /> </div> </div> diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_list.vue index 6c92dc3c89a..ea6d3e3a931 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_list.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue @@ -6,8 +6,6 @@ import { GlTable, GlAlert, GlIcon, - GlDropdown, - GlDropdownItem, GlLink, GlTabs, GlTab, @@ -16,12 +14,13 @@ import { GlSearchBoxByType, GlSprintf, } from '@gitlab/ui'; -import createFlash from '~/flash'; import { __, s__ } from '~/locale'; import { debounce, trim } from 'lodash'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { convertToSnakeCase } from '~/lib/utils/text_utility'; +import Tracking from '~/tracking'; import getAlerts from '../graphql/queries/get_alerts.query.graphql'; import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import { @@ -31,9 +30,7 @@ import { trackAlertListViewsOptions, trackAlertStatusUpdateOptions, } from '../constants'; -import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql'; -import { convertToSnakeCase } from '~/lib/utils/text_utility'; -import Tracking from '~/tracking'; +import AlertStatus from './alert_status.vue'; const tdClass = 'table-col gl-display-flex d-md-table-cell gl-align-items-center'; const thClass = 'gl-hover-bg-blue-50'; @@ -107,11 +104,6 @@ export default { sortable: true, }, ], - statuses: { - TRIGGERED: s__('AlertManagement|Triggered'), - ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), - RESOLVED: s__('AlertManagement|Resolved'), - }, severityLabels: ALERTS_SEVERITY_LABELS, statusTabs: ALERTS_STATUS_TABS, components: { @@ -121,8 +113,6 @@ export default { GlAlert, GlDeprecatedButton, TimeAgo, - GlDropdown, - GlDropdownItem, GlIcon, GlLink, GlTabs, @@ -131,6 +121,7 @@ export default { GlPagination, GlSearchBoxByType, GlSprintf, + AlertStatus, }, props: { projectPath: { @@ -204,6 +195,7 @@ export default { return { searchTerm: '', errored: false, + errorMessage: '', isAlertDismissed: false, isErrorAlertDismissed: false, sort: 'STARTED_AT_DESC', @@ -275,30 +267,6 @@ export default { this.searchTerm = trimmedInput; } }, 500), - updateAlertStatus(status, iid) { - this.$apollo - .mutate({ - mutation: updateAlertStatus, - variables: { - iid, - status: status.toUpperCase(), - projectPath: this.projectPath, - }, - }) - .then(() => { - this.trackStatusUpdate(status); - this.$apollo.queries.alerts.refetch(); - this.$apollo.queries.alertsCount.refetch(); - this.resetPagination(); - }) - .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')); }, @@ -338,6 +306,14 @@ export default { resetPagination() { this.pagination = initialPaginationState; }, + handleAlertError(errorMessage) { + this.errored = true; + this.errorMessage = errorMessage; + }, + dismissError() { + this.isErrorAlertDismissed = true; + this.errorMessage = ''; + }, }, }; </script> @@ -357,8 +333,13 @@ export default { </template> </gl-sprintf> </gl-alert> - <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true"> - {{ $options.i18n.errorMsg }} + <gl-alert + v-if="showErrorMsg" + variant="danger" + data-testid="alert-error" + @dismiss="dismissError" + > + {{ errorMessage || $options.i18n.errorMsg }} </gl-alert> <gl-tabs content-class="gl-p-0" @input="filterAlertsByStatus"> @@ -437,22 +418,12 @@ export default { </template> <template #cell(status)="{ item }"> - <gl-dropdown :text="$options.statuses[item.status]" 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> + <alert-status + :alert="item" + :project-path="projectPath" + :is-sidebar="false" + @alert-error="handleAlertError" + /> </template> <template #empty> diff --git a/app/assets/javascripts/alert_management/components/alert_sidebar.vue b/app/assets/javascripts/alert_management/components/alert_sidebar.vue index 1e0c10c9882..fa9e60e465a 100644 --- a/app/assets/javascripts/alert_management/components/alert_sidebar.vue +++ b/app/assets/javascripts/alert_management/components/alert_sidebar.vue @@ -49,7 +49,7 @@ export default { :project-path="projectPath" :alert="alert" @toggle-sidebar="$emit('toggle-sidebar')" - @alert-sidebar-error="$emit('alert-sidebar-error', $event)" + @alert-error="$emit('alert-error', $event)" /> <sidebar-assignees :project-path="projectPath" @@ -58,7 +58,7 @@ export default { :sidebar-collapsed="sidebarCollapsed" @alert-refresh="$emit('alert-refresh')" @toggle-sidebar="$emit('toggle-sidebar')" - @alert-sidebar-error="$emit('alert-sidebar-error', $event)" + @alert-error="$emit('alert-error', $event)" /> <div class="block"></div> </div> diff --git a/app/assets/javascripts/alert_management/components/alert_status.vue b/app/assets/javascripts/alert_management/components/alert_status.vue new file mode 100644 index 00000000000..c464dda4572 --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_status.vue @@ -0,0 +1,116 @@ +<script> +import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { trackAlertStatusUpdateOptions } from '../constants'; +import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql'; + +export default { + statuses: { + TRIGGERED: s__('AlertManagement|Triggered'), + ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), + RESOLVED: s__('AlertManagement|Resolved'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlButton, + }, + props: { + projectPath: { + type: String, + required: true, + }, + alert: { + type: Object, + required: true, + }, + isDropdownShowing: { + type: Boolean, + required: false, + }, + isSidebar: { + type: Boolean, + required: true, + }, + }, + computed: { + dropdownClass() { + // eslint-disable-next-line no-nested-ternary + return this.isSidebar ? (this.isDropdownShowing ? 'show' : 'gl-display-none') : ''; + }, + }, + methods: { + updateAlertStatus(status) { + this.$emit('handle-updating', true); + this.$apollo + .mutate({ + mutation: updateAlertStatus, + variables: { + iid: this.alert.iid, + status: status.toUpperCase(), + projectPath: this.projectPath, + }, + }) + .then(() => { + this.trackStatusUpdate(status); + this.$emit('hide-dropdown'); + }) + .catch(() => { + this.$emit( + 'alert-error', + s__( + 'AlertManagement|There was an error while updating the status of the alert. Please try again.', + ), + ); + }) + .finally(() => { + this.$emit('handle-updating', false); + }); + }, + trackStatusUpdate(status) { + const { category, action, label } = trackAlertStatusUpdateOptions; + Tracking.event(category, action, { label, property: status }); + }, + }, +}; +</script> + +<template> + <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> + <gl-dropdown + ref="dropdown" + right + :text="$options.statuses[alert.status]" + class="w-100" + toggle-class="dropdown-menu-toggle" + variant="outline-default" + @keydown.esc.native="$emit('hide-dropdown')" + @hide="$emit('hide-dropdown')" + > + <div class="dropdown-title text-center"> + <span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + class="dropdown-title-button dropdown-menu-close" + icon="close" + @click="$emit('hide-dropdown')" + /> + </div> + <div class="dropdown-content dropdown-body"> + <gl-dropdown-item + v-for="(label, field) in $options.statuses" + :key="field" + data-testid="statusDropdownItem" + class="gl-vertical-align-middle" + :active="label.toUpperCase() === alert.status" + :active-class="'is-active'" + @click="updateAlertStatus(label)" + > + {{ label }} + </gl-dropdown-item> + </div> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue index 1c249bc7cee..2e91f1f2d72 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue @@ -142,7 +142,7 @@ export default { this.users = data; }) .catch(() => { - this.$emit('alert-sidebar-error', this.$options.FETCH_USERS_ERROR); + this.$emit('alert-error', this.$options.FETCH_USERS_ERROR); }) .finally(() => { this.isDropdownSearching = false; @@ -172,7 +172,7 @@ export default { return this.$emit('alert-refresh'); }) .catch(() => { - this.$emit('alert-sidebar-error', this.$options.UPDATE_ALERT_ASSIGNEES_ERROR); + this.$emit('alert-error', this.$options.UPDATE_ALERT_ASSIGNEES_ERROR); }) .finally(() => { this.isUpdating = false; diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue index 89dbbedd9c1..44a81aba828 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue @@ -1,17 +1,7 @@ <script> -import { - GlIcon, - GlDropdown, - GlDropdownItem, - GlLoadingIcon, - GlTooltip, - GlButton, - GlSprintf, -} from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; -import Tracking from '~/tracking'; -import { trackAlertStatusUpdateOptions } from '../../constants'; -import updateAlertStatus from '../../graphql/mutations/update_alert_status.graphql'; +import AlertStatus from '../alert_status.vue'; export default { statuses: { @@ -21,12 +11,10 @@ export default { }, components: { GlIcon, - GlDropdown, - GlDropdownItem, GlLoadingIcon, GlTooltip, - GlButton, GlSprintf, + AlertStatus, }, props: { projectPath: { @@ -60,44 +48,13 @@ export default { }, toggleFormDropdown() { this.isDropdownShowing = !this.isDropdownShowing; - const { dropdown } = this.$refs.dropdown.$refs; + const { dropdown } = this.$children[2].$refs.dropdown.$refs; if (dropdown && this.isDropdownShowing) { dropdown.show(); } }, - isSelected(status) { - return this.alert.status === status; - }, - updateAlertStatus(status) { - this.isUpdating = true; - this.$apollo - .mutate({ - mutation: updateAlertStatus, - variables: { - iid: this.alert.iid, - status: status.toUpperCase(), - projectPath: this.projectPath, - }, - }) - .then(() => { - this.trackStatusUpdate(status); - this.hideDropdown(); - }) - .catch(() => { - this.$emit( - 'alert-sidebar-error', - s__( - 'AlertManagement|There was an error while updating the status of the alert. Please try again.', - ), - ); - }) - .finally(() => { - this.isUpdating = false; - }); - }, - trackStatusUpdate(status) { - const { category, action, label } = trackAlertStatusUpdateOptions; - Tracking.event(category, action, { label, property: status }); + handleUpdating(updating) { + this.isUpdating = updating; }, }, }; @@ -132,41 +89,15 @@ export default { </a> </p> - <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-dropdown - ref="dropdown" - :text="$options.statuses[alert.status]" - class="w-100" - toggle-class="dropdown-menu-toggle" - variant="outline-default" - @keydown.esc.native="hideDropdown" - @hide="hideDropdown" - > - <div class="dropdown-title"> - <span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span> - <gl-button - :aria-label="__('Close')" - variant="link" - class="dropdown-title-button dropdown-menu-close" - icon="close" - @click="hideDropdown" - /> - </div> - <div class="dropdown-content dropdown-body"> - <gl-dropdown-item - v-for="(label, field) in $options.statuses" - :key="field" - data-testid="statusDropdownItem" - class="gl-vertical-align-middle" - :active="label.toUpperCase() === alert.status" - :active-class="'is-active'" - @click="updateAlertStatus(label)" - > - {{ label }} - </gl-dropdown-item> - </div> - </gl-dropdown> - </div> + <alert-status + :alert="alert" + :project-path="projectPath" + :is-dropdown-showing="isDropdownShowing" + :is-sidebar="true" + @alert-error="$emit('alert-error', $event)" + @hide-dropdown="hideDropdown" + @handle-updating="handleUpdating" + /> <gl-loading-icon v-if="isUpdating" :inline="true" /> <p diff --git a/app/assets/javascripts/helpers/event_hub_factory.js b/app/assets/javascripts/helpers/event_hub_factory.js index 863f490a63e..a9c301e3a93 100644 --- a/app/assets/javascripts/helpers/event_hub_factory.js +++ b/app/assets/javascripts/helpers/event_hub_factory.js @@ -1,4 +1,87 @@ -import Vue from 'vue'; +/** + * An event hub with a Vue instance like API + * + * NOTE: There's an [issue open][4] to eventually remove this when some + * coupling in our codebase has been fixed. + * + * NOTE: This is a derivative work from [mitt][1] v1.2.0 which is licensed by + * [MIT License][2] © [Jason Miller][3] + * + * [1]: https://github.com/developit/mitt + * [2]: https://opensource.org/licenses/MIT + * [3]: https://jasonformat.com/ + * [4]: https://gitlab.com/gitlab-org/gitlab/-/issues/223864 + */ +class EventHub { + constructor() { + this.$_all = new Map(); + } + + dispose() { + this.$_all.clear(); + } + + /** + * Register an event handler for the given type. + * + * @param {string|symbol} type Type of event to listen for + * @param {Function} handler Function to call in response to given event + */ + $on(type, handler) { + const handlers = this.$_all.get(type); + const added = handlers && handlers.push(handler); + + if (!added) { + this.$_all.set(type, [handler]); + } + } + + /** + * Remove an event handler or all handlers for the given type. + * + * @param {string|symbol} type Type of event to unregister `handler` + * @param {Function} handler Handler function to remove + */ + $off(type, handler) { + const handlers = this.$_all.get(type) || []; + + const newHandlers = handler ? handlers.filter(x => x !== handler) : []; + + if (newHandlers.length) { + this.$_all.set(type, newHandlers); + } else { + this.$_all.delete(type); + } + } + + /** + * Add an event listener to type but only trigger it once + * + * @param {string|symbol} type Type of event to listen for + * @param {Function} handler Handler function to call in response to event + */ + $once(type, handler) { + const wrapHandler = (...args) => { + this.$off(type, wrapHandler); + handler(...args); + }; + this.$on(type, wrapHandler); + } + + /** + * Invoke all handlers for the given type. + * + * @param {string|symbol} type The event type to invoke + * @param {Any} [evt] Any value passed to each handler + */ + $emit(type, ...args) { + const handlers = this.$_all.get(type) || []; + + handlers.forEach(handler => { + handler(...args); + }); + } +} /** * Return a Vue like event hub @@ -14,5 +97,5 @@ import Vue from 'vue'; * We'd like to shy away from using a full fledged Vue instance from this in the future. */ export default () => { - return new Vue(); + return new EventHub(); }; diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index eb6c9bf7eb6..993d51370ec 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,5 +1,7 @@ export const BYTES_IN_KIB = 1024; export const HIDDEN_CLASS = 'hidden'; +export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; +export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; export const DATETIME_RANGE_TYPES = { fixed: 'fixed', diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index be3fe1ed620..e2953ce330c 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,4 +1,9 @@ -import { isString } from 'lodash'; +import { isString, memoize } from 'lodash'; + +import { + TRUNCATE_WIDTH_DEFAULT_WIDTH, + TRUNCATE_WIDTH_DEFAULT_FONT_SIZE, +} from '~/lib/utils/constants'; /** * Adds a , to a string composed by numbers, at every 3 chars. @@ -73,7 +78,79 @@ export const slugifyWithUnderscore = str => slugify(str, '_'); * @param {Number} maxLength * @returns {String} */ -export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`; +export const truncate = (string, maxLength) => { + if (string.length - 1 > maxLength) { + return `${string.substr(0, maxLength - 1)}…`; + } + + return string; +}; + +/** + * This function calculates the average char width. It does so by placing a string in the DOM and measuring the width. + * NOTE: This will cause a reflow and should be used sparsely! + * The default fontFamily is 'sans-serif' and 12px in ECharts, so that is the default basis for calculating the average with. + * https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontFamily + * https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontSize + * @param {Object} options + * @param {Number} options.fontSize style to size the text for measurement + * @param {String} options.fontFamily style of font family to measure the text with + * @param {String} options.chars string of chars to use as a basis for calculating average width + * @return {Number} + */ +const getAverageCharWidth = memoize(function getAverageCharWidth(options = {}) { + const { + fontSize = 12, + fontFamily = 'sans-serif', + // eslint-disable-next-line @gitlab/require-i18n-strings + chars = ' ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + } = options; + const div = document.createElement('div'); + + div.style.fontFamily = fontFamily; + div.style.fontSize = `${fontSize}px`; + // Place outside of view + div.style.position = 'absolute'; + div.style.left = -1000; + div.style.top = -1000; + + div.innerHTML = chars; + + document.body.appendChild(div); + const width = div.clientWidth; + document.body.removeChild(div); + + return width / chars.length / fontSize; +}); + +/** + * This function returns a truncated version of `string` if its estimated rendered width is longer than `maxWidth`, + * otherwise it will return the original `string` + * Inspired by https://bl.ocks.org/tophtucker/62f93a4658387bb61e4510c37e2e97cf + * @param {String} string text to truncate + * @param {Object} options + * @param {Number} options.maxWidth largest rendered width the text may have + * @param {Number} options.fontSize size of the font used to render the text + * @return {String} either the original string or a truncated version + */ +export const truncateWidth = (string, options = {}) => { + const { + maxWidth = TRUNCATE_WIDTH_DEFAULT_WIDTH, + fontSize = TRUNCATE_WIDTH_DEFAULT_FONT_SIZE, + } = options; + const { truncateIndex } = string.split('').reduce( + (memo, char, index) => { + let newIndex = index; + if (memo.width > maxWidth) { + newIndex = memo.truncateIndex; + } + return { width: memo.width + getAverageCharWidth() * fontSize, truncateIndex: newIndex }; + }, + { width: 0, truncateIndex: 0 }, + ); + + return truncate(string, truncateIndex); +}; /** * Truncate SHA to 8 characters diff --git a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js index 674b807edbe..da7f81759ea 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js @@ -32,7 +32,7 @@ export default class AbuseReports { $messageCellElement.text(originalMessage); } else { $messageCellElement.data('messageTruncated', 'true'); - $messageCellElement.text(`${originalMessage.substr(0, MAX_MESSAGE_LENGTH - 3)}...`); + $messageCellElement.text(truncate(originalMessage, MAX_MESSAGE_LENGTH)); } } } diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index e07646e9a9f..3c4b87e2329 100644 --- a/app/assets/javascripts/releases/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -4,7 +4,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ASSET_LINK_TYPE } from '../constants'; import { __, s__, sprintf } from '~/locale'; -import { difference } from 'lodash'; +import { difference, get } from 'lodash'; export default { name: 'ReleaseBlockAssets', @@ -54,7 +54,7 @@ export default { sections() { return [ { - links: this.assets.sources.map(s => ({ + links: get(this.assets, 'sources', []).map(s => ({ url: s.url, name: sprintf(__('Source code (%{fileExtension})'), { fileExtension: s.format }), })), diff --git a/app/assets/stylesheets/pages/alert_management/list.scss b/app/assets/stylesheets/pages/alert_management/list.scss index c1ea9b7604a..ea5e7a1bdea 100644 --- a/app/assets/stylesheets/pages/alert_management/list.scss +++ b/app/assets/stylesheets/pages/alert_management/list.scss @@ -74,7 +74,7 @@ content: none !important; } - div { + div:not(.dropdown-title) { width: 100% !important; padding: 0 !important; } diff --git a/app/graphql/types/milestone_stats_type.rb b/app/graphql/types/milestone_stats_type.rb new file mode 100644 index 00000000000..ef533af59e7 --- /dev/null +++ b/app/graphql/types/milestone_stats_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class MilestoneStatsType < BaseObject + graphql_name 'MilestoneStats' + description 'Contains statistics about a milestone' + + authorize :read_milestone + + field :total_issues_count, GraphQL::INT_TYPE, null: true, + description: 'Total number of issues associated with the milestone' + + field :closed_issues_count, GraphQL::INT_TYPE, null: true, + description: 'Number of closed issues associated with the milestone' + end +end diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb index 99bd6e819d6..ca606c9da44 100644 --- a/app/graphql/types/milestone_type.rb +++ b/app/graphql/types/milestone_type.rb @@ -9,6 +9,8 @@ module Types authorize :read_milestone + alias_method :milestone, :object + field :id, GraphQL::ID_TYPE, null: false, description: 'ID of the milestone' @@ -47,5 +49,14 @@ module Types field :subgroup_milestone, GraphQL::BOOLEAN_TYPE, null: false, description: 'Indicates if milestone is at subgroup level', method: :subgroup_milestone? + + field :stats, Types::MilestoneStatsType, null: true, + description: 'Milestone statistics' + + def stats + return unless Feature.enabled?(:graphql_milestone_stats, milestone.project || milestone.group, default_enabled: true) + + milestone + end end end |