forest_admin_datasource_customizer 1.15.2 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/forest_admin_datasource_customizer/collection_customizer.rb +1 -0
- data/lib/forest_admin_datasource_customizer/datasource_customizer.rb +3 -2
- data/lib/forest_admin_datasource_customizer/dsl/builders/action_builder.rb +111 -0
- data/lib/forest_admin_datasource_customizer/dsl/builders/chart_builder.rb +98 -0
- data/lib/forest_admin_datasource_customizer/dsl/builders/form_builder.rb +118 -0
- data/lib/forest_admin_datasource_customizer/dsl/context/execution_context.rb +124 -0
- data/lib/forest_admin_datasource_customizer/dsl/helpers/collection_helpers.rb +274 -0
- data/lib/forest_admin_datasource_customizer/dsl/helpers/datasource_helpers.rb +69 -0
- data/lib/forest_admin_datasource_customizer/dsl.rb +9 -0
- data/lib/forest_admin_datasource_customizer/version.rb +1 -1
- data/lib/forest_admin_datasource_customizer.rb +5 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3540f2fae58db61afffd231a97a223103cc7e9c05c7ce91310a119fc1338ffd2
|
|
4
|
+
data.tar.gz: 7b885c7eb13546cd8cdf740951598251e0288483db93df84351574294b8a4c5b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8257cf2500f96bbd7c04a91c63427f2546cc8ceb98542a42f07745a83bc6e71717a0df0bddba8675c60d81b0b6238ae80913fa042aa7bd0ff5d594c8825785f6
|
|
7
|
+
data.tar.gz: 9275c5c61c2fe517c2848a01295dec3383ca7e84d03a80d1e31b30ee459c9229384da056d1586a66ca3776e6f16444d92a732465af67b6aae3c12de8af81a3c7
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
module ForestAdminDatasourceCustomizer
|
|
2
2
|
class DatasourceCustomizer
|
|
3
|
+
include DSL::DatasourceHelpers
|
|
3
4
|
attr_reader :stack, :datasources
|
|
4
5
|
|
|
5
6
|
def initialize(_db_config = {})
|
|
@@ -60,8 +61,8 @@ module ForestAdminDatasourceCustomizer
|
|
|
60
61
|
push_customization { plugin.new.run(self, nil, options) }
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
def customize_collection(name
|
|
64
|
-
|
|
64
|
+
def customize_collection(name)
|
|
65
|
+
yield(get_collection(name))
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
def remove_collection(*names)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourceCustomizer
|
|
4
|
+
module DSL
|
|
5
|
+
# ActionBuilder provides a fluent DSL for building custom actions
|
|
6
|
+
#
|
|
7
|
+
# @example Simple action
|
|
8
|
+
# action :approve, scope: :bulk do
|
|
9
|
+
# execute do
|
|
10
|
+
# success "Records approved!"
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Action with form
|
|
15
|
+
# action :export, scope: :global do
|
|
16
|
+
# description "Export all data"
|
|
17
|
+
# generates_file!
|
|
18
|
+
#
|
|
19
|
+
# form do
|
|
20
|
+
# field :format, type: :string, widget: 'Dropdown',
|
|
21
|
+
# options: [{ label: 'CSV', value: 'csv' }]
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# execute do
|
|
25
|
+
# format = form_value(:format)
|
|
26
|
+
# file content: generate_csv, name: "export.#{format}"
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
class ActionBuilder
|
|
30
|
+
def initialize(scope:)
|
|
31
|
+
@scope = normalize_scope(scope)
|
|
32
|
+
@form_fields = nil
|
|
33
|
+
@execute_block = nil
|
|
34
|
+
@description = nil
|
|
35
|
+
@submit_button_label = nil
|
|
36
|
+
@generate_file = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Set the action description
|
|
40
|
+
# @param text [String] description text
|
|
41
|
+
def description(text)
|
|
42
|
+
@description = text
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Set custom submit button label
|
|
46
|
+
# @param label [String] button label
|
|
47
|
+
def submit_button_label(label)
|
|
48
|
+
@submit_button_label = label
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Mark action as generating a file
|
|
52
|
+
def generates_file!
|
|
53
|
+
@generate_file = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Define the action form using FormBuilder DSL
|
|
57
|
+
# @param block [Proc] block to build the form
|
|
58
|
+
def form(&block)
|
|
59
|
+
form_builder = FormBuilder.new
|
|
60
|
+
form_builder.instance_eval(&block)
|
|
61
|
+
@form_fields = form_builder.fields
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Define the action execution logic
|
|
65
|
+
# The block is executed in the context of an ExecutionContext
|
|
66
|
+
# which provides helper methods like success, error, file, etc.
|
|
67
|
+
#
|
|
68
|
+
# @param block [Proc] execution block
|
|
69
|
+
def execute(&block)
|
|
70
|
+
@execute_block = proc do |context, result_builder|
|
|
71
|
+
executor = ExecutionContext.new(context, result_builder)
|
|
72
|
+
executor.instance_eval(&block)
|
|
73
|
+
executor.result
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Build and return the BaseAction instance
|
|
78
|
+
# @return [Decorators::Action::BaseAction] the action
|
|
79
|
+
def to_action
|
|
80
|
+
raise ArgumentError, 'execute block is required' unless @execute_block
|
|
81
|
+
|
|
82
|
+
Decorators::Action::BaseAction.new(
|
|
83
|
+
scope: @scope,
|
|
84
|
+
form: @form_fields,
|
|
85
|
+
is_generate_file: @generate_file,
|
|
86
|
+
description: @description,
|
|
87
|
+
submit_button_label: @submit_button_label,
|
|
88
|
+
&@execute_block
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Normalize scope symbols to ActionScope constants
|
|
95
|
+
# @param scope [String, Symbol] the scope
|
|
96
|
+
# @return [String] normalized scope
|
|
97
|
+
def normalize_scope(scope)
|
|
98
|
+
scope_map = {
|
|
99
|
+
single: Decorators::Action::Types::ActionScope::SINGLE,
|
|
100
|
+
bulk: Decorators::Action::Types::ActionScope::BULK,
|
|
101
|
+
global: Decorators::Action::Types::ActionScope::GLOBAL
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return scope if scope.is_a?(String) && scope_map.value?(scope)
|
|
105
|
+
|
|
106
|
+
scope_sym = scope.to_s.downcase.to_sym
|
|
107
|
+
scope_map[scope_sym] || raise(ArgumentError, "Invalid scope: #{scope}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourceCustomizer
|
|
4
|
+
module DSL
|
|
5
|
+
# ChartBuilder provides a fluent DSL for building charts
|
|
6
|
+
#
|
|
7
|
+
# @example Simple value chart
|
|
8
|
+
# chart :total_revenue do
|
|
9
|
+
# value 12345
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# @example Chart with previous value
|
|
13
|
+
# chart :monthly_sales do
|
|
14
|
+
# value 784, 760
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @example Distribution chart
|
|
18
|
+
# chart :status_breakdown do
|
|
19
|
+
# distribution({ 'Active' => 150, 'Inactive' => 50 })
|
|
20
|
+
# end
|
|
21
|
+
class ChartBuilder
|
|
22
|
+
def initialize(context, result_builder)
|
|
23
|
+
@context = context
|
|
24
|
+
@result_builder = result_builder
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Access the context
|
|
28
|
+
attr_reader :context
|
|
29
|
+
|
|
30
|
+
# Return a simple value chart
|
|
31
|
+
# @param current [Numeric] current value
|
|
32
|
+
# @param previous [Numeric] previous value (optional)
|
|
33
|
+
def value(current, previous = nil)
|
|
34
|
+
if previous
|
|
35
|
+
@result_builder.value(current, previous)
|
|
36
|
+
else
|
|
37
|
+
@result_builder.value(current)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Return a distribution chart
|
|
42
|
+
# @param data [Hash] distribution data
|
|
43
|
+
# @example
|
|
44
|
+
# distribution({ 'Category A' => 10, 'Category B' => 20 })
|
|
45
|
+
def distribution(data)
|
|
46
|
+
@result_builder.distribution(data)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Return an objective chart
|
|
50
|
+
# @param current [Numeric] current value
|
|
51
|
+
# @param target [Numeric] target value
|
|
52
|
+
# @example
|
|
53
|
+
# objective 235, 300
|
|
54
|
+
def objective(current, target)
|
|
55
|
+
@result_builder.objective(current, target)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Return a percentage chart
|
|
59
|
+
# @param value [Numeric] percentage value
|
|
60
|
+
# @example
|
|
61
|
+
# percentage 75.5
|
|
62
|
+
def percentage(value)
|
|
63
|
+
@result_builder.percentage(value)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Return a time-based chart
|
|
67
|
+
# @param data [Array<Hash>] time series data
|
|
68
|
+
# @example
|
|
69
|
+
# time_based([
|
|
70
|
+
# { label: 'Jan', values: { sales: 100 } },
|
|
71
|
+
# { label: 'Feb', values: { sales: 150 } }
|
|
72
|
+
# ])
|
|
73
|
+
def time_based(data)
|
|
74
|
+
@result_builder.time_based(data)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Return a leaderboard chart
|
|
78
|
+
# @param data [Array<Hash>] leaderboard data
|
|
79
|
+
# @example
|
|
80
|
+
# leaderboard([
|
|
81
|
+
# { key: 'User 1', value: 100 },
|
|
82
|
+
# { key: 'User 2', value: 90 }
|
|
83
|
+
# ])
|
|
84
|
+
def leaderboard(data)
|
|
85
|
+
@result_builder.leaderboard(data)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Smart chart - automatically detects the best chart type
|
|
89
|
+
# @param data [Hash, Array, Numeric] chart data
|
|
90
|
+
# @example
|
|
91
|
+
# smart 1234
|
|
92
|
+
# smart({ 'A' => 10, 'B' => 20 })
|
|
93
|
+
def smart(data)
|
|
94
|
+
@result_builder.smart(data)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourceCustomizer
|
|
4
|
+
module DSL
|
|
5
|
+
# FormBuilder provides a fluent DSL for building action forms
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# form do
|
|
9
|
+
# field :email, type: :string, widget: 'TextInput'
|
|
10
|
+
# field :age, type: :number
|
|
11
|
+
# field :photo, type: :file
|
|
12
|
+
#
|
|
13
|
+
# page do
|
|
14
|
+
# field :address, type: :string
|
|
15
|
+
# field :city, type: :string
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
class FormBuilder
|
|
19
|
+
attr_reader :fields
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@fields = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Add a field to the form
|
|
26
|
+
# @param name [String, Symbol] field name
|
|
27
|
+
# @param type [String, Symbol] field type (:string, :number, :boolean, :date, :file, etc.)
|
|
28
|
+
# @param widget [String] optional widget type
|
|
29
|
+
# @param options [Array<Hash>] options for dropdown/radio widgets
|
|
30
|
+
# @param readonly [Boolean] whether field is read-only
|
|
31
|
+
# @param default [Object] default value
|
|
32
|
+
# @param description [String] field description
|
|
33
|
+
# @param placeholder [String] placeholder text
|
|
34
|
+
# @param block [Proc] optional proc for computed values
|
|
35
|
+
def field(name, type:, widget: nil, options: nil, readonly: false, default: nil,
|
|
36
|
+
description: nil, placeholder: nil, &block)
|
|
37
|
+
field_def = {
|
|
38
|
+
label: name.to_s,
|
|
39
|
+
type: normalize_type(type)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
field_def[:widget] = widget if widget
|
|
43
|
+
field_def[:options] = options if options
|
|
44
|
+
field_def[:is_read_only] = readonly if readonly
|
|
45
|
+
field_def[:default_value] = default if default
|
|
46
|
+
field_def[:description] = description if description
|
|
47
|
+
field_def[:placeholder] = placeholder if placeholder
|
|
48
|
+
field_def[:value] = block if block
|
|
49
|
+
|
|
50
|
+
@fields << field_def
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Add a page layout to group fields
|
|
54
|
+
# @param block [Proc] block containing nested fields
|
|
55
|
+
def page(&block)
|
|
56
|
+
page_builder = FormBuilder.new
|
|
57
|
+
page_builder.instance_eval(&block)
|
|
58
|
+
|
|
59
|
+
@fields << {
|
|
60
|
+
type: 'Layout',
|
|
61
|
+
component: 'Page',
|
|
62
|
+
elements: page_builder.fields
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Add a row layout to arrange fields horizontally
|
|
67
|
+
# @param block [Proc] block containing nested fields
|
|
68
|
+
def row(&block)
|
|
69
|
+
row_builder = FormBuilder.new
|
|
70
|
+
row_builder.instance_eval(&block)
|
|
71
|
+
|
|
72
|
+
@fields << {
|
|
73
|
+
type: 'Layout',
|
|
74
|
+
component: 'Row',
|
|
75
|
+
fields: row_builder.fields
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Add a separator
|
|
80
|
+
def separator
|
|
81
|
+
@fields << { type: 'Layout', component: 'Separator' }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Add a HTML block
|
|
85
|
+
# @param content [String] HTML content
|
|
86
|
+
def html(content)
|
|
87
|
+
@fields << {
|
|
88
|
+
type: 'Layout',
|
|
89
|
+
component: 'HtmlBlock',
|
|
90
|
+
content: content
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Normalize type symbols to Forest Admin type strings
|
|
97
|
+
# @param type [String, Symbol] the type
|
|
98
|
+
# @return [String] normalized type
|
|
99
|
+
def normalize_type(type)
|
|
100
|
+
type_map = {
|
|
101
|
+
string: 'String',
|
|
102
|
+
number: 'Number',
|
|
103
|
+
integer: 'Number',
|
|
104
|
+
boolean: 'Boolean',
|
|
105
|
+
date: 'Date',
|
|
106
|
+
datetime: 'Date',
|
|
107
|
+
time: 'Time',
|
|
108
|
+
json: 'Json',
|
|
109
|
+
file: 'File',
|
|
110
|
+
enum: 'Enum'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type_sym = type.to_s.downcase.to_sym
|
|
114
|
+
type_map[type_sym] || type.to_s
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourceCustomizer
|
|
4
|
+
module DSL
|
|
5
|
+
# ExecutionContext provides a cleaner API for action execution blocks
|
|
6
|
+
# It wraps the context and result_builder to provide direct access to common methods
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# collection.action :export do
|
|
10
|
+
# execute do
|
|
11
|
+
# format = form_value(:format)
|
|
12
|
+
# data = generate_export(format)
|
|
13
|
+
# file content: data, name: "export.#{format}"
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
class ExecutionContext
|
|
17
|
+
attr_reader :context, :result_builder, :result
|
|
18
|
+
|
|
19
|
+
def initialize(context, result_builder)
|
|
20
|
+
@context = context
|
|
21
|
+
@result_builder = result_builder
|
|
22
|
+
@result = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Access form values by field name
|
|
26
|
+
# @param key [String, Symbol] the field name
|
|
27
|
+
# @return [Object] the form field value
|
|
28
|
+
def form_value(key)
|
|
29
|
+
@context.get_form_value(key.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get a single record (for single actions)
|
|
33
|
+
# @param fields [Array<String>] fields to retrieve
|
|
34
|
+
# @return [Hash] the record
|
|
35
|
+
def record(fields = [])
|
|
36
|
+
@context.get_record(fields)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get multiple records (for bulk actions)
|
|
40
|
+
# @param fields [Array<String>] fields to retrieve
|
|
41
|
+
# @return [Array<Hash>] the records
|
|
42
|
+
def records(fields = [])
|
|
43
|
+
@context.get_records(fields)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Access the datasource for querying other collections
|
|
47
|
+
# @return [Object] the datasource
|
|
48
|
+
def datasource
|
|
49
|
+
@context.datasource
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Access caller information (user, permissions, etc.)
|
|
53
|
+
# @return [Object] the caller context
|
|
54
|
+
def caller
|
|
55
|
+
@context.caller
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Return a success result
|
|
59
|
+
# @param message [String] success message
|
|
60
|
+
# @param invalidated [Array<String>] collections to invalidate
|
|
61
|
+
# @param html [String] optional HTML content
|
|
62
|
+
# @return [Hash] the success result
|
|
63
|
+
def success(message = 'Success', invalidated: nil, html: nil)
|
|
64
|
+
options = {}
|
|
65
|
+
options[:invalidated] = invalidated if invalidated && !invalidated.empty?
|
|
66
|
+
options[:html] = html if html
|
|
67
|
+
|
|
68
|
+
@result = @result_builder.success(
|
|
69
|
+
message: message,
|
|
70
|
+
options: options
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Return an error result
|
|
75
|
+
# @param message [String] error message
|
|
76
|
+
# @param html [String] optional HTML content
|
|
77
|
+
# @return [Hash] the error result
|
|
78
|
+
def error(message = 'Error', html: nil)
|
|
79
|
+
options = {}
|
|
80
|
+
options[:html] = html if html
|
|
81
|
+
|
|
82
|
+
@result = @result_builder.error(
|
|
83
|
+
message: message,
|
|
84
|
+
options: options
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Return a file download result
|
|
89
|
+
# @param content [String] file content
|
|
90
|
+
# @param name [String] file name
|
|
91
|
+
# @param mime_type [String] MIME type
|
|
92
|
+
# @return [Hash] the file result
|
|
93
|
+
def file(content:, name: 'file', mime_type: 'application/octet-stream')
|
|
94
|
+
@result = @result_builder.file(
|
|
95
|
+
content: content,
|
|
96
|
+
name: name,
|
|
97
|
+
mime_type: mime_type
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Return a webhook result
|
|
102
|
+
# @param url [String] webhook URL
|
|
103
|
+
# @param method [String] HTTP method
|
|
104
|
+
# @param headers [Hash] HTTP headers
|
|
105
|
+
# @param body [Hash] request body
|
|
106
|
+
# @return [Hash] the webhook result
|
|
107
|
+
def webhook(url, method: 'POST', headers: {}, body: {})
|
|
108
|
+
@result = @result_builder.webhook(
|
|
109
|
+
url: url,
|
|
110
|
+
method: method,
|
|
111
|
+
headers: headers,
|
|
112
|
+
body: body
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Return a redirect result
|
|
117
|
+
# @param path [String] redirect path
|
|
118
|
+
# @return [Hash] the redirect result
|
|
119
|
+
def redirect(path)
|
|
120
|
+
@result = @result_builder.redirect_to(path: path)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../builders/chart_builder'
|
|
4
|
+
|
|
5
|
+
module ForestAdminDatasourceCustomizer
|
|
6
|
+
module DSL
|
|
7
|
+
# CollectionHelpers provides Rails-like DSL methods for collection customization
|
|
8
|
+
# These methods are included in CollectionCustomizer to provide a more idiomatic Ruby API
|
|
9
|
+
# rubocop:disable Naming/PredicatePrefix
|
|
10
|
+
module CollectionHelpers
|
|
11
|
+
# Define a computed field with a cleaner syntax
|
|
12
|
+
#
|
|
13
|
+
# @example Simple computed field
|
|
14
|
+
# computed_field :full_name, type: 'String', depends_on: [:first_name, :last_name] do |records|
|
|
15
|
+
# records.map { |r| "#{r['first_name']} #{r['last_name']}" }
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Computed relation
|
|
19
|
+
# computed_field :related_items, type: ['RelatedItem'], depends_on: [:id] do |records, context|
|
|
20
|
+
# records.map { |r| fetch_related(r['id']) }
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @param name [String, Symbol] field name
|
|
24
|
+
# @param type [String, Array<String>] field type (or array of types for relations)
|
|
25
|
+
# @param depends_on [Array<String, Symbol>] fields this computation depends on
|
|
26
|
+
# @param default [Object] default value
|
|
27
|
+
# @param enum_values [Array] enum values if type is enum
|
|
28
|
+
# @param block [Proc] computation block receiving (records, context)
|
|
29
|
+
def computed_field(name, type:, depends_on: [], default: nil, enum_values: nil, &block)
|
|
30
|
+
raise ArgumentError, 'Block is required for computed field' unless block
|
|
31
|
+
|
|
32
|
+
add_field(
|
|
33
|
+
name.to_s,
|
|
34
|
+
Decorators::Computed::ComputedDefinition.new(
|
|
35
|
+
column_type: type,
|
|
36
|
+
dependencies: Array(depends_on).map(&:to_s),
|
|
37
|
+
values: block,
|
|
38
|
+
default_value: default,
|
|
39
|
+
enum_values: enum_values
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Define a custom action with a fluent DSL
|
|
45
|
+
#
|
|
46
|
+
# @example Simple action
|
|
47
|
+
# action :approve, scope: :single do
|
|
48
|
+
# execute do
|
|
49
|
+
# success "Approved!"
|
|
50
|
+
# end
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
53
|
+
# @example Action with form
|
|
54
|
+
# action :export, scope: :global do
|
|
55
|
+
# description "Export all customers"
|
|
56
|
+
# generates_file!
|
|
57
|
+
#
|
|
58
|
+
# form do
|
|
59
|
+
# field :format, type: :string, widget: 'Dropdown',
|
|
60
|
+
# options: [{ label: 'CSV', value: 'csv' }]
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# execute do
|
|
64
|
+
# format = form_value(:format)
|
|
65
|
+
# file content: generate_csv, name: "export.#{format}"
|
|
66
|
+
# end
|
|
67
|
+
# end
|
|
68
|
+
#
|
|
69
|
+
# @param name [String, Symbol] action name
|
|
70
|
+
# @param scope [Symbol] action scope (:single, :bulk, :global)
|
|
71
|
+
# @param block [Proc] action definition block
|
|
72
|
+
def action(name, scope: :single, &block)
|
|
73
|
+
raise ArgumentError, 'Block is required for action' unless block
|
|
74
|
+
|
|
75
|
+
builder = ActionBuilder.new(scope: scope)
|
|
76
|
+
builder.instance_eval(&block)
|
|
77
|
+
add_action(name.to_s, builder.to_action)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Define a segment with a cleaner syntax
|
|
81
|
+
#
|
|
82
|
+
# @example Static segment
|
|
83
|
+
# segment 'Active users' do
|
|
84
|
+
# { field: 'is_active', operator: 'Equal', value: true }
|
|
85
|
+
# end
|
|
86
|
+
#
|
|
87
|
+
# @example Dynamic segment
|
|
88
|
+
# segment 'High value customers' do
|
|
89
|
+
# { field: 'lifetime_value', operator: 'GreaterThan', value: 10000 }
|
|
90
|
+
# end
|
|
91
|
+
#
|
|
92
|
+
# @param name [String] segment name
|
|
93
|
+
# @param block [Proc] block returning condition tree
|
|
94
|
+
def segment(name, &block)
|
|
95
|
+
raise ArgumentError, 'Block is required for segment' unless block
|
|
96
|
+
|
|
97
|
+
add_segment(name, &block)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Add a before hook for an operation
|
|
101
|
+
#
|
|
102
|
+
# @example
|
|
103
|
+
# before :create do |context|
|
|
104
|
+
# # Validate or transform data before create
|
|
105
|
+
# end
|
|
106
|
+
#
|
|
107
|
+
# @param operation [String, Symbol] operation name (:create, :update, :delete, :list, :aggregate)
|
|
108
|
+
# @param block [Proc] hook handler
|
|
109
|
+
def before(operation, &block)
|
|
110
|
+
raise ArgumentError, 'Block is required for before hook' unless block
|
|
111
|
+
|
|
112
|
+
add_hook('Before', operation.to_s.capitalize, &block)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Add an after hook for an operation
|
|
116
|
+
#
|
|
117
|
+
# @example
|
|
118
|
+
# after :create do |context|
|
|
119
|
+
# # Send notification after create
|
|
120
|
+
# end
|
|
121
|
+
#
|
|
122
|
+
# @param operation [String, Symbol] operation name (:create, :update, :delete, :list, :aggregate)
|
|
123
|
+
# @param block [Proc] hook handler
|
|
124
|
+
def after(operation, &block)
|
|
125
|
+
raise ArgumentError, 'Block is required for after hook' unless block
|
|
126
|
+
|
|
127
|
+
add_hook('After', operation.to_s.capitalize, &block)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ActiveRecord-style belongs_to relation
|
|
131
|
+
#
|
|
132
|
+
# @example
|
|
133
|
+
# belongs_to :author, foreign_key: :author_id
|
|
134
|
+
#
|
|
135
|
+
# @param name [String, Symbol] relation name
|
|
136
|
+
# @param collection [String, Symbol] target collection name (defaults to pluralized name)
|
|
137
|
+
# @param foreign_key [String, Symbol] foreign key field
|
|
138
|
+
def belongs_to(name, collection: nil, foreign_key: nil)
|
|
139
|
+
raise ArgumentError, 'Relation name is required for belongs_to' if name.nil? || name.to_s.empty?
|
|
140
|
+
|
|
141
|
+
collection_name = collection&.to_s || "#{name}s"
|
|
142
|
+
foreign_key_name = foreign_key&.to_s || "#{name}_id"
|
|
143
|
+
|
|
144
|
+
add_many_to_one_relation(
|
|
145
|
+
name.to_s,
|
|
146
|
+
collection_name,
|
|
147
|
+
{ foreign_key: foreign_key_name }
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# ActiveRecord-style has_many relation
|
|
152
|
+
#
|
|
153
|
+
# @example
|
|
154
|
+
# has_many :books, origin_key: :author_id
|
|
155
|
+
#
|
|
156
|
+
# @param name [String, Symbol] relation name
|
|
157
|
+
# @param collection [String, Symbol] target collection name (defaults to name)
|
|
158
|
+
# @param origin_key [String, Symbol] origin key field
|
|
159
|
+
# @param foreign_key [String, Symbol] foreign key field (for many-to-many)
|
|
160
|
+
# @param through [String, Symbol] through collection (for many-to-many)
|
|
161
|
+
def has_many(name, collection: nil, origin_key: nil, foreign_key: nil, through: nil)
|
|
162
|
+
raise ArgumentError, 'Relation name is required for has_many' if name.nil? || name.to_s.empty?
|
|
163
|
+
|
|
164
|
+
collection_name = collection&.to_s || name.to_s
|
|
165
|
+
|
|
166
|
+
if through
|
|
167
|
+
# Many-to-many relation
|
|
168
|
+
add_many_to_many_relation(
|
|
169
|
+
name.to_s,
|
|
170
|
+
collection_name,
|
|
171
|
+
through.to_s,
|
|
172
|
+
{
|
|
173
|
+
origin_key: origin_key&.to_s,
|
|
174
|
+
foreign_key: foreign_key&.to_s
|
|
175
|
+
}.compact
|
|
176
|
+
)
|
|
177
|
+
else
|
|
178
|
+
# One-to-many relation
|
|
179
|
+
add_one_to_many_relation(
|
|
180
|
+
name.to_s,
|
|
181
|
+
collection_name,
|
|
182
|
+
{ origin_key: origin_key&.to_s }.compact
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# ActiveRecord-style has_one relation
|
|
188
|
+
#
|
|
189
|
+
# @example
|
|
190
|
+
# has_one :profile, origin_key: :user_id
|
|
191
|
+
#
|
|
192
|
+
# @param name [String, Symbol] relation name
|
|
193
|
+
# @param collection [String, Symbol] target collection name (defaults to pluralized name)
|
|
194
|
+
# @param origin_key [String, Symbol] origin key field
|
|
195
|
+
def has_one(name, collection: nil, origin_key: nil)
|
|
196
|
+
raise ArgumentError, 'Relation name is required for has_one' if name.nil? || name.to_s.empty?
|
|
197
|
+
|
|
198
|
+
collection_name = collection&.to_s || "#{name}s"
|
|
199
|
+
|
|
200
|
+
add_one_to_one_relation(
|
|
201
|
+
name.to_s,
|
|
202
|
+
collection_name,
|
|
203
|
+
{ origin_key: origin_key&.to_s }.compact
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Validate a field with a cleaner syntax
|
|
208
|
+
#
|
|
209
|
+
# @example Simple validation
|
|
210
|
+
# validates :email, :email
|
|
211
|
+
# validates :age, :greater_than, 18
|
|
212
|
+
#
|
|
213
|
+
# @param field_name [String, Symbol] field name
|
|
214
|
+
# @param operator [String, Symbol] validation operator
|
|
215
|
+
# @param value [Object] validation value (optional)
|
|
216
|
+
def validates(field_name, operator, value = nil)
|
|
217
|
+
raise ArgumentError, 'Field name is required for validates' if field_name.nil? || field_name.to_s.empty?
|
|
218
|
+
raise ArgumentError, 'Operator is required for validates' if operator.nil? || operator.to_s.empty?
|
|
219
|
+
|
|
220
|
+
# Convert snake_case to PascalCase
|
|
221
|
+
operator_str = operator.to_s
|
|
222
|
+
.split('_')
|
|
223
|
+
.map(&:capitalize)
|
|
224
|
+
.join
|
|
225
|
+
add_field_validation(field_name.to_s, operator_str, value)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Hide fields from the schema
|
|
229
|
+
#
|
|
230
|
+
# @example
|
|
231
|
+
# hide_fields :internal_id, :secret_token
|
|
232
|
+
#
|
|
233
|
+
# @param field_names [Array<String, Symbol>] fields to hide
|
|
234
|
+
def hide_fields(*field_names)
|
|
235
|
+
raise ArgumentError, 'At least one field name is required for hide_fields' if field_names.empty?
|
|
236
|
+
|
|
237
|
+
remove_field(*field_names.map(&:to_s))
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Add a chart at the collection level with a cleaner syntax
|
|
241
|
+
#
|
|
242
|
+
# @example Simple value chart
|
|
243
|
+
# chart :num_records do
|
|
244
|
+
# value 1234
|
|
245
|
+
# end
|
|
246
|
+
#
|
|
247
|
+
# @example Distribution chart
|
|
248
|
+
# chart :status_distribution do
|
|
249
|
+
# distribution({
|
|
250
|
+
# 'Active' => 150,
|
|
251
|
+
# 'Inactive' => 50
|
|
252
|
+
# })
|
|
253
|
+
# end
|
|
254
|
+
#
|
|
255
|
+
# @example Chart with context
|
|
256
|
+
# chart :monthly_stats do |context|
|
|
257
|
+
# # Access the collection and calculate stats
|
|
258
|
+
# value calculated_value
|
|
259
|
+
# end
|
|
260
|
+
#
|
|
261
|
+
# @param name [String, Symbol] chart name
|
|
262
|
+
# @param block [Proc] chart definition block
|
|
263
|
+
def chart(name, &block)
|
|
264
|
+
raise ArgumentError, 'Block is required for chart' unless block
|
|
265
|
+
|
|
266
|
+
add_chart(name.to_s) do |context, result_builder|
|
|
267
|
+
builder = DSL::ChartBuilder.new(context, result_builder)
|
|
268
|
+
builder.instance_eval(&block)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
# rubocop:enable Naming/PredicatePrefix
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../builders/chart_builder'
|
|
4
|
+
|
|
5
|
+
module ForestAdminDatasourceCustomizer
|
|
6
|
+
module DSL
|
|
7
|
+
# DatasourceHelpers provides Rails-like DSL methods for datasource-level customization
|
|
8
|
+
# These methods are included in DatasourceCustomizer to provide a more idiomatic Ruby API
|
|
9
|
+
module DatasourceHelpers
|
|
10
|
+
# Add a chart at the datasource level with a cleaner syntax
|
|
11
|
+
#
|
|
12
|
+
# @example Simple value chart
|
|
13
|
+
# chart :total_users do
|
|
14
|
+
# value 1234
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @example Chart with context
|
|
18
|
+
# chart :monthly_revenue do |context|
|
|
19
|
+
# collection = context.datasource.get_collection('orders')
|
|
20
|
+
# total = calculate_revenue(collection)
|
|
21
|
+
# value total, previous_total
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @param name [String, Symbol] chart name
|
|
25
|
+
# @param block [Proc] chart definition block
|
|
26
|
+
def chart(name, &block)
|
|
27
|
+
add_chart(name.to_s) do |context, result_builder|
|
|
28
|
+
builder = ChartBuilder.new(context, result_builder)
|
|
29
|
+
builder.instance_eval(&block)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Customize a collection with automatic conversion to string
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# collection :users do |c|
|
|
37
|
+
# c.computed_field :full_name, type: 'String' { }
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# @param name [String, Symbol] collection name
|
|
41
|
+
# @param block [Proc] customization block
|
|
42
|
+
def collection(name, &block)
|
|
43
|
+
customize_collection(name.to_s, &block)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Hide/remove collections from Forest Admin
|
|
47
|
+
#
|
|
48
|
+
# @example
|
|
49
|
+
# hide_collections :internal_logs, :debug_info
|
|
50
|
+
#
|
|
51
|
+
# @param names [Array<String, Symbol>] collection names to hide
|
|
52
|
+
def hide_collections(*names)
|
|
53
|
+
remove_collection(*names.map(&:to_s))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Use a plugin with the datasource
|
|
57
|
+
# (Keeps existing syntax as it's already clean)
|
|
58
|
+
#
|
|
59
|
+
# @example
|
|
60
|
+
# plugin MyCustomPlugin, option1: 'value'
|
|
61
|
+
#
|
|
62
|
+
# @param plugin_class [Class] plugin class
|
|
63
|
+
# @param options [Hash] plugin options
|
|
64
|
+
def plugin(plugin_class, options = {})
|
|
65
|
+
use(plugin_class, options)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourceCustomizer
|
|
4
|
+
# DSL provides helpers for Forest Admin customization
|
|
5
|
+
# This module contains builder classes and helper methods that make
|
|
6
|
+
# the Forest Admin API more idiomatic and easier to use
|
|
7
|
+
module DSL
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -2,6 +2,11 @@ require_relative 'forest_admin_datasource_customizer/version'
|
|
|
2
2
|
require 'zeitwerk'
|
|
3
3
|
|
|
4
4
|
loader = Zeitwerk::Loader.for_gem
|
|
5
|
+
loader.inflector.inflect('dsl' => 'DSL')
|
|
6
|
+
# Collapse subdirectories to avoid creating nested modules
|
|
7
|
+
loader.collapse("#{__dir__}/forest_admin_datasource_customizer/dsl/builders")
|
|
8
|
+
loader.collapse("#{__dir__}/forest_admin_datasource_customizer/dsl/helpers")
|
|
9
|
+
loader.collapse("#{__dir__}/forest_admin_datasource_customizer/dsl/context")
|
|
5
10
|
loader.setup
|
|
6
11
|
|
|
7
12
|
module ForestAdminDatasourceCustomizer
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: forest_admin_datasource_customizer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matthieu
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: exe
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2025-
|
|
12
|
+
date: 2025-12-02 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activesupport
|
|
@@ -210,6 +210,13 @@ files:
|
|
|
210
210
|
- lib/forest_admin_datasource_customizer/decorators/write/write_datasource_decorator.rb
|
|
211
211
|
- lib/forest_admin_datasource_customizer/decorators/write/write_replace/write_customization_context.rb
|
|
212
212
|
- lib/forest_admin_datasource_customizer/decorators/write/write_replace/write_replace_collection_decorator.rb
|
|
213
|
+
- lib/forest_admin_datasource_customizer/dsl.rb
|
|
214
|
+
- lib/forest_admin_datasource_customizer/dsl/builders/action_builder.rb
|
|
215
|
+
- lib/forest_admin_datasource_customizer/dsl/builders/chart_builder.rb
|
|
216
|
+
- lib/forest_admin_datasource_customizer/dsl/builders/form_builder.rb
|
|
217
|
+
- lib/forest_admin_datasource_customizer/dsl/context/execution_context.rb
|
|
218
|
+
- lib/forest_admin_datasource_customizer/dsl/helpers/collection_helpers.rb
|
|
219
|
+
- lib/forest_admin_datasource_customizer/dsl/helpers/datasource_helpers.rb
|
|
213
220
|
- lib/forest_admin_datasource_customizer/plugins/add_external_relation.rb
|
|
214
221
|
- lib/forest_admin_datasource_customizer/plugins/import_field.rb
|
|
215
222
|
- lib/forest_admin_datasource_customizer/plugins/plugin.rb
|