summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue2
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list.vue81
-rw-r--r--app/assets/javascripts/alert_management/components/alert_sidebar.vue4
-rw-r--r--app/assets/javascripts/alert_management/components/alert_status.vue116
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue4
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue99
-rw-r--r--app/assets/javascripts/helpers/event_hub_factory.js87
-rw-r--r--app/assets/javascripts/lib/utils/constants.js2
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js81
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/abuse_reports.js2
-rw-r--r--app/assets/javascripts/releases/components/release_block_assets.vue4
-rw-r--r--app/assets/stylesheets/pages/alert_management/list.scss2
-rw-r--r--app/graphql/types/milestone_stats_type.rb16
-rw-r--r--app/graphql/types/milestone_type.rb11
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