Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ Metrics/ParameterLists:
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/dynamic_field.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/hook/context/after/hook_after_aggregate_context.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/dsl/builders/form_builder.rb'
- 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb'
- 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/many_to_many_schema.rb'
- 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/column_schema.rb'
Expand Down
21 changes: 7 additions & 14 deletions packages/_examples/demo/lib/forest_admin_rails/create_agent.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
module ForestAdminRails
class CreateAgent
include ForestAdminDatasourceToolkit::Components::Query::ConditionTree
include ForestAdminDatasourceCustomizer::Decorators::Action
include ForestAdminDatasourceCustomizer::Decorators::Action::Types
include ForestAdminDatasourceCustomizer::Decorators::Computed

def self.setup!
datasource = ForestAdminDatasourceActiveRecord::Datasource.new(
Rails.env.to_sym,
Expand All @@ -25,23 +20,21 @@ def self.setup!
})
# .add_datasource(mongo_datasource)

# @agent.add_chart('appointments') do |_context, result_builder|
# result_builder.objective(235, 300)
# end
customize
@agent.build
end

def self.customize
@agent.add_chart('appointments') do |context, result_builder|
ds = context.datasource.get_collection('Customer')
puts ds
result_builder.value(784, 760)
# Add a chart at the datasource level (new DSL syntax)
@agent.chart :appointments do
value 784, 760
end
@agent.customize_collection('Customer') do |collection|

# Customize the Customer collection (new DSL syntax)
@agent.collection :Customer do |collection|
# Rename fields
collection.rename_field('lastname', 'last_name')
end
@agent.remove_collection('Customer')
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class AgentFactory
include ForestAdminAgent::Utils::Schema
include ForestAdminAgent::Http::Exceptions
include ForestAdminDatasourceToolkit::Exceptions
include ForestAdminDatasourceCustomizer::DSL::DatasourceHelpers

attr_reader :customizer, :container, :has_env_secret

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,31 @@ module Builder
end
end

describe 'chart (DSL method)' do
it 'provides a fluent DSL for creating charts' do
instance = described_class.instance
allow(instance.customizer).to receive(:add_chart)

result = instance.chart :appointments do
value 784, 760
end

expect(instance.customizer).to have_received(:add_chart).with('appointments')
expect(result).to eq(instance)
end

it 'converts symbol names to strings' do
instance = described_class.instance
allow(instance.customizer).to receive(:add_chart)

instance.chart :my_chart do
value 123
end

expect(instance.customizer).to have_received(:add_chart).with('my_chart')
end
end

describe 'format_schema_json' do
it 'collapses single-element arrays onto one line' do
instance = described_class.instance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
require 'zeitwerk'

loader = Zeitwerk::Loader.for_gem
loader.inflector.inflect('dsl' => 'DSL')
# Collapse subdirectories to avoid creating nested modules
loader.collapse("#{__dir__}/forest_admin_datasource_customizer/dsl/builders")
loader.collapse("#{__dir__}/forest_admin_datasource_customizer/dsl/helpers")
loader.collapse("#{__dir__}/forest_admin_datasource_customizer/dsl/context")
loader.setup

module ForestAdminDatasourceCustomizer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module ForestAdminDatasourceCustomizer
class CollectionCustomizer
include ForestAdminDatasourceToolkit::Validations
include DSL::CollectionHelpers
attr_reader :datasource_customizer, :stack, :name

def initialize(datasource_customizer, stack, name)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module ForestAdminDatasourceCustomizer
class DatasourceCustomizer
include DSL::DatasourceHelpers
attr_reader :stack, :datasources

def initialize(_db_config = {})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module ForestAdminDatasourceCustomizer
# DSL provides helpers for Forest Admin customization
# This module contains builder classes and helper methods that make
# the Forest Admin API more idiomatic and easier to use
module DSL
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# frozen_string_literal: true

module ForestAdminDatasourceCustomizer
module DSL
# ActionBuilder provides a fluent DSL for building custom actions
#
# @example Simple action
# action :approve, scope: :bulk do
# execute do
# success "Records approved!"
# end
# end
#
# @example Action with form
# action :export, scope: :global do
# description "Export all data"
# generates_file!
#
# form do
# field :format, type: :string, widget: 'Dropdown',
# options: [{ label: 'CSV', value: 'csv' }]
# end
#
# execute do
# format = form_value(:format)
# file content: generate_csv, name: "export.#{format}"
# end
# end
class ActionBuilder
def initialize(scope:)
@scope = normalize_scope(scope)
@form_fields = nil
@execute_block = nil
@description = nil
@submit_button_label = nil
@generate_file = false
end

