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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5fccaec1c5eb87a11da4d020ba35ce4c44e0ca32b144e13d2630931f98b3a9a
4
- data.tar.gz: 13556c1d9ea603bc48acfa475023436b0736b8c26f202933193e27224543bcd3
3
+ metadata.gz: 3540f2fae58db61afffd231a97a223103cc7e9c05c7ce91310a119fc1338ffd2
4
+ data.tar.gz: 7b885c7eb13546cd8cdf740951598251e0288483db93df84351574294b8a4c5b
5
5
  SHA512:
6
- metadata.gz: 76797ecde75f6753b1ec0e1e09afa18a9bc4f26dd1d37d82d17b4a100dda0e7de1fbc826649e3a6417d9f3fe9d51646ea2f3818c36ea14452d2bf291d7ffba7c
7
- data.tar.gz: 1001aaa27f0f3ee65c124fbbb97a902b8783fd53854667dcc9620dc1a15ab8d564ef071cded27651eb63c1846ca0d5f05a42af51b02f2bd6813def824ac405d3
6
+ metadata.gz: 8257cf2500f96bbd7c04a91c63427f2546cc8ceb98542a42f07745a83bc6e71717a0df0bddba8675c60d81b0b6238ae80913fa042aa7bd0ff5d594c8825785f6
7
+ data.tar.gz: 9275c5c61c2fe517c2848a01295dec3383ca7e84d03a80d1e31b30ee459c9229384da056d1586a66ca3776e6f16444d92a732465af67b6aae3c12de8af81a3c7
@@ -1,6 +1,7 @@
1
1
  module ForestAdminDatasourceCustomizer
2
2
  class CollectionCustomizer
3
3
  include ForestAdminDatasourceToolkit::Validations
4
+ include DSL::CollectionHelpers
4
5
  attr_reader :datasource_customizer, :stack, :name
5
6
 
6
7
  def initialize(datasource_customizer, stack, name)
@@ -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, handle)
64
- handle.call(get_collection(name))
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
@@ -1,3 +1,3 @@
1
1
  module ForestAdminDatasourceCustomizer
2
- VERSION = "1.15.2"
2
+ VERSION = "1.16.0"
3
3
  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.15.2
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-11-28 00:00:00.000000000 Z
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