summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_merge_request_widget/components/extensions
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-06-20 11:10:13 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-06-20 11:10:13 +0000
commit0ea3fcec397b69815975647f5e2aa5fe944a8486 (patch)
tree7979381b89d26011bcf9bdc989a40fcc2f1ed4ff /app/assets/javascripts/vue_merge_request_widget/components/extensions
parent72123183a20411a36d607d70b12d57c484394c8e (diff)
downloadgitlab-ce-0ea3fcec397b69815975647f5e2aa5fe944a8486.tar.gz
Add latest changes from gitlab-org/gitlab@15-1-stable-eev15.1.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_merge_request_widget/components/extensions')
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue84
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js17
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js207
7 files changed, 305 insertions, 35 deletions
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
index d878a1fa2e0..655ceb5f700 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
@@ -26,6 +26,7 @@ export default {
},
methods: {
onClickAction(action) {
+ this.$emit('clickedAction', action);
if (action.onClick) {
action.onClick();
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index 0bc17de638b..4ba620da00a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -6,16 +6,16 @@ import {
GlTooltipDirective,
GlIntersectionObserver,
} from '@gitlab/ui';
-import { once } from 'lodash';
import * as Sentry from '@sentry/browser';
import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
-import api from '~/api';
import { sprintf, s__, __ } from '~/locale';
import Poll from '~/lib/utils/poll';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
import StatusIcon from './status_icon.vue';
import Actions from './actions.vue';
import ChildContent from './child_content.vue';
+import { createTelemetryHub } from './telemetry';
import { generateText } from './utils';
export const LOADING_STATES = {
@@ -26,6 +26,7 @@ export const LOADING_STATES = {
};
export default {
+ telemetry: true,
components: {
GlButton,
GlLoadingIcon,
@@ -49,6 +50,7 @@ export default {
showFade: false,
modalData: undefined,
modalName: undefined,
+ telemetry: null,
};
},
computed: {
@@ -131,50 +133,85 @@ export default {
}
},
},
+ created() {
+ if (this.$options.telemetry) {
+ this.telemetry = createTelemetryHub(this.$options.name);
+ }
+ },
mounted() {
this.loadCollapsedData();
+
+ this.telemetry?.viewed();
},
methods: {
- triggerRedisTracking: once(function triggerRedisTracking() {
- if (this.$options.expandEvent) {
- api.trackRedisHllUserEvent(this.$options.expandEvent);
- }
- }),
toggleCollapsed(e) {
if (this.isCollapsible && !e?.target?.closest('.btn:not(.btn-icon),a')) {
- this.isCollapsed = !this.isCollapsed;
+ if (this.isCollapsed) {
+ this.telemetry?.expanded({ type: this.statusIconName });
+ }
- this.triggerRedisTracking();
+ this.isCollapsed = !this.isCollapsed;
}
},
+ initExtensionMultiPolling() {
+ const allData = [];
+ const requests = this.fetchMultiData();
+
+ requests.forEach((request) => {
+ const poll = new Poll({
+ resource: {
+ fetchData: () => request(this),
+ },
+ method: 'fetchData',
+ successCallback: (response) => {
+ this.headerCheck(response, (data) => allData.push(data));
+
+ if (allData.length === requests.length) {
+ this.setCollapsedData(allData);
+ }
+ },
+ errorCallback: (e) => {
+ this.setCollapsedError(e);
+ },
+ });
+
+ poll.makeRequest();
+ });
+ },
initExtensionPolling() {
const poll = new Poll({
resource: {
- fetchData: () => this.fetchCollapsedData(this.$props),
+ fetchData: () => this.fetchCollapsedData(this),
},
method: 'fetchData',
- successCallback: ({ data }) => {
- if (Object.keys(data).length > 0) {
- poll.stop();
- this.setCollapsedData(data);
- }
+ successCallback: (response) => {
+ this.headerCheck(response, (data) => this.setCollapsedData(data));
},
errorCallback: (e) => {
- poll.stop();
-
this.setCollapsedError(e);
},
});
poll.makeRequest();
},
+ headerCheck(response, callback) {
+ const headers = normalizeHeaders(response.headers);
+
+ if (!headers['POLL-INTERVAL']) {
+ callback(response.data);
+ }
+ },
loadCollapsedData() {
this.loadingState = LOADING_STATES.collapsedLoading;
if (this.$options.enablePolling) {
- this.initExtensionPolling();
+ if (this.fetchMultiData) {
+ this.initExtensionMultiPolling();
+ } else {
+ this.initExtensionPolling();
+ }
} else {
- this.fetchCollapsedData(this.$props)
+ this.fetchCollapsedData(this)
.then((data) => {
this.setCollapsedData(data);
})
@@ -197,7 +234,7 @@ export default {
this.loadingState = LOADING_STATES.expandedLoading;
- this.fetchFullData(this.$props)
+ this.fetchFullData(this)
.then((data) => {
this.loadingState = null;
this.fullData = data.map((x, i) => ({ id: i, ...x }));
@@ -231,6 +268,11 @@ export default {
this.toggleCollapsed(e);
}
},
+ onClickedAction(action) {
+ if (action.fullReport) {
+ this.telemetry?.fullReportClicked();
+ }
+ },
generateText,
},
EXTENSION_ICON_CLASS,
@@ -268,6 +310,7 @@ export default {
<actions
:widget="$options.label || $options.name"
:tertiary-buttons="tertiaryActionsButtons"
+ @clickedAction="onClickedAction"
/>
<div
v-if="isCollapsible"
@@ -324,6 +367,7 @@ export default {
:widget-label="widgetLabel"
:modal-id="modalId"
:level="2"
+ @clickedAction="onClickedAction"
/>
</gl-intersection-observer>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
index 0ca4c92a5ae..38f83a61b30 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
@@ -39,6 +39,9 @@ export default {
isArray(arr) {
return Array.isArray(arr);
},
+ onClickedAction(action) {
+ this.$emit('clickedAction', action);
+ },
generateText,
},
};
@@ -63,14 +66,14 @@ export default {
<div class="gl-w-full">
<div class="gl-display-flex gl-flex-nowrap">
<div class="gl-flex-wrap gl-display-flex gl-w-full">
- <div class="gl-mr-4 gl-display-flex gl-align-items-center">
+ <div class="gl-display-flex gl-align-items-center">
<p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
</div>
- <div v-if="data.link">
+ <div v-if="data.link" class="gl-pr-2">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div>
- <div v-if="data.modal">
- <gl-link v-gl-modal="modalId" @click="data.modal.onClick">
+ <div v-if="data.modal" class="gl-pr-2">
+ <gl-link v-gl-modal="modalId" data-testid="modal-link" @click="data.modal.onClick">
{{ data.modal.text }}
</gl-link>
</div>
@@ -81,7 +84,12 @@ export default {
{{ data.badge.text }}
</gl-badge>
</div>
- <actions :widget="widgetLabel" :tertiary-buttons="data.actions" class="gl-ml-auto" />
+ <actions
+ :widget="widgetLabel"
+ :tertiary-buttons="data.actions"
+ class="gl-ml-auto gl-pl-3"
+ @clickedAction="onClickedAction"
+ />
</div>
<p
v-if="data.subtext"
@@ -101,6 +109,7 @@ export default {
:modal-id="modalId"
:level="3"
data-testid="child-content"
+ @clickedAction="onClickedAction"
/>
</li>
</ul>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
index b9dfd3bd41e..a58d524b9ed 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
@@ -14,7 +14,7 @@ export default {
if (extensions.length === 0) return null;
return h(
- 'div',
+ 'section',
{
attrs: {
role: 'region',
@@ -34,13 +34,7 @@ export default {
{ ...extension },
{
props: {
- ...extension.props.reduce(
- (acc, key) => ({
- ...acc,
- [key]: this.mr[key],
- }),
- {},
- ),
+ mr: this.mr,
},
},
),
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
index 65273678fb9..f4fcf4c9571 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
@@ -10,12 +10,27 @@ export const registerExtension = (extension) => {
registeredExtensions.extensions.push({
extends: ExtensionBase,
name: extension.name,
- props: extension.props,
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ telemetry: extension.telemetry,
i18n: extension.i18n,
expandEvent: extension.expandEvent,
enablePolling: extension.enablePolling,
modalComponent: extension.modalComponent,
computed: {
+ ...extension.props.reduce(
+ (acc, propKey) => ({
+ ...acc,
+ [propKey]() {
+ return this.mr[propKey];
+ },
+ }),
+ {},
+ ),
...Object.keys(extension.computed).reduce(
(acc, computedKey) => ({
...acc,
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
index 456a1f17aae..bb626c9adba 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
@@ -49,7 +49,7 @@ export default {
]"
class="gl-rounded-full gl-mr-3 gl-relative gl-p-2"
>
- <gl-loading-icon v-if="isLoading" size="lg" inline class="gl-display-block" />
+ <gl-loading-icon v-if="isLoading" size="sm" inline class="gl-display-block" />
<gl-icon
v-else
:name="$options.EXTENSION_ICON_NAMES[iconName]"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
new file mode 100644
index 00000000000..aec3a35f37c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
@@ -0,0 +1,207 @@
+import api from '~/api';
+import createEventHub from '~/helpers/event_hub_factory';
+import {
+ TELEMETRY_WIDGET_VIEWED,
+ TELEMETRY_WIDGET_EXPANDED,
+ TELEMETRY_WIDGET_FULL_REPORT_CLICKED,
+} from '../../constants';
+
+/*
+ * Additional events to send beyond the defaults for certain widget extensions
+ */
+const nonStandardEvents = {
+ codeQuality: {
+ uniqueUser: {
+ expand: ['i_testing_code_quality_widget_total'],
+ },
+ counter: {},
+ },
+ terraform: {
+ uniqueUser: {
+ expand: ['i_testing_terraform_widget_total'],
+ },
+ counter: {},
+ },
+ issues: {
+ uniqueUser: {
+ expand: ['i_testing_load_performance_widget_total'],
+ },
+ counter: {},
+ },
+ testReport: {
+ uniqueUser: {
+ expand: ['i_testing_summary_widget_total'],
+ },
+ counter: {},
+ },
+};
+
+function combineDeepArray(path, ...objects) {
+ const parts = path.split('.');
+ const allEntries = objects.reduce((entries, currentObject) => {
+ let expandedEntries = entries;
+ let traversed = currentObject;
+
+ parts.forEach((part) => {
+ traversed = traversed?.[part];
+ });
+
+ if (traversed) {
+ expandedEntries = [...entries, ...traversed];
+ }
+
+ return expandedEntries;
+ }, []);
+
+ return Array.from(new Set(allEntries));
+}
+
+function simplifyWidgetName(componentName) {
+ const noWidget = componentName.replace(/^Widget/, '');
+
+ return noWidget.charAt(0).toLowerCase() + noWidget.slice(1);
+}
+
+function baseRedisEventName(extensionName) {
+ const redisEventName = extensionName.replace(/([A-Z])/g, '_$1').toLowerCase();
+
+ return `i_merge_request_widget_${redisEventName}`;
+}
+
+function whenable(bus) {
+ return function when(event) {
+ return ({ execute, track, special }) => {
+ bus.$on(event, (busEvent) => {
+ track.forEach((redisEvent) => {
+ execute(redisEvent);
+ });
+
+ special?.({ event, execute, track, bus, busEvent });
+ });
+ };
+ };
+}
+
+function defaultBehaviorEvents({ bus, config }) {
+ const when = whenable(bus);
+ const isViewed = when(TELEMETRY_WIDGET_VIEWED);
+ const isExpanded = when(TELEMETRY_WIDGET_EXPANDED);
+ const fullReportIsClicked = when(TELEMETRY_WIDGET_FULL_REPORT_CLICKED);
+ const toHll = config?.uniqueUser || {};
+ const toCounts = config?.counter || {};
+ const user = api.trackRedisHllUserEvent.bind(api);
+ const count = api.trackRedisCounterEvent.bind(api);
+
+ if (toHll.view) {
+ isViewed({ execute: user, track: toHll.view });
+ }
+ if (toCounts.view) {
+ isViewed({ execute: count, track: toCounts.view });
+ }
+
+ if (toHll.expand) {
+ isExpanded({
+ execute: user,
+ track: toHll.expand,
+ special: ({ execute, track, busEvent }) => {
+ if (busEvent.type) {
+ track.forEach((event) => {
+ execute(`${event}_${busEvent.type}`);
+ });
+ }
+ },
+ });
+ }
+ if (toCounts.expand) {
+ isExpanded({
+ execute: count,
+ track: toCounts.expand,
+ special: ({ execute, track, busEvent }) => {
+ if (busEvent.type) {
+ track.forEach((event) => {
+ execute(`${event}_${busEvent.type}`);
+ });
+ }
+ },
+ });
+ }
+
+ if (toHll.clickFullReport) {
+ fullReportIsClicked({ execute: user, track: toHll.clickFullReport });
+ }
+ if (toCounts.clickFullReport) {
+ fullReportIsClicked({ execute: count, track: toCounts.clickFullReport });
+ }
+}
+
+function baseTelemetry(componentName) {
+ const simpleExtensionName = simplifyWidgetName(componentName);
+ const additionalNonStandard = nonStandardEvents[simpleExtensionName] || {};
+ /*
+ * Telemetry config format is:
+ * {
+ * TELEMETRY_TYPE: {
+ * BEHAVIOR: [ EVENT_NAME, ... ]
+ * }
+ * }
+ *
+ * Right now, there are currently configurations for these telemetry types:
+ * - uniqueUser is sent to RedisHLL
+ * - counter is sent to a regular Redis counter
+ */
+ const defaultTelemetry = {
+ uniqueUser: {
+ view: [`${baseRedisEventName(simpleExtensionName)}_view`],
+ expand: [`${baseRedisEventName(simpleExtensionName)}_expand`],
+ clickFullReport: [`${baseRedisEventName(simpleExtensionName)}_click_full_report`],
+ },
+ counter: {
+ view: [`${baseRedisEventName(simpleExtensionName)}_count_view`],
+ expand: [`${baseRedisEventName(simpleExtensionName)}_count_expand`],
+ clickFullReport: [`${baseRedisEventName(simpleExtensionName)}_count_click_full_report`],
+ },
+ };
+
+ return {
+ uniqueUser: {
+ view: combineDeepArray('uniqueUser.view', defaultTelemetry, additionalNonStandard),
+ expand: combineDeepArray('uniqueUser.expand', defaultTelemetry, additionalNonStandard),
+ clickFullReport: combineDeepArray(
+ 'uniqueUser.clickFullReport',
+ defaultTelemetry,
+ additionalNonStandard,
+ ),
+ },
+ counter: {
+ view: combineDeepArray('counter.view', defaultTelemetry, additionalNonStandard),
+ expand: combineDeepArray('counter.expand', defaultTelemetry, additionalNonStandard),
+ clickFullReport: combineDeepArray(
+ 'counter.clickFullReport',
+ defaultTelemetry,
+ additionalNonStandard,
+ ),
+ },
+ };
+}
+
+export function createTelemetryHub(componentName) {
+ const bus = createEventHub();
+ const config = baseTelemetry(componentName);
+
+ defaultBehaviorEvents({ bus, config });
+
+ return {
+ viewed() {
+ bus.$emit(TELEMETRY_WIDGET_VIEWED);
+ },
+ expanded({ type }) {
+ bus.$emit(TELEMETRY_WIDGET_EXPANDED, { type });
+ },
+ fullReportClicked() {
+ bus.$emit(TELEMETRY_WIDGET_FULL_REPORT_CLICKED);
+ },
+ /* I want a Record here: #{ ...config } // and then the comment would be: This is for debugging only, changing your reference to it does nothing. 😘 */
+ config: Object.freeze({ ...config }), // This is *intended* to be for debugging only, but it's pretty mutable, and it has references to live data in child props
+ bus,
+ };
+}