Skip to content
Snippets Groups Projects
Commit ecc512d9 authored by Robert Hunt's avatar Robert Hunt :palm_tree:
Browse files

Merge branch '382910-add-cube-table-visualization' into 'master'

Add a cube analytics table visualization to product analytics

See merge request !105372



Merged-by: default avatarRobert Hunt <rhunt@gitlab.com>
Approved-by: default avatarAxel García <agarcia@gitlab.com>
Approved-by: default avatarRobert Hunt <rhunt@gitlab.com>
Co-authored-by: default avatarJiaan Louw <jlouw@gitlab.com>
parents bced72dd 4891b3db
No related branches found
No related tags found
1 merge request!105372Add a cube analytics table visualization to product analytics
Pipeline #717053409 passed
Showing
with 233 additions and 22 deletions
......@@ -251,3 +251,8 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
.gl-flex-flow-row-wrap {
flex-flow: row wrap;
}
// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2098
.gl-max-w-0 {
max-width: 0;
}
......@@ -6,6 +6,8 @@ import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboa
const VISUALIZATION_JSONS = {
cube_analytics_line_chart: () =>
import(`../gl_dashboards/visualizations/cube_analytics_line_chart.json`),
cube_analytics_data_table: () =>
import(`../gl_dashboards/visualizations/cube_analytics_data_table.json`),
};
const DASHBOARD_JSONS = {
......
<script>
import { GlTableLite } from '@gitlab/ui';
export default {
name: 'DataTable',
components: {
GlTableLite,
},
props: {
data: {
type: Array,
required: false,
default: () => [],
},
// Part of the visualizations API, but left unused for tables.
// It could be used down the line to allow users to customize tables.
options: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
fields() {
if (this.data.length < 1) {
return null;
}
return Object.keys(this.data[0]).map((key) => ({
key,
tdClass: 'gl-text-truncate gl-max-w-0',
thClass: 'gl-bg-transparent!',
}));
},
},
};
</script>
<template>
<div>
<gl-table-lite :fields="fields" :items="data" hover responsive class="gl-mt-4" />
</div>
</template>
import { CubejsApi, HttpTransport } from '@cubejs-client/core';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import csrf from '~/lib/utils/csrf';
// This can be any value because the cube proxy adds the real API token.
......@@ -6,7 +7,7 @@ const CUBE_API_TOKEN = '1';
const PRODUCT_ANALYTICS_CUBE_PROXY = '/api/v4/projects/:id/product_analytics/request';
const convertToEChartFormat = (resultSet) => {
const convertToLineChartFormat = (resultSet) => {
const seriesNames = resultSet.seriesNames();
const pivot = resultSet.chartPivot();
......@@ -16,7 +17,27 @@ const convertToEChartFormat = (resultSet) => {
}));
};
export const fetch = async (projectId, query, queryOverrides = {}) => {
const convertToTableFormat = (resultSet) => {
const columns = resultSet.tableColumns();
const rows = resultSet.tablePivot();
const columnTitles = Object.fromEntries(
columns.map((column) => [column.key, convertToSnakeCase(column.shortTitle)]),
);
return rows.map((row) => {
return Object.fromEntries(
Object.entries(row).map(([key, value]) => [columnTitles[key], value]),
);
});
};
const VISUALIZATION_PARSERS = {
LineChart: convertToLineChartFormat,
DataTable: convertToTableFormat,
};
export const fetch = async ({ projectId, visualizationType, query, queryOverrides = {} }) => {
const cubejsApi = new CubejsApi(CUBE_API_TOKEN, {
transport: new HttpTransport({
apiUrl: PRODUCT_ANALYTICS_CUBE_PROXY.replace(':id', projectId),
......@@ -31,5 +52,5 @@ export const fetch = async (projectId, query, queryOverrides = {}) => {
const resultSet = await cubejsApi.load({ ...query, ...queryOverrides });
return convertToEChartFormat(resultSet);
return VISUALIZATION_PARSERS[visualizationType](resultSet);
};
......@@ -12,7 +12,7 @@
}
},
{
"visualization": "cube_analytics_line_chart",
"visualization": "cube_analytics_data_table",
"title": "Sources",
"gridAttributes": {
"size": {
......
{
"version": "1",
"type": "DataTable",
"data": {
"type": "cube_analytics",
"query": {
"measures": [
"Jitsu.count"
],
"dimensions": [
"Jitsu.sourceIp"
],
"order": {
"Jitsu.count": "desc"
},
"limit": 100
}
}
}
\ No newline at end of file
......@@ -8,6 +8,8 @@ export default {
GlLoadingIcon,
LineChart: () =>
import('ee/product_analytics/dashboards/components/visualizations/line_chart.vue'),
DataTable: () =>
import('ee/product_analytics/dashboards/components/visualizations/data_table.vue'),
},
inject: ['projectId'],
props: {
......@@ -34,13 +36,19 @@ export default {
};
},
async created() {
const { type, query } = this.visualization.data;
const { projectId, queryOverrides } = this;
const { type: dataType, query } = this.visualization.data;
this.loading = true;
this.error = null;
try {
const { fetch } = await dataSources[type]();
this.data = await fetch(this.projectId, query, this.queryOverrides);
const { fetch } = await dataSources[dataType]();
this.data = await fetch({
projectId,
query,
queryOverrides,
visualizationType: this.visualization.type,
});
} catch (error) {
this.error = error;
this.$emit('error', error);
......
......@@ -17,15 +17,14 @@ jest.mock('~/lib/utils/csrf', () => ({
}));
describe('Cube Analytics Data Source', () => {
describe('fetch', () => {
let result;
beforeEach(async () => {
result = await fetch('TEST_ID', { alpha: 'one' }, { alpha: 'two' });
});
const projectId = 'TEST_ID';
const visualizationType = 'LineChart';
const query = { alpha: 'one' };
const queryOverrides = { alpha: 'two' };
afterEach(() => {
result = null;
describe('fetch', () => {
beforeEach(() => {
return fetch({ projectId, visualizationType, query, queryOverrides });
});
it('creates a new CubejsApi connection', () => {
......@@ -47,13 +46,27 @@ describe('Cube Analytics Data Source', () => {
expect(mockLoad).toHaveBeenCalledWith({ alpha: 'two' });
});
it('returns the data in the expected charts format', () => {
expect(result[0]).toMatchObject({
data: [
['2022-11-09T00:00:00.000', 55],
['2022-11-10T00:00:00.000', 14],
],
name: 'pageview, Jitsu Count',
describe('formarts the data', () => {
it('returns the expected data format for line charts', async () => {
const result = await fetch({ projectId, visualizationType, query });
expect(result[0]).toMatchObject({
data: [
['2022-11-09T00:00:00.000', 55],
['2022-11-10T00:00:00.000', 14],
],
name: 'pageview, Jitsu Count',
});
});
it('returns the expected data format for data tables', async () => {
const result = await fetch({ projectId, visualizationType: 'DataTable', query });
expect(result[0]).toMatchObject({
count: '55',
event_type: 'pageview',
utc_time: '2022-11-09T00:00:00.000',
});
});
});
});
......
......@@ -18,4 +18,39 @@ export const mockResultSet = {
'pageview,Jitsu.count': 14,
},
],
tableColumns: () => [
{
key: 'Jitsu.utcTime.day',
title: 'Jitsu Utc Time',
shortTitle: 'Utc Time',
type: 'time',
dataIndex: 'Jitsu.utcTime.day',
},
{
key: 'Jitsu.eventType',
title: 'Jitsu Event Type',
shortTitle: 'Event Type',
type: 'string',
dataIndex: 'Jitsu.eventType',
},
{
key: 'Jitsu.count',
type: 'number',
dataIndex: 'Jitsu.count',
title: 'Jitsu Count',
shortTitle: 'Count',
},
],
tablePivot: () => [
{
'Jitsu.utcTime.day': '2022-11-09T00:00:00.000',
'Jitsu.eventType': 'pageview',
'Jitsu.count': '55',
},
{
'Jitsu.utcTime.day': '2022-11-10T00:00:00.000',
'Jitsu.eventType': 'pageview',
'Jitsu.count': '14',
},
],
};
import { GlTableLite } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import DataTable from 'ee/product_analytics/dashboards/components/visualizations/data_table.vue';
describe('DataTable Visualization', () => {
let wrapper;
const findTable = () => wrapper.findComponent(GlTableLite);
const findTableHeaders = () => findTable().findAll('th');
const findTableRowCells = (idx) => findTable().find('tbody').findAll('tr').at(idx).findAll('td');
const data = [{ field_one: 'alpha', field_two: 'beta' }];
const createWrapper = (mountFn = shallowMount, props = {}) => {
wrapper = extendedWrapper(
mountFn(DataTable, {
propsData: {
data,
options: {},
...props,
},
}),
);
};
describe('default behaviour', () => {
it('should render the table with the expected attributes', () => {
createWrapper();
expect(findTable().attributes()).toMatchObject({
responsive: '',
hover: '',
});
});
it('should render and style the table headers', () => {
createWrapper(mount);
const headers = findTableHeaders();
expect(headers).toHaveLength(2);
['Field One', 'Field Two'].forEach((headerText, idx) => {
expect(headers.at(idx).text()).toBe(headerText);
expect(headers.at(idx).classes()).toContain('gl-bg-transparent!');
});
});
it('should render and style the table cells', () => {
createWrapper(mount);
const rowCells = findTableRowCells(0);
expect(rowCells).toHaveLength(2);
Object.values(data[0]).forEach((value, idx) => {
expect(rowCells.at(idx).text()).toBe(value);
expect(rowCells.at(idx).classes()).toEqual(
expect.arrayContaining(['gl-text-truncate', 'gl-max-w-0']),
);
});
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment