diff --git a/bitmovin-analytics-datasource/src/components/ConfigEditor.tsx b/bitmovin-analytics-datasource/src/components/ConfigEditor.tsx index 60c8455..13b3104 100644 --- a/bitmovin-analytics-datasource/src/components/ConfigEditor.tsx +++ b/bitmovin-analytics-datasource/src/components/ConfigEditor.tsx @@ -1,9 +1,9 @@ import React, { ChangeEvent, useEffect } from 'react'; import { DataSourceHttpSettings, FieldSet, InlineField, InlineSwitch, Input } from '@grafana/ui'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; -import { MyDataSourceOptions } from '../types'; +import { BitmovinDataSourceOptions } from '../types/grafanaTypes'; -interface Props extends DataSourcePluginOptionsEditorProps {} +interface Props extends DataSourcePluginOptionsEditorProps {} export function ConfigEditor(props: Props) { const { onOptionsChange, options } = props; diff --git a/bitmovin-analytics-datasource/src/components/FilterInput.tsx b/bitmovin-analytics-datasource/src/components/FilterInput.tsx new file mode 100644 index 0000000..ba4a747 --- /dev/null +++ b/bitmovin-analytics-datasource/src/components/FilterInput.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { SelectableValue } from '@grafana/data'; +import { HorizontalGroup, IconButton, Input, Select, Tooltip } from '@grafana/ui'; + +import { QueryAttribute } from '../types/queryAttributes'; +import { QueryAdAttribute } from '../types/queryAdAttributes'; +import { QueryFilterOperator, SELECTABLE_QUERY_FILTER_OPERATORS } from '../types/queryFilter'; + +type Props = { + readonly isAdAnalytics: boolean; + readonly selectableFilterAttributes: Array>; + readonly onAttributeChange: (newValue: SelectableValue) => void; + readonly onOperatorChange: (newValue: SelectableValue) => void; + readonly onValueChange: (newValue: string) => void; + readonly onDelete: () => void; + readonly addFilterDisabled: boolean; + readonly onAddFilter: () => void; + readonly parsingValueError: string | undefined; +}; + +export function FilterInput(props: Props) { + return ( + + props.onOperatorChange(value)} + options={SELECTABLE_QUERY_FILTER_OPERATORS} + width={15} + /> + + props.onValueChange(value.currentTarget.value)} + width={30} + /> + + + + + ); +} diff --git a/bitmovin-analytics-datasource/src/components/FilterRow.tsx b/bitmovin-analytics-datasource/src/components/FilterRow.tsx new file mode 100644 index 0000000..0344fec --- /dev/null +++ b/bitmovin-analytics-datasource/src/components/FilterRow.tsx @@ -0,0 +1,168 @@ +import React, { useState } from 'react'; +import { difference, isEmpty } from 'lodash'; +import { SelectableValue } from '@grafana/data'; +import { Box, HorizontalGroup, IconButton, InlineLabel, VerticalGroup } from '@grafana/ui'; + +import { QueryFilter, QueryFilterOperator, QueryFilterValue } from '../types/queryFilter'; +import { QueryAdAttribute, SELECTABLE_QUERY_AD_ATTRIBUTES } from '../types/queryAdAttributes'; +import { QueryAttribute, SELECTABLE_QUERY_ATTRIBUTES } from '../types/queryAttributes'; +import { FilterInput } from './FilterInput'; +import { convertFilterValueToProperType } from '../utils/filterUtils'; + +type Filter = { + selectedAttribute: SelectableValue; + selectedOperator: SelectableValue; + rawFilterValue: string; + convertedFilterValue: QueryFilterValue; + parsingValueError: string; +}; + +const mapFilterAttributesToSelectableValue = ( + filters: Filter[], + isAdAnalytics: boolean +): Array> => { + const selectedAttributes = filters.map((filter) => filter.selectedAttribute); + if (isAdAnalytics) { + return difference(SELECTABLE_QUERY_AD_ATTRIBUTES, selectedAttributes); + } else { + return difference(SELECTABLE_QUERY_ATTRIBUTES, selectedAttributes); + } +}; + +const mapFiltersToQueryFilters = (filters: Filter[]): QueryFilter[] => { + return filters.map((filter) => { + return { + name: filter.selectedAttribute.value!, + operator: filter.selectedOperator.value!, + value: filter.convertedFilterValue, + } as QueryFilter; + }); +}; + +type Props = { + readonly isAdAnalytics: boolean; + readonly onChange: (newFilters: QueryFilter[]) => void; +}; + +export function FilterRow(props: Props) { + const [filters, setFilters] = useState([]); + + const addFilterInput = () => { + setFilters((prevState) => [ + ...prevState, + { + selectedAttribute: {}, + selectedOperator: {}, + rawFilterValue: '', + convertedFilterValue: '', + parsingValueError: '', + } as Filter, + ]); + }; + + const onAddFilter = (index: number) => { + const filter = filters[index]; + try { + const convertedValue = convertFilterValueToProperType( + filter.rawFilterValue, + filter.selectedAttribute.value!, + filter.selectedAttribute.label!, + filter.selectedOperator.value!, + props.isAdAnalytics + ); + + const newFilter = { ...filter, convertedFilterValue: convertedValue, parsingValueError: '' } as Filter; + + const newFilters = [...filters]; + newFilters.splice(index, 1, newFilter); + + setFilters(newFilters); + + props.onChange(mapFiltersToQueryFilters(newFilters)); + } catch (e: unknown) { + if (e instanceof Error) { + const errorMessage = e.message; + const newFilter = { ...filter, parsingValueError: errorMessage } as Filter; + + const newFilters = [...filters]; + newFilters.splice(index, 1, newFilter); + + setFilters(newFilters); + } + } + }; + + const deleteFilterInput = (index: number) => { + const newFilters = [...filters]; + newFilters.splice(index, 1); + + setFilters(newFilters); + + props.onChange(mapFiltersToQueryFilters(newFilters)); + }; + + const onAttributesChange = (index: number, newAttribute: SelectableValue) => { + const filter = filters[index]; + const newFilter = { ...filter, selectedAttribute: newAttribute } as Filter; + const newFilters = [...filters]; + newFilters.splice(index, 1, newFilter); + + setFilters(newFilters); + }; + + const onOperatorsChange = (index: number, newOperator: SelectableValue) => { + const filter = filters[index]; + const newFilter = { ...filter, selectedOperator: newOperator } as Filter; + const newFilters = [...filters]; + newFilters.splice(index, 1, newFilter); + + setFilters(newFilters); + }; + + const onValuesChange = (index: number, newValue: string) => { + const filter = filters[index]; + const newFilter = { ...filter, rawFilterValue: newValue }; + const newFilters = [...filters]; + newFilters.splice(index, 1, newFilter); + + setFilters(newFilters); + }; + + return ( + + {filters.length !== 0 && ( + + + Dimension + + + Operator + + + Value + + + )} + {filters.map((filter, index, filtersArray) => ( + ) => + onAttributesChange(index, newValue) + } + onOperatorChange={(newValue: SelectableValue) => onOperatorsChange(index, newValue)} + onValueChange={(newValue: string) => onValuesChange(index, newValue)} + onDelete={() => deleteFilterInput(index)} + addFilterDisabled={isEmpty(filter.selectedAttribute) || isEmpty(filter.selectedOperator)} + onAddFilter={() => onAddFilter(index)} + parsingValueError={isEmpty(filter.parsingValueError) ? undefined : filter.parsingValueError} + /> + ))} + + + addFilterInput()} size="xl" /> + + + ); +} diff --git a/bitmovin-analytics-datasource/src/components/GroupByInput.tsx b/bitmovin-analytics-datasource/src/components/GroupByInput.tsx index 1ac2561..2a0e2f7 100644 --- a/bitmovin-analytics-datasource/src/components/GroupByInput.tsx +++ b/bitmovin-analytics-datasource/src/components/GroupByInput.tsx @@ -4,6 +4,7 @@ import { HorizontalGroup, IconButton, Select } from '@grafana/ui'; import { QueryAttribute } from '../types/queryAttributes'; import { QueryAdAttribute } from '../types/queryAdAttributes'; +import { isEmpty } from 'lodash'; export enum REORDER_DIRECTION { UP, @@ -23,7 +24,12 @@ type Props = { export function GroupByInput(props: Props) { return ( - props.onReorderGroupBy(REORDER_DIRECTION.DOWN)} @@ -36,13 +42,7 @@ export function GroupByInput(props: Props) { name="arrow-up" disabled={props.isFirst} /> - props.onDelete()} - size="lg" - variant="destructive" - /> + ); } diff --git a/bitmovin-analytics-datasource/src/components/GroupByRow.tsx b/bitmovin-analytics-datasource/src/components/GroupByRow.tsx index 2ff83a8..4dd5aa9 100644 --- a/bitmovin-analytics-datasource/src/components/GroupByRow.tsx +++ b/bitmovin-analytics-datasource/src/components/GroupByRow.tsx @@ -7,6 +7,17 @@ import { QueryAdAttribute, SELECTABLE_QUERY_AD_ATTRIBUTES } from '../types/query import { QueryAttribute, SELECTABLE_QUERY_ATTRIBUTES } from '../types/queryAttributes'; import { GroupByInput, REORDER_DIRECTION } from './GroupByInput'; +const mapGroupBysToSelectableValue = ( + selectedGroupBys: Array>, + isAdAnalytics: boolean +): Array> => { + if (isAdAnalytics) { + return difference(SELECTABLE_QUERY_AD_ATTRIBUTES, selectedGroupBys); + } else { + return difference(SELECTABLE_QUERY_ATTRIBUTES, selectedGroupBys); + } +}; + type Props = { readonly isAdAnalytics: boolean; readonly onChange: (newGroupBys: QueryAdAttribute[] | QueryAttribute[]) => void; @@ -17,14 +28,6 @@ export function GroupByRow(props: Props) { [] ); - const mapGroupBysToSelectableValue = (): Array> => { - if (props.isAdAnalytics) { - return difference(SELECTABLE_QUERY_AD_ATTRIBUTES, selectedGroupBys); - } else { - return difference(SELECTABLE_QUERY_ATTRIBUTES, selectedGroupBys); - } - }; - const deleteGroupByInput = (index: number) => { const newSelectedGroupBys = [...selectedGroupBys]; newSelectedGroupBys.splice(index, 1); @@ -66,17 +69,17 @@ export function GroupByRow(props: Props) { return ( - {selectedGroupBys.map((item, index, groupBys) => ( + {selectedGroupBys.map((item, index, selectedGroupBysArray) => ( ) => onSelectedGroupByChange(index, newValue) } - selectableGroupBys={mapGroupBysToSelectableValue()} + selectableGroupBys={mapGroupBysToSelectableValue(selectedGroupBysArray, props.isAdAnalytics)} onDelete={() => deleteGroupByInput(index)} isFirst={index === 0} - isLast={index === groupBys.length - 1} + isLast={index === selectedGroupBysArray.length - 1} onReorderGroupBy={(direction: REORDER_DIRECTION) => reorderGroupBy(direction, index)} /> ))} diff --git a/bitmovin-analytics-datasource/src/components/OrderByInput.tsx b/bitmovin-analytics-datasource/src/components/OrderByInput.tsx index 033a3fc..04d47d7 100644 --- a/bitmovin-analytics-datasource/src/components/OrderByInput.tsx +++ b/bitmovin-analytics-datasource/src/components/OrderByInput.tsx @@ -6,6 +6,7 @@ import { QueryAttribute } from '../types/queryAttributes'; import { QueryAdAttribute } from '../types/queryAdAttributes'; import { QuerySortOrder } from '../types/queryOrderBy'; import { REORDER_DIRECTION } from './GroupByInput'; +import { isEmpty } from 'lodash'; type Props = { readonly isAdAnalytics: boolean; @@ -29,7 +30,7 @@ export function OrderByInput(props: Props) { return ( onIntervalChange(item)} + onChange={(item) => handleIntervalChange(item)} width={30} options={SELECTABLE_QUERY_INTERVALS} /> @@ -113,9 +134,10 @@ export function QueryEditor({ query, onChange, onRunQuery, datasource }: Props) invalid={licenseLoadingState === LoadingState.Error} error={`Error when fetching Analytics Licenses: ${licenseErrorMessage}`} disabled={licenseLoadingState === LoadingState.Error} + required > onAggregationChange(item)} - width={30} - options={SELECTABLE_AGGREGATIONS} - /> + + + + + - + - + + + + - + {isTimeSeries && renderTimeSeriesOption()} + + + ); diff --git a/bitmovin-analytics-datasource/src/datasource.ts b/bitmovin-analytics-datasource/src/datasource.ts index 6211340..0d384b2 100644 --- a/bitmovin-analytics-datasource/src/datasource.ts +++ b/bitmovin-analytics-datasource/src/datasource.ts @@ -1,4 +1,5 @@ import { + CoreApp, createDataFrame, DataQueryRequest, DataQueryResponse, @@ -7,36 +8,45 @@ import { Field, } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; +import { filter } from 'lodash'; import { catchError, lastValueFrom, map, Observable, of } from 'rxjs'; -import { MixedDataRowList, MyDataSourceOptions, BitmovinAnalyticsDataQuery, NumberDataRowList } from './types'; -import { transformGroupedTimeSeriesData, transformSimpleTimeSeries, transformTableData } from './utils/dataUtils'; +import { BitmovinDataSourceOptions, BitmovinAnalyticsDataQuery, DEFAULT_QUERY } from './types/grafanaTypes'; +import { + MixedDataRowList, + NumberDataRowList, + transformGroupedTimeSeriesData, + transformSimpleTimeSeries, + transformTableData, +} from './utils/dataUtils'; import { calculateQueryInterval, QueryInterval } from './utils/intervalUtils'; -import { QueryAttribute } from './types/queryAttributes'; -import { QueryAdAttribute } from './types/queryAdAttributes'; import { Metric } from './types/metric'; import { Aggregation } from './types/aggregations'; +import { QueryFilter } from './types/queryFilter'; +import { QueryAttribute } from './types/queryAttributes'; +import { QueryAdAttribute } from './types/queryAdAttributes'; import { QueryOrderBy } from './types/queryOrderBy'; -type AnalyticsQuery = { - filters: Array<{ name: string; operator: string; value: number }>; +type BitmovinAnalyticsRequestQuery = { + licenseKey: string; + start: Date; + end: Date; + filters: QueryFilter[]; groupBy: QueryAttribute[] | QueryAdAttribute[]; orderBy: QueryOrderBy[]; dimension?: QueryAttribute | QueryAdAttribute; metric?: Metric; - start: Date; - end: Date; - licenseKey: string; interval?: QueryInterval; + limit?: number; }; -export class DataSource extends DataSourceApi { +export class DataSource extends DataSourceApi { baseUrl: string; apiKey: string; tenantOrgId?: string; adAnalytics?: boolean; - constructor(instanceSettings: DataSourceInstanceSettings) { + constructor(instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); this.apiKey = instanceSettings.jsonData.apiKey; @@ -45,6 +55,10 @@ export class DataSource extends DataSourceApi { + return DEFAULT_QUERY; + } + /** * The Bitmovin API Response follows these rules: * - If the interval property is provided in the request query, time series data is returned and the first value of each row is a timestamp in milliseconds. @@ -59,19 +73,16 @@ export class DataSource extends DataSourceApi { + //filter disabled queries + const enabledQueries = (options.targets = filter(options.targets, (t) => !t.hide)); + + const promises = enabledQueries.map(async (target) => { const interval = target.interval ? calculateQueryInterval(target.interval!, from.getTime(), to.getTime()) : undefined; - const query: AnalyticsQuery = { - filters: [ - { - name: 'VIDEO_STARTUPTIME', - operator: 'GT', - value: 0, - }, - ], + const query: BitmovinAnalyticsRequestQuery = { + filters: target.filters, groupBy: target.groupBy, orderBy: target.orderBy, dimension: target.dimension, @@ -80,6 +91,7 @@ export class DataSource extends DataSourceApi(DataSource) +export const plugin = new DataSourcePlugin( + DataSource +) .setConfigEditor(ConfigEditor) .setQueryEditor(QueryEditor); diff --git a/bitmovin-analytics-datasource/src/types.ts b/bitmovin-analytics-datasource/src/types.ts deleted file mode 100644 index b7d5f82..0000000 --- a/bitmovin-analytics-datasource/src/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DataSourceJsonData } from '@grafana/data'; -import { DataQuery } from '@grafana/schema'; -import { QueryInterval } from './utils/intervalUtils'; -import { Aggregation } from './types/aggregations'; -import { QueryAttribute } from './types/queryAttributes'; -import { QueryAdAttribute } from './types/queryAdAttributes'; -import { Metric } from './types/metric'; -import { QueryOrderBy } from './types/queryOrderBy'; - -export interface BitmovinAnalyticsDataQuery extends DataQuery { - licenseKey: string; - interval?: QueryInterval | 'AUTO'; - aggregation?: Aggregation; - metric?: Metric; - dimension?: QueryAttribute | QueryAdAttribute; - groupBy: QueryAttribute[] | QueryAdAttribute[]; - orderBy: QueryOrderBy[]; -} - -export const DEFAULT_QUERY: Partial = {}; - -/** - * These are options configured for each DataSource instance - */ -export interface MyDataSourceOptions extends DataSourceJsonData { - apiKey: string; - tenantOrgId?: string; - adAnalytics?: boolean; -} - -export type MixedDataRow = Array; -export type MixedDataRowList = MixedDataRow[]; - -export type NumberDataRow = number[]; -export type NumberDataRowList = NumberDataRow[]; diff --git a/bitmovin-analytics-datasource/src/types/aggregations.ts b/bitmovin-analytics-datasource/src/types/aggregations.ts index 273a437..72072ad 100644 --- a/bitmovin-analytics-datasource/src/types/aggregations.ts +++ b/bitmovin-analytics-datasource/src/types/aggregations.ts @@ -11,5 +11,3 @@ export const SELECTABLE_AGGREGATIONS: Array<{ value: Aggregation; label: string { value: 'variance', label: 'Variance' }, { value: 'median', label: 'Median' }, ]; - -export const DEFAULT_SELECTABLE_AGGREGATION = SELECTABLE_AGGREGATIONS[0]; diff --git a/bitmovin-analytics-datasource/src/types/grafanaTypes.ts b/bitmovin-analytics-datasource/src/types/grafanaTypes.ts new file mode 100644 index 0000000..e5d3f2b --- /dev/null +++ b/bitmovin-analytics-datasource/src/types/grafanaTypes.ts @@ -0,0 +1,42 @@ +import { DataSourceJsonData } from '@grafana/data'; +import { DataQuery } from '@grafana/schema'; +import { QueryInterval } from '../utils/intervalUtils'; +import { Aggregation } from './aggregations'; +import { QueryAttribute } from './queryAttributes'; +import { QueryAdAttribute } from './queryAdAttributes'; +import { Metric } from './metric'; +import { QueryOrderBy } from './queryOrderBy'; +import { QueryFilter } from './queryFilter'; + +/** + * These are the options configurable via the QueryEditor + * */ +export interface BitmovinAnalyticsDataQuery extends DataQuery { + licenseKey: string; + interval?: QueryInterval | 'AUTO'; + aggregation?: Aggregation; + metric?: Metric; + dimension?: QueryAttribute | QueryAdAttribute; + groupBy: QueryAttribute[] | QueryAdAttribute[]; + orderBy: QueryOrderBy[]; + limit?: number; + filters: QueryFilter[]; + aliasBy?: string; +} + +export const DEFAULT_QUERY: Partial = { + licenseKey: '', + interval: 'AUTO', + orderBy: [], + groupBy: [], + filters: [], +}; + +/** + * These are options configured for each DataSource instance + */ +export interface BitmovinDataSourceOptions extends DataSourceJsonData { + apiKey: string; + tenantOrgId?: string; + adAnalytics?: boolean; +} diff --git a/bitmovin-analytics-datasource/src/types/queryFilter.ts b/bitmovin-analytics-datasource/src/types/queryFilter.ts new file mode 100644 index 0000000..ba1c86a --- /dev/null +++ b/bitmovin-analytics-datasource/src/types/queryFilter.ts @@ -0,0 +1,37 @@ +import { SelectableValue } from '@grafana/data'; +import { QueryAdAttribute } from './queryAdAttributes'; +import { QueryAttribute } from './queryAttributes'; + +export enum QUERY_FILTER_OPERATORS { + GT = 'GT', + GTE = 'GTE', + LT = 'LT', + LTE = 'LTE', + EQ = 'EQ', + NE = 'NE', + CONTAINS = 'CONTAINS', + NOTCONTAINS = 'NOTCONTAINS', + IN = 'IN', +} + +export type QueryFilterOperator = keyof typeof QUERY_FILTER_OPERATORS; + +export const SELECTABLE_QUERY_FILTER_OPERATORS: Array> = [ + { value: QUERY_FILTER_OPERATORS.GT, label: QUERY_FILTER_OPERATORS.GT }, + { value: QUERY_FILTER_OPERATORS.GTE, label: QUERY_FILTER_OPERATORS.GTE }, + { value: QUERY_FILTER_OPERATORS.LT, label: QUERY_FILTER_OPERATORS.LT }, + { value: QUERY_FILTER_OPERATORS.LTE, label: QUERY_FILTER_OPERATORS.LTE }, + { value: QUERY_FILTER_OPERATORS.EQ, label: QUERY_FILTER_OPERATORS.EQ }, + { value: QUERY_FILTER_OPERATORS.NE, label: QUERY_FILTER_OPERATORS.NE }, + { value: QUERY_FILTER_OPERATORS.CONTAINS, label: QUERY_FILTER_OPERATORS.CONTAINS }, + { value: QUERY_FILTER_OPERATORS.NOTCONTAINS, label: QUERY_FILTER_OPERATORS.NOTCONTAINS }, + { value: QUERY_FILTER_OPERATORS.IN, label: QUERY_FILTER_OPERATORS.IN }, +]; + +export type QueryFilter = { + name: QueryAdAttribute | QueryAttribute; + operator: QueryFilterOperator; + value: QueryFilterValue; +}; + +export type QueryFilterValue = boolean | number | string | string[] | null; diff --git a/bitmovin-analytics-datasource/src/utils/dataUtils.ts b/bitmovin-analytics-datasource/src/utils/dataUtils.ts index 0ce63b7..2f88ed5 100644 --- a/bitmovin-analytics-datasource/src/utils/dataUtils.ts +++ b/bitmovin-analytics-datasource/src/utils/dataUtils.ts @@ -1,7 +1,12 @@ import { differenceWith, sortBy, zip } from 'lodash'; import { ceilTimestampAccordingToQueryInterval, intervalToMilliseconds, QueryInterval } from './intervalUtils'; import { Field, FieldType } from '@grafana/data'; -import { MixedDataRow, MixedDataRowList, NumberDataRow, NumberDataRowList } from '../types'; + +export type MixedDataRow = Array; +export type MixedDataRowList = MixedDataRow[]; + +export type NumberDataRow = number[]; +export type NumberDataRowList = NumberDataRow[]; /** * Adds padding to a given time series to fill in any missing timestamps for a given interval. diff --git a/bitmovin-analytics-datasource/src/utils/filterUtils.test.ts b/bitmovin-analytics-datasource/src/utils/filterUtils.test.ts new file mode 100644 index 0000000..bafe87c --- /dev/null +++ b/bitmovin-analytics-datasource/src/utils/filterUtils.test.ts @@ -0,0 +1,179 @@ +import { convertFilterValueToProperType } from './filterUtils'; +import { QUERY_ATTRIBUTES } from '../types/queryAttributes'; +import { QUERY_FILTER_OPERATORS } from '../types/queryFilter'; +import { QUERY_AD_ATTRIBUTES } from '../types/queryAdAttributes'; + +describe('convertFilterValueToProperType', () => { + it('should return null if rawValue is empty and attribute a NullFilter', () => { + //arrange & act + const result = convertFilterValueToProperType( + '', + QUERY_ATTRIBUTES.CUSTOM_DATA_1, + 'Custom Data 1', + QUERY_FILTER_OPERATORS.IN, + false + ); + + //assert + expect(result).toBeNull(); + }); + + it('should throw an error if value for IN filter is not a json array', () => { + //arrange & act && assert + expect(() => + convertFilterValueToProperType('Firefox', QUERY_ATTRIBUTES.BROWSER, 'Browser', QUERY_FILTER_OPERATORS.IN, false) + ).toThrow( + new Error('Couldn\'t parse IN filter, please provide data in JSON array form (e.g.: ["Firefox", "Chrome"]).') + ); + }); + + it('should correctly parse value for IN filter', () => { + //arrange & act + const result = convertFilterValueToProperType( + '["Firefox", "Safari"]', + QUERY_ATTRIBUTES.BROWSER, + 'Browser', + QUERY_FILTER_OPERATORS.IN, + false + ); + + //assert + expect(result).toEqual(['Firefox', 'Safari']); + }); + + it('should correctly convert to boolean value for ad attributes', () => { + //arrange & act + const result = convertFilterValueToProperType( + 'true', + QUERY_AD_ATTRIBUTES.IS_LINEAR, + 'Is Linear', + QUERY_FILTER_OPERATORS.EQ, + true + ); + + //assert + expect(result).toEqual(true); + }); + + it('should correctly convert to int value for ad attributes', () => { + //arrange & act + const result = convertFilterValueToProperType( + '2000', + QUERY_AD_ATTRIBUTES.CLICK_POSITION, + 'Click Position', + QUERY_FILTER_OPERATORS.EQ, + true + ); + + //assert + expect(result).toEqual(2000); + }); + + it('should throw error if int parsing fails for ad attributes', () => { + //arrange & act & assert + expect(() => + convertFilterValueToProperType( + 'true', + QUERY_AD_ATTRIBUTES.CLICK_POSITION, + 'Click Position', + QUERY_FILTER_OPERATORS.EQ, + true + ) + ).toThrow(new Error(`Couldn't parse filter for Click Position, please provide data as a number`)); + }); + + it('should correctly convert to float value for ad attributes', () => { + //arrange & act + const result = convertFilterValueToProperType( + '12.56', + QUERY_AD_ATTRIBUTES.CLICK_PERCENTAGE, + 'Click Percentage', + QUERY_FILTER_OPERATORS.EQ, + true + ); + + //assert + expect(result).toEqual(12.56); + }); + + it('should throw error if float parsing fails for ad attributes', () => { + //arrange & act & assert + expect(() => + convertFilterValueToProperType( + 'two', + QUERY_AD_ATTRIBUTES.CLICK_PERCENTAGE, + 'Click Percentage', + QUERY_FILTER_OPERATORS.EQ, + true + ) + ).toThrow(new Error(`Couldn't parse filter for Click Percentage, please provide data as a number`)); + }); + + it('should correctly convert to boolean value for attributes', () => { + //arrange & act + const result = convertFilterValueToProperType( + 'true', + QUERY_ATTRIBUTES.IS_CASTING, + 'Is Casting', + QUERY_FILTER_OPERATORS.EQ, + false + ); + + //assert + expect(result).toEqual(true); + }); + + it('should correctly convert to int value for attributes', () => { + //arrange & act + const result = convertFilterValueToProperType( + '2000', + QUERY_AD_ATTRIBUTES.AUDIO_BITRATE, + 'Audio Bitrate', + QUERY_FILTER_OPERATORS.EQ, + false + ); + + //assert + expect(result).toEqual(2000); + }); + + it('should throw error if int parsing fails for attributes', () => { + //arrange & act & assert + expect(() => + convertFilterValueToProperType( + 'zero', + QUERY_ATTRIBUTES.AUDIO_BITRATE, + 'Audio Bitrate', + QUERY_FILTER_OPERATORS.EQ, + false + ) + ).toThrow(new Error(`Couldn't parse filter for Audio Bitrate, please provide data as a number`)); + }); + + it('should correctly convert to float value for attributes', () => { + //arrange & act + const result = convertFilterValueToProperType( + '12.56', + QUERY_ATTRIBUTES.ERROR_PERCENTAGE, + 'Error Percentage', + QUERY_FILTER_OPERATORS.EQ, + false + ); + + //assert + expect(result).toEqual(12.56); + }); + + it('should throw error if float parsing fails for attributes', () => { + //arrange & act & assert + expect(() => + convertFilterValueToProperType( + 'two', + QUERY_ATTRIBUTES.ERROR_PERCENTAGE, + 'Error Percentage', + QUERY_FILTER_OPERATORS.EQ, + false + ) + ).toThrow(new Error(`Couldn't parse filter for Error Percentage, please provide data as a number`)); + }); +}); diff --git a/bitmovin-analytics-datasource/src/utils/filterUtils.ts b/bitmovin-analytics-datasource/src/utils/filterUtils.ts new file mode 100644 index 0000000..082d490 --- /dev/null +++ b/bitmovin-analytics-datasource/src/utils/filterUtils.ts @@ -0,0 +1,198 @@ +import { isEmpty } from 'lodash'; + +import { QUERY_AD_ATTRIBUTES, QueryAdAttribute } from '../types/queryAdAttributes'; +import { QUERY_FILTER_OPERATORS, QueryFilterOperator, QueryFilterValue } from '../types/queryFilter'; +import { QUERY_ATTRIBUTES, QueryAttribute } from '../types/queryAttributes'; + +const isNullFilter = (filterAttribute: QueryAttribute | QueryAdAttribute): boolean => { + switch (filterAttribute) { + case QUERY_ATTRIBUTES.CDN_PROVIDER: + case QUERY_ATTRIBUTES.CUSTOM_DATA_1: + case QUERY_ATTRIBUTES.CUSTOM_DATA_2: + case QUERY_ATTRIBUTES.CUSTOM_DATA_3: + case QUERY_ATTRIBUTES.CUSTOM_DATA_4: + case QUERY_ATTRIBUTES.CUSTOM_DATA_5: + case QUERY_ATTRIBUTES.CUSTOM_DATA_6: + case QUERY_ATTRIBUTES.CUSTOM_DATA_7: + case QUERY_ATTRIBUTES.CUSTOM_DATA_8: + case QUERY_ATTRIBUTES.CUSTOM_DATA_9: + case QUERY_ATTRIBUTES.CUSTOM_DATA_10: + case QUERY_ATTRIBUTES.CUSTOM_DATA_11: + case QUERY_ATTRIBUTES.CUSTOM_DATA_12: + case QUERY_ATTRIBUTES.CUSTOM_DATA_13: + case QUERY_ATTRIBUTES.CUSTOM_DATA_14: + case QUERY_ATTRIBUTES.CUSTOM_DATA_15: + case QUERY_ATTRIBUTES.CUSTOM_DATA_16: + case QUERY_ATTRIBUTES.CUSTOM_DATA_17: + case QUERY_ATTRIBUTES.CUSTOM_DATA_18: + case QUERY_ATTRIBUTES.CUSTOM_DATA_19: + case QUERY_ATTRIBUTES.CUSTOM_DATA_20: + case QUERY_ATTRIBUTES.CUSTOM_DATA_21: + case QUERY_ATTRIBUTES.CUSTOM_DATA_22: + case QUERY_ATTRIBUTES.CUSTOM_DATA_23: + case QUERY_ATTRIBUTES.CUSTOM_DATA_24: + case QUERY_ATTRIBUTES.CUSTOM_DATA_25: + case QUERY_ATTRIBUTES.CUSTOM_DATA_26: + case QUERY_ATTRIBUTES.CUSTOM_DATA_27: + case QUERY_ATTRIBUTES.CUSTOM_DATA_28: + case QUERY_ATTRIBUTES.CUSTOM_DATA_29: + case QUERY_ATTRIBUTES.CUSTOM_DATA_30: + case QUERY_ATTRIBUTES.CUSTOM_USER_ID: + case QUERY_ATTRIBUTES.EXPERIMENT_NAME: + case QUERY_ATTRIBUTES.ISP: + case QUERY_ATTRIBUTES.PLAYER_TECH: + case QUERY_ATTRIBUTES.PLAYER_VERSION: + case QUERY_ATTRIBUTES.VIDEO_ID: + return true; + default: + return false; + } +}; + +const parseValueForInFilter = (rawValue: string) => { + const value: string[] = JSON.parse(rawValue); + if (!Array.isArray(value)) { + throw new Error(); + } + return value; +}; + +const convertFilterForAds = (rawValue: string, filterAttribute: QueryAdAttribute, filterAttributeLabel: string) => { + switch (filterAttribute) { + case QUERY_AD_ATTRIBUTES.IS_LINEAR: + return rawValue === 'true'; + + case QUERY_AD_ATTRIBUTES.AD_STARTUP_TIME: + case QUERY_AD_ATTRIBUTES.AD_WRAPPER_ADS_COUNT: + case QUERY_AD_ATTRIBUTES.AUDIO_BITRATE: + case QUERY_AD_ATTRIBUTES.CLICK_POSITION: + case QUERY_AD_ATTRIBUTES.CLOSE_POSITION: + case QUERY_AD_ATTRIBUTES.ERROR_CODE: + case QUERY_AD_ATTRIBUTES.MANIFEST_DOWNLOAD_TIME: + case QUERY_AD_ATTRIBUTES.MIN_SUGGESTED_DURATION: + case QUERY_AD_ATTRIBUTES.PAGE_LOAD_TIME: + case QUERY_AD_ATTRIBUTES.PLAYER_STARTUPTIME: + case QUERY_AD_ATTRIBUTES.SCREEN_HEIGHT: + case QUERY_AD_ATTRIBUTES.SCREEN_WIDTH: + case QUERY_AD_ATTRIBUTES.SKIP_POSITION: + case QUERY_AD_ATTRIBUTES.TIME_HOVERED: + case QUERY_AD_ATTRIBUTES.TIME_IN_VIEWPORT: + case QUERY_AD_ATTRIBUTES.TIME_PLAYED: + case QUERY_AD_ATTRIBUTES.TIME_UNTIL_HOVER: + case QUERY_AD_ATTRIBUTES.VIDEO_BITRATE: + case QUERY_AD_ATTRIBUTES.VIDEO_WINDOW_HEIGHT: + case QUERY_AD_ATTRIBUTES.VIDEO_WINDOW_WIDTH: { + const parsedValue = parseInt(rawValue, 10); + if (isNaN(parsedValue)) { + throw new Error(`Couldn't parse filter for ${filterAttributeLabel}, please provide data as a number`); + } + return parsedValue; + } + + case QUERY_AD_ATTRIBUTES.CLICK_PERCENTAGE: + case QUERY_AD_ATTRIBUTES.CLOSE_PERCENTAGE: + case QUERY_AD_ATTRIBUTES.PERCENTAGE_IN_VIEWPORT: + case QUERY_AD_ATTRIBUTES.SKIP_PERCENTAGE: { + const parsedValue = parseFloat(rawValue); + if (isNaN(parsedValue)) { + throw new Error(`Couldn't parse filter for ${filterAttributeLabel}, please provide data as a number`); + } + return parsedValue; + } + + default: + return rawValue; + } +}; + +const convertFilter = (rawValue: string, filterAttribute: QueryAttribute, filterAttributeLabel: string) => { + switch (filterAttribute) { + case QUERY_ATTRIBUTES.IS_CASTING: + case QUERY_ATTRIBUTES.IS_LIVE: + case QUERY_ATTRIBUTES.IS_MUTED: + return rawValue === 'true'; + + case QUERY_ATTRIBUTES.AUDIO_BITRATE: + case QUERY_ATTRIBUTES.BUFFERED: + case QUERY_ATTRIBUTES.CLIENT_TIME: + case QUERY_ATTRIBUTES.DOWNLOAD_SPEED: + case QUERY_ATTRIBUTES.DRM_LOAD_TIME: + case QUERY_ATTRIBUTES.DROPPED_FRAMES: + case QUERY_ATTRIBUTES.DURATION: + case QUERY_ATTRIBUTES.ERROR_CODE: + case QUERY_ATTRIBUTES.PAGE_LOAD_TIME: + case QUERY_ATTRIBUTES.PAGE_LOAD_TYPE: + case QUERY_ATTRIBUTES.PAUSED: + case QUERY_ATTRIBUTES.PLAYED: + case QUERY_ATTRIBUTES.PLAYER_STARTUPTIME: + case QUERY_ATTRIBUTES.SCREEN_HEIGHT: + case QUERY_ATTRIBUTES.SCREEN_WIDTH: + case QUERY_ATTRIBUTES.SEEKED: + case QUERY_ATTRIBUTES.STARTUPTIME: + case QUERY_ATTRIBUTES.VIDEO_BITRATE: + case QUERY_ATTRIBUTES.VIDEO_DURATION: + case QUERY_ATTRIBUTES.VIDEO_PLAYBACK_HEIGHT: + case QUERY_ATTRIBUTES.VIDEO_PLAYBACK_WIDTH: + case QUERY_ATTRIBUTES.VIDEO_STARTUPTIME: + case QUERY_ATTRIBUTES.VIDEO_WINDOW_HEIGHT: + case QUERY_ATTRIBUTES.VIDEO_WINDOW_WIDTH: + case QUERY_ATTRIBUTES.VIDEOTIME_END: + case QUERY_ATTRIBUTES.VIDEOTIME_START: + case QUERY_ATTRIBUTES.VIEWTIME: { + const parsedValue = parseInt(rawValue, 10); + if (isNaN(parsedValue)) { + throw new Error(`Couldn't parse filter for ${filterAttributeLabel}, please provide data as a number`); + } + return parsedValue; + } + + case QUERY_ATTRIBUTES.ERROR_PERCENTAGE: + case QUERY_ATTRIBUTES.REBUFFER_PERCENTAGE: { + const parsedValue = parseFloat(rawValue); + if (isNaN(parsedValue)) { + throw new Error(`Couldn't parse filter for ${filterAttributeLabel}, please provide data as a number`); + } + return parsedValue; + } + + default: + return rawValue; + } +}; + +/** + * Transforms the string filter Value from the UI to the appropriate type for our API. + * + * @param {string} rawValue The raw string value from the Filter Input. + * @param {QueryAttribute | QueryAdAttribute} filterAttribute The filter attribute. + * @param {string} filterAttributeLabel The filter attribute label. + * @param {QueryFilterOperator} filterOperator The filter operator. + * @param {boolean} isAdAnalytics If Ad Analytics are queried. + * @returns {QueryFilterValue} The correctly converted Filter Value. + * */ +export const convertFilterValueToProperType = ( + rawValue: string, + filterAttribute: QueryAttribute | QueryAdAttribute, + filterAttributeLabel: string, + filterOperator: QueryFilterOperator, + isAdAnalytics: boolean +): QueryFilterValue => { + if (isEmpty(rawValue) && isNullFilter(filterAttribute)) { + return null; + } + + if (filterOperator === QUERY_FILTER_OPERATORS.IN) { + try { + return parseValueForInFilter(rawValue); + } catch (e) { + throw new Error( + 'Couldn\'t parse IN filter, please provide data in JSON array form (e.g.: ["Firefox", "Chrome"]).' + ); + } + } + + if (isAdAnalytics) { + return convertFilterForAds(rawValue, filterAttribute as QueryAdAttribute, filterAttributeLabel); + } + return convertFilter(rawValue, filterAttribute as QueryAttribute, filterAttributeLabel); +}; diff --git a/tsconfig.json b/tsconfig.json index 82a7ca4..2a23f69 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,8 @@ "target": "es5", "sourceMap": true, "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "strict": true }, "include": ["src/**/*"] }