Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(decorator): add action support #24

Merged
merged 25 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9218ad0
feat(decorator): add action context
nicolasalexandre9 Jan 2, 2024
373522d
feat(decorator): add scope and field types
nicolasalexandre9 Jan 3, 2024
73d4801
feat(decorator): add base action class
nicolasalexandre9 Jan 3, 2024
6521669
feat(decorator): add dynamic field
nicolasalexandre9 Jan 3, 2024
5ed0845
feat(decorator): add result builder
nicolasalexandre9 Jan 3, 2024
eeb7908
feat(decorator): add action collection decorator
nicolasalexandre9 Jan 4, 2024
e2670c9
feat(decorator): add generate action schema
nicolasalexandre9 Jan 4, 2024
6fafaf2
refactor: rename module actions to action
nicolasalexandre9 Jan 5, 2024
f8f9074
feat: update generate apimap with action
nicolasalexandre9 Jan 5, 2024
d85eb0b
feat: add proxy method add action
nicolasalexandre9 Jan 5, 2024
dc8f815
fix: base action
nicolasalexandre9 Jan 5, 2024
ba9d8a4
feat: add action routes
nicolasalexandre9 Jan 22, 2024
7b810ad
fix: action collection decorator
nicolasalexandre9 Jan 26, 2024
0647075
fix: action context
nicolasalexandre9 Jan 29, 2024
b496ef6
test: add test on ProjectionValidator
nicolasalexandre9 Jan 30, 2024
aeece41
test: add test on ResultBuilder
nicolasalexandre9 Jan 30, 2024
f97d8d6
test: add test on ActionContext
nicolasalexandre9 Jan 30, 2024
ec553b1
test: udpate some tests
nicolasalexandre9 Jan 30, 2024
82af1ae
fix: schemaEmmiter
nicolasalexandre9 Jan 30, 2024
015ce22
test: add tests on action collection decorator
nicolasalexandre9 Jan 31, 2024
cd75780
test: update test on collection contract
nicolasalexandre9 Feb 1, 2024
14978de
test: add tests on action route
nicolasalexandre9 Feb 1, 2024
df77648
feat: add set_header on result builder
nicolasalexandre9 Feb 12, 2024
5cb5fc9
test: add test for set_header on result builder
nicolasalexandre9 Feb 12, 2024
a58dd9c
fix: test on action collection decorator
nicolasalexandre9 Feb 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,15 @@ Metrics/ParameterLists:
- 'packages/forest_admin_agent/lib/forest_admin_agent/services/smart_action_checker.rb'
- 'packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/related/list_related_spec.rb'
- 'packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/related/count_related_spec.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/context/action_context.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/dynamic_field.rb'
- 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/many_to_many_schema.rb'
- 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/column_schema.rb'
- 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/caller.rb'
- 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter.rb'
- 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb'
- 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/action_field.rb'

Metrics/ModuleLength:
CountAsOne: [ 'array', 'hash', 'method_call' ]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class Router

