Browse Source

Initial commit extracted from another project now as an independent gem

main
rodley82 2 months ago
commit
39240fffb5
  1. 8
      .gitignore
  2. 5
      CHANGELOG.md
  3. 8
      Gemfile
  4. 35
      README.md
  5. 4
      Rakefile
  6. 11
      bin/console
  7. 8
      bin/setup
  8. 25
      lib/questions_crafter.rb
  9. 11
      lib/questions_crafter/constants.rb
  10. 86
      lib/questions_crafter/controller_helpers.rb
  11. 178
      lib/questions_crafter/dsl.rb
  12. 15
      lib/questions_crafter/generators/controller_generator.rb
  13. 54
      lib/questions_crafter/generators/questions_generator.rb
  14. 16
      lib/questions_crafter/generators/templates/controller.rb.erb
  15. 54
      lib/questions_crafter/generators/templates/questions.rb.erb
  16. 290
      lib/questions_crafter/ui/components/form_builder.rb
  17. 40
      lib/questions_crafter/ui/components/questions_page.rb
  18. 109
      lib/questions_crafter/ui/css_classes_manager.rb
  19. 13
      lib/questions_crafter/utils.rb
  20. 5
      lib/questions_crafter/version.rb
  21. 44
      question_crafter.gemspec
  22. 4
      sig/question_crafter.rbs

8
.gitignore

@ -0,0 +1,8 @@
/.bundle/
/.yardoc
/_yardoc/
/coverage/
/doc/
/pkg/
/spec/reports/
/tmp/

5
CHANGELOG.md

@ -0,0 +1,5 @@
## [Unreleased]
## [0.1.0] - 2025-10-20
- Initial release

8
Gemfile

@ -0,0 +1,8 @@
# frozen_string_literal: true
source "https://rubygems.org"
# Specify your gem's dependencies in question_crafter.gemspec
gemspec
gem "rake", "~> 13.0"

35
README.md

