# lib/questions_crafter/dsl.rb module QuestionsCrafter module Dsl def self.included(base) base.class_eval do include ActiveModel::Model include ActiveModel::Attributes if defined?(ActiveModel::Attributes) include ActiveModel::Dirty extend ClassMethods # Add instance method to access class-level questions define_method :questions do self.class.questions end # Add method to get question attributes with values define_method :question_attributes do result = {} self.class.questions.each do |question| name = question[:name] result[name] = { value: self.send(name), **question } end result end # Add instance method for normalizing checkbox values define_method :normalize_checkbox_group_value do |value| case value when Array value when String if value.start_with?("[") && value.end_with?("]") JSON.parse(value) rescue [] else value.present? ? [ value ] : [] end when NilClass, FalseClass [] else value.present? ? [ value ] : [] end end end end module ClassMethods def questions @questions ||= [] end def question_names @question_names ||= {} end def question(name, as:, title: nil, description: nil, options: {}, required: false) type_map = { string: QuestionsCrafter::Constants::STRING, text: QuestionsCrafter::Constants::TEXT, radio: QuestionsCrafter::Constants::RADIO, boolean: QuestionsCrafter::Constants::BOOLEAN, integer: QuestionsCrafter::Constants::INTEGER, select: QuestionsCrafter::Constants::SELECT, checkbox_group: QuestionsCrafter::Constants::CHECKBOX_GROUP } type = type_map[as] raise NotImplementedError, "Question type '#{as}' is not implemented. Supported types are: #{type_map.keys.inspect}" unless type add_question(name, type: type, title: title, description: description, options: options, required: required) end def title(title = nil) @title = title if title @title || name.demodulize.titleize end def description(description = nil) @description = description if description @description || "" end def back_link(link = nil) @back_link = link if link @back_link || "#" end def custom_html_classes(custom_html_classes = nil) @custom_html_classes = custom_html_classes if custom_html_classes @custom_html_classes || {} end private # options: Can be either a hash of options with value as a key and text as the visible text for the option # or an array of strings where each string is a symbol and it gets translated using I18n.t def add_question(name, type:, options: {}, title: nil, description: nil, required: false) raise QuestionAlreadyDefined, "Question #{name} already defined" if question_names[name] question_names[name] = true title ||= name.titleize question = { name: name, type: type, options: options, title: title, description: description, required: required } questions << question configure_question(question: question) # TODO: Along with the adding of each question we need to set validations and other ActiveModel stuff end def configure_question(question:) name = question[:name] type = question[:type] options = question[:options] required = question[:required] create_attribute(name, type, options) setup_validations(name: name, type: type, options: options, required: required) end def setup_validations(name:, type:, options: {}, required:) class_name = self.class.name.underscore if type == QuestionsCrafter::Constants::CHECKBOX_GROUP setup_checkbox_group_validations(name, class_name, options, required) else validates name, presence: { message: I18n.t("questions.errors.#{class_name}.#{name}.blank", default: I18n.t("errors.messages.blank")) } if required validates name, inclusion: { in: options.keys, message: I18n.t("questions.errors.#{class_name}.#{name}.inclusion", default: I18n.t("errors.messages.inclusion")) } if options.any? end end def setup_checkbox_group_validations(name, class_name, options, required) validate do |record| values = record.normalize_checkbox_group_value(record.send(name)) if required && values.blank? record.errors.add(name, I18n.t("questions.errors.#{class_name}.#{name}.blank", default: I18n.t("errors.messages.blank"))) end if values.any? invalid_values = values.reject { |v| options.keys.include?(v) } if invalid_values.any? record.errors.add(name, I18n.t("questions.errors.#{class_name}.#{name}.inclusion", default: I18n.t("errors.messages.inclusion"))) end end end end # As we've included ActiveModel::Attributes we can use the attribute method to define attributes def create_attribute(name, type, options) attr_options = {} # TODO: we have options such as: default: true attribute name.to_sym, crafter_type_to_attribute_type(type), **attr_options end def crafter_type_to_attribute_type(type) case type when QuestionsCrafter::Constants::STRING, QuestionsCrafter::Constants::TEXT, QuestionsCrafter::Constants::SELECT, QuestionsCrafter::Constants::CHECKBOX_GROUP, QuestionsCrafter::Constants::RADIO :string when QuestionsCrafter::Constants::BOOLEAN :boolean when QuestionsCrafter::Constants::INTEGER :integer end end end end end