lib/action_view/component/base.rb in actionview-component-1.6.2 vs lib/action_view/component/base.rb in actionview-component-1.7.0

- old
+ new

@@ -9,10 +9,13 @@ include ActiveSupport::Configurable include ActionView::Component::Previewable delegate :form_authenticity_token, :protect_against_forgery?, to: :helpers + class_attribute :content_areas, default: [] + self.content_areas = [] # default doesn't work until Rails 5.2 + # Entrypoint for rendering components. Called by ActionView::Base#render. # # view_context: ActionView context from calling view # args(hash): params to be passed to component being rendered # block: optional block to be captured within the view context @@ -35,21 +38,22 @@ # <%= render MyComponent, title: "greeting" do %>world<% end %> # returns: # <span title="greeting">Hello, world!</span> # def render_in(view_context, *args, &block) - self.class.compile + self.class.compile! @view_context = view_context @view_renderer ||= view_context.view_renderer @lookup_context ||= view_context.lookup_context @view_flow ||= view_context.view_flow @virtual_path ||= virtual_path @variant = @lookup_context.variants.first old_current_template = @current_template @current_template = self - @content = view_context.capture(&block) if block_given? + @content = view_context.capture(self, &block) if block_given? + validate! send(self.class.call_method_name(@variant)) ensure @current_template = old_current_template @@ -85,10 +89,23 @@ def format # :nodoc: @variant end + def with(area, content = nil, &block) + unless content_areas.include?(area) + raise ArgumentError.new "Unknown content_area '#{area}' - expected one of '#{content_areas}'" + end + + if block_given? + content = view_context.capture(&block) + end + + instance_variable_set("@#{area}".to_sym, content) + nil + end + private def request @request ||= controller.request end @@ -108,38 +125,44 @@ else "call" end end - def has_initializer? - self.instance_method(:initialize).owner == self - end - def source_location - # Require #initialize to be defined so that we can use - # method#source_location to look up the file name - # of the component. - # - # If we were able to only support Ruby 2.7+, - # We could just use Module#const_source_location, - # rendering this unnecessary. - raise NotImplementedError.new("#{self} must implement #initialize.") unless has_initializer? - - instance_method(:initialize).source_location[0] + @source_location ||= + begin + # Require #initialize to be defined so that we can use + # method#source_location to look up the file name + # of the component. + # + # If we were able to only support Ruby 2.7+, + # We could just use Module#const_source_location, + # rendering this unnecessary. + # + initialize_method = instance_method(:initialize) + initialize_method.source_location[0] if initialize_method.owner == self + end end def compiled? @compiled && ActionView::Base.cache_template_loading end + def compile! + compile(validate: true) + end + # Compile templates to instance methods, assuming they haven't been compiled already. # We could in theory do this on app boot, at least in production environments. # Right now this just compiles the first time the component is rendered. - def compile + def compile(validate: false) return if compiled? - validate_templates + if template_errors.present? + raise ActionView::Component::TemplateError.new(template_errors) if validate + return false + end templates.each do |template| class_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{call_method_name(template[:variant])} @output_buffer = ActionView::OutputBuffer.new @@ -162,42 +185,59 @@ def identifier source_location end + def with_content_areas(*areas) + if areas.include?(:content) + raise ArgumentError.new ":content is a reserved content area name. Please use another name, such as ':body'" + end + attr_reader *areas + self.content_areas = areas + end + private def matching_views_in_source_location + return [] unless source_location (Dir["#{source_location.sub(/#{File.extname(source_location)}$/, '')}.*{#{ActionView::Template.template_handler_extensions.join(',')}}"] - [source_location]) end def templates @templates ||= matching_views_in_source_location.each_with_object([]) do |path, memo| pieces = File.basename(path).split(".") memo << { path: path, - variant: pieces.second.split("+")[1]&.to_sym, + variant: pieces.second.split("+").second&.to_sym, handler: pieces.last } end end - def validate_templates - if templates.empty? - raise NotImplementedError.new("Could not find a template file for #{self}.") - end + def template_errors + @template_errors ||= + begin + errors = [] + errors << "#{self} must implement #initialize." if source_location.nil? + errors << "Could not find a template file for #{self}." if templates.empty? - if templates.select { |template| template[:variant].nil? }.length > 1 - raise StandardError.new("More than one template found for #{self}. There can only be one default template file per component.") - end + if templates.count { |template| template[:variant].nil? } > 1 + errors << "More than one template found for #{self}. There can only be one default template file per component." + end - variants.each_with_object(Hash.new(0)) { |variant, counts| counts[variant] += 1 }.each do |variant, count| - next unless count > 1 + invalid_variants = templates + .group_by { |template| template[:variant] } + .map { |variant, grouped| variant if grouped.length > 1 } + .compact + .sort - raise StandardError.new("More than one template found for variant '#{variant}' in #{self}. There can only be one template file per variant.") - end + unless invalid_variants.empty? + errors << "More than one template found for #{'variant'.pluralize(invalid_variants.count)} #{invalid_variants.map { |v| "'#{v}'" }.to_sentence} in #{self}. There can only be one template file per variant." + end + errors + end end def compiled_template(file_path) handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", "")) template = File.read(file_path)