diff options
Diffstat (limited to 'app/assets/javascripts/analytics')
5 files changed, 138 insertions, 115 deletions
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue index 133513d6c21..33d6eb139f7 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue @@ -22,6 +22,7 @@ import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_t import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; +import { MAX_LABELS } from '../constants'; export default { name: 'FilterBar', @@ -70,6 +71,7 @@ export default { symbol: '~', operators: OPERATORS_IS, fetchLabels: this.fetchLabels, + maxSuggestions: MAX_LABELS, }, { icon: 'pencil', @@ -146,6 +148,7 @@ export default { :search-input-placeholder="__('Filter results')" :tokens="tokens" :initial-filter-value="initialFilterValue()" + terms-as-tokens @onFilter="handleFilter" /> <url-sync :query="query" /> diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue index b9d1c4b0fe0..0de62013a63 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue @@ -82,6 +82,7 @@ export default { <div> <projects-dropdown-filter v-if="hasProjectFilter" + toggle-classes="gl-max-w-26" class="js-projects-dropdown-filter project-select gl-mb-2 gl-lg-mb-0" :group-namespace="groupPath" :query-params="projectsQueryParams" diff --git a/app/assets/javascripts/analytics/cycle_analytics/constants.js b/app/assets/javascripts/analytics/cycle_analytics/constants.js index bea562fb18c..c14f3cfc6c9 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/constants.js +++ b/app/assets/javascripts/analytics/cycle_analytics/constants.js @@ -43,3 +43,4 @@ export const METRICS_REQUESTS = [ export const MILESTONES_ENDPOINT = '/-/milestones.json'; export const LABELS_ENDPOINT = '/-/labels.json'; +export const MAX_LABELS = 100; diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue index 98193de4a12..f881c924ae5 100644 --- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -1,14 +1,5 @@ <script> -import { - GlIcon, - GlLoadingIcon, - GlAvatar, - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, - GlSearchBoxByType, - GlTruncate, -} from '@gitlab/ui'; +import { GlButton, GlIcon, GlAvatar, GlCollapsibleListbox, GlTruncate } from '@gitlab/ui'; import { debounce } from 'lodash'; import { filterBySearchTerm } from '~/analytics/shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -18,17 +9,15 @@ import { n__, s__, __ } from '~/locale'; import getProjects from '../graphql/projects.query.graphql'; const sortByProjectName = (projects = []) => projects.sort((a, b) => a.name.localeCompare(b.name)); +const mapItemToListboxFormat = (item) => ({ ...item, value: item.id, text: item.name }); export default { name: 'ProjectsDropdownFilter', components: { + GlButton, GlIcon, - GlLoadingIcon, GlAvatar, - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, - GlSearchBoxByType, + GlCollapsibleListbox, GlTruncate, }, props: { @@ -61,6 +50,11 @@ export default { required: false, default: false, }, + toggleClasses: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -94,6 +88,9 @@ export default { selectedProjectIds() { return this.selectedProjects.map((p) => p.id); }, + selectedListBoxItems() { + return this.multiSelect ? this.selectedProjectIds : this.selectedProjectIds[0]; + }, hasSelectedProjects() { return Boolean(this.selectedProjects.length); }, @@ -110,6 +107,28 @@ export default { unselectedItems() { return this.availableProjects.filter(({ id }) => !this.selectedProjectIds.includes(id)); }, + selectedGroupOptions() { + return this.selectedItems.map(mapItemToListboxFormat); + }, + unSelectedGroupOptions() { + return this.unselectedItems.map(mapItemToListboxFormat); + }, + listBoxItems() { + if (this.selectedGroupOptions.length === 0) { + return this.unSelectedGroupOptions; + } + + return [ + { + text: __('Selected'), + options: this.selectedGroupOptions, + }, + { + text: __('Unselected'), + options: this.unSelectedGroupOptions, + }, + ]; + }, }, watch: { searchTerm() { @@ -129,32 +148,29 @@ export default { search: debounce(function debouncedSearch() { this.fetchData(); }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - getSelectedProjects(selectedProject, isSelected) { - return isSelected - ? this.selectedProjects.concat([selectedProject]) - : this.selectedProjects.filter((project) => project.id !== selectedProject.id); - }, singleSelectedProject(selectedObj, isMarking) { return isMarking ? [selectedObj] : []; }, - setSelectedProjects(project) { + setSelectedProjects(payload) { this.selectedProjects = this.multiSelect - ? this.getSelectedProjects(project, !this.isProjectSelected(project)) - : this.singleSelectedProject(project, !this.isProjectSelected(project)); + ? payload + : this.singleSelectedProject(payload, !this.isProjectSelected(payload)); }, - onClick(project) { + onClick(projectId) { + const project = this.availableProjects.find(({ id }) => id === projectId); this.setSelectedProjects(project); this.handleUpdatedSelectedProjects(); }, - onMultiSelectClick(project) { - this.setSelectedProjects(project); + onMultiSelectClick(projectIds) { + const projects = this.availableProjects.filter(({ id }) => projectIds.includes(id)); + this.setSelectedProjects(projects); this.isDirty = true; }, - onSelected(project) { + onSelected(payload) { if (this.multiSelect) { - this.onMultiSelectClick(project); + this.onMultiSelectClick(payload); } else { - this.onClick(project); + this.onClick(payload); } }, onHide() { @@ -201,97 +217,67 @@ export default { getEntityId(project) { return getIdFromGraphQLId(project.id); }, + setSearchTerm(val) { + this.searchTerm = val; + }, }, AVATAR_SHAPE_OPTION_RECT, }; </script> <template> - <gl-dropdown + <gl-collapsible-listbox ref="projectsDropdown" - class="dropdown dropdown-projects" - toggle-class="gl-shadow-none gl-mb-0" + :header-text="__('Projects')" + :items="listBoxItems" + :reset-button-label="__('Clear All')" :loading="loadingDefaultProjects" - :show-clear-all="hasSelectedProjects" - show-highlighted-items-title - highlighted-items-title-class="gl-p-3" - block - @clear-all.stop="onClearAll" - @hide="onHide" + :multiple="multiSelect" + :no-results-text="__('No matching results')" + :selected="selectedListBoxItems" + :searching="loading" + searchable + @hidden="onHide" + @reset="onClearAll" + @search="setSearchTerm" + @select="onSelected" > - <template #button-content> - <gl-loading-icon v-if="loadingDefaultProjects" class="gl-mr-2 gl-flex-shrink-0" /> - <gl-avatar - v-if="isOnlyOneProjectSelected" - :src="selectedProjects[0].avatarUrl" - :entity-id="getEntityId(selectedProjects[0])" - :entity-name="selectedProjects[0].name" - :size="16" - :shape="$options.AVATAR_SHAPE_OPTION_RECT" - :alt="selectedProjects[0].name" - class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2 gl-flex-shrink-0" - /> - <gl-truncate :text="selectedProjectsLabel" class="gl-min-w-0 gl-flex-grow-1" /> - <gl-icon class="gl-ml-2 gl-flex-shrink-0" name="chevron-down" /> - </template> - <template #header> - <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header> - <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search')" /> - </template> - <template #highlighted-items> - <gl-dropdown-item - v-for="project in selectedItems" - :key="project.id" - is-check-item - :is-checked="isProjectSelected(project)" - @click.native.capture.stop="onSelected(project)" + <template #toggle> + <gl-button + button-text-classes="gl-w-full gl-justify-content-space-between gl-display-flex gl-shadow-none gl-mb-0" + :class="['dropdown-projects', toggleClasses]" > - <div class="gl-display-flex"> - <gl-avatar - class="gl-mr-2 gl-vertical-align-middle" - :alt="project.name" - :size="16" - :entity-id="getEntityId(project)" - :entity-name="project.name" - :src="project.avatarUrl" - :shape="$options.AVATAR_SHAPE_OPTION_RECT" - /> - <div> - <div data-testid="project-name">{{ project.name }}</div> - <div class="gl-text-gray-500" data-testid="project-full-path"> - {{ project.fullPath }} - </div> - </div> - </div> - </gl-dropdown-item> + <gl-avatar + v-if="isOnlyOneProjectSelected" + :src="selectedProjects[0].avatarUrl" + :entity-id="getEntityId(selectedProjects[0])" + :entity-name="selectedProjects[0].name" + :size="16" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :alt="selectedProjects[0].name" + class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2 gl-flex-shrink-0" + /> + <gl-truncate :text="selectedProjectsLabel" class="gl-min-w-0 gl-flex-grow-1" /> + <gl-icon class="gl-ml-2 gl-flex-shrink-0" name="chevron-down" /> + </gl-button> </template> - <gl-dropdown-item - v-for="project in unselectedItems" - :key="project.id" - @click.native.capture.stop="onSelected(project)" - > + <template #list-item="{ item }"> <div class="gl-display-flex"> <gl-avatar - class="gl-mr-2 vertical-align-middle" - :alt="project.name" + class="gl-mr-2 gl-vertical-align-middle" + :alt="item.name" :size="16" - :entity-id="getEntityId(project)" - :entity-name="project.name" - :src="project.avatarUrl" + :entity-id="getEntityId(item)" + :entity-name="item.name" + :src="item.avatarUrl" :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> <div> - <div data-testid="project-name" data-qa-selector="project_name">{{ project.name }}</div> + <div data-testid="project-name" data-qa-selector="project_name">{{ item.name }}</div> <div class="gl-text-gray-500" data-testid="project-full-path"> - {{ project.fullPath }} + {{ item.fullPath }} </div> </div> </div> - </gl-dropdown-item> - <gl-dropdown-item v-show="noResultsAvailable" class="gl-pointer-events-none text-secondary">{{ - __('No matching results') - }}</gl-dropdown-item> - <gl-dropdown-item v-if="loading"> - <gl-loading-icon size="lg" /> - </gl-dropdown-item> - </gl-dropdown> + </template> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index c98cf90f406..25699c17b10 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -1,4 +1,5 @@ -import { masks } from '~/lib/dateformat'; +import dateFormat, { masks } from '~/lib/dateformat'; +import { nDaysBefore, getStartOfDay } from '~/lib/utils/datetime_utility'; import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -13,12 +14,19 @@ export const dateFormats = { month: 'mmmm', }; +const startOfToday = getStartOfDay(new Date(), { utc: true }); +const last180Days = nDaysBefore(startOfToday, DATE_RANGE_LIMIT, { utc: true }); +const formatDateParam = (d) => dateFormat(d, dateFormats.isoDate, true); + export const METRIC_POPOVER_LABEL = s__('ValueStreamAnalytics|View details'); -export const KEY_METRICS = { +export const ISSUES_COMPLETED_TYPE = 'issues_completed'; + +export const FLOW_METRICS = { LEAD_TIME: 'lead_time', CYCLE_TIME: 'cycle_time', ISSUES: 'issues', + ISSUES_COMPLETED: ISSUES_COMPLETED_TYPE, COMMITS: 'commits', DEPLOYS: 'deploys', }; @@ -33,7 +41,7 @@ export const DORA_METRICS = { const VSA_FLOW_METRICS_GROUP = { key: 'key_metrics', title: s__('ValueStreamAnalytics|Key metrics'), - keys: Object.values(KEY_METRICS), + keys: Object.values(FLOW_METRICS), }; export const VSA_METRICS_GROUPS = [VSA_FLOW_METRICS_GROUP]; @@ -46,6 +54,12 @@ export const VULNERABILITY_METRICS = { HIGH: VULNERABILITY_HIGH_TYPE, }; +export const MERGE_REQUEST_THROUGHPUT_TYPE = 'merge_request_throughput'; + +export const MERGE_REQUEST_METRICS = { + THROUGHPUT: MERGE_REQUEST_THROUGHPUT_TYPE, +}; + export const METRIC_TOOLTIPS = { [DORA_METRICS.DEPLOYMENT_FREQUENCY]: { description: s__( @@ -79,7 +93,7 @@ export const METRIC_TOOLTIPS = { projectLink: '-/pipelines/charts?chart=change-failure-rate', docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'change-failure-rate' }), }, - [KEY_METRICS.LEAD_TIME]: { + [FLOW_METRICS.LEAD_TIME]: { description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), groupLink: '-/analytics/value_stream_analytics', projectLink: '-/value_stream_analytics', @@ -87,7 +101,7 @@ export const METRIC_TOOLTIPS = { anchor: 'view-the-lead-time-and-cycle-time-for-issues', }), }, - [KEY_METRICS.CYCLE_TIME]: { + [FLOW_METRICS.CYCLE_TIME]: { description: s__( "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.", ), @@ -97,13 +111,21 @@ export const METRIC_TOOLTIPS = { anchor: 'view-the-lead-time-and-cycle-time-for-issues', }), }, - [KEY_METRICS.ISSUES]: { + [FLOW_METRICS.ISSUES]: { description: s__('ValueStreamAnalytics|Number of new issues created.'), groupLink: '-/issues_analytics', projectLink: '-/analytics/issues_analytics', docsLink: helpPagePath('user/analytics/issue_analytics'), }, - [KEY_METRICS.DEPLOYS]: { + [FLOW_METRICS.ISSUES_COMPLETED]: { + description: s__('ValueStreamAnalytics|Number of issues closed by month.'), + groupLink: '-/analytics/value_stream_analytics', + projectLink: '-/value_stream_analytics', + docsLink: helpPagePath('user/analytics/value_streams_dashboard', { + anchor: 'dashboard-metrics-and-drill-down-reports', + }), + }, + [FLOW_METRICS.DEPLOYS]: { description: s__('ValueStreamAnalytics|Total number of deploys to production.'), groupLink: '-/analytics/productivity_analytics', projectLink: '-/analytics/merge_request_analytics', @@ -111,15 +133,25 @@ export const METRIC_TOOLTIPS = { }, [VULNERABILITY_METRICS.CRITICAL]: { description: s__('ValueStreamAnalytics|Critical vulnerabilities over time.'), - groupLink: '-/security/vulnerabilities', - projectLink: '-/security/vulnerability_report', - docsLink: helpPagePath('user/application_security/vulnerability_report/index'), + groupLink: '-/security/vulnerabilities?severity=CRITICAL', + projectLink: '-/security/vulnerability_report?severity=CRITICAL', + docsLink: helpPagePath('user/application_security/vulnerabilities/severities.html'), }, [VULNERABILITY_METRICS.HIGH]: { description: s__('ValueStreamAnalytics|High vulnerabilities over time.'), - groupLink: '-/security/vulnerabilities', - projectLink: '-/security/vulnerability_report', - docsLink: helpPagePath('user/application_security/vulnerability_report/index'), + groupLink: '-/security/vulnerabilities?severity=HIGH', + projectLink: '-/security/vulnerability_report?severity=HIGH', + docsLink: helpPagePath('user/application_security/vulnerabilities/severities.html'), + }, + [MERGE_REQUEST_METRICS.THROUGHPUT]: { + description: s__('ValueStreamAnalytics|The number of merge requests merged by month.'), + groupLink: '-/analytics/productivity_analytics', + projectLink: `-/analytics/merge_request_analytics?start_date=${formatDateParam( + last180Days, + )}&end_date=${formatDateParam(startOfToday)}`, + docsLink: helpPagePath('user/analytics/merge_request_analytics', { + anchor: 'view-the-number-of-merge-requests-in-a-date-range', + }), }, }; |