diff --git a/lib/props_template.rb b/lib/props_template.rb index 88eba7d..f46383a 100644 --- a/lib/props_template.rb +++ b/lib/props_template.rb @@ -22,6 +22,7 @@ class << self self.template_lookup_options = {handlers: [:props]} delegate :result!, :array!, + :optional!, :deferred!, :fragments!, :set_block_content!, diff --git a/lib/props_template/base.rb b/lib/props_template/base.rb index 0a51f7c..9483234 100644 --- a/lib/props_template/base.rb +++ b/lib/props_template/base.rb @@ -7,9 +7,12 @@ class InvalidScopeForArrayError < StandardError; end class InvalidScopeForObjError < StandardError; end class Base + attr_accessor :contains + def initialize(encoder = nil) @stream = Oj::StringWriter.new(mode: :rails) @scope = nil + @contains = nil end def set_block_content!(options = {}) @@ -87,6 +90,21 @@ def handle_collection(collection, options) end end + # builds structure of rendered optional attributes as hash + def transform_contain_keys(values) + return {} if values.blank? + + values.each_with_object({}) do |item, acc| + if item.is_a?(Hash) + item.each do |key, value| + acc[key] = transform_contain_keys(value) + end + else + acc[item] = :present + end + end + end + # todo, add ability to define contents of array def array!(collection, options = {}) if @scope.nil? @@ -105,6 +123,21 @@ def array!(collection, options = {}) nil end + # value should be lambda to avoid calculations before optional check + # if attribute should be rendered then call set with calculated value + def optional!(key, value = nil, &block) + return set!(key, value ? value.call : {}, &block) if contains.blank? + + # traveled path without arra indexes + path = traveled_path.select { |item| item.is_a?(Symbol) } + # render if + # 1) "contains" hash contains key at required nesting + # 2) "contains" hash contains parent key at required nesting with all rendering attributes + render_attribute = contains.dig(*(path + [key])) || path.size > 0 && contains.dig(*path) == {} + + set!(key, value ? value.call : {}, &block) if render_attribute + end + def result! if @scope.nil? @stream.push_object diff --git a/lib/props_template/base_with_extensions.rb b/lib/props_template/base_with_extensions.rb index 81ee5f5..453fb37 100644 --- a/lib/props_template/base_with_extensions.rb +++ b/lib/props_template/base_with_extensions.rb @@ -7,6 +7,7 @@ def initialize(builder, context = nil, options = {}) @builder = builder @em = ExtensionManager.new(self) @traveled_path = [] + super() end diff --git a/lib/props_template/extensions/partial_renderer.rb b/lib/props_template/extensions/partial_renderer.rb index a8dfdfd..9a5807e 100644 --- a/lib/props_template/extensions/partial_renderer.rb +++ b/lib/props_template/extensions/partial_renderer.rb @@ -140,6 +140,11 @@ def refine_options(options, item = nil) locals = (rest[:locals] || {}).clone rest[:locals] = locals + if rest + @base.contains ||= @base.transform_contain_keys(rest.dig(:locals, :contains)) + rest[:locals][:contains] = @base.contains + end + if item as = if !rest[:as] retrieve_variable(partial) diff --git a/lib/props_template/searcher.rb b/lib/props_template/searcher.rb index 9518ac5..11047e2 100644 --- a/lib/props_template/searcher.rb +++ b/lib/props_template/searcher.rb @@ -1,7 +1,10 @@ module Props class Searcher + attr_accessor :contains attr_reader :builder, :context, :fragments, :traveled_path + delegate :transform_contain_keys, to: :builder! + def initialize(builder, path = [], context = nil) @search_path = path @depth = 0 @@ -10,9 +13,14 @@ def initialize(builder, path = [], context = nil) @found_options = nil @builder = builder @traveled_path = [] + @contains = nil @partialer = Partialer.new(self, context, builder) end + def builder! + @builder + end + def deferred! [] end diff --git a/spec/extensions/partial_render_spec.rb b/spec/extensions/partial_render_spec.rb index 2753fb0..bff73d2 100644 --- a/spec/extensions/partial_render_spec.rb +++ b/spec/extensions/partial_render_spec.rb @@ -323,4 +323,70 @@ expect(json).to eql_json([]) end + + context "when locals with contains" do + it "renders only required attributes" do + json = render(<<~PROPS) + emails = [ + {value: 'joe@j.com'}, + {value: 'foo@f.com'}, + ] + json.array! emails, partial: ['optional', locals: {contains: [:email]}] do + end + PROPS + + expect(json).to eql_json([ + {email: "joe@j.com"}, + {email: "foo@f.com"} + ]) + end + + it "renders object with all nested attributes" do + json = render(<<~PROPS) + emails = [ + {value: 'joe@j.com'}, + {value: 'foo@f.com'}, + ] + json.array! emails, partial: ['optional', locals: {contains: [emailAsObject: []]}] do + end + PROPS + + expect(json).to eql_json([ + {emailAsObject: {email: "joe@j.com", something: nil}}, + {emailAsObject: {email: "foo@f.com", something: nil}} + ]) + end + + it "renders object with only required nested attributes" do + json = render(<<~PROPS) + emails = [ + {value: 'joe@j.com'}, + {value: 'foo@f.com'}, + ] + json.array! emails, partial: ['optional', locals: {contains: [emailAsObject: [:email]]}] do + end + PROPS + + expect(json).to eql_json([ + {emailAsObject: {email: "joe@j.com"}}, + {emailAsObject: {email: "foo@f.com"}} + ]) + end + + it "renders all attributes" do + json = render(<<~PROPS) + emails = [ + {value: 'joe@j.com'}, + {value: 'foo@f.com'}, + ] + json.array! emails, partial: ['optional', locals: {contains: [:email, {emailAsObject: [:email]}, :something]}] do + end + PROPS + + expect(json).to eql_json([ + {email: "joe@j.com", something: nil, emailAsObject: {email: "joe@j.com"}}, + {email: "foo@f.com", something: nil, emailAsObject: {email: "foo@f.com"}} + ]) + end + end end diff --git a/spec/fixtures/_optional.json.props b/spec/fixtures/_optional.json.props new file mode 100644 index 0000000..649d379 --- /dev/null +++ b/spec/fixtures/_optional.json.props @@ -0,0 +1,7 @@ +json.optional! :email, -> { optional[:value] } +json.optional! :something, -> { nil } + +json.optional! :emailAsObject do + json.optional! :email, -> { optional[:value] } + json.optional! :something, -> { nil } +end diff --git a/spec/props_template_spec.rb b/spec/props_template_spec.rb index 16f5f5a..1c39140 100644 --- a/spec/props_template_spec.rb +++ b/spec/props_template_spec.rb @@ -296,4 +296,52 @@ }.to raise_error(Props::InvalidScopeForArrayError) end end + + context "optional!" do + it "sets a value" do + json = Props::Base.new + json.optional! :foo, -> { "bar" } + attrs = json.result!.strip + + expect(attrs).to eql_json({ + foo: "bar" + }) + end + + it "sets a empty obj when block is empty" do + json = Props::Base.new + json.optional! :foo do + end + attrs = json.result!.strip + + expect(attrs).to eql_json({ + foo: {} + }) + end + + it "sets a empty obj when nested block is empty" do + json = Props::Base.new + json.optional! :foo do + json.optional! :bar do + end + end + attrs = json.result!.strip + + expect(attrs).to eql_json({ + foo: { + bar: {} + } + }) + end + + it "sets a null value" do + json = Props::Base.new + json.optional! :foo, -> { nil } + attrs = json.result!.strip + + expect(attrs).to eql_json({ + foo: nil + }) + end + end end