BindaCMS/binda

View on GitHub
app/models/binda/field_setting.rb

Summary

Maintainability
A
0 mins
Test Coverage
module Binda
    class FieldSetting < ApplicationRecord
        cattr_accessor :field_settings_array
        cattr_accessor :get_field_classes

    # An array of all classes which represent fields associated to Binda::FieldSetting
    # This definition must stay on the top of the file
        def self.get_field_classes
            %w( String Text Date Image Video Audio Repeater Radio Selection Checkbox Relation Svg )
        end

        # ASSOCIATIONS
        # ------------

        belongs_to :field_group
        has_ancestry orphan_strategy: :destroy

        # FIELDS ASSOCIATIONS
        # 
        # If you add a new field remember to update:
        #   - get_field_classes (see here below)
        #   - component_params (app/controllers/binda/components_controller.rb)
        has_many :texts,         as: :fieldable
        has_many :strings,       as: :fieldable
        has_many :dates,         as: :fieldable
        has_many :galleries,     as: :fieldable
        has_many :assets,        as: :fieldable
        has_many :images,        as: :fieldable
        has_many :videos,        as: :fieldable
        has_many :audios,        as: :fieldable
        has_many :repeaters,     as: :fieldable
        has_many :radios,        as: :fieldable
        has_many :selections,    as: :fieldable
        has_many :checkboxes,    as: :fieldable
        has_many :relations,     as: :fieldable
        has_many :svgs,          as: :fieldable

        # The following direct associations are used to securely delete associated fields
        # Infact via `fieldable` the associated fields might not be deleted 
        # as the fieldable_id is related to the `component`, `board` or `repeater` rather than `field_setting`
        has_many :texts,          dependent: :destroy
        has_many :strings,        dependent: :destroy
        has_many :dates,          dependent: :destroy
        has_many :galleries,      dependent: :destroy
        has_many :assets,         dependent: :destroy
        has_many :images,         dependent: :destroy
        has_many :videos,         dependent: :destroy
        has_many :audios,         dependent: :destroy
        has_many :repeaters,      dependent: :destroy
        has_many :radios,         dependent: :destroy
        has_many :selections,     dependent: :destroy
        has_many :checkboxes,     dependent: :destroy
        has_many :relations,      dependent: :destroy
        has_many :svgs,           dependent: :destroy

        # We don't want to run callbacks for choices!
        # If you run a callback the last choice will throw a error
        # @see `Binda::Choice` `before_destroy :check_last_choice`
        has_many :choices,        dependent: :delete_all 

        has_one  :default_choice, -> (field_setting) { where(id: field_setting.default_choice_id) }, class_name: 'Binda::Choice'
        
        has_and_belongs_to_many :accepted_structures, class_name: 'Binda::Structure'

        accepts_nested_attributes_for :accepted_structures, :texts, :strings, :dates, :galleries,
                                      :assets, :images, :videos, :audios, :repeaters, :radios, :selections,
                                      :checkboxes, :relations, :svgs, :choices, allow_destroy: true, reject_if: :is_rejected

        # Sets the validation rules to accept and save an attribute
        def is_rejected( attributes )
            attributes['label'].blank? || attributes['content'].blank?
        end



        # CALLBACKS
        # ---------

        before_save do
            check_allow_null_for_radio
        end

        after_save do
        self.class.reset_field_settings_array
            add_choice_if_allow_null_is_false
            create_field_instances
        end

    after_create do 
        convert_allow_null__nil_to_false
        set_default_position
    end

    after_update do
            self.class.remove_orphan_fields
            if %w(radio selection checkbox).include?(self.field_type) && self.choices.empty?
                check_allow_null_option
            end
    end

    after_destroy do 
        self.class.reset_field_settings_array 
    end



    # VALIDATIONS
    # -----------
    # @see http://guides.rubyonrails.org/active_record_validations.html#message

        validates :name, presence: { 
            message: I18n.t("binda.field_setting.validation_message.name") 
        }
        validates :field_type, inclusion: { 
            in: [ *FieldSetting.get_field_classes.map{ |fc| fc.to_s.underscore } ], 
            allow_nil: false,
            message: -> (field_setting, _) { 
                I18n.t(
                    "binda.field_setting.validation_message.field_type", 
                    { arg1: field_setting.name, arg2: FieldSetting.get_field_classes.join(", ") }
                )
            }
        }
        validates :field_group_id, presence: {
            message: -> (field_setting, data) {
                I18n.t(
                    "binda.field_setting.validation_messag e.field_group_id", 
                    { arg1: field_setting.name, arg2: data[:value] }
                )
            }
        }
        validate :slug_uniqueness


    # FRIENDLY ID
    # -----------

        extend FriendlyId
        friendly_id :default_slug, use: [:slugged, :finders]

        # Friendly id preference on slug generation
        #
        # Method inherited from friendly id 
        # @see https://github.com/norman/friendly_id/issues/436
      def should_generate_new_friendly_id?
        slug.blank?
      end

        # Set slug name
        #
        # It generates 4 possible slugs before falling back to FriendlyId default behaviour
        def default_slug
            return self.name if self.field_group_id.nil?
            slug = ''
            slug << self.field_group.structure.name
            slug << '-'
            slug << self.field_group.name
            unless self.parent.nil?
                slug << '-' 
                slug << self.parent.name 
            end
            return [ 
                "#{ slug }--#{ self.name }",
                "#{ slug }--#{ self.name }-1",
                "#{ slug }--#{ self.name }-2",
                "#{ slug }--#{ self.name }-3"
            ]
        end

        # Check slug uniqueness
        def slug_uniqueness
            record_with_same_slug = self.class.where(slug: slug)
            if record_with_same_slug.any? && !record_with_same_slug.ids.include?(id)
                errors.add(:slug, I18n.t("binda.field_setting.validation_message.slug", { arg1: slug }))
                return false
            else
                return true 
            end
        end

        # It makes sure radio buttons have allow_null set to false.
        def check_allow_null_for_radio
            if field_type == 'radio' && allow_null?
                self.allow_null = false
                warn "WARNING: it's not possible that a field setting with type `radio` has allow_null=true."
            end
        end


        # MISCELLANEOUS
        # -------------

        # Retrieve the ID if a slug is provided and update the field_settings_array 
        #   in order to avoid calling the database (or the cached response) every time.
        #   This should speed up requests and make Rails logs are cleaner.
        # 
        # @return [integer] The ID of the field setting
        def self.get_id(field_slug)
            # Get field setting id from slug, without multiple calls to database 
            # (the query runs once and caches the result, then any further call uses the cached result)
            @@field_settings_array = self.pluck(:slug, :id) if @@field_settings_array.nil?
            selected_field_setting = @@field_settings_array.select{ |fs| fs[0] == field_slug }[0]
            raise ArgumentError, "There isn't any field setting with the current slug \"#{field_slug}\".", caller if selected_field_setting.nil?
            id = selected_field_setting[1]
            return id
        end

        # Reset the field_settings_array. It's called every time 
        #   the user creates or destroyes a Binda::FieldSetting
        # 
        # @return [null]
        def self.reset_field_settings_array
            # Reset the result of the query taken with the above method,
            # this is needed when a user creates a new field_setting but 
            # `get_field_setting_id` has already run once
            @@field_settings_array = nil
        end

        # Make sure that allow_null is set to false instead of nil.
        #   (This isn't done with a database constraint in order to gain flexibility)
        # 
        # REVIEW: not sure what flexibility is needed. Maybe should be done differently
        def convert_allow_null__nil_to_false
            self.allow_null = false if self.allow_null.nil?
        end

        # Get structure on which the current field setting is attached. It should be one, but in order to 
        #   be able to add other methods to the query this methods returns a `ActiveRecord::Relation` object, not
        #   a `ActiveRecord`
        # 
        # @return [ActiveRecord::Relation] An array of `Binda::Structure` instances
        def structures
            Structure.left_outer_joins(
                field_groups: [:field_settings]
            ).where(
                binda_field_settings: { id: self.id }
            )
        end

        # Get the structure of the field group to which the field setting belongs.
        # 
        # @return [ActiveRecord] The `Binda::Structure` instance
        def structure
            self.structures.first
        end

        # Generates a default field instances for each existing component or board
        #   which is associated to that field setting. This avoid having issues 
        #   with Binda::FieldSetting.get_id method which would throw an ambiguous error 
        #   saying that there isn't any field setting associated when infact it's 
        #   the actual field missing, not the field setting itself.
        #   
        # A similar script runs after saving components and boards which makes sure
        #   a field instance is always present no matter if the component has been created
        #   before the field setting or the other way around.
        #   
        # TODO this MUST be optimized
        def create_field_instances
      CreateFieldInstancesJob.perform_later self
        end

        def create_field_instance_for(instance)
            field_class = "Binda::#{self.field_type.classify}"
            if self.is_root?
                create_field_instance_for_instance(instance, field_class, self.id)
            else
                instance.repeaters.select{|r| r.field_setting_id == self.parent_id}.each do |repeater|
                    create_field_instance_for_instance(repeater, field_class, self.id)
                end
            end
        end

        # Helper for create_field_instances method
        def create_field_instance_for_instance(instance, field_class, field_setting_id)
            field_class.constantize.find_or_create_by!(
                field_setting_id: field_setting_id,
                fieldable_id: instance.id,
                fieldable_type: instance.class.name
            )
        end

        # Check `allow_null` option
        # 
        # Creating a selection with `allow_null` set to `false` will automatically generate a critical error.
        #   This is due to the fact that 1) there is no choice to select, but 2) the selection field must have at least one.
        #   The error can be easily removed by assigning a choice to the current field setting.
        # 
        # This method is preferred to a validation because it allows to remove all choices before adding new ones.
        #   
        def check_allow_null_option
        return if self.allow_null?
          Selection.check_all_selections_depending_on(self)
        end

        # Validation method that check if the current `Binda::Selection` instance has at least a choice before 
        #   updating allow null to false
        def add_choice_if_allow_null_is_false
            if %(selection radio checkbox).include?(self.field_type) && 
                !self.allow_null?
                if self.choices.empty?
                    # Add a choice if there is none, it will be automatically assign as default choice
                    self.choices.create!(label: I18n.t("binda.choice.default_label"), value: I18n.t("binda.choice.default_value"))
                elsif self.default_choice_id.nil?
                    # Assign a choice as default one if there is any
                    # REVIEW there is some deprecation going on, but I'm not sure i directly involves the `update` method
                    self.update!(default_choice_id: self.choices.first.id)
                end
            end
        end

        # Remove all orphan fields
        # 
        # Specifically:
        # - all fields where their field setting doesn't exist anymore
        # - all fields where their field setting has change type
        # 
        # Used by task `rails binda:remove_orphan_fields`
        def self.remove_orphan_fields
            FieldSetting.get_field_classes.each do |field_class|
                FieldSetting.remove_orphan_fields_with_no_settings(field_class)
                FieldSetting.remove_orphan_fields_with_wrong_field_type(field_class)
            end
        end

        # Remove orphan fields that isn't associated to any field setting
        def self.remove_orphan_fields_with_no_settings(field_class)
            "Binda::#{field_class}"
                .constantize
                .includes(:field_setting)
                .where(binda_field_settings: {id: nil})
                .each do |s| 
                    s.destroy!
                    puts "Binda::#{field_class} with id ##{s.id} successfully destroyed"
                end
        end

        # Remove orphan fields with wrong field type
        def self.remove_orphan_fields_with_wrong_field_type(field_class)
            field_types = []
            case field_class
            when 'Selection'
                field_types = %w(selection checkbox radio)
            when 'Text'
                field_types = %w(string text)
            else
                field_types = [ field_class.underscore ]
            end
            "Binda::#{field_class}"
                .constantize
                .includes(:field_setting)
                .where.not(binda_field_settings: {field_type: field_types})
                .each do |s| 
                    s.destroy!
                    puts "Binda::#{field_class} with id ##{s.id} but wrong type successfully destroyed"
                end
        end

        # Set a default position if isn't set and updates all related field settings
        # Update all field settings related to the one created
        def set_default_position
            FieldSetting
                .where(
                    field_group_id: self.field_group_id,
                    ancestry: self.ancestry
                )
        .update_all('position = position + 1')
        end

    end
end