Improved validation, assorted tweaks and fixes
This commit is contained in:
parent
c9fc27431e
commit
d22b740289
256
lib/Surveys.jl
256
lib/Surveys.jl
|
@ -5,7 +5,8 @@ using Genie.Renderers.Html
|
||||||
import Base: show, isvalid, isempty
|
import Base: show, isvalid, isempty
|
||||||
|
|
||||||
export Survey, SurveyPart, Question,
|
export Survey, SurveyPart, Question,
|
||||||
Answer, Response, update!, clear!, nonempty,
|
Answer, Response, update!, clear!,
|
||||||
|
nonempty, wordlimit, charlimit,
|
||||||
SurveyID, ResponseID,
|
SurveyID, ResponseID,
|
||||||
Checkbox, TextInput, DateInput, NumberInput, IntegerInput,
|
Checkbox, TextInput, DateInput, NumberInput, IntegerInput,
|
||||||
TextArea, Dropdown, RadioSelect, MultiSelect, RangeSelect
|
TextArea, Dropdown, RadioSelect, MultiSelect, RangeSelect
|
||||||
|
@ -50,19 +51,47 @@ function nonempty(values::Vector{<:AbstractString})
|
||||||
end
|
end
|
||||||
|
|
||||||
nonempty(::Bool) = nothing
|
nonempty(::Bool) = nothing
|
||||||
|
nonempty(::Number) = nothing
|
||||||
|
nonempty(::Date) = nothing
|
||||||
|
|
||||||
lastsoftfail(v::Vector) = if length(v) > 0
|
lastsoftfail(v::Vector) =
|
||||||
last(v)
|
if length(v) > 0
|
||||||
else
|
last(v)
|
||||||
""
|
else "" end
|
||||||
|
|
||||||
|
wordlimit(min::Int, max::Int) = function(text::AbstractString)
|
||||||
|
wordcount = length(split(text))
|
||||||
|
if wordcount < min
|
||||||
|
string("Need at least ", min, if min > 1 " words" else " word" end,
|
||||||
|
" (currently ", wordcount, ")")
|
||||||
|
elseif wordcount > max
|
||||||
|
string("No more than ", max, if max > 1 " words" else " word" end,
|
||||||
|
" are permitted (currently ", wordcount, ")")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
wordlimit(max::Int) = wordlimit(0, max)
|
||||||
|
|
||||||
defaultpostprocessors(::FormField) = Function[last, strip]
|
charlimit(min::Int, max::Int) = function(text::AbstractString)
|
||||||
|
charcount = length(split(text))
|
||||||
|
if charcount < min
|
||||||
|
string("Need at least ", min, if min > 1 " characters" else " character" end,
|
||||||
|
" (currently ", charcount, ")")
|
||||||
|
elseif charcount > max
|
||||||
|
string("No more than ", max, if max > 1 " characters" else " character" end,
|
||||||
|
" are permitted (currently ", charcount, ")")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
charlimit(max::Int) = charlimit(0, max)
|
||||||
|
|
||||||
|
default_postprocessors(::FormField) = Function[last, strip]
|
||||||
|
default_validators(::FormField) = Function[]
|
||||||
|
|
||||||
function Question(id::Symbol, prompt::AbstractString, field::FormField;
|
function Question(id::Symbol, prompt::AbstractString, field::FormField;
|
||||||
validators::Union{Function, Vector{<:Function}}=Function[],
|
postprocessors::Union{Function, Vector{<:Function}} =
|
||||||
postprocessors::Union{Function, Vector{<:Function}}=defaultpostprocessors(field),
|
default_postprocessors(field),
|
||||||
mandatory::Bool=true)
|
validators::Union{Function, Vector{<:Function}} =
|
||||||
|
default_validators(field),
|
||||||
|
mandatory::Bool = true)
|
||||||
fullvalidators = if mandatory
|
fullvalidators = if mandatory
|
||||||
vcat(nonempty, validators)
|
vcat(nonempty, validators)
|
||||||
else
|
else
|
||||||
|
@ -73,10 +102,10 @@ end
|
||||||
|
|
||||||
function prompttoid(prompt::String)
|
function prompttoid(prompt::String)
|
||||||
prompt |>
|
prompt |>
|
||||||
p -> replace(p, r"[^A-Za-z0-9\s]" => "") |>
|
p -> replace(p, r"[^A-Za-z0-9\s]" => "") |>
|
||||||
p -> replace(p, r"\s+" => "_") |>
|
p -> replace(p, r"\s+" => "_") |>
|
||||||
lowercase |>
|
lowercase |>
|
||||||
Symbol
|
Symbol
|
||||||
end
|
end
|
||||||
|
|
||||||
Question(prompt::AbstractString, field::FormField; kargs...) =
|
Question(prompt::AbstractString, field::FormField; kargs...) =
|
||||||
|
@ -84,11 +113,17 @@ Question(prompt::AbstractString, field::FormField; kargs...) =
|
||||||
|
|
||||||
# Field-based question constructors
|
# Field-based question constructors
|
||||||
|
|
||||||
function (F::Type{<:FormField})(id::Symbol, prompt::AbstractString, args...;
|
function (F::Type{<:FormField})(id::Symbol, prompt::AbstractString,
|
||||||
validators::Union{Function, Vector{<:Function}}=Function[],
|
args...; kwargs...)
|
||||||
mandatory::Bool=true,
|
question_extra_kwargs = (:postprocessors, :validators, :mandatory)
|
||||||
kwargs...)
|
question_kwargs = filter(kw -> kw.first ∈ question_extra_kwargs, kwargs)
|
||||||
Question(id, prompt, F(args...; kwargs...); validators, mandatory)
|
field_kwargs = filter(kw -> kw.first ∉ question_extra_kwargs, kwargs)
|
||||||
|
try
|
||||||
|
Question(id, prompt, F(args...; field_kwargs...); question_kwargs...)
|
||||||
|
catch e
|
||||||
|
print(stderr, "\nError while processing question $id\n")
|
||||||
|
rethrow(e)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
function (F::Type{<:FormField})(prompt::AbstractString, args...; kwargs...)
|
function (F::Type{<:FormField})(prompt::AbstractString, args...; kwargs...)
|
||||||
F(prompttoid(prompt), prompt, args...; kwargs...)
|
F(prompttoid(prompt), prompt, args...; kwargs...)
|
||||||
|
@ -120,6 +155,24 @@ struct Survey
|
||||||
description::Union{AbstractString, Nothing}
|
description::Union{AbstractString, Nothing}
|
||||||
parts::Vector{Pair{Union{AbstractString, Nothing}, Vector{Symbol}}}
|
parts::Vector{Pair{Union{AbstractString, Nothing}, Vector{Symbol}}}
|
||||||
questions::Dict{Symbol, Question}
|
questions::Dict{Symbol, Question}
|
||||||
|
function Survey(name::AbstractString,
|
||||||
|
description::Union{AbstractString, Nothing},
|
||||||
|
parts::Vector{<:Pair{<:Union{<:AbstractString, Nothing}, <:Vector{Symbol}}},
|
||||||
|
questions::Dict{Symbol, Question})
|
||||||
|
# Create an id that only depends on:
|
||||||
|
# 1. Question IDs
|
||||||
|
# 2. Question field types
|
||||||
|
# These are the two essential components to hash, as the database interactions
|
||||||
|
# rely on the assumption that these two components are stable.
|
||||||
|
# Hopefully memhashing a Tuple of Symbols and Strings is somewhat stable,
|
||||||
|
# I checked this on Julia 1.3 and 1.6 and it looked alright.
|
||||||
|
function qhash(q::Question{<:FormField{T}}) where {T}
|
||||||
|
hash((q.id, string(T)))
|
||||||
|
end
|
||||||
|
id = xor(map(qhash, values(questions))...) |>
|
||||||
|
h -> xor(reinterpret(SurveyID, [h])...)
|
||||||
|
new(id, name, description, parts, questions)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Base.getindex(s::Survey, id::Symbol) = s.questions[id]
|
Base.getindex(s::Survey, id::Symbol) = s.questions[id]
|
||||||
|
@ -129,23 +182,6 @@ Base.getindex(s::Survey, part::Integer) =
|
||||||
|
|
||||||
Base.length(s::Survey) = length(s.parts)
|
Base.length(s::Survey) = length(s.parts)
|
||||||
|
|
||||||
function Survey(name::AbstractString,
|
|
||||||
description::Union{AbstractString, Nothing},
|
|
||||||
parts::Vector{<:Pair{<:Union{<:AbstractString, Nothing}, <:Vector{Symbol}}},
|
|
||||||
questions::Dict{Symbol, Question})
|
|
||||||
# Create an id that only depends on:
|
|
||||||
# 1. Question IDs
|
|
||||||
# 2. Question Prompts
|
|
||||||
# 3. Question field types
|
|
||||||
# Hopefully memhashing a Tuple of Symbols and Strings is somewhat stable,
|
|
||||||
# I checked this on Julia 1.3 and 1.6 and it looked alright.
|
|
||||||
id = xor(map(values(questions)) do q
|
|
||||||
hash((q.id, q.prompt, string(typeof(q.field))))
|
|
||||||
end...) |>
|
|
||||||
h -> xor(reinterpret(SurveyID, [h])...)
|
|
||||||
Survey(id, name, description, parts, questions)
|
|
||||||
end
|
|
||||||
|
|
||||||
Survey(name::AbstractString,
|
Survey(name::AbstractString,
|
||||||
description::Union{AbstractString, Nothing},
|
description::Union{AbstractString, Nothing},
|
||||||
parts::SurveyPart...) =
|
parts::SurveyPart...) =
|
||||||
|
@ -214,30 +250,54 @@ function Response(s::Survey, oldids::Vector{ResponseID})
|
||||||
end
|
end
|
||||||
|
|
||||||
interpret(::FormField{<:AbstractString}, value::AbstractString) = value
|
interpret(::FormField{<:AbstractString}, value::AbstractString) = value
|
||||||
|
interpret(::FormField{Integer}, value::AbstractString) = parse(Int64, value)
|
||||||
|
default_validators(::FormField{Integer}) = function(unparseable::String)
|
||||||
|
"Integer required. \"$unparseable\" could not be parsed as an integer."
|
||||||
|
end
|
||||||
interpret(::FormField{Number}, value::AbstractString) =
|
interpret(::FormField{Number}, value::AbstractString) =
|
||||||
something(tryparse(Int64, value), parse(Float64, value))
|
something(tryparse(Int64, value), parse(Float64, value))
|
||||||
|
default_validators(::FormField{Number}) = function(unparseable::String)
|
||||||
|
"Number required. \"$unparseable\" could not be parsed as a number."
|
||||||
|
end
|
||||||
interpret(::FormField{T}, value::AbstractString) where {T} = parse(T, value)
|
interpret(::FormField{T}, value::AbstractString) where {T} = parse(T, value)
|
||||||
|
|
||||||
# Response interpretation
|
# Response interpretation
|
||||||
|
|
||||||
function Answer(q::Question{<:FormField{T}}, value::Union{String, Vector{String}}) where {T}
|
function Answer(q::Question{<:FormField{T}}, value::Vector{String}) where {T}
|
||||||
try
|
try
|
||||||
processedvalue = interpret(q.field, ∘(identity, reverse(q.postprocessors)...)(value))
|
processedvalue = interpret(q.field, ∘(identity, reverse(q.postprocessors)...)(value))
|
||||||
error = nothing
|
error = nothing
|
||||||
for validator in q.validators
|
for validator in q.validators
|
||||||
error = validator(processedvalue)
|
if applicable(validator, processedvalue)
|
||||||
|
error = validator(processedvalue)
|
||||||
|
end
|
||||||
isnothing(error) || break
|
isnothing(error) || break
|
||||||
end
|
end
|
||||||
Answer{T}(processedvalue, error)
|
Answer{T}(processedvalue, error)
|
||||||
catch e
|
catch e
|
||||||
@warn "Answer construction failure" exception=(e, catch_backtrace())
|
construction_error = nothing
|
||||||
Answer{T}(missing, first(split(sprint(showerror, e), '\n')))
|
try
|
||||||
|
for validator in q.validators
|
||||||
|
if hasmethod(validator, Tuple{String})
|
||||||
|
construction_error = validator(last(value))
|
||||||
|
elseif hasmethod(validator, Tuple{Vector{String}})
|
||||||
|
construction_error = validator(value)
|
||||||
|
end
|
||||||
|
isnothing(construction_error) || break
|
||||||
|
end
|
||||||
|
catch _
|
||||||
|
end
|
||||||
|
if isnothing(construction_error)
|
||||||
|
@warn "Answer construction failure" exception = (e, catch_backtrace())
|
||||||
|
construction_error = first(split(sprint(showerror, e), '\n'))
|
||||||
|
end
|
||||||
|
Answer{T}(missing, construction_error)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Answer(q::Question{<:FormField{T}}, ::Missing) where {T}
|
function Answer(q::Question{<:FormField{T}}, ::Missing) where {T}
|
||||||
if nonempty in q.validators
|
if nonempty in q.validators
|
||||||
Answer{T}(missing, "Must be answered")
|
Answer{T}(missing, nonempty(""))
|
||||||
else
|
else
|
||||||
Answer{T}(missing, nothing)
|
Answer{T}(missing, nothing)
|
||||||
end
|
end
|
||||||
|
@ -358,9 +418,14 @@ const DateInput = FormInput{Date}
|
||||||
const NumberInput = FormInput{Number}
|
const NumberInput = FormInput{Number}
|
||||||
const IntegerInput = FormInput{Integer}
|
const IntegerInput = FormInput{Integer}
|
||||||
|
|
||||||
|
default_validators(::TextInput) = Function[wordlimit(100), charlimit(500)]
|
||||||
|
|
||||||
# <textarea>
|
# <textarea>
|
||||||
|
|
||||||
struct TextArea <: FormField{String} end
|
struct TextArea <: FormField{String} end
|
||||||
|
|
||||||
|
default_validators(::TextArea) = Function[wordlimit(500), charlimit(2500)]
|
||||||
|
|
||||||
htmlelement(::TextArea) = "textarea"
|
htmlelement(::TextArea) = "textarea"
|
||||||
htmlcontent(::TextArea, value) = value
|
htmlcontent(::TextArea, value) = value
|
||||||
|
|
||||||
|
@ -428,16 +493,36 @@ end
|
||||||
|
|
||||||
# Many <input type="radio|checkbox">
|
# Many <input type="radio|checkbox">
|
||||||
|
|
||||||
function (F::Type{<:Union{RadioSelect, MultiSelect}})(opts::Vector)
|
function (F::Type{<:Union{RadioSelect,MultiSelect}})(opts::Vector{T}) where {T}
|
||||||
nosymbol = filter(o -> o isa String || o isa Pair{String, String}, opts)
|
if T == Any
|
||||||
|
try
|
||||||
|
if Pair{String,String} in typeof.(opts)
|
||||||
|
opts = map(opts) do o
|
||||||
|
if o isa String
|
||||||
|
o => o
|
||||||
|
else
|
||||||
|
o
|
||||||
|
end
|
||||||
|
end |> Vector{Union{Pair{String,String},Symbol}}
|
||||||
|
else
|
||||||
|
opts = Vector{Union{String,Symbol}}(opts)
|
||||||
|
end
|
||||||
|
catch e
|
||||||
|
@error "Could not coerce Vector{$T} to a Vector{Union{String, Pair{String, String}, Symbol}}"
|
||||||
|
rethrow(e)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
nosymbol = filter(o -> o isa String || o isa Pair{String,String}, opts)
|
||||||
F(Options(Vector{typeof(first(nosymbol))}(nosymbol)), :other in opts)
|
F(Options(Vector{typeof(first(nosymbol))}(nosymbol)), :other in opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
interpret(::FormField{Vector{String}}, value::Vector{<:AbstractString}) =
|
interpret(::FormField{Vector{String}}, value::Vector{<:AbstractString}) =
|
||||||
value
|
value
|
||||||
|
|
||||||
defaultpostprocessors(::MultiSelect) = Function[s -> filter(!isempty, strip.(s))]
|
default_postprocessors(::MultiSelect) =
|
||||||
defaultpostprocessors(::RadioSelect) = Function[s -> filter(!isempty, strip.(s)), last]
|
Function[s -> filter(!isempty, strip.(s))]
|
||||||
|
default_postprocessors(::RadioSelect) =
|
||||||
|
Function[s -> filter(!isempty, strip.(s)), last]
|
||||||
|
|
||||||
# Render many <input type="radio|checkbox">
|
# Render many <input type="radio|checkbox">
|
||||||
|
|
||||||
|
@ -448,41 +533,44 @@ function htmlrender(q::Union{<:Question{RadioSelect}, Question{MultiSelect}},
|
||||||
end
|
end
|
||||||
type = if q isa Question{RadioSelect} "radio" else "checkbox" end
|
type = if q isa Question{RadioSelect} "radio" else "checkbox" end
|
||||||
string(elem("legend", q.prompt,
|
string(elem("legend", q.prompt,
|
||||||
Symbol("data-mandatory") =>
|
Symbol("data-mandatory") =>
|
||||||
if nonempty in q.validators "true" else false end),
|
if nonempty in q.validators "true" else false end),
|
||||||
'\n',
|
'\n',
|
||||||
join(map(q.field.options.options |> enumerate) do (i, opt)
|
join(map(q.field.options.options |> enumerate) do (i, opt)
|
||||||
id = string("qn-", q.id, "-", opt.first)
|
id = string("qn-", q.id, "-", opt.second)
|
||||||
elem("label",
|
elem("label",
|
||||||
elem("input", :type => type, :id => id,
|
elem("input", :type => type, :id => id,
|
||||||
:name => string(q.id, "[]"), :value => opt.first,
|
:name => string(q.id, "[]"), :value => opt.second,
|
||||||
:checked => (!ismissing(value) && opt.first ∈ value),
|
:checked => (!ismissing(value) && opt.second ∈ value),
|
||||||
if q.field.other
|
if type == "radio" && q.field.other
|
||||||
[:oninput => "document.getElementById('$(string("qn-", q.id, "--other-input"))').value = ''"]
|
[:oninput => "document.getElementById('$(string("qn-", q.id, "--other-input"))').value = ''"]
|
||||||
else [] end...) *
|
else [] end...) *
|
||||||
opt.second,
|
opt.first,
|
||||||
:for => id)
|
:for => id)
|
||||||
end, '\n'),
|
end, '\n'),
|
||||||
if q.field.other
|
if q.field.other
|
||||||
othervals = if !ismissing(value)
|
othervals = if !ismissing(value)
|
||||||
filter(v -> v ∉ getfield.(q.field.options.options, :first), value)
|
filter(v -> v ∉ getfield.(q.field.options.options, :second), value)
|
||||||
else
|
else [] end
|
||||||
[]
|
'\n' *
|
||||||
end
|
elem("label",
|
||||||
'\n' *
|
string(
|
||||||
elem("label",
|
elem("input", :type => type, :name => if type == "radio"
|
||||||
string(
|
string(q.id, "[]")
|
||||||
elem("input", :type => type, :name => if type == "radio" string(q.id, "[]") else "" end,
|
else "" end,
|
||||||
:id => string("qn-", q.id, "--other"), :value => "",
|
:id => string("qn-", q.id, "--other"), :value => "",
|
||||||
:checked => length(othervals) > 0),
|
:checked => length(othervals) > 0,
|
||||||
elem("input", :type => "text",
|
if type == "checkbox"
|
||||||
:id => string("qn-", q.id, "--other-input"),
|
[:oninput => "if (!this.checked) { document.getElementById('$(string("qn-", q.id, "--other-input"))').value = '' }"]
|
||||||
:class => "other", :placeholder => "Other",
|
else [] end...),
|
||||||
:name => string(q.id, "[]"), :value => join(othervals, ", "),
|
elem("input", :type => "text",
|
||||||
:oninput => "document.getElementById('$(string("qn-", q.id, "--other"))').checked = this.value.length > 0"
|
:id => string("qn-", q.id, "--other-input"),
|
||||||
)),
|
:class => "other", :placeholder => "Other",
|
||||||
:for => string("qn-", q.id, "--other"))
|
:name => string(q.id, "[]"), :value => join(othervals, ", "),
|
||||||
else "" end) |> fieldset
|
:oninput => "document.getElementById('$(string("qn-", q.id, "--other"))').checked = this.value.length > 0"
|
||||||
|
)),
|
||||||
|
:for => string("qn-", q.id, "--other"))
|
||||||
|
else "" end) |> fieldset
|
||||||
end
|
end
|
||||||
|
|
||||||
# <input type="range">
|
# <input type="range">
|
||||||
|
@ -591,6 +679,9 @@ end
|
||||||
show(io::IO, qa::Pair{<:Question, <:Answer}) = show(io, MIME("text/plain"), qa)
|
show(io::IO, qa::Pair{<:Question, <:Answer}) = show(io, MIME("text/plain"), qa)
|
||||||
|
|
||||||
function show(io::IO, m::MIME"text/plain", part::SurveyPart)
|
function show(io::IO, m::MIME"text/plain", part::SurveyPart)
|
||||||
|
printstyled(" -- ", if isnothing(part.label)
|
||||||
|
"Unlabeled part"
|
||||||
|
else part.label end, " --\n", color=:yellow)
|
||||||
for q in part.questions
|
for q in part.questions
|
||||||
show(io, m, q)
|
show(io, m, q)
|
||||||
q === last(part.questions) || print(io, "\n")
|
q === last(part.questions) || print(io, "\n")
|
||||||
|
@ -599,9 +690,12 @@ end
|
||||||
|
|
||||||
function show(io::IO, m::MIME"text/plain", pr::Pair{SurveyPart, Response})
|
function show(io::IO, m::MIME"text/plain", pr::Pair{SurveyPart, Response})
|
||||||
part, response = pr
|
part, response = pr
|
||||||
|
printstyled(" -- ", if isnothing(part.label)
|
||||||
|
"Unlabeled part"
|
||||||
|
else part.label end, " --\n", color=:yellow)
|
||||||
foreach(part.questions) do q
|
foreach(part.questions) do q
|
||||||
show(io, m, q => response[q.id])
|
show(io, m, q => response[q.id])
|
||||||
last(part.questions) === q || print(io, "\n\n")
|
last(part.questions) === q || print(io, '\n')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
show(io::IO, pr::Pair{SurveyPart, Response}) = show(io, MIME("text/plain"), pr)
|
show(io::IO, pr::Pair{SurveyPart, Response}) = show(io, MIME("text/plain"), pr)
|
||||||
|
@ -611,7 +705,7 @@ function show(io::IO, m::MIME"text/plain", s::Survey)
|
||||||
parts = [s[p] for p in 1:length(s)]
|
parts = [s[p] for p in 1:length(s)]
|
||||||
for part in parts
|
for part in parts
|
||||||
show(io, m, part)
|
show(io, m, part)
|
||||||
part === last(parts) || printstyled(io, "\n ---\n", color=:yellow)
|
part === last(parts) || print(io, '\n')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue