diff --git a/.rubocop.yml b/.rubocop.yml index 55b0163a6..49bf75ca1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -227,6 +227,7 @@ Metrics/MethodLength: - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/action_field_factory.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_action.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/rename_field/rename_field_collection_decorator.rb' + - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb' Metrics/BlockLength: Exclude: @@ -258,6 +259,7 @@ Metrics/ClassLength: - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/validations/rules.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_action_field_widget.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb' + - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_action.rb' Style/OpenStructUse: Exclude: diff --git a/package.json b/package.json index 5988e93ce..89cad004d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "agent-php", + "name": "agent-ruby", "version": "1.0.0-beta.66", "description": "The official agent Ruby for Forest.", "private": false, diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/action/actions.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/action/actions.rb index 38d95b6b9..58e544ef0 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/action/actions.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/action/actions.rb @@ -69,7 +69,11 @@ def handle_request(args = {}) ) # Now that we have the field list, we can parse the data again. - data = Schema::ForestValueConverter.make_form_data(@datasource, raw_data, fields) + data = Schema::ForestValueConverter.make_form_data( + @datasource, + raw_data, + fields.reject { |field| field.type == 'Layout' } + ) { content: @collection.execute(@caller, @action_name, data, filter_for_caller) } end @@ -82,7 +86,7 @@ def handle_hook_request(args = {}) search_values = {} forest_fields&.each { |field| search_values[field['field']] = field['searchValue'] } - fields = @collection.get_form( + form = @collection.get_form( @caller, @action_name, data, @@ -94,10 +98,12 @@ def handle_hook_request(args = {}) includeHiddenFields: false } ) + form_elements = Schema::GeneratorAction.extract_fields_and_layout(form) { content: { - fields: fields&.map { |field| Schema::GeneratorAction.build_field_schema(@datasource, field) } || {} + fields: Schema::GeneratorAction.build_fields(@collection, form_elements[:fields]), + layout: Schema::GeneratorAction.build_layout(form_elements[:layout]) } } end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_action.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_action.rb index 7a135a02c..013579e84 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_action.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_action.rb @@ -2,6 +2,8 @@ module ForestAdminAgent module Utils module Schema class GeneratorAction + include ForestAdminDatasourceToolkit::Components + DEFAULT_FIELDS = [ { field: 'Loading...', @@ -23,27 +25,46 @@ def self.get_action_slug(name) end def self.build_schema(collection, name) - schema = collection.schema[:actions][name] + action = collection.schema[:actions][name] action_index = collection.schema[:actions].keys.index(name) slug = get_action_slug(name) - fields = build_fields(collection, name, schema) - { + form_elements = extract_fields_and_layout(collection.get_form(nil, name)) + if action.static_form? && form_elements[:layout].empty? + # if action.static_form? + fields = build_fields(collection, form_elements[:fields]) + layout = form_elements[:layout] + else + fields = DEFAULT_FIELDS + layout = nil + end + + schema = { id: "#{collection.name}-#{action_index}-#{slug}", name: name, - type: schema.scope.downcase, + type: action.scope.downcase, baseUrl: nil, endpoint: "/forest/_actions/#{collection.name}/#{action_index}/#{slug}", httpMethod: 'POST', redirect: nil, # frontend ignores this attribute - download: schema.is_generate_file, + download: action.is_generate_file, fields: fields, hooks: { - load: !schema.static_form?, + load: !action.static_form?, # Always registering the change hook has no consequences, even if we don't use it. change: ['changeHook'] } } + + return schema unless layout && !layout.empty? + + schema[:layout] = build_layout(layout) + + schema + end + + def self.build_layout_schema(field) + { **field.to_h, component: field.component.downcase } end def self.build_field_schema(datasource, field) @@ -76,26 +97,49 @@ def self.build_field_schema(datasource, field) output end - class << self - private - - def build_fields(collection, name, action) - return DEFAULT_FIELDS unless action.static_form? + def self.build_fields(collection, fields) + if fields + return fields.map do |field| + new_field = build_field_schema(collection.datasource, field) + new_field[:default_value] = new_field[:value] + new_field.delete(:value) - fields = collection.get_form(nil, name) + new_field + end + end - if fields - return fields.map do |field| - new_field = build_field_schema(collection.datasource, field) - new_field[:default_value] = new_field[:value] - new_field.delete(:value) + [] + end - new_field - end + def self.build_layout(elements) + if elements + return elements.map do |element| + build_layout_schema(element) end + end + + [] + end + + def self.extract_fields_and_layout(form) + fields = [] + layout = [] + has_real_layout = false - [] + form&.each do |element| + if element.type == Actions::FieldType::LAYOUT + layout << element + has_real_layout = true + else + fields << element + # frontend rule + layout << Actions::ActionLayoutElement::InputElement.new(component: 'Input', field_id: element.label) + end end + + layout = [] unless has_real_layout + + { fields: fields, layout: layout } end end end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb index 2e546be4a..52229441e 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb @@ -66,6 +66,7 @@ def serialize(schema) schema[:collections].each do |collection| collection_actions = collection[:actions] collection_segments = collection[:segments] + collection.delete(:actions) collection.delete(:segments) @@ -94,6 +95,7 @@ def serialize(schema) def get_smart_features_by_collection(type, data, with_attributes: false) smart_features = [] + data.each do |value| smart_feature = { id: value[:id], type: type } smart_feature[:attributes] = value if with_attributes diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/action/actions_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/action/actions_spec.rb index 70e32afae..c3db89ed8 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/action/actions_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/action/actions_spec.rb @@ -162,7 +162,7 @@ module Action describe 'handle_hook' do it 'generate a clean form if called without params' do - allow(@action_collection).to receive(:get_form) + allow(@action_collection).to receive(:get_form).and_return([]) action.handle_hook_request(args) expect(@action_collection).to have_received(:get_form) do |caller, action, data, filter, meta| expect(caller).to be_instance_of(Components::Caller) diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_action_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_action_spec.rb index 1faf1b375..1b9311582 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_action_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/schema/generator_action_spec.rb @@ -200,6 +200,86 @@ module Schema ) end end + + describe 'with layout element' do + before do + @collection = collection_build( + schema: { + actions: { + 'Send email' => BaseAction.new( + scope: Types::ActionScope::SINGLE + ) + } + }, + get_form: [ + ActionField.new(label: 'label', type: 'String'), + ActionLayoutElement::SeparatorElement.new + ] + ) + end + + describe 'build_schema' do + it 'generate schema correctly' do + schema = described_class.build_schema(@collection, 'Send email') + + expect(schema).to eq( + { + id: 'collection-0-send-email', + name: 'Send email', + type: 'single', + baseUrl: nil, + endpoint: '/forest/_actions/collection/0/send-email', + httpMethod: 'POST', + redirect: nil, + download: false, + fields: [ + { + defaultValue: 'Form is loading', + description: '', + enums: nil, + field: 'Loading...', + hook: nil, + isReadOnly: true, + isRequired: false, + reference: nil, + type: 'String', + value: nil, + widgetEdit: nil + } + ], + # uncomment when back validations will be done ... + # fields: [ + # { + # default_value: nil, + # description: nil, + # field: 'label', + # isReadOnly: false, + # isRequired: false, + # type: 'String', + # widgetEdit: nil + # } + # ], + # layout: [ + # { component: 'input', fieldId: 'label', type: 'Layout' }, + # { component: 'separator', type: 'Layout' } + # ], + hooks: { load: false, change: ['changeHook'] } + } + ) + end + end + + describe 'extract_fields_and_layout' do + it 'return fields and layout separately from form' do + result = described_class.extract_fields_and_layout(@collection.get_form(nil, 'Send email')) + + expect(result).to have_key(:fields) + expect(result).to have_key(:layout) + expect(result[:fields]).to all(be_a(ActionField)) + expect(result[:layout]).to all(be_a(ActionLayoutElement::BaseLayoutElement)) + end + end + end end end end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb index bf4c568b5..35b445961 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb @@ -10,7 +10,7 @@ def initialize(child_collection, datasource) end def add_action(name, action) - action.build_fields + action.build_elements @actions[name] = action mark_schema_as_dirty @@ -51,6 +51,8 @@ def get_form(caller, name, data = nil, filter = nil, metas = {}) fields = drop_deferred(context, metas[:search_values], dynamic_fields) fields.each do |field| + next if field.type == 'Layout' + if field.value.nil? # customer did not define a handler to rewrite the previous value => reuse current one. field.value = form_values[field.label] @@ -72,13 +74,20 @@ def refine_schema(sub_schema) private def drop_defaults(context, fields, data) - unvalued_fields = fields.reject { |field| data.key?(field.label) } - defaults = unvalued_fields.map { |field| evaluate(context, field.default_value) } - unvalued_fields.each_with_index { |field, index| data[field.label] = defaults[index] } + fields.map do |field| + if field.type == 'Layout' + field + else + drop_default(context, field, data) + end + end + end - fields.each { |field| field.default_value = nil } + def drop_default(context, field, data) + data[field.label] = evaluate(context, field.default_value) unless data.key?(field.label) + field.default_value = nil - fields + field end def drop_ifs(context, fields) @@ -105,7 +114,8 @@ def drop_deferred(context, search_values, fields) value = field.send(key) key = key.to_s.concat('=').to_sym - field.send(key, evaluate(context, value, search_values&.dig(field.label))) + search_value = field.type == 'Layout' ? nil : search_values&.dig(field.label) + field.send(key, evaluate(context, value, search_value)) end new_fields << Actions::ActionFieldFactory.build(field.to_h) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_action.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_action.rb index d343cdeea..675612c17 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_action.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_action.rb @@ -11,51 +11,71 @@ def initialize(scope:, form: nil, is_generate_file: false, &execute) @execute = execute end - def build_fields + def build_elements @form = @form&.map do |field| - case field[:widget] - when 'AddressAutocomplete' - WidgetField::AddressAutocompleteField.new(**field) - when 'Checkbox' - WidgetField::CheckboxField.new(**field) - when 'CheckboxGroup' - WidgetField::CheckboxGroupField.new(**field) - when 'ColorPicker' - WidgetField::ColorPickerField.new(**field) - when 'CurrencyInput' - WidgetField::CurrencyInputField.new(**field) - when 'DatePicker' - WidgetField::DatePickerField.new(**field) - when 'Dropdown' - WidgetField::DropdownField.new(**field) - when 'FilePicker' - WidgetField::FilePickerField.new(**field) - when 'JsonEditor' - WidgetField::JsonEditorField.new(**field) - when 'NumberInput' - WidgetField::NumberInputField.new(**field) - when 'NumberInputList' - WidgetField::NumberInputListField.new(**field) - when 'RadioGroup' - WidgetField::RadioGroupField.new(**field) - when 'RichText' - WidgetField::RichTextField.new(**field) - when 'TextArea' - WidgetField::TextAreaField.new(**field) - when 'TextInput' - WidgetField::TextInputField.new(**field) - when 'TextInputList' - WidgetField::TextInputListField.new(**field) - when 'TimePicker' - WidgetField::TimePickerField.new(**field) - when 'UserDropdown' - WidgetField::UserDropdownField.new(**field) + if field.key? :widget + build_widget(field) + elsif field[:type] == 'Layout' + build_layout_element(field) else DynamicField.new(**field) end end end + def build_widget(field) + case field[:widget] + when 'AddressAutocomplete' + WidgetField::AddressAutocompleteField.new(**field) + when 'Checkbox' + WidgetField::CheckboxField.new(**field) + when 'CheckboxGroup' + WidgetField::CheckboxGroupField.new(**field) + when 'ColorPicker' + WidgetField::ColorPickerField.new(**field) + when 'CurrencyInput' + WidgetField::CurrencyInputField.new(**field) + when 'DatePicker' + WidgetField::DatePickerField.new(**field) + when 'Dropdown' + WidgetField::DropdownField.new(**field) + when 'FilePicker' + WidgetField::FilePickerField.new(**field) + when 'JsonEditor' + WidgetField::JsonEditorField.new(**field) + when 'NumberInput' + WidgetField::NumberInputField.new(**field) + when 'NumberInputList' + WidgetField::NumberInputListField.new(**field) + when 'RadioGroup' + WidgetField::RadioGroupField.new(**field) + when 'RichText' + WidgetField::RichTextField.new(**field) + when 'TextArea' + WidgetField::TextAreaField.new(**field) + when 'TextInput' + WidgetField::TextInputField.new(**field) + when 'TextInputList' + WidgetField::TextInputListField.new(**field) + when 'TimePicker' + WidgetField::TimePickerField.new(**field) + when 'UserDropdown' + WidgetField::UserDropdownField.new(**field) + else + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, "Unknow widget type: #{field[:widget]}" + end + end + + def build_layout_element(field) + case field[:component] + when 'Separator' + FormLayoutElement::SeparatorElement.new(**field) + else + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + "Unknow component type: #{field[:component]}" + end + end + def static_form? return form&.all?(&:static?) if form diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_form_element.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_form_element.rb new file mode 100644 index 000000000..c5ac60fc9 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_form_element.rb @@ -0,0 +1,29 @@ +module ForestAdminDatasourceCustomizer + module Decorators + module Action + class BaseFormElement + attr_accessor :type + + def initialize( + type:, + **_kwargs + ) + @type = type + end + + def static? + instance_variables.all? { |attribute| !instance_variable_get(attribute).respond_to?(:call) } + end + + def to_h + result = {} + instance_variables.each do |attribute| + result[attribute.to_s.delete('@').to_sym] = instance_variable_get(attribute) + end + + result + end + end + end + end +end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/dynamic_field.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/dynamic_field.rb index d7a905e42..8c5b5a76e 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/dynamic_field.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/dynamic_field.rb @@ -1,7 +1,7 @@ module ForestAdminDatasourceCustomizer module Decorators module Action - class DynamicField + class DynamicField < BaseFormElement attr_accessor :type, :label, :description, :is_required, :is_read_only, :if_condition, :value, :default_value, :collection_name, :enum_values, :placeholder @@ -19,7 +19,8 @@ def initialize( placeholder: nil, **_kwargs ) - @type = type + super(type: type) + @label = label @description = description @is_required = is_required @@ -31,19 +32,6 @@ def initialize( @enum_values = enum_values @placeholder = placeholder end - - def static? - instance_variables.all? { |attribute| !instance_variable_get(attribute).respond_to?(:call) } - end - - def to_h - result = {} - instance_variables.each do |attribute| - result[attribute.to_s.delete('@').to_sym] = instance_variable_get(attribute) - end - - result - end end end end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/form_layout_element.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/form_layout_element.rb new file mode 100644 index 000000000..1b206e37a --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/form_layout_element.rb @@ -0,0 +1,26 @@ +module ForestAdminDatasourceCustomizer + module Decorators + module Action + module FormLayoutElement + include Types + + class LayoutElement < BaseFormElement + attr_accessor :if_condition, :component + + def initialize(component:, if_condition: nil, **kwargs) + super(type: 'Layout', **kwargs) + + @component = component + @if_condition = if_condition + end + end + + class SeparatorElement < LayoutElement + def initialize(options) + super(component: 'Separator', **options) + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/layout_element.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/layout_element.rb new file mode 100644 index 000000000..98791ecab --- /dev/null +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/layout_element.rb @@ -0,0 +1,343 @@ +module ForestAdminDatasourceCustomizer + module Decorators + module Action + module LayoutElement + include Types + + class TimePickerField < DynamicField + attr_accessor :widget + + def initialize(options) + super(**options) + WidgetField.validate_arg(options, :type, { type: 'contains', value: ['Time'] }) + @widget = 'TimePicker' + end + end + + class AddressAutocompleteField < DynamicField + attr_accessor :widget + + def initialize(options) + super(**options) + WidgetField.validate_arg(options, :type, { type: 'contains', value: ['String'] }) + + @widget = 'AddressAutocomplete' + end + end + + class CheckboxField < DynamicField + attr_accessor :widget + + def initialize(options) + super(**options) + WidgetField.validate_arg( + options, + :type, + { type: 'contains', value: [Types::FieldType::BOOLEAN] } + ) + + @widget = 'Checkbox' + end + end + + class CheckboxGroupField < DynamicField + attr_accessor :widget, :options + + def initialize(options) + super(**options) + + WidgetField.validate_arg(options, :options, { type: 'present' }) + WidgetField.validate_arg( + options, + :type, + { type: 'contains', value: [Types::FieldType::STRING_LIST, Types::FieldType::NUMBER_LIST] } + ) + + @widget = 'CheckboxGroup' + @options = options[:options] + end + end + + class ColorPickerField < DynamicField + attr_accessor :widget, :enable_opacity, :quick_palette + + def initialize(options) + super(**options) + + WidgetField.validate_arg(options, :enable_opacity, { type: 'contains', value: [Types::FieldType::STRING] }) + + @widget = 'ColorPicker' + @enable_opacity = options[:enable_opacity] || nil + @quick_palette = options[:quick_palette] || nil + end + end + + class CurrencyInputField < DynamicField + attr_accessor :widget, :currency, :base, :min, :max, :step + + def initialize(options) + super(**options) + + WidgetField.validate_arg(options, :type, { type: 'contains', value: [Types::FieldType::NUMBER] }) + WidgetField.validate_arg(options, :currency, { type: 'present' }) + + @widget = 'CurrencyInput' + @currency = options[:currency] + @base = options[:base] || 'Unit' + @min = options[:min] || nil + @max = options[:max] || nil + @step = options[:step] || nil + end + end + + class DatePickerField < DynamicField + attr_accessor :widget, :min, :max, :format, :step + + def initialize(options) + super(**options) + + WidgetField.validate_arg( + options, + 'type', + { type: 'contains', + value: [Types::FieldType::DATE, Types::FieldType::DATE_ONLY, Types::FieldType::STRING] } + ) + + @widget = 'DatePicker' + @format = options[:format] || nil + @min = options[:min] || nil + @max = options[:max] || nil + @step = options[:step] || nil + end + end + + class DropdownField < DynamicField + attr_accessor :widget, :options, :search + + def initialize(options) + super(**options) + WidgetField.validate_arg(options, :options, { type: 'present' }) + WidgetField.validate_arg( + options, + 'type', + { + type: 'contains', + value: [Types::FieldType::DATE, Types::FieldType::DATE_ONLY, Types::FieldType::STRING, + Types::FieldType::STRING_LIST] + } + ) + + @widget = 'Dropdown' + @options = options[:options] + @search = options[:search] || nil + end + end + + class FilePickerField < DynamicField + attr_accessor :widget, :extensions, :max_count, :max_size_mb + + def initialize(options) + super(**options) + WidgetField.validate_arg(options, :options, { type: 'present' }) + WidgetField.validate_arg( + options, + 'type', + { + type: 'contains', + value: [Types::FieldType::FILE, Types::FieldType::FILE_LIST] + } + ) + + @widget = 'FilePicker' + @extensions = options[:extensions] || nil + @max_size_mb = options[:max_size_mb] || nil + @max_count = options[:max_count] || nil + end + end + + class NumberInputField < DynamicField + attr_accessor :widget, :step, :min, :max + + def initialize(options) + super(**options) + WidgetField.validate_arg(options, :options, { type: 'present' }) + WidgetField.validate_arg( + options, + 'type', + { + type: 'contains', + value: [Types::FieldType::NUMBER] + } + ) + + @widget = 'NumberInput' + @step = options[:step] || nil + @min = options[:min] || nil + @max = options[:max] || nil + end + end + + class JsonEditorField < DynamicField + attr_accessor :widget + + def initialize(options) + super(**options) + WidgetField.validate_arg( + options, + 'type', + { + type: 'contains', + value: [Types::FieldType::DATE, Types::FieldType::DATE_ONLY, Types::FieldType::STRING, + Types::FieldType::STRING_LIST] + } + ) + + @widget = 'JsonEditor' + end + end + + class NumberInputListField < DynamicField + attr_accessor :widget, :allow_duplicates, :allow_empty_values, :enable_reorder, :min, :max, :step + + def initialize(options) + super(**options) + WidgetField.validate_arg(options, :options, { type: 'present' }) + WidgetField.validate_arg( + options, + 'type', + { + type: 'contains', + value: [Types::FieldType::NUMBER_LIST] + } + ) + + @widget = 'NumberInputList' + @allow_duplicates = options[:allow_duplicates] || nil + @allow_empty_values = options[:allow_empty_values] || nil + @enable_reorder = options[:enable_reorder] || nil + @min = options[:min] || nil + @max = options[:max] || nil + @step = options[:step] || nil + end + end + + class RadioGroupField < DynamicField + attr_accessor :widget, :options + + def initialize(options) + super(**options) + WidgetField.validate_arg(options, :options, { type: 'present' }) + WidgetField.validate_arg( + options, + 'type', + { + type: 'contains', + value: [Types::FieldType::DATE, Types::FieldType::DATEONLY, Types::FieldType::NUMBER, + Types::FieldType::STRING] + } + ) + + @widget = 'RadioGroup' + @options = options[:options] + end + end + + class RichTextField < DynamicField + attr_accessor :widget + + def initialize(options) + super(**options) + WidgetField.validate_arg(options, :options, { type: 'present' }) + WidgetField.validate_arg( + options, + 'type', + { + type: 'contains', + value: [Types::FieldType::STRING] + } + ) + + @widget = 'RichText' + end + end + + class TextAreaField < DynamicField + attr_accessor :widget, :rows + + def initialize(options) + super(**options) + WidgetField.validate_arg( + options, + 'type', + { + type: 'contains', + value: [Types::FieldType::STRING] + } + ) + + @widget = 'TextArea' + @rows = options[:rows] || nil + end + end + + class TextInputField < DynamicField + attr_accessor :widget + + def initialize(options) + super(**options) + WidgetField.validate_arg( + options, + 'type', + { + type: 'contains', + value: [Types::FieldType::STRING] + } + ) + + @widget = 'TextInput' + end + end + + class TextInputListField < DynamicField + attr_accessor :widget, :allow_duplicates, :allow_empty_values, :enable_reorder + + def initialize(options) + super(**options) + WidgetField.validate_arg( + options, + 'type', + { + type: 'contains', + value: [Types::FieldType::STRING_LIST] + } + ) + + @widget = 'TextInput' + @allow_duplicates = options[:allow_duplicates] || nil + @allow_empty_values = options[:allow_empty_values] || nil + @enable_reorder = options[:enable_reorder] || nil + end + end + + class UserDropdownField < DynamicField + attr_accessor :widget + + def initialize(options) + super(**options) + WidgetField.validate_arg(options, :options, { type: 'present' }) + WidgetField.validate_arg( + options, + 'type', + { + type: 'contains', + value: [Types::FieldType::STRING, Types::FieldType::STRING_LIST] + } + ) + + @widget = 'UserDropdown' + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/widget_field.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/widget_field.rb index 47c1a73db..f647fe7e9 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/widget_field.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/widget_field.rb @@ -246,7 +246,7 @@ def initialize(options) 'type', { type: 'contains', - value: [Types::FieldType::DATE, Types::FieldType::DATEONLY, Types::FieldType::NUMBER, + value: [Types::FieldType::DATE, Types::FieldType::DATE_ONLY, Types::FieldType::NUMBER, Types::FieldType::STRING] } ) @@ -326,7 +326,7 @@ def initialize(options) } ) - @widget = 'TextInput' + @widget = 'TextInputList' @allow_duplicates = options[:allow_duplicates] || nil @allow_empty_values = options[:allow_empty_values] || nil @enable_reorder = options[:enable_reorder] || nil diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/action/base_action_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/action/base_action_spec.rb new file mode 100644 index 000000000..b889a0223 --- /dev/null +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/action/base_action_spec.rb @@ -0,0 +1,244 @@ +require 'spec_helper' + +module ForestAdminDatasourceCustomizer + module Decorators + module Action + describe BaseAction do + let(:scope) { Types::ActionScope::SINGLE } + let(:field_send_notification) { { label: 'Send a notification', type: 'Boolean', widget: 'Checkbox', is_required: true, default_value: false } } + let(:field_message) { { label: 'Notification message', type: 'String', is_required: true, default_value: 'Hello' } } + let(:form) do + [ + field_send_notification, + field_message + ] + end + let(:action) { described_class.new(scope: scope, form: form) } + + describe 'when initialize' do + it 'initializes with correct attributes' do + expect(action.scope).to eq(scope) + expect(action.form).to eq(form) + expect(action.is_generate_file).to be(false) + expect(action.execute).to be_nil + end + end + + describe 'when build_widget' do + it 'returns a CheckboxField for widget type "Checkbox"' do + result = action.build_widget(field_send_notification) + expect(result).to be_a(WidgetField::CheckboxField) + end + + it 'raises an exception for an unknown widget type' do + field = { widget: 'UnknownWidget' } + expect { action.build_widget(field) }.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) + end + + context 'when widget is AddressAutocomplete' do + let(:field) { { label: 'foo', widget: 'AddressAutocomplete', type: 'String' } } + + it 'returns an AddressAutocompleteField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::AddressAutocompleteField) + expect(result.widget).to eq('AddressAutocomplete') + end + end + + context 'when widget is CheckboxGroup' do + let(:field) { { label: 'foo', widget: 'CheckboxGroup', type: 'StringList', options: [] } } + + it 'returns a CheckboxGroupField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::CheckboxGroupField) + expect(result.widget).to eq('CheckboxGroup') + end + end + + context 'when widget is ColorPicker' do + let(:field) { { label: 'foo', widget: 'ColorPicker', type: 'base_', enable_opacity: true } } + + it 'returns a ColorPickerField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::ColorPickerField) + expect(result.widget).to eq('ColorPicker') + end + end + + context 'when widget is CurrencyInput' do + let(:field) { { label: 'foo', widget: 'CurrencyInput', type: 'Number', currency: 'USD' } } + + it 'returns a CurrencyInputField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::CurrencyInputField) + expect(result.widget).to eq('CurrencyInput') + end + end + + context 'when widget is DatePicker' do + let(:field) { { label: 'foo', widget: 'DatePicker', type: 'Date' } } + + it 'returns a DatePickerField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::DatePickerField) + expect(result.widget).to eq('DatePicker') + end + end + + context 'when widget is Dropdown' do + let(:field) { { label: 'foo', widget: 'Dropdown', type: 'String', options: [] } } + + it 'returns a DropdownField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::DropdownField) + expect(result.widget).to eq('Dropdown') + end + end + + context 'when widget is FilePicker' do + let(:field) { { label: 'foo', widget: 'FilePicker', type: 'File', options: [] } } + + it 'returns a FilePickerField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::FilePickerField) + expect(result.widget).to eq('FilePicker') + end + end + + context 'when widget is JsonEditor' do + let(:field) { { label: 'foo', widget: 'JsonEditor', type: 'String' } } + + it 'returns a JsonEditorField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::JsonEditorField) + expect(result.widget).to eq('JsonEditor') + end + end + + context 'when widget is NumberInput' do + let(:field) { { label: 'foo', widget: 'NumberInput', type: 'Number' } } + + it 'returns a NumberInputField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::NumberInputField) + expect(result.widget).to eq('NumberInput') + end + end + + context 'when widget is NumberInputList' do + let(:field) { { label: 'foo', widget: 'NumberInputList', type: 'NumberList', options: [] } } + + it 'returns a NumberInputListField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::NumberInputListField) + expect(result.widget).to eq('NumberInputList') + end + end + + context 'when widget is RadioGroup' do + let(:field) { { label: 'foo', widget: 'RadioGroup', type: 'Number', options: [] } } + + it 'returns a RadioGroupField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::RadioGroupField) + expect(result.widget).to eq('RadioGroup') + end + end + + context 'when widget is RichText' do + let(:field) { { label: 'foo', widget: 'RichText', type: 'String' } } + + it 'returns a RichTextField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::RichTextField) + expect(result.widget).to eq('RichText') + end + end + + context 'when widget is TextArea' do + let(:field) { { label: 'foo', widget: 'TextArea', type: 'String' } } + + it 'returns a TextAreaField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::TextAreaField) + expect(result.widget).to eq('TextArea') + end + end + + context 'when widget is TextInput' do + let(:field) { { label: 'foo', widget: 'TextInput', type: 'String' } } + + it 'returns a TextInputField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::TextInputField) + expect(result.widget).to eq('TextInput') + end + end + + context 'when widget is TextInputList' do + let(:field) { { label: 'foo', widget: 'TextInputList', type: 'StringList', options: [] } } + + it 'returns a TextInputListField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::TextInputListField) + expect(result.widget).to eq('TextInputList') + end + end + + context 'when widget is TimePicker' do + let(:field) { { label: 'foo', widget: 'TimePicker', type: 'Time' } } + + it 'returns a TimePickerField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::TimePickerField) + expect(result.widget).to eq('TimePicker') + end + end + + context 'when widget is UserDropdown' do + let(:field) { { label: 'foo', widget: 'UserDropdown', type: 'String', options: [] } } + + it 'returns a UserDropdownField' do + result = action.build_widget(field) + expect(result).to be_a(ForestAdminDatasourceCustomizer::Decorators::Action::WidgetField::UserDropdownField) + expect(result.widget).to eq('UserDropdown') + end + end + end + + it 'builds elements correctly' do + action.build_elements + expect(action.form.first).to be_a(WidgetField::CheckboxField) + end + + describe 'when check form is static' do + context 'when form is nil' do + let(:action) { described_class.new(scope: :single) } + + it 'returns true' do + expect(action.static_form?).to be true + end + end + + context 'when all fields are static' do + let(:form) { [instance_double(DynamicField, static?: true), instance_double(DynamicField, static?: true)] } + let(:action) { described_class.new(scope: :single, form: form) } + + it 'returns true' do + expect(action.static_form?).to be true + end + end + + context 'when some fields are not static' do + let(:form) { [instance_double(DynamicField, static?: true), instance_double(DynamicField, static?: false)] } + let(:action) { described_class.new(scope: :single, form: form) } + + it 'returns false' do + expect(action.static_form?).to be false + end + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/action_field_factory.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/action_field_factory.rb index 8e609802a..27e393592 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/action_field_factory.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/action_field_factory.rb @@ -3,6 +3,23 @@ module Components module Actions class ActionFieldFactory def self.build(field) + if field.key? :widget + build_widget(field) + elsif field[:type] == 'Layout' + build_layout_element(field) + else + ActionField.new(**field) + end + end + + def self.build_layout_element(field) + case field[:component] + when 'Separator' + ActionLayoutElement::SeparatorElement.new(**field) + end + end + + def self.build_widget(field) case field[:widget] when 'AddressAutocomplete' WidgetField::AddressAutocompleteField.new(**field) @@ -40,8 +57,6 @@ def self.build(field) WidgetField::TimePickerField.new(**field) when 'UserDropdown' WidgetField::UserDropdownField.new(**field) - else - ActionField.new(**field) end end end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/action_layout_element.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/action_layout_element.rb new file mode 100644 index 000000000..0e379bf50 --- /dev/null +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/action_layout_element.rb @@ -0,0 +1,40 @@ +module ForestAdminDatasourceToolkit + module Components + module Actions + module ActionLayoutElement + class BaseLayoutElement + attr_reader :type, :component + + def initialize(component:, **_kwargs) + @type = FieldType::LAYOUT + @component = component + end + + def to_h + result = {} + instance_variables.each do |attribute| + result[attribute.to_s.delete('@').camelize(:lower).to_sym] = instance_variable_get(attribute) + end + + result + end + end + + class InputElement < BaseLayoutElement + attr_reader :field_id + + def initialize(field_id:, **options) + super(component: 'Separator', **options) + @field_id = field_id + end + end + + class SeparatorElement < BaseLayoutElement + def initialize(**options) + super(component: 'Separator', **options) + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/field_type.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/field_type.rb index 9754b4baa..1a9098779 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/field_type.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/actions/field_type.rb @@ -30,6 +30,8 @@ class FieldType STRING_LIST = 'StringList'.freeze + LAYOUT = 'Layout'.freeze + def self.all constants.map { |constant| const_get(constant) } end