commit
39240fffb5
22 changed files with 1023 additions and 0 deletions
@ -0,0 +1,8 @@ |
|||
/.bundle/ |
|||
/.yardoc |
|||
/_yardoc/ |
|||
/coverage/ |
|||
/doc/ |
|||
/pkg/ |
|||
/spec/reports/ |
|||
/tmp/ |
|||
@ -0,0 +1,5 @@ |
|||
## [Unreleased] |
|||
|
|||
## [0.1.0] - 2025-10-20 |
|||
|
|||
- Initial release |
|||
@ -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" |
|||
@ -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. |
|||
@ -0,0 +1,4 @@ |
|||
# frozen_string_literal: true |
|||
|
|||
require "bundler/gem_tasks" |
|||
task default: %i[] |
|||
@ -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__) |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -0,0 +1,5 @@ |
|||
# lib/questions_crafter/version.rb |
|||
|
|||
module QuestionsCrafter |
|||
Version = "0.1.0" |
|||
end |
|||
@ -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 |
|||
@ -0,0 +1,4 @@ |
|||
module QuestionsCrafter |
|||
VERSION: String |
|||
# See the writing guide of rbs: https://github.com/ruby/rbs#guides |
|||
end |
|||
Loading…
Reference in new issue