Skip to content
Snippets Groups Projects
Commit 2746170c authored by Max Woolf's avatar Max Woolf
Browse files

Add additional context to product analytics dashboards

Adds support for shared visualizations
adds support for visualizations in the graphql API
parent a5fbd28f
1 merge request!103613Add additional fields to product analytics processor to be able to retrieve dashboard/visualization configurations via GraphQL
Showing
with 275 additions and 10 deletions
......@@ -16541,6 +16541,18 @@ Represents a product analytics dashboard.
| <a id="productanalyticsdashboardtitle"></a>`title` | [`String!`](#string) | Title of the dashboard. |
| <a id="productanalyticsdashboardwidgets"></a>`widgets` | [`ProductAnalyticsDashboardWidgetConnection!`](#productanalyticsdashboardwidgetconnection) | Widgets shown on the dashboard. (see [Connections](#connections)) |
 
### `ProductAnalyticsDashboardVisualization`
Represents a product analytics dashboard visualization.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="productanalyticsdashboardvisualizationdata"></a>`data` | [`JSON!`](#json) | Data of the visualization. |
| <a id="productanalyticsdashboardvisualizationoptions"></a>`options` | [`JSON!`](#json) | Options of the visualization. |
| <a id="productanalyticsdashboardvisualizationtype"></a>`type` | [`String!`](#string) | Type of the visualization. |
### `ProductAnalyticsDashboardWidget`
 
Represents a product analytics dashboard widget.
......@@ -16551,6 +16563,7 @@ Represents a product analytics dashboard widget.
| ---- | ---- | ----------- |
| <a id="productanalyticsdashboardwidgetgridattributes"></a>`gridAttributes` | [`JSON`](#json) | Description of the position and size of the widget. |
| <a id="productanalyticsdashboardwidgettitle"></a>`title` | [`String!`](#string) | Title of the widget. |
| <a id="productanalyticsdashboardwidgetvisualization"></a>`visualization` | [`ProductAnalyticsDashboardVisualization!`](#productanalyticsdashboardvisualization) | Visualization of the widget. |
 
### `Project`
 
# frozen_string_literal: true
module Resolvers
module ProductAnalytics
class VisualizationResolver < BaseResolver
type ::Types::ProductAnalytics::VisualizationType, null: true
def resolve
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Visualization does not exist' unless object.visualization
object.visualization
end
end
end
end
# frozen_string_literal: true
module Types
module ProductAnalytics
class VisualizationType < BaseObject # rubocop:disable Graphql/AuthorizeTypes
graphql_name 'ProductAnalyticsDashboardVisualization'
description 'Represents a product analytics dashboard visualization.'
field :type,
type: GraphQL::Types::String,
null: false,
description: 'Type of the visualization.'
field :options,
type: GraphQL::Types::JSON,
null: false,
description: 'Options of the visualization.'
field :data,
type: GraphQL::Types::JSON,
null: false,
description: 'Data of the visualization.'
end
end
end
......@@ -15,6 +15,12 @@ class WidgetType < BaseObject
type: GraphQL::Types::JSON,
null: true,
description: 'Description of the position and size of the widget.'
field :visualization,
type: Types::ProductAnalytics::VisualizationType,
null: false,
description: 'Visualization of the widget.',
resolver: Resolvers::ProductAnalytics::VisualizationResolver
end
end
end
......
......@@ -2,15 +2,15 @@
module ProductAnalytics
class Dashboard
attr_reader :title, :description, :schema_version, :widgets, :project, :slug
attr_reader :title, :description, :schema_version, :widgets, :project, :slug, :path
DASHBOARD_ROOT_LOCATION = '.gitlab/product_analytics/dashboards'
def self.for_project(project)
root_trees = project.repository.tree(:head, DASHBOARD_ROOT_LOCATION)
return [] unless root_trees
return [] unless root_trees&.entries&.any?
root_trees.trees.map do |tree|
root_trees.trees.delete_if { |tree| tree.name == 'visualizations' }.map do |tree|
config = YAML.safe_load(
project.repository.blob_data_at(project.repository.root_ref_sha,
"#{tree.path}/#{tree.name}.yaml")
......@@ -22,7 +22,7 @@ def self.for_project(project)
slug: tree.name,
description: config['description'],
schema_version: config['version'],
widgets: ProductAnalytics::Widget.from_data(config['widgets'])
widgets: ProductAnalytics::Widget.from_data(config['widgets'], project)
)
end
end
......
# frozen_string_literal: true
module ProductAnalytics
class Visualization
attr_reader :type, :project, :data, :options, :config
def self.from_data(data:, project:)
config = project.repository.blob_data_at(
project.repository.root_ref_sha,
visualization_config_path(data)
)
return unless config
new(config: config)
end
def initialize(config:)
@config = YAML.safe_load(config)
@type = @config['type']
@options = @config['options']
@data = @config['data']
end
private
def self.visualization_config_path(data)
"#{ProductAnalytics::Dashboard::DASHBOARD_ROOT_LOCATION}/visualizations/#{data}.yaml"
end
end
end
......@@ -2,20 +2,24 @@
module ProductAnalytics
class Widget
attr_reader :title, :grid_attributes
attr_reader :title, :grid_attributes, :visualization, :project
def self.from_data(widget_yaml)
def self.from_data(widget_yaml, project)
widget_yaml.map do |widget|
new(
title: widget['title'],
grid_attributes: widget['gridAttributes']
project: project,
grid_attributes: widget['gridAttributes'],
visualization: widget['visualization']
)
end
end
def initialize(title:, grid_attributes:)
def initialize(title:, grid_attributes:, visualization:, project:)
@title = title
@project = project
@grid_attributes = grid_attributes
@visualization = ::ProductAnalytics::Visualization.from_data(data: visualization, project: project)
end
end
end
......@@ -93,6 +93,14 @@
message: 'test',
branch_name: 'master'
)
project.repository.create_file(
project.creator,
'.gitlab/product_analytics/dashboards/visualizations/cube_line_chart.yaml',
File.open(Rails.root.join('ee/spec/fixtures/product_analytics/cube_line_chart.yaml')).read,
message: 'test',
branch_name: 'master'
)
end
end
end
......
version: '1'
type: LineChart
options:
xAxis:
name: Time
type: time
yAxis:
name: Counts
data:
type: Cube
query:
measures:
- Stories.count
dimensions:
- Stories.category
filters:
- member: Stories.isDraft
operator: equals
values:
- 'No'
timeDimensions:
- dimension: Stories.time
# dateRange set by the dashboard filter
granularity: month
limit: 100
offset: 50
order:
Stories.time: asc
Stories.count: desc
timezone: America/Los_Angeles
......@@ -9,7 +9,7 @@ widgets:
xPos: 1
width: 12
height: 2
figure: cube_line_chart
visualization: cube_line_chart
queryOverrides:
timeDimensions:
dateRange:
......@@ -21,7 +21,7 @@ widgets:
xPos: 1
width: 12
height: 2
figure: cube_line_chart
visualization: cube_line_chart
queryOverrides:
timeDimensions:
dateRange:
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::ProductAnalytics::VisualizationResolver do
include GraphqlHelpers
describe '#resolve' do
subject do
resolve(
described_class, obj: project.product_analytics_dashboards.first.widgets.first, ctx: { current_user: user }
)
end
before do
stub_licensed_features(product_analytics: true)
end
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :with_product_analytics_dashboard) }
it 'returns the visualization object' do
expect(subject).to be_a(ProductAnalytics::Visualization)
end
context 'when the visualization does not exist' do
before do
allow_next_instance_of(ProductAnalytics::Widget) do |widget|
allow(widget).to receive(:visualization).and_return(nil)
end
end
it 'raises an error' do
expect(subject).to be_a(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end
......@@ -16,6 +16,12 @@
expect(subject.first.schema_version).to eq('1')
end
context 'when the project does not have a dashboards directory' do
let_it_be(:project) { create(:project, :repository) }
it { is_expected.to be_empty }
end
context 'when the dashboard file does not exist in the directory' do
before do
project.repository.create_file(
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ProductAnalytics::Widget do
let_it_be(:project) { create(:project, :with_product_analytics_dashboard) }
subject { project.product_analytics_dashboard('dashboard_example_1').widgets.first.visualization }
before do
stub_licensed_features(product_analytics: true)
stub_feature_flags(cube_api_proxy: true)
end
it 'returns the correct object' do
expect(subject.type).to eq('LineChart')
expect(subject.options)
.to eq({ 'xAxis' => { 'name' => 'Time', 'type' => 'time' }, 'yAxis' => { 'name' => 'Counts' } })
expect(subject.data['type']).to eq('Cube')
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Query.project(id).dashboards.widgets(id).visualization' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :with_product_analytics_dashboard) }
let(:query) do
<<~GRAPHQL
query {
project(fullPath: "#{project.full_path}") {
name
productAnalyticsDashboards {
nodes {
title
description
widgets {
nodes {
title
gridAttributes
visualization {
type
options
data
}
}
}
}
}
}
}
GRAPHQL
end
before do
stub_licensed_features(product_analytics: true)
end
context 'when current user is a developer' do
before_all do
project.add_developer(user)
end
it 'returns visualization' do
get_graphql(query, current_user: user)
expect(
graphql_data_at(:project, :product_analytics_dashboards, :nodes, 0, :widgets, :nodes, 0, :visualization, :type)
).to eq('LineChart')
end
context 'when the visualization does not exist' do
before do
allow_next_instance_of(ProductAnalytics::Widget) do |widget|
allow(widget).to receive(:visualization).and_return(nil)
end
end
it 'returns an error' do
get_graphql(query, current_user: user)
expect(graphql_errors).to include(a_hash_including('message' => 'Visualization does not exist'))
end
end
end
end
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