diff options
Diffstat (limited to 'app/assets/javascripts/analytics')
-rw-r--r-- | app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue (renamed from app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue) | 23 | ||||
-rw-r--r-- | app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js (renamed from app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js) | 18 | ||||
-rw-r--r-- | app/assets/javascripts/analytics/shared/components/daterange.vue | 121 | ||||
-rw-r--r-- | app/assets/javascripts/analytics/shared/components/metric_card.vue | 80 | ||||
-rw-r--r-- | app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue | 241 | ||||
-rw-r--r-- | app/assets/javascripts/analytics/shared/constants.js | 12 | ||||
-rw-r--r-- | app/assets/javascripts/analytics/shared/graphql/projects.query.graphql | 22 | ||||
-rw-r--r-- | app/assets/javascripts/analytics/shared/utils.js | 4 | ||||
-rw-r--r-- | app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue | 32 |
9 files changed, 450 insertions, 103 deletions
diff --git a/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue b/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue index c0ad814172d..7c14cf3767f 100644 --- a/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue +++ b/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue @@ -25,28 +25,33 @@ export default { }; </script> <template> - <gl-empty-state class="js-empty-state" :title="__('Usage ping is off')" :svg-path="svgPath"> + <gl-empty-state :title="s__('ServicePing|Service ping is off')" :svg-path="svgPath"> <template #description> <gl-sprintf v-if="!isAdmin" :message=" - __( - 'To view instance-level analytics, ask an admin to turn on %{docLinkStart}usage ping%{docLinkEnd}.', + s__( + 'ServicePing|To view instance-level analytics, ask an admin to turn on %{docLinkStart}service ping%{docLinkEnd}.', ) " > <template #docLink="{ content }"> - <gl-link :href="docsLink" target="_blank">{{ content }}</gl-link> + <gl-link :href="docsLink" target="_blank" data-testid="docs-link">{{ content }}</gl-link> </template> </gl-sprintf> - <template v-else - ><p> - {{ __('Turn on usage ping to review instance-level analytics.') }} + <template v-else> + <p> + {{ s__('ServicePing|Turn on service ping to review instance-level analytics.') }} </p> - <gl-button category="primary" variant="success" :href="primaryButtonPath"> - {{ __('Turn on usage ping') }}</gl-button + <gl-button + category="primary" + variant="success" + :href="primaryButtonPath" + data-testid="power-on-button" > + {{ s__('ServicePing|Turn on service ping') }} + </gl-button> </template> </template> </gl-empty-state> diff --git a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js index 0131407e723..63b36f35247 100644 --- a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js +++ b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js @@ -1,27 +1,33 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import UserCallout from '~/user_callout'; -import UsagePingDisabled from './components/usage_ping_disabled.vue'; +import ServicePingDisabled from './components/service_ping_disabled.vue'; export default () => { // eslint-disable-next-line no-new new UserCallout(); - const emptyStateContainer = document.getElementById('js-devops-usage-ping-disabled'); + const emptyStateContainer = document.getElementById('js-devops-service-ping-disabled'); if (!emptyStateContainer) return false; - const { emptyStateSvgPath, enableUsagePingLink, docsLink, isAdmin } = emptyStateContainer.dataset; + const { + isAdmin, + emptyStateSvgPath, + enableServicePingPath, + docsLink, + } = emptyStateContainer.dataset; return new Vue({ el: emptyStateContainer, provide: { - isAdmin: Boolean(isAdmin), + isAdmin: parseBoolean(isAdmin), svgPath: emptyStateSvgPath, - primaryButtonPath: enableUsagePingLink, + primaryButtonPath: enableServicePingPath, docsLink, }, render(h) { - return h(UsagePingDisabled); + return h(ServicePingDisabled); }, }); }; diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue new file mode 100644 index 00000000000..a5b9c40b9c9 --- /dev/null +++ b/app/assets/javascripts/analytics/shared/components/daterange.vue @@ -0,0 +1,121 @@ +<script> +import { GlDaterangePicker, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { getDayDifference } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; +import { OFFSET_DATE_BY_ONE } from '../constants'; + +export default { + components: { + GlDaterangePicker, + GlSprintf, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + show: { + type: Boolean, + required: false, + default: true, + }, + startDate: { + type: Date, + required: false, + default: null, + }, + endDate: { + type: Date, + required: false, + default: null, + }, + minDate: { + type: Date, + required: false, + default: null, + }, + maxDate: { + type: Date, + required: false, + default() { + return new Date(); + }, + }, + maxDateRange: { + type: Number, + required: false, + default: 0, + }, + includeSelectedDate: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + maxDateRangeTooltip: sprintf( + __( + 'Showing data for workflow items created in this date range. Date range cannot exceed %{maxDateRange} days.', + ), + { + maxDateRange: this.maxDateRange, + }, + ), + }; + }, + computed: { + dateRange: { + get() { + return { startDate: this.startDate, endDate: this.endDate }; + }, + set({ startDate, endDate }) { + this.$emit('change', { startDate, endDate }); + }, + }, + numberOfDays() { + const dayDifference = getDayDifference(this.startDate, this.endDate); + return this.includeSelectedDate ? dayDifference + OFFSET_DATE_BY_ONE : dayDifference; + }, + }, +}; +</script> +<template> + <div + v-if="show" + class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row align-items-lg-center justify-content-lg-end" + > + <gl-daterange-picker + v-model="dateRange" + class="d-flex flex-column flex-lg-row" + :default-start-date="startDate" + :default-end-date="endDate" + :default-min-date="minDate" + :max-date-range="maxDateRange" + :default-max-date="maxDate" + :same-day-selection="includeSelectedDate" + theme="animate-picker" + start-picker-class="js-daterange-picker-from gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-lg-mr-3 gl-mb-2 gl-lg-mb-0" + end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center" + label-class="gl-mb-2 gl-lg-mb-0" + /> + <div + v-if="maxDateRange" + class="daterange-indicator d-flex flex-row flex-lg-row align-items-flex-start align-items-lg-center" + > + <span class="number-of-days pl-2 pr-1"> + <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)"> + <template #numberOfDays>{{ numberOfDays }}</template> + </gl-sprintf> + </span> + <gl-icon + v-gl-tooltip + data-testid="helper-icon" + :title="maxDateRangeTooltip" + name="question" + :size="14" + class="text-secondary" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue deleted file mode 100644 index e6e12821bec..00000000000 --- a/app/assets/javascripts/analytics/shared/components/metric_card.vue +++ /dev/null @@ -1,80 +0,0 @@ -<script> -import { - GlCard, - GlDeprecatedSkeletonLoading as GlSkeletonLoading, - GlLink, - GlIcon, - GlTooltipDirective, -} from '@gitlab/ui'; - -export default { - name: 'MetricCard', - components: { - GlCard, - GlSkeletonLoading, - GlLink, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - title: { - type: String, - required: true, - }, - metrics: { - type: Array, - required: true, - }, - isLoading: { - type: Boolean, - required: false, - default: false, - }, - }, - methods: { - valueText(metric) { - const { value = null, unit = null } = metric; - if (!value || value === '-') return '-'; - return unit && value ? `${value} ${unit}` : value; - }, - }, -}; -</script> -<template> - <gl-card class="gl-mb-5"> - <template #header> - <strong ref="title">{{ title }}</strong> - </template> - <template #default> - <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3" /> - <div v-else ref="metricsWrapper" class="gl-display-flex"> - <div - v-for="metric in metrics" - :key="metric.key" - ref="metricItem" - class="js-metric-card-item gl-flex-grow-1 gl-text-center" - > - <gl-link v-if="metric.link" :href="metric.link"> - <h3 class="gl-my-2 gl-text-blue-700">{{ valueText(metric) }}</h3> - </gl-link> - <h3 v-else class="gl-my-2">{{ valueText(metric) }}</h3> - <p class="text-secondary gl-font-sm gl-mb-2"> - {{ metric.label }} - <span v-if="metric.tooltipText"> - - <gl-icon - v-gl-tooltip="{ title: metric.tooltipText }" - :size="14" - class="gl-vertical-align-middle" - name="question" - data-testid="tooltip" - /> - </span> - </p> - </div> - </div> - </template> - </gl-card> -</template> diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue new file mode 100644 index 00000000000..a490111e13b --- /dev/null +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -0,0 +1,241 @@ +<script> +import { + GlIcon, + GlLoadingIcon, + GlAvatar, + GlDropdown, + GlDropdownSectionHeader, + GlDropdownItem, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { filterBySearchTerm } from '~/analytics/shared/utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { n__, s__, __ } from '~/locale'; +import getProjects from '../graphql/projects.query.graphql'; + +export default { + name: 'ProjectsDropdownFilter', + components: { + GlIcon, + GlLoadingIcon, + GlAvatar, + GlDropdown, + GlDropdownSectionHeader, + GlDropdownItem, + GlSearchBoxByType, + }, + props: { + groupId: { + type: Number, + required: true, + }, + groupNamespace: { + type: String, + required: true, + }, + multiSelect: { + type: Boolean, + required: false, + default: false, + }, + label: { + type: String, + required: false, + default: s__('CycleAnalytics|project dropdown filter'), + }, + queryParams: { + type: Object, + required: false, + default: () => ({}), + }, + defaultProjects: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + loading: true, + projects: [], + selectedProjects: this.defaultProjects || [], + searchTerm: '', + isDirty: false, + }; + }, + computed: { + selectedProjectsLabel() { + if (this.selectedProjects.length === 1) { + return this.selectedProjects[0].name; + } else if (this.selectedProjects.length > 1) { + return n__( + 'CycleAnalytics|Project selected', + 'CycleAnalytics|%d projects selected', + this.selectedProjects.length, + ); + } + + return this.selectedProjectsPlaceholder; + }, + selectedProjectsPlaceholder() { + return this.multiSelect ? __('Select projects') : __('Select a project'); + }, + isOnlyOneProjectSelected() { + return this.selectedProjects.length === 1; + }, + selectedProjectIds() { + return this.selectedProjects.map((p) => p.id); + }, + availableProjects() { + return filterBySearchTerm(this.projects, this.searchTerm); + }, + noResultsAvailable() { + const { loading, availableProjects } = this; + return !loading && !availableProjects.length; + }, + }, + watch: { + searchTerm() { + this.search(); + }, + }, + mounted() { + this.search(); + }, + methods: { + search: debounce(function debouncedSearch() { + this.fetchData(); + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + getSelectedProjects(selectedProject, isMarking) { + return isMarking + ? this.selectedProjects.concat([selectedProject]) + : this.selectedProjects.filter((project) => project.id !== selectedProject.id); + }, + singleSelectedProject(selectedObj, isMarking) { + return isMarking ? [selectedObj] : []; + }, + setSelectedProjects(selectedObj, isMarking) { + this.selectedProjects = this.multiSelect + ? this.getSelectedProjects(selectedObj, isMarking) + : this.singleSelectedProject(selectedObj, isMarking); + }, + onClick({ project, isSelected }) { + this.setSelectedProjects(project, !isSelected); + this.$emit('selected', this.selectedProjects); + }, + onMultiSelectClick({ project, isSelected }) { + this.setSelectedProjects(project, !isSelected); + this.isDirty = true; + }, + onSelected(ev) { + if (this.multiSelect) { + this.onMultiSelectClick(ev); + } else { + this.onClick(ev); + } + }, + onHide() { + if (this.multiSelect && this.isDirty) { + this.$emit('selected', this.selectedProjects); + } + this.searchTerm = ''; + this.isDirty = false; + }, + fetchData() { + this.loading = true; + + return this.$apollo + .query({ + query: getProjects, + variables: { + groupFullPath: this.groupNamespace, + search: this.searchTerm, + ...this.queryParams, + }, + }) + .then((response) => { + const { + data: { + group: { + projects: { nodes }, + }, + }, + } = response; + + this.loading = false; + this.projects = nodes; + }); + }, + isProjectSelected(id) { + return this.selectedProjects ? this.selectedProjectIds.includes(id) : false; + }, + getEntityId(project) { + return getIdFromGraphQLId(project.id); + }, + }, +}; +</script> +<template> + <gl-dropdown + ref="projectsDropdown" + class="dropdown dropdown-projects" + toggle-class="gl-shadow-none" + @hide="onHide" + > + <template #button-content> + <div class="gl-display-flex gl-flex-grow-1"> + <gl-avatar + v-if="isOnlyOneProjectSelected" + :src="selectedProjects[0].avatarUrl" + :entity-id="getEntityId(selectedProjects[0])" + :entity-name="selectedProjects[0].name" + :size="16" + shape="rect" + :alt="selectedProjects[0].name" + class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2" + /> + {{ selectedProjectsLabel }} + </div> + <gl-icon class="gl-ml-2" name="chevron-down" /> + </template> + <template #header> + <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header> + <gl-search-box-by-type v-model.trim="searchTerm" /> + </template> + <gl-dropdown-item + v-for="project in availableProjects" + :key="project.id" + :is-check-item="true" + :is-checked="isProjectSelected(project.id)" + @click.native.capture.stop=" + onSelected({ project, isSelected: isProjectSelected(project.id) }) + " + > + <div class="gl-display-flex"> + <gl-avatar + class="gl-mr-2 vertical-align-middle" + :alt="project.name" + :size="16" + :entity-id="getEntityId(project)" + :entity-name="project.name" + :src="project.avatarUrl" + shape="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-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> diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js new file mode 100644 index 00000000000..44d9b4b4262 --- /dev/null +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -0,0 +1,12 @@ +import { masks } from 'dateformat'; + +export const DATE_RANGE_LIMIT = 180; +export const OFFSET_DATE_BY_ONE = 1; +export const PROJECTS_PER_PAGE = 50; + +const { isoDate, mediumDate } = masks; +export const dateFormats = { + isoDate, + defaultDate: mediumDate, + defaultDateTime: 'mmm d, yyyy h:MMtt', +}; diff --git a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql new file mode 100644 index 00000000000..63e95d6804c --- /dev/null +++ b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql @@ -0,0 +1,22 @@ +query getGroupProjects( + $groupFullPath: ID! + $search: String! + $first: Int! + $includeSubgroups: Boolean = false +) { + group(fullPath: $groupFullPath) { + projects( + search: $search + first: $first + includeSubgroups: $includeSubgroups + sort: SIMILARITY + ) { + nodes { + id + name + avatarUrl + fullPath + } + } + } +} diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js new file mode 100644 index 00000000000..84189b675f2 --- /dev/null +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -0,0 +1,4 @@ +export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => { + if (!searchTerm?.length) return data; + return data.filter((item) => item[filterByKey].toLowerCase().includes(searchTerm.toLowerCase())); +}; diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue index 0b4fa879b03..1eb4832a2a3 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue @@ -1,5 +1,6 @@ <script> -import MetricCard from '~/analytics/shared/components/metric_card.vue'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; import createFlash from '~/flash'; import { number } from '~/lib/utils/unit_format'; import { s__ } from '~/locale'; @@ -10,7 +11,8 @@ const defaultPrecision = 0; export default { name: 'UsageCounts', components: { - MetricCard, + GlSkeletonLoading, + GlSingleStat, }, data() { return { @@ -56,10 +58,24 @@ export default { </script> <template> - <metric-card - :title="__('Usage Trends')" - :metrics="counts" - :is-loading="$apollo.queries.counts.loading" - class="gl-mt-4" - /> + <div> + <h2> + {{ __('Usage Trends') }} + </h2> + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-my-6 gl-align-items-flex-start" + > + <gl-skeleton-loading v-if="$apollo.queries.counts.loading" /> + <template v-else> + <gl-single-stat + v-for="count in counts" + :key="count.key" + class="gl-pr-9 gl-my-4 gl-md-mt-0 gl-md-mb-0" + :value="`${count.value}`" + :title="count.label" + :should-animate="true" + /> + </template> + </div> + </div> </template> |