diff --git a/lib/generators/rolemodel/README.md b/lib/generators/rolemodel/README.md index 3b221cbf..671b3875 100644 --- a/lib/generators/rolemodel/README.md +++ b/lib/generators/rolemodel/README.md @@ -4,6 +4,7 @@ ## What you get +* [Blazer](./blazer) * [CSS](./css) * [Github](./github) * [Heroku](./heroku) diff --git a/lib/generators/rolemodel/all_generator.rb b/lib/generators/rolemodel/all_generator.rb index d57ecf71..9b4fbaf9 100644 --- a/lib/generators/rolemodel/all_generator.rb +++ b/lib/generators/rolemodel/all_generator.rb @@ -13,6 +13,7 @@ def run_all_the_generators generate 'rolemodel:simple_form' generate 'rolemodel:soft_destroyable' generate 'rolemodel:saas:all' + generate 'rolemodel:blazer' generate 'rolemodel:editors' generate 'rolemodel:linters:all' generate 'rolemodel:mailers' diff --git a/lib/generators/rolemodel/blazer/README.md b/lib/generators/rolemodel/blazer/README.md new file mode 100644 index 00000000..9beda368 --- /dev/null +++ b/lib/generators/rolemodel/blazer/README.md @@ -0,0 +1,21 @@ +# Blazer Generator + +Depends on the blazer generator +Depends on [RSpec Generator](../testing/rspec) +Depends on slim +Works best with Optics +* If not using with Optics, update blazer.css to use your own tokens. + +## What you get + +Installs [blazer](https://github.com/ankane/blazer) with customization for enabling non-admin users to run Dashboard-type reports (SQL queries with variables) at /reports/dashboards. Prevents SQL-injection vulnerability present in base gem which assumes super-admin access only. + +### Controllers +* Adds Reports::DashboardController +* Add Reports::QueriesController + +### Views +* Reports list and show + +### Tests +* Report system test diff --git a/lib/generators/rolemodel/blazer/USAGE b/lib/generators/rolemodel/blazer/USAGE new file mode 100644 index 00000000..af9a0656 --- /dev/null +++ b/lib/generators/rolemodel/blazer/USAGE @@ -0,0 +1,5 @@ +Description: + installs and configures Blazer + +Example: + rails generate rolemodel:blazer diff --git a/lib/generators/rolemodel/blazer/blazer_generator.rb b/lib/generators/rolemodel/blazer/blazer_generator.rb new file mode 100644 index 00000000..40304a30 --- /dev/null +++ b/lib/generators/rolemodel/blazer/blazer_generator.rb @@ -0,0 +1,69 @@ +require_relative '../../../bundler_helpers' + +module Rolemodel + class BlazerGenerator < Rails::Generators::Base + include Rolemodel::BundlerHelpers + source_root File.expand_path('templates', __dir__) + + def install_blazer + gem 'sprockets-rails' unless File.readlines('Gemfile').grep(/sprockets/).any? + gem 'blazer' + run_bundle + + generate 'blazer:install' + end + + def update_migration + filename = Dir.glob('db/migrate/*_install_blazer.rb').first + inject_into_file filename, ', foreign_key: { to_table: :users }', after: 't.references :creator' + end + + def add_routes + return if File.readlines('config/routes.rb').grep(/blazer/).any? + + # add routes for the report controllers + route_info = " namespace :reports do\n" + route_info += " resources :dashboards, only: %i[index show]\n" + route_info += " resources :queries, only: [] do\n" + route_info += " post :run, on: :collection\n" + route_info += " # post :cancel, on: :collection\n" + route_info += " end\n" + route_info += " end\n" + route route_info + + # add routes for the Blazer engine + route_info = " # authenticate :user, ->(u) { Admin::ReportPolicy.new(u, :report).manage? } do\n" + route_info += " mount Blazer::Engine => '/admin/reports', as: :blazer\n" + route_info += " # end\n" + route route_info + end + + def add_extensions + copy_file 'config/initializers/blazer.rb' + copy_file 'lib/blazer_extensions/data_source.rb' + end + + def add_controllers + directory 'app/controllers/reports' + end + + def add_views + directory 'app/views/reports' + end + + def add_styles + copy_file 'app/assets/stylesheets/blazer.css' + copy_file 'app/assets/stylesheets/selectize.css' + append_to_file 'app/assets/config/manifest.js', "\n//= link blazer.css" + end + + def add_tests + copy_file 'spec/factories/blazer_queries.rb' + copy_file 'spec/system/reporting_spec.rb' + end + + def add_rake_task + copy_file 'lib/tasks/reports.rake' + end + end +end diff --git a/lib/generators/rolemodel/blazer/templates/app/assets/stylesheets/blazer.css b/lib/generators/rolemodel/blazer/templates/app/assets/stylesheets/blazer.css new file mode 100644 index 00000000..465d2621 --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/app/assets/stylesheets/blazer.css @@ -0,0 +1,82 @@ +/* + *= require ./selectize + *= require_self + *= require blazer/daterangepicker + */ + +/* These styles map Blazer's html structure into our look and feel. */ + +.results-container { /* Copied from .table__wrapper */ + overflow: auto; +} + +.text-muted { + color: var(--rm-color-disabled); +} + +.chart-container { /* Copied from .card */ + position: relative; + border: 1px solid var(--rm-color-contrast-medium); + border-radius: var(--rm-radius); + padding: var(--rm-space-large); + width: 100%; + background: var(--rm-color-white); + margin-bottom: var(--rm-space-medium); +} + +.btn-success { /* Copied from .btn--rm-primary */ + background: var(--rm-color-primary-minus-four); + color: var(--rm-color-white); + &:hover { + opacity: 0.7; + } + &:visited { + color: var(--rm-color-white); + } +} + +.form-inline { + display: flex; + align-items: center; +} + +.form-inline label { + margin-bottom: 0; +} + +.selectize-control { + margin-right: var(--rm-space-medium); +} + +/* date range picker */ +body .daterangepicker .drp-buttons .btn { + font-size: var(--rm-text-small); + padding: inherit; + margin-top: var(--rm-space-x-small); + margin-bottom: var(--rm-space-x-small); +} + +body .daterangepicker .drp-buttons .btn.cancelBtn { + box-shadow: var(--rm-border-all) var(--rm-border-color); + background: var(--rm-color-white); + color: var(--rm-color-neutral-minus-max); +} + +body .daterangepicker .drp-buttons .btn.applyBtn { + background: var(--rm-color-primary-minus-four); + color: var(--rm-color-white); +} + +body .daterangepicker td.in-range { + background: var(--rm-color-primary-plus-four); +} + +body .daterangepicker td.active, body .daterangepicker td.active:hover { + background: var(--rm-color-primary-minus-four); + color: var(--rm-color-white); +} + +body .daterangepicker .ranges li.active { + background: var(--rm-color-primary-minus-four); + color: var(--rm-color-white); +} diff --git a/lib/generators/rolemodel/blazer/templates/app/assets/stylesheets/selectize.css b/lib/generators/rolemodel/blazer/templates/app/assets/stylesheets/selectize.css new file mode 100644 index 00000000..2cdb1c24 --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/app/assets/stylesheets/selectize.css @@ -0,0 +1,403 @@ +/** + * selectize.default.css (v0.12.6) - Default Theme + * Copyright (c) 2013–2015 Brian Reavis & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this + * file except in compliance with the License. You may obtain a copy of the License at: + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF + * ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + * + * @author Brian Reavis + */ +.selectize-control.plugin-drag_drop.multi > .selectize-input > div.ui-sortable-placeholder { + visibility: visible !important; + background: #f2f2f2 !important; + background: rgba(0, 0, 0, 0.06) !important; + border: 0 none !important; + -webkit-box-shadow: inset 0 0 12px 4px #fff; + box-shadow: inset 0 0 12px 4px #fff; +} +.selectize-control.plugin-drag_drop .ui-sortable-placeholder::after { + content: '!'; + visibility: hidden; +} +.selectize-control.plugin-drag_drop .ui-sortable-helper { + -webkit-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} +.selectize-dropdown-header { + position: relative; + padding: 5px 8px; + border-bottom: 1px solid #d0d0d0; + background: #f8f8f8; + -webkit-border-radius: 3px 3px 0 0; + -moz-border-radius: 3px 3px 0 0; + border-radius: 3px 3px 0 0; +} +.selectize-dropdown-header-close { + position: absolute; + right: 8px; + top: 50%; + color: #303030; + opacity: 0.4; + margin-top: -12px; + line-height: 20px; + font-size: 20px !important; +} +.selectize-dropdown-header-close:hover { + color: #000000; +} +.selectize-dropdown.plugin-optgroup_columns .optgroup { + border-right: 1px solid #f2f2f2; + border-top: 0 none; + float: left; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child { + border-right: 0 none; +} +.selectize-dropdown.plugin-optgroup_columns .optgroup:before { + display: none; +} +.selectize-dropdown.plugin-optgroup_columns .optgroup-header { + border-top: 0 none; +} +.selectize-control.plugin-remove_button [data-value] { + position: relative; + padding-right: 24px !important; +} +.selectize-control.plugin-remove_button [data-value] .remove { + z-index: 1; + /* fixes ie bug (see #392) */ + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 17px; + text-align: center; + font-weight: bold; + font-size: 12px; + color: inherit; + text-decoration: none; + vertical-align: middle; + display: inline-block; + padding: 2px 0 0 0; + border-left: 1px solid #0073bb; + -webkit-border-radius: 0 2px 2px 0; + -moz-border-radius: 0 2px 2px 0; + border-radius: 0 2px 2px 0; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +.selectize-control.plugin-remove_button [data-value] .remove:hover { + background: rgba(0, 0, 0, 0.05); +} +.selectize-control.plugin-remove_button [data-value].active .remove { + border-left-color: #00578d; +} +.selectize-control.plugin-remove_button .disabled [data-value] .remove:hover { + background: none; +} +.selectize-control.plugin-remove_button .disabled [data-value] .remove { + border-left-color: #aaaaaa; +} +.selectize-control.plugin-remove_button .remove-single { + position: absolute; + right: 0; + top: 0; + font-size: 23px; +} +.selectize-control { + position: relative; +} +.selectize-dropdown, +.selectize-input, +.selectize-input input { + color: #303030; + font-family: inherit; + font-size: 13px; + line-height: 18px; + -webkit-font-smoothing: inherit; +} +.selectize-input, +.selectize-control.single .selectize-input.input-active { + background: #fff; + cursor: text; + display: inline-block; +} +.selectize-input { + border: 1px solid #d0d0d0; + padding: 8px 8px; + display: inline-block; + width: 100%; + overflow: hidden; + position: relative; + z-index: 1; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1); + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; +} +.selectize-control.multi .selectize-input.has-items { + padding: 5px 8px 2px; +} +.selectize-input.full { + background-color: #fff; +} +.selectize-input.disabled, +.selectize-input.disabled * { + cursor: default !important; +} +.selectize-input.focus { + -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); +} +.selectize-input.dropdown-active { + -webkit-border-radius: 3px 3px 0 0; + -moz-border-radius: 3px 3px 0 0; + border-radius: 3px 3px 0 0; +} +.selectize-input > * { + vertical-align: baseline; + display: -moz-inline-stack; + display: inline-block; + zoom: 1; + *display: inline; +} +.selectize-control.multi .selectize-input > div { + cursor: pointer; + margin: 0 3px 3px 0; + padding: 2px 6px; + background: #1da7ee; + color: #fff; + border: 1px solid #0073bb; +} +.selectize-control.multi .selectize-input > div.active { + background: #92c836; + color: #fff; + border: 1px solid #00578d; +} +.selectize-control.multi .selectize-input.disabled > div, +.selectize-control.multi .selectize-input.disabled > div.active { + color: #ffffff; + background: #d2d2d2; + border: 1px solid #aaaaaa; +} +.selectize-input > input { + display: inline-block !important; + padding: 0 !important; + min-height: 0 !important; + max-height: none !important; + max-width: 100% !important; + margin: 0 1px !important; + text-indent: 0 !important; + border: 0 none !important; + background: none !important; + line-height: inherit !important; + -webkit-user-select: auto !important; + -webkit-box-shadow: none !important; + box-shadow: none !important; +} +.selectize-input > input::-ms-clear { + display: none; +} +.selectize-input > input:focus { + outline: none !important; +} +.selectize-input::after { + content: ' '; + display: block; + clear: left; +} +.selectize-input.dropdown-active::before { + content: ' '; + display: block; + position: absolute; + background: #f0f0f0; + height: 1px; + bottom: 0; + left: 0; + right: 0; +} +.selectize-dropdown { + position: absolute; + z-index: 10; + border: 1px solid #d0d0d0; + background: #fff; + margin: -1px 0 0 0; + border-top: 0 none; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + -webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + -webkit-border-radius: 0 0 3px 3px; + -moz-border-radius: 0 0 3px 3px; + border-radius: 0 0 3px 3px; +} +.selectize-dropdown [data-selectable] { + cursor: pointer; + overflow: hidden; +} +.selectize-dropdown [data-selectable] .highlight { + background: rgba(125, 168, 208, 0.2); + -webkit-border-radius: 1px; + -moz-border-radius: 1px; + border-radius: 1px; +} +.selectize-dropdown .option, +.selectize-dropdown .optgroup-header { + padding: 5px 8px; +} +.selectize-dropdown .option, +.selectize-dropdown [data-disabled], +.selectize-dropdown [data-disabled] [data-selectable].option { + cursor: inherit; + opacity: 0.5; +} +.selectize-dropdown [data-selectable].option { + opacity: 1; +} +.selectize-dropdown .optgroup:first-child .optgroup-header { + border-top: 0 none; +} +.selectize-dropdown .optgroup-header { + color: #303030; + background: #fff; + cursor: default; +} +.selectize-dropdown .active { + background-color: #f5fafd; + color: #495c68; +} +.selectize-dropdown .active.create { + color: #495c68; +} +.selectize-dropdown .create { + color: rgba(48, 48, 48, 0.5); +} +.selectize-dropdown-content { + overflow-y: auto; + overflow-x: hidden; + max-height: 200px; + -webkit-overflow-scrolling: touch; +} +.selectize-control.single .selectize-input, +.selectize-control.single .selectize-input input { + cursor: pointer; +} +.selectize-control.single .selectize-input.input-active, +.selectize-control.single .selectize-input.input-active input { + cursor: text; +} +.selectize-control.single .selectize-input:after { + content: ' '; + display: block; + position: absolute; + top: 50%; + right: 15px; + margin-top: -3px; + width: 0; + height: 0; + border-style: solid; + border-width: 5px 5px 0 5px; + border-color: #808080 transparent transparent transparent; +} +.selectize-control.single .selectize-input.dropdown-active:after { + margin-top: -4px; + border-width: 0 5px 5px 5px; + border-color: transparent transparent #808080 transparent; +} +.selectize-control.rtl.single .selectize-input:after { + left: 15px; + right: auto; +} +.selectize-control.rtl .selectize-input > input { + margin: 0 4px 0 -2px !important; +} +.selectize-control .selectize-input.disabled { + opacity: 0.5; + background-color: #fafafa; +} +.selectize-control.multi .selectize-input.has-items { + padding-left: 5px; + padding-right: 5px; +} +.selectize-control.multi .selectize-input.disabled [data-value] { + color: #999; + text-shadow: none; + background: none; + -webkit-box-shadow: none; + box-shadow: none; +} +.selectize-control.multi .selectize-input.disabled [data-value], +.selectize-control.multi .selectize-input.disabled [data-value] .remove { + border-color: #e6e6e6; +} +.selectize-control.multi .selectize-input.disabled [data-value] .remove { + background: none; +} +.selectize-control.multi .selectize-input [data-value] { + text-shadow: 0 1px 0 rgba(0, 51, 83, 0.3); + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; + background-color: #1b9dec; + background-image: -moz-linear-gradient(top, #1da7ee, #178ee9); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#1da7ee), to(#178ee9)); + background-image: -webkit-linear-gradient(top, #1da7ee, #178ee9); + background-image: -o-linear-gradient(top, #1da7ee, #178ee9); + background-image: linear-gradient(to bottom, #1da7ee, #178ee9); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff1da7ee', endColorstr='#ff178ee9', GradientType=0); + -webkit-box-shadow: 0 1px 0 rgba(0,0,0,0.2),inset 0 1px rgba(255,255,255,0.03); + box-shadow: 0 1px 0 rgba(0,0,0,0.2),inset 0 1px rgba(255,255,255,0.03); +} +.selectize-control.multi .selectize-input [data-value].active { + background-color: #0085d4; + background-image: -moz-linear-gradient(top, #008fd8, #0075cf); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#008fd8), to(#0075cf)); + background-image: -webkit-linear-gradient(top, #008fd8, #0075cf); + background-image: -o-linear-gradient(top, #008fd8, #0075cf); + background-image: linear-gradient(to bottom, #008fd8, #0075cf); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff008fd8', endColorstr='#ff0075cf', GradientType=0); +} +.selectize-control.single .selectize-input { + -webkit-box-shadow: 0 1px 0 rgba(0,0,0,0.05), inset 0 1px 0 rgba(255,255,255,0.8); + box-shadow: 0 1px 0 rgba(0,0,0,0.05), inset 0 1px 0 rgba(255,255,255,0.8); + background-color: #f9f9f9; + background-image: -moz-linear-gradient(top, #fefefe, #f2f2f2); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#fefefe), to(#f2f2f2)); + background-image: -webkit-linear-gradient(top, #fefefe, #f2f2f2); + background-image: -o-linear-gradient(top, #fefefe, #f2f2f2); + background-image: linear-gradient(to bottom, #fefefe, #f2f2f2); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffefefe', endColorstr='#fff2f2f2', GradientType=0); +} +.selectize-control.single .selectize-input, +.selectize-dropdown.single { + border-color: #b8b8b8; +} +.selectize-dropdown .optgroup-header { + padding-top: 7px; + font-weight: bold; + font-size: 0.85em; +} +.selectize-dropdown .optgroup { + border-top: 1px solid #f0f0f0; +} +.selectize-dropdown .optgroup:first-child { + border-top: 0 none; +} diff --git a/lib/generators/rolemodel/blazer/templates/app/controllers/reports/dashboards_controller.rb b/lib/generators/rolemodel/blazer/templates/app/controllers/reports/dashboards_controller.rb new file mode 100644 index 00000000..89479c45 --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/app/controllers/reports/dashboards_controller.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Reports + class DashboardsController < ApplicationController + helper Blazer::BaseHelper + + def index + # authorize Blazer::Dashboard.new + # @dashboards = policy_scope(Blazer::Dashboard).distinct.order(:name) + @dashboards = Blazer::Dashboard.distinct.order(:name) + end + + def show + @dashboard = Blazer::Dashboard.find(params[:id]) + # authorize @dashboard + + # The rest is copied from Blazer::DashboardsController + @queries = @dashboard.dashboard_queries.order(:position).preload(:query).map(&:query) + @queries.each do |query| + @success = process_vars(query.statement_object) + end + @bind_vars ||= [] + + @smart_vars = {} + @sql_errors = [] + @data_sources = @queries.map { |q| Blazer.data_sources[q.data_source] }.uniq + @bind_vars.each do |var| + @data_sources.each do |data_source| + smart_var, error = parse_smart_variables(var, data_source) + ((@smart_vars[var] ||= []).concat(smart_var)).uniq! if smart_var + @sql_errors << error if error + end + end + + add_cohort_analysis_vars if @queries.any?(&:cohort_analysis?) + end + + private + + # Copied from Blazer::BaseController + def process_vars(statement, var_params = nil) + var_params ||= request.query_parameters + (@bind_vars ||= []).concat(statement.variables).uniq! + # update in-place so populated in view and consistent across queries on dashboard + @bind_vars.each do |var| + if !var_params[var] + default = statement.data_source.variable_defaults[var] + # only add if default exists + var_params[var] = default if default + end + end + runnable = @bind_vars.all? { |v| var_params[v] } + statement.add_values(var_params) if runnable + runnable + end + + # Copied from Blazer::BaseController + def add_cohort_analysis_vars + @bind_vars << "cohort_period" unless @bind_vars.include?("cohort_period") + @smart_vars["cohort_period"] = ["day", "week", "month"] if @smart_vars + # TODO create var_params method + request.query_parameters["cohort_period"] ||= "week" + end + + # Copied from Blazer::BaseController + def parse_smart_variables(var, data_source) + smart_var_data_source = + ([data_source] + Array(data_source.settings["inherit_smart_settings"]).map { |ds| Blazer.data_sources[ds] }).find { |ds| ds.smart_variables[var] } + + if smart_var_data_source + query = smart_var_data_source.smart_variables[var] + + if query.is_a? Hash + smart_var = query.map { |k,v| [v, k] } + elsif query.is_a? Array + smart_var = query.map { |v| [v, v] } + elsif query + result = smart_var_data_source.run_statement(query) + smart_var = result.rows.map { |v| v.reverse } + error = result.error if result.error + end + end + + [smart_var, error] + end + + # Copied from Blazer::BaseController + def variable_params(resource, var_params = nil) + permitted_keys = resource.variables + var_params ||= request.query_parameters + var_params.slice(*permitted_keys) + end + helper_method :variable_params + + # Copied from Blazer::BaseController + def nested_variable_params(resource) + variable_params(resource, request.query_parameters["variables"] || {}) + end + helper_method :nested_variable_params + end +end diff --git a/lib/generators/rolemodel/blazer/templates/app/controllers/reports/queries_controller.rb b/lib/generators/rolemodel/blazer/templates/app/controllers/reports/queries_controller.rb new file mode 100644 index 00000000..76d1f8b0 --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/app/controllers/reports/queries_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Reports + # Since this doesn't rely on our layout, we can get away with inheriting straight from blazer, + # and mixing in our authorization + class QueriesController < Blazer::QueriesController + # !!! Blazer disables all inherited before/around/after callbacks so add whatever you need here + before_action :authenticate_user! + after_action :verify_authorized + + def run + @query = Blazer::Query.find_by(id: params[:query_id]) + # authorize @query + params[:statement] = @query.statement.dup + super + end + + # def cancel + # authorize Blazer::Query.new + # super + # end + end +end diff --git a/lib/generators/rolemodel/blazer/templates/app/views/reports/dashboards/index.html.slim b/lib/generators/rolemodel/blazer/templates/app/views/reports/dashboards/index.html.slim new file mode 100644 index 00000000..f6d33a4e --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/app/views/reports/dashboards/index.html.slim @@ -0,0 +1,17 @@ +header.header--index-action + h1= t('.reports', default: 'Reports') + +.table__wrapper + - # Since blazer loads its own JS, we can't rely on turbo to work + table.table + thead + tr + th= t('activerecord.attributes.blazer_dashboard.name', default: 'Name') + th= t('.actions', default: 'Actions') + + tbody + - @dashboards.each do |dashboard| + tr data-test-id="#{dashboard.id}" + td= dashboard.name + td.table__actions + = link_to t('.show', default: 'Show'), reports_dashboard_path(dashboard), data: { turbo: false } diff --git a/lib/generators/rolemodel/blazer/templates/app/views/reports/dashboards/show.html.slim b/lib/generators/rolemodel/blazer/templates/app/views/reports/dashboards/show.html.slim new file mode 100644 index 00000000..3ef39e8f --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/app/views/reports/dashboards/show.html.slim @@ -0,0 +1,34 @@ +- # changed v += stylesheet_link_tag "blazer" += javascript_include_tag "blazer/application" +javascript: + #{blazer_js_var "rootPath", "/reports/"} // < changed +- if blazer_maps? + = stylesheet_link_tag "https://api.mapbox.com/mapbox.js/v3.3.1/mapbox.css", integrity: "sha384-vxzdEt+wZRPNQbhChjmiaFMLWg86IGuq1NGDehJHsD2mphYkxXll/eSs16WWi6Dq", crossorigin: "anonymous" + = javascript_include_tag "https://api.mapbox.com/mapbox.js/v3.3.1/mapbox.js", integrity: "sha384-CTBEiDLiZJ8gkAQ3fYGoeiRp81/ecNiBkGz11jXFALOZ6++rbnqmdo6OImkmr1MO", crossorigin: "anonymous" + +h1= @dashboard.name + +- if @bind_vars.any? + - # changed v (route) + = render partial: "blazer/variables", locals: { action: reports_dashboard_path(@dashboard) } + +- @queries.each_with_index do |query, i| + .chart-container + h4= query.friendly_name + .chart id="chart-#{i}" + - # changed v (translation added) + p.text-muted= t('.loading', default: 'Loading...') + + - # changed v (removed statement) + - data = {query_id: query.id, data_source: query.data_source, variables: variable_params(query), only_chart: true} + - data.merge!(cohort_period: params[:cohort_period]) if params[:cohort_period] + javascript: + #{blazer_js_var "data", data } + + runQuery(data, function (data) { + $("#chart-#{i}").html(data) + $("#chart-#{i} table").stupidtable(stupidtableCustomSettings) + }, function (message) { + $("#chart-#{i}").addClass("query-error").html(message) + }); diff --git a/lib/generators/rolemodel/blazer/templates/config/initializers/blazer.rb b/lib/generators/rolemodel/blazer/templates/config/initializers/blazer.rb new file mode 100644 index 00000000..a63befe3 --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/config/initializers/blazer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'blazer_extensions/data_source' + +Rails.application.config.to_prepare do + Blazer::DataSource.prepend BlazerExtensions::DataSource +end diff --git a/lib/generators/rolemodel/blazer/templates/lib/blazer_extensions/data_source.rb b/lib/generators/rolemodel/blazer/templates/lib/blazer_extensions/data_source.rb new file mode 100644 index 00000000..b0fb0a81 --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/lib/blazer_extensions/data_source.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module BlazerExtensions + module DataSource + # add Rails.env.test? so reports can run when testing + def adapter_instance # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + @adapter_instance ||= begin + unless settings['url'] || + Rails.env.development? || + Rails.env.test? || + ['bigquery', 'athena', 'snowflake', + 'salesforce'].include?(settings['adapter']) + raise Blazer::Error, "Empty url for data source: #{id}" + end + + raise Blazer::Error, 'Unknown adapter' unless Blazer.adapters[adapter] + + Blazer.adapters[adapter].new(self) + end + end + end +end diff --git a/lib/generators/rolemodel/blazer/templates/lib/tasks/reports.rake b/lib/generators/rolemodel/blazer/templates/lib/tasks/reports.rake new file mode 100644 index 00000000..c714258a --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/lib/tasks/reports.rake @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +def import_blazer_query(author_user_id, query_path_name) + file_name = query_path_name.split('/').last + puts "Importing blazer query #{file_name}" + file_contents = File.readlines(query_path_name) + description = file_contents.select { |line| line.start_with?('--')} + .map { |comment_line| comment_line.gsub('--', '').strip } + .join('. ') + .presence + query = file_contents.select {|line| !line.start_with?('--') }.join(' ') + name = File.basename(file_name, File.extname(file_name)) + Blazer::Query.create_with( + description: description, + statement: query, + status: 'active', + data_source: 'main', + creator_id: author_user_id + ).find_or_create_by!(name: name) +end + +def import_blazer_dashboard(author_user_id, dashboard_path_name) + file_name = dashboard_path_name.split('/').last + puts "Importing blazer dashboard #{file_name}" + name = File.basename(file_name, File.extname(file_name)) + Blazer::Dashboard.where(name: name).destroy_all + blazer_dashboard = Blazer::Dashboard.create( + name: name, creator_id: author_user_id + ) + + file_contents = File.readlines(dashboard_path_name).reject { |s| s.strip.empty? } + file_contents.each do |query_name| + query = Blazer::Query.find_by(name: query_name.strip) + if query.nil? + puts "Unable to find blazer query #{query_name}" + next + end + blazer_dashboard.queries << query + end +end + +namespace :import do + desc 'import all stock reports' + task reports: :environment do + author_user_id = ENV['USER_ID'] + if author_user_id.blank? + puts "USER_ID is required" + exit(1) + end + Dir[Rails.root.join('db/blazer/queries/*.sql')].sort.each do |path_name| + import_blazer_query(author_user_id, path_name) + end + Dir[Rails.root.join('db/blazer/dashboards/*.txt')].sort.each do |path_name| + import_blazer_dashboard(author_user_id, path_name) + end + end +end diff --git a/lib/generators/rolemodel/blazer/templates/spec/factories/blazer_dashboards.rb b/lib/generators/rolemodel/blazer/templates/spec/factories/blazer_dashboards.rb new file mode 100644 index 00000000..5b5eac4d --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/spec/factories/blazer_dashboards.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :blazer_dashboard, class: Blazer::Dashboard do + association :creator, factory: :user + sequence(:name) { |n| "Dashboard #{n}" } + end +end diff --git a/lib/generators/rolemodel/blazer/templates/spec/factories/blazer_queries.rb b/lib/generators/rolemodel/blazer/templates/spec/factories/blazer_queries.rb new file mode 100644 index 00000000..f5ae7d03 --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/spec/factories/blazer_queries.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :blazer_query, class: Blazer::Query do + association :creator, factory: :user + sequence(:name) { |n| "Query #{n}" } + statement { 'select * from users' } + data_source { 'main' } + status { 'active' } + end +end diff --git a/lib/generators/rolemodel/blazer/templates/spec/system/reporting_spec.rb b/lib/generators/rolemodel/blazer/templates/spec/system/reporting_spec.rb new file mode 100644 index 00000000..c4f23f05 --- /dev/null +++ b/lib/generators/rolemodel/blazer/templates/spec/system/reporting_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe 'Reporting', type: :system, js: true do + let(:user) { create(:user) } + let!(:basic_report) do + create :blazer_dashboard, name: 'Basic Report' + end + + before { sign_in user } + + context 'Happy Path' do + it 'allows a user to show a report' do + visit reports_dashboards_path + expect(page).to have_current_path(reports_dashboards_path) + expect(page).to have_content('Basic Report') + click_on 'Show' + expect(page).to have_current_path(reports_dashboard_path(basic_report)) + expect(page).to have_content('Basic Report') + end + end +end diff --git a/lib/generators/rolemodel/css/base/base_generator.rb b/lib/generators/rolemodel/css/base/base_generator.rb index 880137a2..a27100eb 100644 --- a/lib/generators/rolemodel/css/base/base_generator.rb +++ b/lib/generators/rolemodel/css/base/base_generator.rb @@ -36,7 +36,7 @@ def use_webpacker_styles_in_layout layouts = [ 'erb', 'slim' ].each do |template_language| layout_file = "app/views/layouts/application.html.#{template_language}" - next unless File.exists? layout_file + next unless File.exist? layout_file gsub_file layout_file, "stylesheet_link_tag 'application', media: 'all'", "stylesheet_pack_tag 'stylesheets'" end end diff --git a/lib/generators/rolemodel/modals/modals_generator.rb b/lib/generators/rolemodel/modals/modals_generator.rb index 6f7e9c5d..8cff4cae 100644 --- a/lib/generators/rolemodel/modals/modals_generator.rb +++ b/lib/generators/rolemodel/modals/modals_generator.rb @@ -3,7 +3,7 @@ class ModalsGenerator < Rails::Generators::Base source_root File.expand_path('templates', __dir__) def install_icons - generate 'rolemodel:css:base' unless File.exists?(Rails.root.join('app/javascript/packs/stylesheets.scss')) + generate 'rolemodel:css:base' unless File.exist?(Rails.root.join('app/javascript/packs/stylesheets.scss')) generate 'rolemodel:css:icons' end diff --git a/lib/generators/rolemodel/testing/rspec/rspec_generator.rb b/lib/generators/rolemodel/testing/rspec/rspec_generator.rb index 3eb5a62e..b0a872e3 100644 --- a/lib/generators/rolemodel/testing/rspec/rspec_generator.rb +++ b/lib/generators/rolemodel/testing/rspec/rspec_generator.rb @@ -12,9 +12,11 @@ def install_rspec end run_bundle - gem_group :test do - gem 'capybara' - gem 'webdrivers' + if File.readlines('Gemfile').grep(/capybara|webdrivers/).none? + gem_group :test do + gem 'capybara' unless File.readlines('Gemfile').grep(/capybara/).any? + gem 'webdrivers' unless File.readlines('Gemfile').grep(/webdrivers/).any? + end end run_bundle end