# Set the action description
# @param text [String] description text
def description(text)
@description = text
end

# Set custom submit button label
# @param label [String] button label
def submit_button_label(label)
@submit_button_label = label
end

# Mark action as generating a file
def generates_file!
@generate_file = true
end

# Define the action form using FormBuilder DSL
# @param block [Proc] block to build the form
def form(&block)
form_builder = FormBuilder.new
form_builder.instance_eval(&block)
@form_fields = form_builder.fields
end

# Define the action execution logic
# The block is executed in the context of an ExecutionContext
# which provides helper methods like success, error, file, etc.
#
# @param block [Proc] execution block
def execute(&block)
@execute_block = proc do |context, result_builder|
executor = ExecutionContext.new(context, result_builder)
executor.instance_eval(&block)
executor.result
end
end

# Build and return the BaseAction instance
# @return [Decorators::Action::BaseAction] the action
def to_action
raise ArgumentError, 'execute block is required' unless @execute_block

Decorators::Action::BaseAction.new(
scope: @scope,
form: @form_fields,
is_generate_file: @generate_file,
description: @description,
submit_button_label: @submit_button_label,
&@execute_block
)
end

private

# Normalize scope symbols to ActionScope constants
# @param scope [String, Symbol] the scope
# @return [String] normalized scope
def normalize_scope(scope)
scope_map = {
single: Decorators::Action::Types::ActionScope::SINGLE,
bulk: Decorators::Action::Types::ActionScope::BULK,
global: Decorators::Action::Types::ActionScope::GLOBAL
}

return scope if scope.is_a?(String) && scope_map.value?(scope)

scope_sym = scope.to_s.downcase.to_sym
scope_map[scope_sym] || raise(ArgumentError, "Invalid scope: #{scope}")
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

module ForestAdminDatasourceCustomizer
module DSL
# ChartBuilder provides a fluent DSL for building charts
#
# @example Simple value chart
# chart :total_revenue do
# value 12345
# end
#
# @example Chart with previous value
# chart :monthly_sales do
# value 784, 760
# end
#
# @example Distribution chart
# chart :status_breakdown do
# distribution({ 'Active' => 150, 'Inactive' => 50 })
# end
class ChartBuilder
def initialize(context, result_builder)
@context = context
@result_builder = result_builder
end

# Access the context
attr_reader :context

# Return a simple value chart
# @param current [Numeric] current value
# @param previous [Numeric] previous value (optional)
def value(current, previous = nil)
if previous
@result_builder.value(current, previous)
else
@result_builder.value(current)
end
end

# Return a distribution chart
# @param data [Hash] distribution data
# @example
# distribution({ 'Category A' => 10, 'Category B' => 20 })
def distribution(data)
@result_builder.distribution(data)
end

# Return an objective chart
# @param current [Numeric] current value
# @param target [Numeric] target value
# @example
# objective 235, 300
def objective(current, target)
@result_builder.objective(current, target)
end

# Return a percentage chart
# @param value [Numeric] percentage value
# @example
# percentage 75.5
def percentage(value)
@result_builder.percentage(value)
end

# Return a time-based chart
# @param data [Array<Hash>] time series data
# @example
# time_based([
# { label: 'Jan', values: { sales: 100 } },
# { label: 'Feb', values: { sales: 150 } }
# ])
def time_based(data)
@result_builder.time_based(data)
end

# Return a leaderboard chart
# @param data [Array<Hash>] leaderboard data
# @example
# leaderboard([
# { key: 'User 1', value: 100 },
# { key: 'User 2', value: 90 }
# ])
def leaderboard(data)
@result_builder.leaderboard(data)
end

# Smart chart - automatically detects the best chart type
# @param data [Hash, Array, Numeric] chart data
# @example
# smart 1234
# smart({ 'A' => 10, 'B' => 20 })
def smart(data)
@result_builder.smart(data)
end
end
end
end
Loading