@ -0,0 +1,35 @@
# QuestionCrafter
TODO: Delete this and the text below, and describe your gem
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/question_crafter`. To experiment with that code, run `bin/console` for an interactive prompt.
## Installation
TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
Install the gem and add to the application's Gemfile by executing:
```bash
bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
```
If bundler is not being used to manage dependencies, install the gem by executing:
```bash
gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
```
## Usage
TODO: Write usage instructions here
## Development
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/question_crafter.

4
Rakefile

@ -0,0 +1,4 @@
# frozen_string_literal: true
require "bundler/gem_tasks"
task default: %i[]

11
bin/console

@ -0,0 +1,11 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "bundler/setup"
require "question_crafter"
# You can add fixtures and/or initialization code here to make experimenting
# with your gem easier. You can also use a different console, if you like.
require "irb"
IRB.start(__FILE__)

8
bin/setup

@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -vx
bundle install
# Do any other automated setup that you need to do here

25
lib/questions_crafter.rb

@ -0,0 +1,25 @@
# frozen_string_literal: true
require "phlex-rails"
module QuestionsCrafter
# Autoload or require components
require_relative "questions_crafter/dsl"
require_relative "questions_crafter/version"
require_relative "questions_crafter/constants"
require_relative "questions_crafter/controller_helpers"
require_relative "questions_crafter/utils"
require_relative "questions_crafter/ui/css_classes_manager"
require_relative "questions_crafter/ui/components/form_builder"
require_relative "questions_crafter/ui/components/questions_page"
# Load generators only when running inside a Rails app with generators
if defined?(Rails::Generators)
require_relative "questions_crafter/generators/questions_generator"
require_relative "questions_crafter/generators/controller_generator"
end
# The base error class for all QuestionsCrafter errors
class Error < StandardError; end
class QuestionAlreadyDefined < Error; end
end

11
lib/questions_crafter/constants.rb

@ -0,0 +1,11 @@
module QuestionsCrafter
module Constants
STRING = :string
TEXT = :text
RADIO = :radio
BOOLEAN = :boolean
INTEGER = :integer
SELECT = :select
CHECKBOX_GROUP = :checkbox_group
end
end

86
lib/questions_crafter/controller_helpers.rb

@ -0,0 +1,86 @@
# lib/questions_crafter/controller_helpers.rb
module QuestionsCrafter
module ControllerHelpers
def self.included(base)
base.class_eval do
extend ClassMethods
include InstanceMethods
end
end
module ClassMethods
# Class methods for controllers using QuestionsCrafter
def handles_questions(questions_class, on_success:)
underscore_questions_class_name = questions_class.name.underscore
define_method :questions_action_name do
underscore_questions_class_name
end
define_method :questions_class do
questions_class
end
define_method :questions_params do
question_definitions = questions_class.questions
permitted_params = question_definitions.map do |q|
if q[:type] == :checkbox_group
# For checkbox groups, permit an array of values
{ q[:name] => [] }
else
q[:name]
end
end
params.require(underscore_questions_class_name).permit(permitted_params)
end
# This is the action that gets injected into the controller with the name of the provided class
# but with underscore for example SignUpQuestions -> sign_up_questions
# It is meant to handle both the GET and the POST verbs
define_method underscore_questions_class_name do
if request.post?
send("#{underscore_questions_class_name}_post")
elsif request.get?
send("#{underscore_questions_class_name}_get")
end
end
# The POST action is used to handle the form submission and redisplay if there are still validation errors
define_method "#{underscore_questions_class_name}_post" do
questions_object = questions_class.new(questions_params)
success = false
method_res = if questions_object.valid?
success = true
send(on_success, questions_object)
end
# method_res has a 302 if a redirect happened in the on_success method
# TODO: should we halt the chain and handle the redirect after showing the confirmation with the read only form?
# Maybe we should be able to render the form with a confirmation message and a countdown to redirect
# TODO: What should we do here if all goes well? Display the form again with the values already filled but NON editable
render QuestionsCrafter::Ui::Components::QuestionsPage.new(
questions_object: questions_object,
form_url: "#",
form_method: :post,
read_only: success
) unless method_res == 302
end
# The GET action is used to display the form
define_method "#{underscore_questions_class_name}_get" do
questions_object = questions_class.new
render QuestionsCrafter::Ui::Components::QuestionsPage.new(
questions_object: questions_object,
form_url: "#",
form_method: :post,
read_only: false
)
end
end
end
module InstanceMethods
# Instance methods for controllers using QuestionsCrafter
end
end
end

178
lib/questions_crafter/dsl.rb

@ -0,0 +1,178 @@
# 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

15
lib/questions_crafter/generators/controller_generator.rb

@ -0,0 +1,15 @@
# frozen_string_literal: true
require "rails/generators"
module QuestionsCrafter
module Generators
class ControllerGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
def create_controller_file
template "controller.rb.erb", "app/controllers/#{file_name}_controller.rb"
end
end
end
end

54
lib/questions_crafter/generators/questions_generator.rb

@ -0,0 +1,54 @@
require "rails/generators"
module QuestionsCrafter
module Generators
class QuestionsGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
argument :questions, type: :array, default: [], desc: "List of questions in format name:type (types: short, long, boolean, choice)"
def create_questions_file
@parsed_questions = parse_questions(questions)
template "questions.rb.erb", "app/questions/#{file_path}.rb"
end
# Both class_name and file_path were overriden to add the "Questions" suffix if needed similar to how Rails does it for controllers
def class_name
intermediate_class_name = super
intermediate_class_name.match(/Questions$/) ? intermediate_class_name : "#{intermediate_class_name}Questions"
end
def file_path
path = super
path.match(/questions$/) ? path : "#{path}_questions"
end
private
def parse_questions(questions_array)
questions_array.map do |question_def|
name, type = question_def.split(":")
type = "string" if type.nil? || type.empty?
case type.downcase
when "string"
{ name: name, type: "string", title: name.humanize }
when "text"
{ name: name, type: "text", title: name.humanize }
when "boolean"
{ name: name, type: "boolean", title: name.humanize }
when "radio"
{ name: name, type: "radio", title: name.humanize, has_options: true }
when "integer"
{ name: name, type: "integer", title: name.humanize }
when "select"
{ name: name, type: "select", title: name.humanize, has_options: true }
when "checkbox_group"
{ name: name, type: "checkbox_group", title: name.humanize, has_options: true }
else
{ name: name, type: "string", title: name.humanize }
end
end
end
end
end
end

16
lib/questions_crafter/generators/templates/controller.rb.erb

@ -0,0 +1,16 @@
# frozen_string_literal: true
# This file was generated by QuestionsCrafter
# Generated at: <%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %>
class <%= class_name.camelize %>Controller < ApplicationController
include QuestionsCrafter::ControllerHelpers
handles_questions <%= class_name %>Questions, on_success: :create_handler
def create_handler(questions_object)
# Bussiness layer logic that can now access the fields defined in the SurveyQuestions instance
# if questions_object.age > 40
# If no redirect is detected it will just display the answers
redirect_to root_path
end
end

54
lib/questions_crafter/generators/templates/questions.rb.erb

@ -0,0 +1,54 @@
# frozen_string_literal: true
# This file was generated by QuestionsCrafter
# Generated at: <%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %>
class <%= class_name %>
include QuestionsCrafter::Dsl
# The following are used by the questions_crafter to display the form title and description
title "<%= class_name.demodulize.titleize %>"
description "Add a description for the form"
# Used by the questions_crafter to generate the back link
back_link "/root-to-back"
# --------------------------------------------------------------------
# Examples of how to define questions:
#
# question :name, as: :string, title: "Name", description: "Enter your full name", required: true
# validates :name, length: { in: 3..50, message: "must be between 3 and 50 characters" }
#
# question :email, as: :string, title: "Email", description: "We'll use this to contact you", required: true
# validates :email, format: { with: URI::MailTo::EMAIL_REGEXP, message: "must be a valid email" }
#
# question :message, as: :text, title: "Message", description: "Tell us what you think"
#
# question :gender, as: :radio,
# title: "Gender",
# options: { "M" => "Male", "F" => "Female", "O" => "Other" },
# required: true
#
# question :age, as: :integer,
# title: "Age"
#
# question :country, as: :select,
# title: "Country",
# options: { "US" => "United States", "AR" => "Argentina" },
# required: true
#
# question :interests, as: :checkbox_group,
# title: "Interests",
# options: { "sports" => "Sports", "music" => "Music", "books" => "Books" }
#
# question :accept_terms, as: :boolean,
# title: "I accept the terms and conditions",
# required: true
# --------------------------------------------------------------------
<% if @parsed_questions.any? -%>
# Questions defined from generator
<% @parsed_questions.each do |question| -%>
question :<%= question[:name] %>, as: :<%= question[:type] %>, title: "<%= question[:title] %>"<%=
", options: { \"1\" => \"Option 1\", \"2\" => \"Option 2\", \"3\" => \"Option 3\" }" if question[:has_options]
%>
<% end -%><% end -%>
end

290
lib/questions_crafter/ui/components/form_builder.rb

@ -0,0 +1,290 @@
# frozen_string_literal: true
require_relative '../css_classes_manager'
module QuestionsCrafter
module Ui
module Components
class FormBuilder < ::Phlex::HTML
include Phlex::Rails::Helpers::FormWith
include Phlex::Rails::Helpers::Checkbox
include ActionView::Helpers::TextHelper # For pluralize
def initialize(questions_object:, form_url:, form_method:, read_only:, render_top_error_bar: false, custom_html_classes: {})
@questions_object = questions_object
@class_prefix = QuestionsCrafter::Utils.html_class_name_prefix(questions: questions_object)
@errors = nil
if @questions_object.changed? && @questions_object.invalid?
# TODO: The changed? method is not a strong one.. check if there's a better way
@errors = @questions_object.errors
end
@form_url = form_url
@form_method = form_method
@read_only = read_only
@render_top_error_bar = render_top_error_bar
@css_manager = CssClassesManager.new(custom_html_classes)
end
def errors
@errors || []
end
def read_only
@read_only
end
def questions_object
@questions_object
end
def render_top_error_bar
@render_top_error_bar
end
def css_classes(section, key = nil)
@css_manager.get(section, key)
end
private
def required_fields_message_notice
div(class: css_classes(:required_fields_notice, :container)) do
# TODO: Localize
p(class: css_classes(:required_fields_notice, :text)) { "* Indica campo requerido" }
end
end
def view_template
form_wrapper_class = css_classes(:form, :wrapper)
div(class: "#{form_wrapper_class} questions-#{form_wrapper_class}") do
div do
render_errors if errors.any? && render_top_error_bar
end
div do
form
end
end
div(class: css_classes(:form, :back_link_wrapper)) do
a(href: @questions_object.class.back_link, class: css_classes(:back_link)) { "Back" }
end
end
# TODO: FOR NOW we disable turbo for the submitting of this form, how we'll implement this will be a matter of a next iteration
def form
form_with(model: @questions_object, url: @form_url, method: @form_method, class: css_classes(:form, :main), data: { turbo: false }, html: { readonly: read_only }) do |form|
required_fields_message_notice
div(class: css_classes(:form, :fields_wrapper)) do
render_fields(form: form)
end
unless read_only
div(class: css_classes(:form, :submit_wrapper)) do
form.submit("Enviar", class: css_classes(:submit_button))
end
end
end
end
def render_fields(form:)
@questions_object.questions.each do |question|
render_question(question: question, form: form)
end
end
def render_question(form:, question:)
div(class: css_classes(:question, :wrapper)) do
label(for: "sign_up_questions_#{question[:name]}", class: css_classes(:question, :label)) do
plain question[:title]
if question[:required]
# TODO: allow the required class
span(class: css_classes(:question, :required_notice)) { " *" }
end
end
case question[:type]
when :string
render_string(form: form, question: question)
when :text
render_text(form: form, question: question)
when :radio
render_radio(form: form, question: question)
when :boolean
render_boolean(form: form, question: question)
when :integer
render_integer(form: form, question: question)
when :select
render_select(form: form, question: question)
when :checkbox_group
render_checkbox_group(form: form, question: question)
else
# Fallback for any other type
render_string(form: form, question: question)
end
if question[:description].present?
div(class: css_classes(:question, :description)) { question[:description] }
end
if errors.include?(question[:name])
# TODO: Are we only showing one error here?
div(class: css_classes(:question, :error)) { errors[question[:name]].first }
end
end
end
def render_string(form:, question:)
input(
type: "text",
name: "sign_up_questions[#{question[:name]}]",
id: "sign_up_questions_#{question[:name]}",
class: css_classes(:input_fields, :text_input),
placeholder: "Enter your #{question[:title].downcase}",
value: question_value(question: question),
readonly: read_only,
disabled: read_only
)
end
def render_text(form:, question:)
textarea(
name: "sign_up_questions[#{question[:name]}]",
id: "sign_up_questions_#{question[:name]}",
class: css_classes(:input_fields, :textarea),
placeholder: "Enter your #{question[:title].downcase}",
rows: 3,
readonly: read_only,
disabled: read_only
) do
question_value(question: question)
end
end
# TODO: Make this a select with options or make a different type :radio_options or :select_options
def render_radio(form:, question:)
div(class: css_classes(:radio_group, :container)) do
question[:options].each do |value, label|
wrapper_class = question[:options].size <= 3 ? css_classes(:radio_group, :item_wrapper_inline) : css_classes(:radio_group, :item_wrapper)
div(class: wrapper_class) do
input(
type: "radio",
name: "sign_up_questions[#{question[:name]}]",
id: "sign_up_questions_#{question[:name]}_#{value}",
value: value,
class: css_classes(:radio_group, :input),
checked: question_value(question: question) == value,
disabled: read_only
)
label(
for: "sign_up_questions_#{question[:name]}_#{value}",
class: css_classes(:radio_group, :label)
) { label }
end
end
end
end
def render_boolean(form:, question:)
div(class: css_classes(:checkbox, :wrapper)) do
input(
type: "checkbox",
name: "sign_up_questions[#{question[:name]}]",
id: "sign_up_questions_#{question[:name]}",
value: "1",
class: css_classes(:checkbox, :input),
checked: question_value(question: question).present?,
disabled: read_only
)
label(
for: "sign_up_questions_#{question[:name]}",
class: css_classes(:checkbox, :label)
) { question[:description] || "Yes" }
end
end
def render_integer(form:, question:)
input(
type: "number",
name: "sign_up_questions[#{question[:name]}]",
id: "sign_up_questions_#{question[:name]}",
class: css_classes(:input_fields, :number_input),
value: question_value(question: question),
readonly: read_only,
disabled: read_only
)
end
def render_select(form:, question:)
select(
name: "sign_up_questions[#{question[:name]}]",
id: "sign_up_questions_#{question[:name]}",
class: css_classes(:input_fields, :select),
disabled: read_only
) do
option(value: "", disabled: true, selected: question_value(question: question).blank?) { "Seleccione una opci\u00f3n" }
question[:options].each do |value, label|
option(value: value, selected: question_value(question: question) == value) { label }
end
end
end
def render_checkbox_group(form:, question:)
div(class: css_classes(:checkbox_group, :container)) do
question[:options].each do |value, label|
div(class: css_classes(:checkbox_group, :item_wrapper)) do
input(
type: "checkbox",
name: "sign_up_questions[#{question[:name]}][]",
id: "sign_up_questions_#{question[:name]}_#{value}",
value: value,
class: css_classes(:checkbox_group, :input),
checked: Array(question_value(question: question)).include?(value),
disabled: read_only
)
label(
for: "sign_up_questions_#{question[:name]}_#{value}",
class: css_classes(:checkbox_group, :label)
) { label }
end
end
end
end
def question_value(question:)
value = questions_object.send(question[:name])
return value unless question[:type] == :checkbox_group
normalize_checkbox_group_value(value)
end
def normalize_checkbox_group_value(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
Array(value.presence)
end
end
def render_errors
div(class: css_classes(:error_display, :container)) do
h4(class: css_classes(:error_display, :title)) { "#{pluralize(@questions_object.errors.count, 'error')} prohibited this form from being saved:" }
ul(class: css_classes(:error_display, :list)) do
@questions_object.errors.full_messages.each do |message|
li { message }
end
end
end
end
end
end
end
end

40
lib/questions_crafter/ui/components/questions_page.rb

@ -0,0 +1,40 @@
# frozen_string_literal: true
module QuestionsCrafter
module Ui
module Components
class QuestionsPage < ::Phlex::HTML
include Phlex::Rails::Helpers::FormWith
include ActionView::Helpers::TextHelper # For pluralize
def initialize(questions_object:, form_url:, form_method:, read_only:)
@questions_object = questions_object
@form_url = form_url
@form_method = form_method
@read_only = read_only
@custom_html_classes = @questions_object.class.custom_html_classes
@css_manager = QuestionsCrafter::Ui::CssClassesManager.new(@custom_html_classes)
@class_prefix = QuestionsCrafter::Utils.html_class_name_prefix(questions: questions_object)
end
def view_template
div(class: css_classes(:page, :main)) do
h1(class: css_classes(:page, :title)) { @questions_object.class.title }
p(class: css_classes(:page, :description)) { @questions_object.class.description }
render QuestionsCrafter::Ui::Components::FormBuilder.new(
questions_object: @questions_object,
form_url: @form_url,
form_method: @form_method,
read_only: @read_only,
custom_html_classes: @custom_html_classes
)
end
end
def css_classes(section, key = nil)
@css_manager.get(section, key)
end
end
end
end
end

109
lib/questions_crafter/ui/css_classes_manager.rb

@ -0,0 +1,109 @@
# frozen_string_literal: true
module QuestionsCrafter
module Ui
class CssClassesManager
# Class structure template with default semantic/structural classes
CLASS_STRUCTURE = {
required_fields_notice: {
container: "",
text: ""
},
page: {
main: "questions-page",
title: "questions-title",
description: "questions-description"
},
form: {
wrapper: "questions-form-wrapper",
main: "questions-form",
fields_wrapper: "questions-form-fields-wrapper",
submit_wrapper: "questions-form-submit-wrapper",
back_link_wrapper: ""
},
submit_button: "",
back_link: "",
question: {
wrapper: "question-field-wrapper",
label: "question-field-label",
required_notice: "question-required-notice",
description: "question-field-description",
error: "question-field-error"
},
input_fields: {
text_input: "question-string-field",
textarea: "question-textarea",
number_input: "question-number-field",
select: "question-select-field"
},
radio_group: {
container: "question-radio-group",
item_wrapper: "question-radio-group-item-wrapper",
item_wrapper_inline: "question-radio-group-item-wrapper-inline",
input: "question-radio-group-input",
label: "question-radio-group-label"
},
checkbox: {
wrapper: "question-checkbox-wrapper",
input: "question-checkbox-input",
label: "question-checkbox-label"
},
checkbox_group: {
container: "question-checkbox-group",
item_wrapper: "question-checkbox-group-item-wrapper",
input: "question-checkbox-group-input",
label: "question-checkbox-group-label"
},
error_display: {
container: "question-error-display",
title: "question-error-display-title",
list: "question-error-display-list"
}
}.freeze
def initialize(custom_classes = {})
@classes = deep_merge(CLASS_STRUCTURE, custom_classes)
end
# Get classes for a specific section and optional key
def get(section, key = nil)
if key
@classes.dig(section, key) || ""
else
@classes[section] || ""
end
end
# Get all classes for debugging/inspection
def all_classes
@classes
end
# Check if a section/key exists
def exists?(section, key = nil)
if key
@classes.dig(section, key) != nil
else
@classes.key?(section)
end
end
private
# Deep merge helper to merge custom classes with structure
# Concatenates string values when both default and custom exist
def deep_merge(hash1, hash2)
hash1.merge(hash2) do |key, oldval, newval|
if oldval.is_a?(Hash) && newval.is_a?(Hash)
deep_merge(oldval, newval)
elsif oldval.is_a?(String) && newval.is_a?(String)
# Concatenate classes: default structural classes + custom styling classes
[oldval, newval].reject(&:empty?).join(" ")
else
newval.presence || oldval
end
end
end
end
end
end

13
lib/questions_crafter/utils.rb

@ -0,0 +1,13 @@
module QuestionsCrafter
class Utils
class << self
# Returns a dasherized string that can be used as a prefix for HTML class names. It removes the Questions suffix.
# Example: "SignUpQuestions" -> "sign-up"
def html_class_name_prefix(questions:)
name = questions.class.name
name = name.demodulize.gsub(/Questions$/, "").underscore.dasherize
name
end
end
end
end

5
lib/questions_crafter/version.rb

@ -0,0 +1,5 @@
# lib/questions_crafter/version.rb
module QuestionsCrafter
Version = "0.1.0"
end

44
question_crafter.gemspec

@ -0,0 +1,44 @@
# frozen_string_literal: true
require_relative "lib/questions_crafter/version"
Gem::Specification.new do |spec|
spec.name = "questions_crafter"
spec.version = QuestionsCrafter::Version
spec.authors = ["rodley82"]
spec.email = ["rodolfo.leyes@gmail.com"]
spec.summary = "A gem to create questions and quizzes."
spec.description = "A gem to create questions and quizzes. It provides a DSL to define questions."
spec.homepage = "https://github.com/rodley82/questions_crafter"
spec.required_ruby_version = ">= 3.0.0"
spec.metadata["allowed_push_host"] = "https://rubygems.org"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "https://github.com/rodley82/questions_crafter"
spec.metadata["changelog_uri"] = "https://github.com/rodley82/questions_crafter/blob/main/CHANGELOG.md"
# Specify which files should be added to the gem when it is released.
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
gemspec = File.basename(__FILE__)
spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
ls.readlines("\x0", chomp: true).reject do |f|
(f == gemspec) ||
f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile])
end
end
spec.bindir = "exe"
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]
spec.add_dependency "phlex-rails"
spec.add_dependency "rails", ">= 7.0.0"
# Uncomment to register a new dependency of your gem
# spec.add_dependency "example-gem", "~> 1.0"
# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
end

4
sig/question_crafter.rbs

@ -0,0 +1,4 @@
module QuestionsCrafter
VERSION: String
# See the writing guide of rbs: https://github.com/ruby/rbs#guides
end
Loading…
Cancel
Save