module Scrivito # This class is for handling attributes: setting and accessing their values, providing # default values, restricting the types of widgets that may be added to a +widgetlist+ # attribute, plus a couple of convenience methods. # @api public # @see https://scrivito.com/attribute-types Attributes and their type # module AttributeContent ATTRIBUTE_TYPES = %w[ binary date enum float html integer link linklist multienum reference referencelist string stringlist widgetlist ] COMPATIBLE_ATTRIBUTE_TYPES = [ %w[enum string], %w[html string], %w[multienum stringlist], %w[string html], %w[integer number], %w[integer string], %w[float number], %w[float string], ] # # @api public # # Default attribute values. # DEFAULT_ATTRIBUTE_VALUES = { 'binary' => nil, 'date' => nil, 'enum' => nil, 'float' => nil, 'html' => '', 'integer' => nil, 'link' => nil, 'linklist' => [], 'multienum' => [], 'reference' => nil, 'referencelist' => [], 'string' => '', 'stringlist' => [], 'widgetlist' => [], } INLINE_DEFAULT_ALLOWED = %w[ enum float html integer multienum string stringlist ] delegate :attribute_definitions, to: :class def has_attribute?(attribute_name) has_public_system_attribute?(attribute_name) || has_custom_attribute?(attribute_name) end def has_custom_attribute?(attribute_name) !!attribute_definitions[attribute_name] end def read_attribute(attribute_name) @attribute_cache.fetch(attribute_name) do @attribute_cache[attribute_name] = value_of_attribute(attribute_name) end end def type_of_attribute(attribute_name) type_of_system_attribute(attribute_name) || attribute_definitions[attribute_name].try(:type) end def respond_to?(method_id, include_private = false) has_custom_attribute?(method_id) || super end def method_missing(method_name, *args) attribute_name = method_name.to_s has_custom_attribute?(attribute_name) ? read_attribute(attribute_name) : super end def respond_to_missing?(method_name, include_private = false) has_custom_attribute?(method_name.to_s) || super end # # Returns the value of an attribute specified by its name. # Passing an invalid key will not raise an error but return +nil+. # # @api public # @param [Symbol, String] attribute_name the name of the attribute. # @return the value of the attribute if it's defined or +nil+ otherwise. # def [](attribute_name) attribute_name = attribute_name.to_s read_attribute(attribute_name) if has_attribute?(attribute_name) end # # Hook method that lets you control the widget classes that are made available for adding # instances of them to this page or widget. Override it to allow only specific classes # or none at all. # Must return either +NilClass+, or +Array+. # # If +nil+ is returned (default), all widget classes will be available for this page # or widget. # # If an +Array+ is returned, it is expected to include the permitted classes. # Their order is preserved as they are offered to the user via the widget browser. # # @api public # @param [String] field_name Name of the widget attribute. # @return [nil, Array] # @see # https://scrivito.com/limiting-widget-and-page-types Limiting available widget and page types # def valid_widget_classes_for(field_name) end def valid_widget_ruby_classes_for(field) computed_classes = self.class.assert_classes(valid_widget_classes_for(field), '#valid_widget_classes_for') ruby_classes = (computed_classes || Scrivito.models.widgets.to_a).uniq ruby_classes.select do |ruby_class| ruby_class.valid_inside_container?(self.class) && !ruby_class.hide_from_editor? end end def modification_for_attribute(attribute_name, revision=Workspace.current.base_revision) return Modification::UNMODIFIED unless revision if new?(revision) Modification::NEW elsif deleted?(revision) Modification::DELETED else cms_data_in_revision = cms_data_for_revision(revision) if cms_data_in_revision other_value = cms_data_in_revision.value_of(attribute_name.to_s) if data_from_cms.value_of(attribute_name.to_s) == other_value Modification::UNMODIFIED else Modification::EDITED end else # I am deleted in both revisions! Modification::UNMODIFIED end end end # # Returns the object class name of this CMS object. # @api public # @deprecated Use {Scrivito::AttributeContent#obj_class} instead. # @return [String] # def obj_class_name Scrivito::Deprecation.warn( 'Scrivito::BasicObj#obj_class_name and Scrivito::BasicWidget#obj_class_name are deprecated'\ ' and will be removed in a future version.'\ ' Please use Scrivito::BasicObj#obj_class and Scrivito::BasicWidget#obj_class instead.') obj_class end # # Returns the object class name of this CMS object. # @api public # @return [String] # @example # BlogPost.all.first.obj_class # #=> "BlogPost" # # @example # FrenchBlog::BlogPost.all.first.obj_class # #=> "FrenchBlog::BlogPost" # # GermanBlog::BlogPost.all.first.obj_class # #=> "GermanBlog::BlogPost" # def obj_class data_from_cms.value_of('_obj_class') end def find_attribute_containing_widget(widget_id) attribute_definitions.each do |attribute_definition| if attribute_definition.widgetlist? widgetlist_name = attribute_definition.name widgetlist_value = data_from_cms.value_of(widgetlist_name) return widgetlist_name if widgetlist_value && widgetlist_value.include?(widget_id) end end nil end def referenced_widgets widgets = [] data_from_cms.attribute_names.each do |attr_name| if data_from_cms.type_of(attr_name) == "widgetlist" value = data_from_cms.value_of(attr_name) || [] widgets_in_value = value.map { |widget_id| widget_from_pool(widget_id) } widgets += widgets_in_value end end widgets end def update_data(data) self.data_from_cms = data @attribute_cache = {} end def show_view_path view_path('show') end def details_view_path view_path('details') end def data_from_cms if @data_from_cms.respond_to?(:call) @data_from_cms = @data_from_cms.call else @data_from_cms end end # # Returns a hash to be used for the JSON serialization. # @api public # @note Override it in subclasses to fit your needs. # @param [Hash] options # @return [Hash] # @see http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html # def as_json(options = nil) {id: id} end private attr_writer :data_from_cms def value_of_attribute(attribute_name) return obj_class if attribute_name == '_obj_class' return value_of_system_attribute(attribute_name) if has_system_attribute?(attribute_name) if attribute_definition = attribute_definitions[attribute_name] if has_compatible_type_in_backend?(attribute_definition) deserialize_attribute_value(attribute_definition) else default_attribute_value(attribute_definition) end end end def has_compatible_type_in_backend?(attribute_definition) attribute_name, attribute_type = attribute_definition.name, attribute_definition.type attribute_type_from_backend = data_from_cms.type_of(attribute_name) attribute_type == attribute_type_from_backend || COMPATIBLE_ATTRIBUTE_TYPES.include?([attribute_type, attribute_type_from_backend]) end def deserialize_attribute_value(attribute_definition) serialized_attribute_value = data_from_cms.value_of(attribute_definition.name) attribute_deserializer.deserialize(serialized_attribute_value, attribute_definition) end def attribute_deserializer @attribute_deserializer ||= AttributeDeserializer.new(self, workspace) end def default_attribute_value(attribute_definition) if attribute_value = DEFAULT_ATTRIBUTE_VALUES[attribute_definition.type] case attribute_value when Numeric attribute_value else attribute_value.dup end end end def view_path(view_name) "#{obj_class.underscore}/#{view_name}" end # # @api public # module ClassMethods # # Instantiate an {Scrivito::BasicObj Obj} or {Scrivito::BasicWidget Widget} instance from # +obj_data+. If a subclass of +Obj+ or +Widget+ with the same name as the +_obj_class+ property # exists, the instantiated +Obj+ or +Widget+ will be an instance of that subclass. # def instantiate(obj_data) obj_class = obj_data.value_of('_obj_class') instance = type_computer.compute_type(obj_class).allocate instance.update_data(obj_data) instance end def prepare_attributes_for_instantiation(attributes) attributes.with_indifferent_access.tap do |indifferent_attributes| prepare_obj_class_attribute(indifferent_attributes) unless special_class? end end def extract_obj_class_from_attributes(attributes) if special_class? if obj_class = attributes[:_obj_class] || attributes['_obj_class'] type_computer.compute_type_without_fallback(obj_class) else raise ArgumentError, 'Please provide an obj class as the "_obj_class" property.' end end end # # Defines an attribute. # # @api public # # For the purpose of persisting model data in the CMS, the attributes of the model need # to be defined. When defining an attribute, you specify the name under which Scrivito # should persist its value as well as the type of content it is meant to contain, and, # for the +enum+ and +multienum+ types, the selectable values. # # Attributes are inherited. If, for example, the +Page+ model defines a +title+ attribute # of the +string+ type, the +SpecialPage+ model, which inherits from +Page+, is also equipped # with +title+. Inherited attributes can be overridden, i.e. you may redefine +title+ in # +SpecialPage+ if you want its type to be +html+, for example. # # @param [Symbol, String] name name of the attribute. The first character must be an ASCII # lowercase letter, subsequent characters may also be digits or underscores. # Also, the name must not be longer than 50 characters and must not be +"id"+. # @param [Symbol, String] type type of the attribute. Scrivito supports the following types: # +string+, +stringlist+, +html+, +enum+, +multienum+, +widgetlist+, +reference+, # +referencelist+, +link+, +linklist+, +integer+, +float+ and +binary+. # @param [Hash] options definition options. # # @option options [Array, Array] :values selectable values for +enum+ and # +multienum+ attributes. If no values are provided for attributes of these types, the # resulting array of selectable values is empty. Empty string is not allowed as value. # @option options [Symbol, String] :default custom default value. # See {Scrivito::AttributeContent::DEFAULT_ATTRIBUTE_VALUES} for factory defaults. # See {Scrivito::AttributeContent::ClassMethods#default_for} for more advanced defaults. # @option options [Class, Array] :only specifies (for use in the UI) the valid classes # _of_ _the_ _values_ in a +reference+ or a +referencelist+ attribute, # e.g. +Image+ or +Video+. Raises an error if the attribute type is neither +reference+ # nor +referencelist+. If not specified, the UI assumes that any class is valid. # # @return nil # # @raise [Scrivito::ScrivitoError] if the +name+ is invalid # @raise [Scrivito::ScrivitoError] if the +type+ is unknown # @raise [Scrivito::ScrivitoError] if the +values+ include an empty string # @raise [Scrivito::ScrivitoError] if the +only+ option is specified, but the +type+ is neither # +reference+ nor +referencelist+ # @raise [Scrivito::ScrivitoError] if the +only+ option includes invalid value(s) # # @see Scrivito::AttributeContent::DEFAULT_ATTRIBUTE_VALUES # @see Scrivito::AttributeContent::ClassMethods#default_for # # @example Defining attributes: # class Page < Obj # attribute :my_string, :string # attribute 'my_html', 'my_html' # attribute :my_enum, :enum, values: %w[a b c], default: 'a' # attribute :my_multienum, :multienum # end # # Page.attribute_definitions # #=> # # # Page.attribute_definitions[:my_string] # #=> # # # Page.attribute_definitions[:my_string].type # #=> "string" # # Page.attribute_definitions[:my_html].type # #=> "html" # # Page.attribute_definitions[:my_enum].type # #=> "enum" # Page.attribute_definitions[:my_enum].values # #=> ["a", "b", "c"] # # Page.attribute_definitions[:my_multienum].type # #=> "multienum" # Page.attribute_definitions[:my_multienum].values # #=> [] # # @example Inheriting attributes: # class Page < Obj # attribute :title, :string # end # # class SpecialPage < Page # end # # SpecialPage.attribute_definitions[:title].type # #=> "string" # # @example Overriding inherited attributes: # class Page < Obj # attribute :title, :string # end # # class SpecialPage < Page # attribute :title, :html # end # # Page.attribute_definitions[:title].type # #=> "string" # # SpecialPage.attribute_definitions[:title].type # #=> "html" # # @example Specifying valid classes for +reference+ attributes: # class Page < Obj # attribute :my_reference1, :reference, only: Image # attribute :my_reference2, :reference, only: [Image, Video] # end # # ImageWidget.attribute_definitions[:my_reference1].valid_classes # #=> [Image] # # ImageWidget.attribute_definitions[:my_reference2].valid_classes # #=> [Image, Video] # # @see https://scrivito.com/default-attribute-values Specifying default attribute values # def attribute(name, type, options = {}) name, type, options = name.to_s, type.to_s, options assert_valid_attribute_name(name) assert_valid_attribute_type(type) default = options.delete(:default) || options.delete('default') if default assert_valid_attribute_default(name, type, default) default_for(name) { default } end own_attribute_definitions[name] = AttributeDefinition.new(name, type, options) nil end # # Sets the default value of an attribute defined by # {Scrivito::AttributeContent::ClassMethods#attribute} or for the built-in attributes +_path+ # and +_permalink+. # # @api public # # If {Scrivito::BasicObj.create Obj.create} or {Scrivito::BasicWidget.new Widget.new} are called # without providing a value for a specific custom attribute, the +block+ is called, and its # return value is used as the initial value of this attribute. # # The +block+ is called with two parameters. # # The first parameter is an +ActiveSupport::HashWithIndifferentAccess+ containing the attributes # that were passed to {Scrivito::BasicObj.create Obj.create} or # {Scrivito::BasicWidget.new Widget.new}. # # The second parameter, +context+, is a hash. If the +default_for+ callback is triggered by a UI # user creating a CMS object or a Widget, Scrivito places the {Scrivito::User +:scrivito_user+} # and the {Scrivito::BasicObj +:current_page+} (originating from the UI calling the creation # method) into this hash. +:current_page+ won't be present in the +context+ if the user creates # the object or widget while viewing a page which isn't a CMS object. The +context+ hash is # empty if the object or widget is not created using the UI but, for example, via the console. # # @param [Symbol, String] attribute_name the name of the attribute. # @param [Proc] block that returns the default value. # @raise [Scrivito::ScrivitoError] if no block is present # @return nil # # @see Scrivito::BasicObj.create # @see Scrivito::BasicWidget.new # @see Scrivito::Configuration.editing_auth # # @example Setting a simple default: # class MyPage < Obj # attribute :title, :string # default_for(:title) { 'Spam' } # end # # my_page = MyPage.create # my_page.title # => 'Spam' # # @example A default depending on the given attributes: # class MyPage < Obj # attribute :title, :string # # default_for :title do |attributes| # if (path = attributes[:_path]) && path.starts_with('/de') # 'Hier den Titel eingeben' # else # 'Your title here' # end # end # end # # my_page = MyPage.create(_path: '/en/test') # my_page.title # => 'Your title here' # # my_page = MyPage.create(_path: '/de/test') # my_page.title # => 'Hier den Titel eingeben' # # @example A more complex default, depending on the given attributes and the current user: # class MyPage < Obj # attribute :title, :string # # default_for :title do |attributes, context| # if use_german_title?(context[:scrivito_user], attributes[:_path]) # 'Hier den Titel eingeben' # else # 'Your title here' # end # end # # private # # # # # Assuming there is a +MyUser+ model equipped with a +preferences+ method which # # returns the preferences of the current user. # # The +email+ of a +MyUser+ is the +id+ of the corresponding +Scrivito::User+ as set in # # +Scrivito::Configuration.editing_auth+. # # # def use_german_title?(scrivito_user, path) # scrivito_user && MyUser.find_by(email: scrivito_user.id).preferences[:locale] == 'de' || # path && path.starts_with?('/de') # end # end # # MyUser.find_by(email: 'alice@scrivito.com').preferences[:locale] # => 'en' # alice = Scrivito::User.define('alice@scrivito.com') # # my_page = MyPage.create({_path: '/de/test'}, alice) # my_page.title # => 'Your title here' # # MyUser.find_by(email: 'bob@scrivito.com').preferences[:locale] # => 'de' # bob = Scrivito::User.define('bob@scrivito.com') # # my_page = MyPage.create({_path: '/en/test'}, bob) # my_page.title # => 'Hier den Titel eingeben' # # @see https://scrivito.com/default-attribute-values Specifying default attribute values # def default_for(attribute_name, &block) attribute_name = attribute_name.to_s raise ScrivitoError, 'No block given' unless block_given? attribute_default_definitions[attribute_name] = block nil end # # Short description of a CMS object or widget type for the UI. # # @api public # # The description is displayed when adding new pages or widgets, for example. As a general rule, # it is used whenever no widget or object instance is available. If there is, the # {Scrivito::BasicObj#description_for_editor} and, respectively, # {Scrivito::BasicWidget#description_for_editor} instance methods are used instead. # # This method can be overridden to customize the description displayed to editors. # # @return [String] short description of a CMS object or widget type for the UI # def description_for_editor name.titleize end # # Returns the attribute definitions. # # @api public # @see Scrivito::AttributeContent::ClassMethods#attribute # @return [Scrivito::AttributeDefinitionCollection] # def attribute_definitions AttributeDefinitionCollection.new(all_attribute_definitions) end # For testing purposes only. def reset_attribute_defaults! @attribute_default_definitions = nil end def build_attributes_with_defaults(attributes = {}, context = {}) attributes_with_indifferent_access = attributes.with_indifferent_access context_with_indifferent_access = context.with_indifferent_access attributes_with_defaults = attributes_with_indifferent_access.dup attribute_defaults.each do |name, default_block| unless attributes_with_indifferent_access.key?(name) attributes_with_defaults[name] = default_block.call(attributes_with_indifferent_access, context_with_indifferent_access) end end if superclass.respond_to?(:build_attributes_with_defaults) attributes_with_defaults.merge!( superclass.build_attributes_with_defaults(attributes_with_defaults, context)) end attributes_with_defaults end # @api public # # This method prevents UI users from creating +Objs+ or +Widgets+ of the given type. # It does not prevent adding such objects programatically. # # By default, +hide_from_editor+ is +false+. # # @example Hiding error pages: # class ErrorPage < Obj # hide_from_editor # end # # @example Hiding admin widgets: # class AdminWidget < Widget # hide_from_editor # end def hide_from_editor @hide_from_editor = true end def hide_from_editor? !!@hide_from_editor end def special_class? type_computer.special_class?(self) end def assert_classes(classes_expected, callback_method_name) if (classes_expected || []).any?{ |a| !a.is_a?(Class) } raise "Overridden method #{callback_method_name} must return actual classes!" end classes_expected end def reset_blank_attributes(attributes) obj_class = type_computer.compute_type_without_fallback(attributes['_obj_class']) missing_attributes = obj_class.attribute_definitions.map(&:name) - attributes.keys missing_attributes.each do |attribute_name| attributes[attribute_name] = nil end attributes end def as_json { name: name, descriptionForEditor: description_for_editor, attributes: attribute_definitions.map(&:as_json), } end protected def assert_valid_attribute_name(name) if name == 'id' raise ScrivitoError, "Invalid attribute name 'id'. 'id' is reserved for the system." end if name !~ /\A[a-z][a-z0-9_]*\z/ raise ScrivitoError, "Invalid attribute name '#{name}'. " \ "The first character must be a lowercase ASCII letter, " \ "subsequent characters may also be digits or underscores." end if name.size > 50 raise ScrivitoError, "Invalid attribute name '#{name}'. " \ "Attribute names must not be longer than 50 characters." end end def assert_valid_attribute_type(type) raise ScrivitoError, "Unknown attribute type '#{type}'" unless ATTRIBUTE_TYPES.include?(type) end def assert_valid_attribute_default(name, type, default) unless INLINE_DEFAULT_ALLOWED.include?(type) raise ScrivitoError, "Attribute '#{name}': " \ "Inline defaults for '#{type}' attributes are not supported. " \ "Please use 'default_for(:#{name})' for '#{type}' attributes instead." end end def all_attribute_definitions if superclass.respond_to?(:all_attribute_definitions, true) superclass.all_attribute_definitions.merge(own_attribute_definitions) else own_attribute_definitions end end def own_attribute_definitions @own_attribute_definitions ||= {} end def attribute_defaults attribute_default_definitions.slice(*default_attribute_names) end def default_attribute_names attribute_definitions.map(&:name) end private def prepare_obj_class_attribute(attributes) if obj_class = attributes['_obj_class'] assert_valid_obj_class(obj_class) else attributes.merge!('_obj_class' => to_s) end end def assert_valid_obj_class(obj_class) unless obj_class == to_s raise ScrivitoError, "Cannot set _obj_class to #{obj_class.inspect} when creating #{self}" end end def attribute_default_definitions @attribute_default_definitions ||= {} end end end end