def self.routes
[
# actions_routes,
actions_routes,
# api_charts_routes,
System::HealthCheck.new.routes,
Security::Authentication.new.routes,
Expand All @@ -25,14 +25,14 @@ def self.routes
end

def self.actions_routes
routes = []
# TODO
# AgentFactory.get('datasource').collections.each do |collection|
# collection.get_actions.each do |action_name, action|
# routes << Actions.new(collection, action_name).routes
# end
# end
routes.flatten
routes = {}
Facades::Container.datasource.collections.each_value do |collection|
collection.schema[:actions].each_key do |action_name|
routes.merge!(Action::Action.new(collection, action_name).routes)
end
end

routes
end

def self.api_charts_routes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
require 'jsonapi-serializers'
require 'active_support/inflector'

module ForestAdminAgent
module Routes
module Action
class Action < AbstractAuthenticatedRoute
include ForestAdminAgent::Builder
include ForestAdminAgent::Utils
include ForestAdminDatasourceToolkit::Components::Query
include ForestAdminDatasourceToolkit::Components::Query::ConditionTree
include ForestAdminDatasourceCustomizer::Decorators::Action

def initialize(collection, action)
@action_name = action
@collection = collection
super()
end

def setup_routes
action_index = @collection.schema[:actions].keys.index(@action_name)
slug = ForestAdminAgent::Utils::Schema::GeneratorAction.get_action_slug(@action_name)
route_name = "forest_action_#{@collection.name}_#{action_index}_#{slug}"
path = "/_actions/:collection_name/#{action_index}/#{slug}"

add_route(route_name, 'post', path, proc { |args| handle_request(args) })
add_route(
"#{route_name}_load",
'post',
"#{path}/hooks/load",
proc { |args| handle_hook_request(args) }
)
add_route(
"#{route_name}_change",
'post',
"#{path}/hooks/change",
proc { |args| handle_hook_request(args) }
)
self
end

def handle_request(args = {})
build(args)
filter_for_caller = get_record_selection(args)
get_record_selection(args, include_user_scope: false)

# TODO: permission

raw_data = args.dig(:params, :data, :attributes, :values)

# As forms are dynamic, we don't have any way to ensure that we're parsing the data correctly
# better send invalid data to the getForm() customer handler than to the execute() one.
unsafe_data = Schema::ForestValueConverter.make_form_data_unsafe(raw_data)

fields = @collection.get_form(
@caller,
@action_name,
unsafe_data,
filter_for_caller,
{ include_hidden_fields: true } # during execute, we need all possible fields
)

# Now that we have the field list, we can parse the data again.
data = Schema::ForestValueConverter.make_form_data(@datasource, raw_data, fields)

{ content: @collection.execute(@caller, @action_name, data, filter_for_caller) }
end

def handle_hook_request(args = {})
build(args)
forest_fields = args.dig(:params, :data, :attributes, :fields)
data = (Schema::ForestValueConverter.make_form_data_from_fields(@datasource, forest_fields) if forest_fields)
filter = get_record_selection(args)

fields = @collection.get_form(
@caller,
@action_name,
data,
filter,
{
change_field: nil,
search_field: nil,
search_values: {},
includeHiddenFields: false
}
)

{
content: {
fields: fields&.map { |field| Schema::GeneratorAction.build_field_schema(@datasource, field) } || {}
}
}
end

private

def get_record_selection(args, include_user_scope: true)
attributes = args.dig(:params, :data, :attributes)

# Match user filter + search + scope? + segment
scope = include_user_scope ? @permissions.get_scope(@collection) : nil
filter = Filter.new(
condition_tree: ConditionTreeFactory.intersect(
[
scope,
ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(
@collection, args
)
]
)
)

# Restrict the filter to the selected records for single or bulk actions
if @collection.schema[:actions][@action_name].scope != Types::ActionScope::GLOBAL
selection_ids = Utils::Id.parse_selection_ids(@collection, args[:params])
selected_ids = ConditionTreeFactory.match_ids(@collection, selection_ids[:ids])
selected_ids = selected_ids.inverse if selection_ids[:are_excluded]
filter = filter.override(
condition_tree: ConditionTreeFactory.intersect([filter.condition_tree, selected_ids])
)
end

# Restrict the filter further for the "related data" page
unless attributes[:parent_association_name].nil?
relation = attributes[:parent_association_name]
parent = @datasource.get_collection(attributes[:parent_collection_name])
parent_id = Utils::Id.unpack_id(parent, attributes[:parent_collection_id])

filter = FilterFactory.make_foreign_filter(parent, parent_id, relation, @caller, filter)
end

filter
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,16 @@ def self.unpack_ids(collection, packed_ids, with_key: false)

def self.parse_selection_ids(collection, params, with_key: false)
attributes = begin
params.dig('data', 'attributes')
params.dig(:data, :attributes)
rescue StandardError
nil
end

are_excluded = attributes&.key?('all_records') ? attributes['all_records'] : false
input_ids = attributes&.key?('ids') ? attributes['ids'] : params['data'].map { |item| item['id'] }
are_excluded = attributes&.key?(:all_records) ? attributes[:all_records] : false
input_ids = attributes&.key?(:ids) ? attributes[:ids] : params[:data].map { |item| item['id'] }
ids = unpack_ids(
collection,
are_excluded ? attributes['all_records_ids_excluded'] : input_ids,
are_excluded ? attributes[:all_records_ids_excluded] : input_ids,
with_key: with_key
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module ForestAdminAgent
module Utils
module Schema
class ActionFields
def self.collection_field?(field)
field&.type == 'Collection'
end

def self.enum_field?(field)
field&.type == 'Enum'
end

def self.enum_list_field?(field)
field&.type == 'EnumList'
end

def self.file_field?(field)
field&.type == 'File'
end

def self.file_list_field?(field)
field&.type == 'FileList'
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
module ForestAdminAgent
module Utils
module Schema
class ForestValueConverter
# This last form data parser tries to guess the types from the data itself.
#
# - Fields with type "Collection" which target collections where the pk is not a string or
# derivative (mongoid, uuid, ...) won't be parser correctly, as we don't have enough information
# to properly guess the type
# - Fields of type "String" but where the final user entered a data-uri manually in the frontend
# will be wrongfully parsed.
def self.make_form_data_unsafe(raw_data)
data = {}
raw_data.each do |key, value|
# Skip fields from the default form
next if Schema::GeneratorAction::DEFAULT_FIELDS.map { |f| f[:field] }.include?(key)

data[key] = if value.is_a?(Array) && value.all? { |v| data_uri?(v) }
value.map { |uri| parse_data_uri(uri) }
elsif data_uri?(value)
parse_data_uri(value)
else
value
end
end

data
end

def self.make_form_data_from_fields(datasource, fields)
data = {}
fields.each_value do |field|
next if Schema::GeneratorAction::DEFAULT_FIELDS.map { |f| f[:field] }.include?(field.field)

if field.reference && field.value
collection_name = field.reference.split('.').first
collection = datasource.get_collection(collection_name)
data[field.field] = Utils::Id.unpack_id(collection, field.value)
elsif field.type == 'File'
data[field.field] = parse_data_uri(field.value)
elsif field.type.is_a?(Array) && field.type[0] == 'File'
data[field.field] = field.value.map { |v| parse_data_uri(v) }
else
data[field.field] = field.value
end
end

data
end

# Proper form data parser which converts data from an action form result to the format
# that is internally used in datasources.
def self.make_form_data(datasource, raw_data, fields)
data = {}
raw_data.each do |key, value|
field = fields.find { |f| f.label == key }
# Skip fields from the default form
next if Schema::GeneratorAction::DEFAULT_FIELDS.map { |f| f[:field] }.include?(key)

if ActionFields.collection_field?(field) && !value.nil?
collection = datasource.get_collection(field.collection_name)
data[key] = Utils::Id.unpack_id(collection, value)
elsif ActionFields.file_field?(field)
data[key] = parse_data_uri(value)
elsif ActionFields.file_list_field?(field)
data[key] = value.map { |v| parse_data_uri(v) }
else
data[key] = value
end
end

data
end

def self.data_uri?(value)
value.is_a?(String) && value.start_with?('data:')
end

def self.value_to_forest(field)
if ActionFields.enum_field?(field)
return field.enum_values.include?(field.value) ? field.value : nil
end

return field.value.select { |v| field.enum_values.include?(v) } if ActionFields.enum_list_field?(field)

return field.value.join('|') if ActionFields.collection_field?(field)

# return make_data_uri(field.value) if ActionFields.file_field?(field)
#
# return value.map { |f| make_data_uri(f) } if ActionFields.file_list_field?(field)

field.value
end

def self.make_data_uri(file)
# TODO: to implement
end

def self.parse_data_uri(file)
# TODO: to implement
end
end
end
end
end
Loading
Loading