A working prototype
This commit is contained in:
parent
34e6745900
commit
afbb862ec7
|
@ -4,11 +4,16 @@ authors = ["TEC <tec@tecosaur.com>"]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
[deps]
|
[deps]
|
||||||
|
CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
|
||||||
|
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
|
||||||
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
|
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
|
||||||
Genie = "c43c736e-a2d1-11e8-161f-af95117fbd1e"
|
Genie = "c43c736e-a2d1-11e8-161f-af95117fbd1e"
|
||||||
|
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
|
||||||
Inflector = "6d011eab-0732-4556-8808-e463c76bf3b6"
|
Inflector = "6d011eab-0732-4556-8808-e463c76bf3b6"
|
||||||
|
JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
|
||||||
Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
|
Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
|
||||||
LoggingExtras = "e6f89c97-d47a-5376-807f-9c37f3926c36"
|
LoggingExtras = "e6f89c97-d47a-5376-807f-9c37f3926c36"
|
||||||
MbedTLS = "739be429-bea8-5141-9913-cc70e7f3736d"
|
MbedTLS = "739be429-bea8-5141-9913-cc70e7f3736d"
|
||||||
|
SQLite = "0aa819cd-b072-5ff4-a722-6bc24af294d9"
|
||||||
SearchLight = "340e8cb6-72eb-11e8-37ce-c97ebeb32050"
|
SearchLight = "340e8cb6-72eb-11e8-37ce-c97ebeb32050"
|
||||||
SearchLightSQLite = "21a827c4-482a-11ea-3a19-4d2243a4a2c5"
|
SearchLightSQLite = "21a827c4-482a-11ea-3a19-4d2243a4a2c5"
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
module ValidationHelper
|
|
||||||
|
|
||||||
using Genie, SearchLight, SearchLight.Validation
|
|
||||||
|
|
||||||
export output_errors
|
|
||||||
|
|
||||||
function output_errors(m::T, field::Symbol)::String where {T<:SearchLight.AbstractModel}
|
|
||||||
v = ispayload() ? validate(m) : ModelValidator()
|
|
||||||
|
|
||||||
haserrorsfor(v, field) ?
|
|
||||||
"""
|
|
||||||
<div class="text-danger form-text">
|
|
||||||
$(errors_to_string(v, field, separator = "<br/>\n", uppercase_first = true))
|
|
||||||
</div>""" : ""
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
|
@ -1,11 +0,0 @@
|
||||||
module ViewHelper
|
|
||||||
|
|
||||||
using Genie, Genie.Flash, Genie.Router
|
|
||||||
|
|
||||||
export output_flash
|
|
||||||
|
|
||||||
function output_flash(flashtype::String = "danger") :: String
|
|
||||||
flash_has_message() ? """<div class="alert alert-$flashtype alert-dismissable">$(flash())</div>""" : ""
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
|
@ -9,7 +9,7 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
// to prevent Firefox FOUC, this must be here
|
// to prevent Firefox FoUC (Flash of Unstyled Content), this must be here
|
||||||
// see https://bugzilla.mozilla.org/show_bug.cgi?id=1404468
|
// see https://bugzilla.mozilla.org/show_bug.cgi?id=1404468
|
||||||
let FF_FOUC_FIX;
|
let FF_FOUC_FIX;
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,12 +1,203 @@
|
||||||
module Results
|
module Results
|
||||||
|
|
||||||
import SearchLight: AbstractModel, DbId
|
using SearchLight
|
||||||
import Base: @kwdef
|
using Surveys, Dates
|
||||||
|
using DataFrames, CSV, JSON3, SQLite
|
||||||
|
|
||||||
export Result
|
export surveys, questions, responseids, results,
|
||||||
|
register!, deregister!, save!, clear!
|
||||||
|
|
||||||
@kwdef mutable struct Result <: AbstractModel
|
const SURVEYS = Dict{SurveyID, String}()
|
||||||
id::DbId = DbId()
|
const QUESTIONS = Dict{SurveyID, Dict{Symbol, Dict{Symbol, Any}}}()
|
||||||
|
const RESPONSES = Dict{SurveyID, Dict{ResponseID, Union{Response, Nothing}}}()
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Reading
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
function surveys(;cache::Bool=true)
|
||||||
|
if !cache || isempty(SURVEYS)
|
||||||
|
foreach(SearchLight.query("SELECT * from surveys") |> eachrow) do s
|
||||||
|
SURVEYS[SurveyID(s.id)] = s.name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
SURVEYS
|
||||||
|
end
|
||||||
|
surveys(id::SurveyID; cache::Bool=true) = surveys(;cache)[id]
|
||||||
|
|
||||||
|
function questions(survey::SurveyID; cache::Bool=true)
|
||||||
|
if !haskey(QUESTIONS, survey) || !cache
|
||||||
|
qns = SearchLight.query("SELECT id, type, prompt, input FROM questions WHERE survey=$survey")
|
||||||
|
qns.id = Symbol.(qns.id)
|
||||||
|
qns.type = Vector{Type}(@. eval(Meta.parse(qns.type)))
|
||||||
|
qns.input = Vector{Type}(@. eval(Meta.parse(qns.input)))
|
||||||
|
QUESTIONS[survey] = Dict(q.id => Dict(:type => q.type, :prompt => q.prompt, :input => q.input) for q in eachrow(qns))
|
||||||
|
end
|
||||||
|
QUESTIONS[survey]
|
||||||
|
end
|
||||||
|
|
||||||
|
function responseids(survey::SurveyID; cache::Bool=true)
|
||||||
|
if !haskey(RESPONSES, survey) || !cache
|
||||||
|
ids = SearchLight.query("SELECT DISTINCT response FROM results WHERE survey=$survey") |>
|
||||||
|
r -> ResponseID.(r.response)
|
||||||
|
if !haskey(RESPONSES, survey)
|
||||||
|
RESPONSES[survey] =
|
||||||
|
Dict{ResponseID, Union{Response, Nothing}}(id => nothing for id in ids)
|
||||||
|
else
|
||||||
|
foreach(ids) do id
|
||||||
|
if !haskey(RESPONSES[survey], id)
|
||||||
|
RESPONSES[survey][id] = nothing
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Vector{ResponseID}(keys(RESPONSES[survey]) |> collect)
|
||||||
|
end
|
||||||
|
|
||||||
|
function responseids(survey::SurveyID, type::Symbol; cache::Bool=true)
|
||||||
|
if type == :all
|
||||||
|
responseids(surveyid; cache)
|
||||||
|
elseif type == :complete
|
||||||
|
SearchLight.query("SELECT id FROM responses WHERE completed IS NOT NULL") |>
|
||||||
|
r -> ResponseID.(r.id)
|
||||||
|
elseif type == :incomplete
|
||||||
|
SearchLight.query("SELECT id FROM responses WHERE completed IS NULL") |>
|
||||||
|
r -> ResponseID.(r.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function response(survey::SurveyID, response::ResponseID; cache::Bool=true)
|
||||||
|
if cache && response ∉ responseids(survey; cache)
|
||||||
|
responseids(survey, cache=false)
|
||||||
|
end
|
||||||
|
@assert response in responseids(survey; cache)
|
||||||
|
if RESPONSES[survey][response] === nothing
|
||||||
|
responsedata = SearchLight.query("SELECT question, value FROM results WHERE survey=$survey AND response=$response")
|
||||||
|
answers = Dict(map(eachrow(responsedata)) do ans
|
||||||
|
qid = Symbol(ans.question)
|
||||||
|
anstype = questions(survey)[qid][:type]
|
||||||
|
value = eval(Meta.parse(ans.value))
|
||||||
|
qid => Answer{anstype}(value, nothing)
|
||||||
|
end)
|
||||||
|
metadata = SearchLight.query("SELECT started, completed, page FROM responses WHERE survey=$survey AND id=$response")
|
||||||
|
started = parse(DateTime, metadata.started[1])
|
||||||
|
completed = if !ismissing(metadata.completed[1])
|
||||||
|
parse(DateTime, metadata.completed[1]) end
|
||||||
|
RESPONSES[survey][response] =
|
||||||
|
Response(survey, response, metadata.page[1], answers,
|
||||||
|
started, completed)
|
||||||
|
end
|
||||||
|
RESPONSES[survey][response]
|
||||||
|
end
|
||||||
|
|
||||||
|
function results(survey::SurveyID; cache::Bool=true, format::Symbol=:DataFrame)
|
||||||
|
resids = responseids(survey; cache)
|
||||||
|
res = response.(survey, resids; cache)
|
||||||
|
qids = keys(questions(survey; cache))
|
||||||
|
data = Dict(q => map(r -> r[q].value, res) for q in qids)
|
||||||
|
DataFrame(data) |> if format == :DataFrame
|
||||||
|
identity
|
||||||
|
elseif format == :csv
|
||||||
|
df -> sprint(CSV.write, df)
|
||||||
|
elseif format == :tsv
|
||||||
|
df -> sprint((io, df) -> CSV.write(io, df, delim='\t'), df)
|
||||||
|
elseif format == :json
|
||||||
|
df -> sprint(JSON3.pretty,
|
||||||
|
[Dict([string(col) => df[i, col]
|
||||||
|
for col in names(df)])
|
||||||
|
for i in 1:size(df, 1)])
|
||||||
|
elseif format == :sqlite
|
||||||
|
df -> (mktemp() do path, io
|
||||||
|
SQLite.load!(df, SQLite.DB(path), "results")
|
||||||
|
read(path)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
questions(s::Survey; cache::Bool=true) = questions(s.id; cache)
|
||||||
|
responseids(s::Survey; cache::Bool=true) = responseids(s.id; cache)
|
||||||
|
response(s::Survey; cache::Bool=true) = response(s.id; cache)
|
||||||
|
results(s::Survey; cache::Bool=true) = results(s.id; cache)
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Writing
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
function register!(survey::Survey)
|
||||||
|
qvaltype(::Question{<:Surveys.FormField{T}}) where {T} = T
|
||||||
|
if survey.id ∉ keys(surveys())
|
||||||
|
name = if isnothing(survey.name) "NULL" else
|
||||||
|
SearchLight.escape_value(survey.name) end
|
||||||
|
srepr = repr(survey) |> SearchLight.escape_value
|
||||||
|
SearchLight.query("INSERT INTO surveys (id, name, repr) VALUES ($(survey.id), $name, $srepr)")
|
||||||
|
for (qid, q) in survey.questions
|
||||||
|
qid_s = string(qid) |> SearchLight.escape_value
|
||||||
|
prompt = q.prompt |> SearchLight.escape_value
|
||||||
|
type = string(qvaltype(q)) |> SearchLight.escape_value
|
||||||
|
field = string(typeof(q.field)) |> SearchLight.escape_value
|
||||||
|
SearchLight.query("INSERT INTO questions (survey, id, type, prompt, input) VALUES ($(survey.id), $qid_s, $type, $prompt, $field)")
|
||||||
|
end
|
||||||
|
SURVEYS[survey.id] = survey.name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function register!(response::Response; cache::Bool=true)
|
||||||
|
if response.survey ∉ keys(surveys(;cache))
|
||||||
|
register!(response.survey)
|
||||||
|
end
|
||||||
|
@assert response.id ∉ responseids(response.survey; cache)
|
||||||
|
SearchLight.query("INSERT INTO responses (survey, id, started, page) VALUES ($(response.survey), $(response.id), '$(string(response.started))', $(response.page))")
|
||||||
|
for (qid, ans) in response.answers
|
||||||
|
qid_s = SearchLight.escape_value(string(qid))
|
||||||
|
value_s = SearchLight.escape_value(repr(ans.value))
|
||||||
|
SearchLight.query("INSERT INTO results (survey, response, question, value) VALUES ($(response.survey), $(response.id), $qid_s, $value_s)")
|
||||||
|
end
|
||||||
|
RESPONSES[response.survey][response.id] = nothing
|
||||||
|
end
|
||||||
|
|
||||||
|
function deregister!(response::Response; cache::Bool=true)
|
||||||
|
@assert response.id ∈ responseids(response.survey; cache)
|
||||||
|
SearchLight.query("DELETE FROM responses WHERE survey = $(response.survey) AND id = $(response.id)")
|
||||||
|
SearchLight.query("DELETE FROM results WHERE survey = $(response.survey) AND response = $(response.id)")
|
||||||
|
delete!(RESPONSES[response.survey], response.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
function save!(response::Response, questionsids::Vector{Symbol}=keys(response.answers); cache::Bool=true)
|
||||||
|
@assert response.id ∈ responseids(response.survey; cache)
|
||||||
|
if !isnothing(response.completed)
|
||||||
|
SearchLight.query("UPDATE responses SET completed = '$(string(response.completed))' WHERE survey = $(response.survey) AND id = $(response.id)")
|
||||||
|
end
|
||||||
|
SearchLight.query("UPDATE responses SET page = $(response.page) WHERE survey = $(response.survey) AND id = $(response.id)")
|
||||||
|
for qid in questionsids
|
||||||
|
qid_s = SearchLight.escape_value(string(qid))
|
||||||
|
ans = response.answers[qid]
|
||||||
|
value_s = SearchLight.escape_value(repr(ans.value))
|
||||||
|
SearchLight.query("UPDATE results SET value = $value_s WHERE survey = $(response.survey) AND response = $(response.id) AND question = $qid_s")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
save!(response::Response, questions::Vector{Question}; cache::Bool=true) =
|
||||||
|
save!(response, getfield.(questions, :id); cache)
|
||||||
|
|
||||||
|
save!(response::Response, question::Question; cache::Bool=true) =
|
||||||
|
save!(response, [question.id]; cache)
|
||||||
|
|
||||||
|
save!(response::Response, part::SurveyPart; cache::Bool=true) =
|
||||||
|
save!(response, part.questions; cache)
|
||||||
|
|
||||||
|
save!(response::Response, survey::Survey; cache::Bool=true) =
|
||||||
|
save!(response, survey.questions; cache)
|
||||||
|
|
||||||
|
function clear!(survey::Survey)
|
||||||
|
SearchLight.query("DELETE FROM survey WHERE id = $(survey.id)")
|
||||||
|
SearchLight.query("DELETE FROM questions WHERE survey = $(survey.id)")
|
||||||
|
SearchLight.query("DELETE FROM responses WHERE survey = $(survey.id)")
|
||||||
|
SearchLight.query("DELETE FROM results WHERE survey = $(survey.id)")
|
||||||
|
end
|
||||||
|
|
||||||
|
function clear!(response::Response)
|
||||||
|
SearchLight.query("DELETE FROM responses WHERE survey = $(response.survey), id = $(response.id)")
|
||||||
|
SearchLight.query("DELETE FROM results WHERE survey = $(response.survey), response = $(response.id)")
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,3 +1,44 @@
|
||||||
module ResultsController
|
module ResultsController
|
||||||
# Build something great
|
|
||||||
|
using Genie, Genie.Renderer, Genie.Renderers.Html, HTTP, JSON3, StatsBase
|
||||||
|
|
||||||
|
using Results, Surveys
|
||||||
|
|
||||||
|
function resultsindex(survey::Union{SurveyID, Nothing}=nothing)
|
||||||
|
if isnothing(survey)
|
||||||
|
html(:results, :listsurveys, layout=:base;
|
||||||
|
surveys, questions, responseids)
|
||||||
|
else
|
||||||
|
html(:results, :survey, layout=:base;
|
||||||
|
name=surveys()[survey], id=survey,
|
||||||
|
questions, sresults=results(survey),
|
||||||
|
countmap)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function resultsfile(survey::SurveyID, format::AbstractString)
|
||||||
|
@assert survey in keys(surveys())
|
||||||
|
if format == "txt"
|
||||||
|
WebRenderable(sprint(show, results(survey)), :text) |>
|
||||||
|
Genie.Renderer.respond
|
||||||
|
elseif format in ("csv", "tsv", "json")
|
||||||
|
WebRenderable(results(survey, format=Symbol(format)),
|
||||||
|
if format == "json"; :json else :text end) |>
|
||||||
|
Genie.Renderer.respond
|
||||||
|
elseif format == "db" || format == "sqlite"
|
||||||
|
HTTP.Response(200, ["Content-Type" => "application/octet-stream"],
|
||||||
|
body = results(survey, format=:sqlite))
|
||||||
|
else
|
||||||
|
error("format $format not recognised")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function listsurveys()
|
||||||
|
WebRenderable(sprint(JSON3.pretty,
|
||||||
|
[Dict(:id => id,
|
||||||
|
:name => name)
|
||||||
|
for (id, name) in Results.surveys()]),
|
||||||
|
:json) |> Genie.Renderer.respond
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
<header>
|
||||||
|
<h1>Recorded Surveys</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Questions</th>
|
||||||
|
<th>Responses</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<%
|
||||||
|
[tr(string(td(a(name, href=string("/results/", id))),
|
||||||
|
td(code(string(id))),
|
||||||
|
td(string(length(questions(id)))),
|
||||||
|
td(string(length(responseids(id))))))
|
||||||
|
for (id, name) in surveys()] |> join
|
||||||
|
%>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
|
@ -0,0 +1,51 @@
|
||||||
|
<style>
|
||||||
|
svg > g > rect {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
svg > g > rect:hover {
|
||||||
|
color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
svg > g > text {
|
||||||
|
font-size: 2pt;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h1>$(name)</h1>
|
||||||
|
<span>
|
||||||
|
<strong>$(size(sresults, 1)) results also available as</strong>
|
||||||
|
<a href="$(id).txt" target="_blank">Text</a>,
|
||||||
|
<a href="$(id).csv" target="_blank">CSV</a>,
|
||||||
|
<a href="$(id).tsv" target="_blank">TSV</a>,
|
||||||
|
<a href="$(id).json" target="_blank">JSON</a>,
|
||||||
|
<a href="$(id).db" target="_blank">SQLite DB</a>
|
||||||
|
</span>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<% [ %>
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
$(question[:prompt])
|
||||||
|
<small><code>$(question[:type])</code></small>
|
||||||
|
</summary>
|
||||||
|
<% let qres=filter(!ismissing, sresults[!, qid])
|
||||||
|
if length(qres) > 0
|
||||||
|
let qresexpanded=if qres[1] isa Vector; collect(Iterators.flatten(qres)) else qres end
|
||||||
|
qresfreq=sort(countmap(qresexpanded) |> collect, by=x->-x[2])
|
||||||
|
maxfreq=qresfreq[1][2] %>
|
||||||
|
<svg viewBox="0 0 130 $(6*length(qresfreq))">
|
||||||
|
<% [ %>
|
||||||
|
<g class="bar">
|
||||||
|
<rect width="$(100*res[2]/maxfreq)" height="4" y="$(6*(i-1))"></rect>
|
||||||
|
<text x="$(5+100*res[2]/maxfreq)" y="$(3+6*(i-1))" dy=".05em">$(res[1])</text>
|
||||||
|
</g>
|
||||||
|
<% for (i, res) in enumerate(qresfreq)] |> join %>
|
||||||
|
</svg>
|
||||||
|
<% end end end %>
|
||||||
|
</details>
|
||||||
|
<% for (qid, question) in questions(id)] |> join %>
|
||||||
|
</main>
|
|
@ -0,0 +1,162 @@
|
||||||
|
module SurveysController
|
||||||
|
|
||||||
|
using Genie.Router, Genie.Requests, Genie.Renderers.Html, HTTP
|
||||||
|
|
||||||
|
using Results, Surveys, Dates
|
||||||
|
|
||||||
|
const SURVEY = include("../../../config/survey.jl")
|
||||||
|
const INPROGRESS = Dict{Surveys.ResponseID, Surveys.Response}()
|
||||||
|
|
||||||
|
const UID_ENCBASE = 36
|
||||||
|
|
||||||
|
const RESUME_COOKIE_MAX_AGE = 60*60*24*2 # two days, in seconds
|
||||||
|
|
||||||
|
donesetup = false
|
||||||
|
function setup()
|
||||||
|
global donesetup
|
||||||
|
if !donesetup
|
||||||
|
register!(SURVEY)
|
||||||
|
for rid in responseids(SURVEY.id, :incomplete)
|
||||||
|
INPROGRESS[rid] = Results.response(SURVEY.id, rid)
|
||||||
|
end
|
||||||
|
donesetup = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function index()
|
||||||
|
setup()
|
||||||
|
request = Genie.Router.params()[:REQUEST]
|
||||||
|
responseid = Genie.Cookies.get(request, "response-id")
|
||||||
|
html(:surveys, :index, layout=:base;
|
||||||
|
survey=SURVEY, responseid)
|
||||||
|
end
|
||||||
|
|
||||||
|
function new()
|
||||||
|
r = Surveys.Response(SURVEY, vcat(responseids(SURVEY),
|
||||||
|
Vector{Surveys.ResponseID}(keys(INPROGRESS) |> collect)))
|
||||||
|
INPROGRESS[r.id] = r
|
||||||
|
register!(r)
|
||||||
|
uid_str = string(r.id, base=UID_ENCBASE)
|
||||||
|
Genie.Renderer.redirect(HTTP.URIs.URI(currenturl()).path * "?uid=$uid_str&page=1")
|
||||||
|
end
|
||||||
|
|
||||||
|
function completed(uid)
|
||||||
|
res = html(:surveys, :thanks, layout=:base;
|
||||||
|
uid=string(uid, base=UID_ENCBASE), survey=SURVEY)
|
||||||
|
Genie.Cookies.set!(res, "response-id", "", Dict{String, Any}("maxage" => -1))
|
||||||
|
Genie.Cookies.set!(res, "response-page", 0, Dict{String, Any}("maxage" => -1))
|
||||||
|
end
|
||||||
|
|
||||||
|
function serve(forminfo::Dict)
|
||||||
|
setup()
|
||||||
|
uid = if haskey(forminfo, :uid)
|
||||||
|
tryparse(Surveys.ResponseID, forminfo[:uid], base=UID_ENCBASE)
|
||||||
|
end
|
||||||
|
if haskey(forminfo, :uid) && !haskey(INPROGRESS, uid) &&
|
||||||
|
uid ∈ Results.responseids(SURVEY.id)
|
||||||
|
# response has been completed
|
||||||
|
completed(uid)
|
||||||
|
elseif !haskey(forminfo, :uid) || !haskey(INPROGRESS, uid)
|
||||||
|
clear = if haskey(forminfo, :clear)
|
||||||
|
tryparse(Surveys.ResponseID, forminfo[:clear], base=UID_ENCBASE)
|
||||||
|
end
|
||||||
|
if haskey(INPROGRESS, clear)
|
||||||
|
deregister!(INPROGRESS[clear])
|
||||||
|
delete!(INPROGRESS, clear)
|
||||||
|
end
|
||||||
|
new()
|
||||||
|
elseif !haskey(forminfo, :page)
|
||||||
|
Genie.Renderer.redirect(string(currenturl(),
|
||||||
|
"&page=", INPROGRESS[uid].page))
|
||||||
|
else
|
||||||
|
uid_str = string(uid, base=UID_ENCBASE)
|
||||||
|
page = parse(Int, forminfo[:page])
|
||||||
|
if page > length(SURVEY)
|
||||||
|
@info SURVEY => INPROGRESS[uid]
|
||||||
|
INPROGRESS[uid].completed = now()
|
||||||
|
save!(INPROGRESS[uid], Symbol[])
|
||||||
|
delete!(INPROGRESS, uid)
|
||||||
|
completed(uid)
|
||||||
|
else
|
||||||
|
res = html(:surveys, :survey, layout=:base,
|
||||||
|
uid=uid_str, survey=SURVEY,
|
||||||
|
response=INPROGRESS[uid], page=page)
|
||||||
|
Genie.Cookies.set!(res, "response-id", uid_str,
|
||||||
|
Dict{String, Any}("maxage" => RESUME_COOKIE_MAX_AGE,
|
||||||
|
"httponly" => true))
|
||||||
|
Genie.Cookies.set!(res, "response-page", INPROGRESS[uid].page,
|
||||||
|
Dict{String, Any}("maxage" => RESUME_COOKIE_MAX_AGE,
|
||||||
|
"httponly" => true))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function submit(forminfo::Dict; backpage::Bool=false)
|
||||||
|
uid = parse(Surveys.ResponseID, forminfo[:uid], base=UID_ENCBASE)
|
||||||
|
page = parse(Int, forminfo[:page])
|
||||||
|
data = reduce(function(acc::Dict, datum::Pair)
|
||||||
|
key, value = datum
|
||||||
|
if key ∈ (:uid, :page, :debug)
|
||||||
|
elseif match(r"\[\]$", string(key)) |> !isnothing
|
||||||
|
pkey = Symbol(replace(string(key), r"\[\]$" => ""))
|
||||||
|
acc[pkey] = value
|
||||||
|
elseif match(r"__override$", string(key)) |> !isnothing
|
||||||
|
pkey = Symbol(replace(string(key), r"__override$" => ""))
|
||||||
|
acc[pkey] = vcat(value)
|
||||||
|
else
|
||||||
|
acc[key] = vcat(value)
|
||||||
|
end
|
||||||
|
acc
|
||||||
|
end, forminfo, init=Dict{Symbol, Any}())
|
||||||
|
# Questions on this page with no data are unanswered, but should be updated anyway
|
||||||
|
for qid in getproperty.(SURVEY[page].questions, :id)
|
||||||
|
if !haskey(data, qid)
|
||||||
|
data[qid] = missing
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if forminfo[:debug] != "yes"
|
||||||
|
uid_str = string(uid, base=UID_ENCBASE)
|
||||||
|
response = INPROGRESS[uid]
|
||||||
|
update!(response, SURVEY, data)
|
||||||
|
@info SURVEY[page] => response
|
||||||
|
if backpage || isvalid(response, SURVEY[page])
|
||||||
|
response.page = if backpage
|
||||||
|
max(1, page - 1)
|
||||||
|
else
|
||||||
|
max(response.page, page + 1)
|
||||||
|
end
|
||||||
|
save!(response, SURVEY[page])
|
||||||
|
Genie.Renderer.redirect("/survey?uid=$uid_str&page=$(response.page)")
|
||||||
|
else
|
||||||
|
save!(response, SURVEY[page])
|
||||||
|
Genie.Renderer.redirect("/survey?uid=$uid_str&page=$page")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
io = IOBuffer()
|
||||||
|
ioc = IOContext(io, :limit => true, :displaysize => (80, 100))
|
||||||
|
show(ioc, "text/plain", data)
|
||||||
|
postdata = String(take!(io))
|
||||||
|
html("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Emacs User Survey</title>
|
||||||
|
<link href="/css/style.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>Data from page $page, response $(sprint(show, uid))</header>
|
||||||
|
<main><pre><code> $(postdata) </code></pre></main>
|
||||||
|
<footer>
|
||||||
|
This work is licensed under a
|
||||||
|
<a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/">CC-BY-SA 4.0 License</a>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -0,0 +1,33 @@
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h1>$(survey.name)</h1>
|
||||||
|
<% if !isnothing(survey.description) %>
|
||||||
|
<h2>$(survey.description)</h2>
|
||||||
|
<% else %>
|
||||||
|
<br />
|
||||||
|
<% end %>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<% if isnothing(responseid) %>
|
||||||
|
<main>
|
||||||
|
<form method="get" action="/survey">
|
||||||
|
<button type="submit">Take the survey</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<% else %>
|
||||||
|
<main>
|
||||||
|
<form method="get" action="/survey">
|
||||||
|
<div class="grid">
|
||||||
|
<input type="text" name="uid" value="$(responseid)"
|
||||||
|
placeholder="Response ID" style ="font-family:monospace"/>
|
||||||
|
<button type="submit">Resume survey</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<hr /> <br />
|
||||||
|
<form method="get" action="/survey">
|
||||||
|
<input type="hidden" name="clear" value="$(responseid)" />
|
||||||
|
<button id="do-btn" type="submit" class="secondary">Start a new response</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<% end %>
|
|
@ -0,0 +1,57 @@
|
||||||
|
<style>
|
||||||
|
label[data-mandatory="true"] > span::after,
|
||||||
|
legend[data-mandatory="true"]::after
|
||||||
|
{
|
||||||
|
content: ' *';
|
||||||
|
color: var(--del-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
small.formerror {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--del-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h1>$(survey.name)</h1>
|
||||||
|
<h2>Survey Attempt ID
|
||||||
|
<code style="color:var(--primary)">$(uid)</code>
|
||||||
|
<% if length(survey) > 1 %>
|
||||||
|
<span style="float:right">
|
||||||
|
Page $(page) / $(length(survey))
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</h2>
|
||||||
|
</hgroup>
|
||||||
|
<progress value="$(page)" max="$(length(survey))">
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<% if !isnothing(survey[page].label) %>
|
||||||
|
<h2>$(survey[page].label)</h2>
|
||||||
|
<% end %>
|
||||||
|
<form method="POST" action="/submit" enctype="application/x-www-form-urlencoded" novalidate>
|
||||||
|
<section>
|
||||||
|
<input type="hidden" name="uid" value="$(uid)" />
|
||||||
|
<input type="hidden" name="page" value="$(page)" />
|
||||||
|
$(sprint(show, MIME("text/html"), survey[page] => response))
|
||||||
|
</section>
|
||||||
|
<% if page > 1 %>
|
||||||
|
<div class="grid">
|
||||||
|
<button class="secondary" type="submit" formaction="/submit-backpage" formnovalidate="true">Previous page</button>
|
||||||
|
<button type="submit" style="grid-column: span 2">$(cta = if page < length(survey); "Next page" else "Submit" end)</button>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<button type="submit">$(cta = if page < length(survey); "Next page" else "Submit" end)</button>
|
||||||
|
<% end %>
|
||||||
|
<hr /> <br />
|
||||||
|
<input type="hidden" name="debug" value="no" />
|
||||||
|
<small>
|
||||||
|
<label for="qn-debug">
|
||||||
|
<input type="checkbox" id="qn-debug" name="debug" role="switch" value="yes">
|
||||||
|
Debug
|
||||||
|
</label>
|
||||||
|
</small>
|
||||||
|
</form>
|
||||||
|
</main>
|
|
@ -0,0 +1,16 @@
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h1>$(survey.name)</h1>
|
||||||
|
<h2>Survey Attempt
|
||||||
|
<code style="color:var(--primary)">$(uid)</code>
|
||||||
|
has been recorded
|
||||||
|
</h2>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h2 style="color: var(--primary)"><em>Thank you</em> for taking part in the survey!</h2>
|
||||||
|
<h5>We hope to see you next year 🙂</h5>
|
||||||
|
<br /><br />
|
||||||
|
<a href="/" role="button">Go Home</a>
|
||||||
|
</main>
|
|
@ -6,6 +6,13 @@ try
|
||||||
if SearchLight.config.db_config_settings["adapter"] !== nothing
|
if SearchLight.config.db_config_settings["adapter"] !== nothing
|
||||||
eval(Meta.parse("using SearchLight$(SearchLight.config.db_config_settings["adapter"])"))
|
eval(Meta.parse("using SearchLight$(SearchLight.config.db_config_settings["adapter"])"))
|
||||||
SearchLight.connect()
|
SearchLight.connect()
|
||||||
|
|
||||||
|
if !(SearchLight.config.db_migrations_table_name in
|
||||||
|
SearchLight.query("SELECT name FROM sqlite_master WHERE type='table'").name)
|
||||||
|
SearchLight.Migration.create_migrations_table()
|
||||||
|
SearchLight.Migrations.last_up() # probably a good idea
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
catch ex
|
catch ex
|
||||||
@error ex
|
@error ex
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Survey("Emacs User Survey — 2022")
|
|
@ -2,7 +2,7 @@ env: ENV["GENIE_ENV"]
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
adapter: SQLite
|
adapter: SQLite
|
||||||
database: db/survey.sqlite
|
database: db/results.sqlite
|
||||||
config:
|
config:
|
||||||
|
|
||||||
prod:
|
prod:
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
module CreateTableResults
|
||||||
|
|
||||||
|
import SearchLight.Migrations: create_table, column, columns, primary_key, add_index, drop_table, add_indices
|
||||||
|
|
||||||
|
function up()
|
||||||
|
create_table(:surveys) do
|
||||||
|
[
|
||||||
|
primary_key(:id),
|
||||||
|
column(:name, :text),
|
||||||
|
column(:repr, :text, not_null=true)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table(:questions) do
|
||||||
|
[
|
||||||
|
column(:survey, :integer, not_null=true),
|
||||||
|
column(:id, :text, not_null=true),
|
||||||
|
column(:type, :text, not_null=true),
|
||||||
|
column(:prompt, :text, not_null=true),
|
||||||
|
column(:input, :text, not_null=true)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table(:responses) do
|
||||||
|
[
|
||||||
|
column(:survey, :integer, not_null=true),
|
||||||
|
column(:id, :integer, not_null=true),
|
||||||
|
column(:started, :text, not_null=true),
|
||||||
|
column(:completed, :text),
|
||||||
|
column(:page, :integer, not_null=true),
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table(:results) do
|
||||||
|
[
|
||||||
|
column(:survey, :integer, not_null=true),
|
||||||
|
column(:response, :integer, not_null=true),
|
||||||
|
column(:question, :integer, not_null=true),
|
||||||
|
column(:value, :text, not_null=true)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index(:questions, :survey)
|
||||||
|
add_index(:results, :survey)
|
||||||
|
add_indices(:results, [:response, :question])
|
||||||
|
end
|
||||||
|
|
||||||
|
function down()
|
||||||
|
drop_table(:surveys)
|
||||||
|
drop_table(:questions)
|
||||||
|
drop_table(:results)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -1,24 +0,0 @@
|
||||||
module CreateTableResults
|
|
||||||
|
|
||||||
import SearchLight.Migrations: create_table, column, columns, primary_key, add_index, drop_table, add_indices
|
|
||||||
|
|
||||||
function up()
|
|
||||||
create_table(:results) do
|
|
||||||
[
|
|
||||||
primary_key()
|
|
||||||
column(:column_name, :column_type)
|
|
||||||
columns([
|
|
||||||
:column_name => :column_type
|
|
||||||
])
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
add_index(:results, :column_name)
|
|
||||||
add_indices(results, :column_name_1, :column_name_2)
|
|
||||||
end
|
|
||||||
|
|
||||||
function down()
|
|
||||||
drop_table(:results)
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
|
@ -0,0 +1,645 @@
|
||||||
|
module Surveys
|
||||||
|
|
||||||
|
using Dates
|
||||||
|
using Genie.Renderers.Html
|
||||||
|
import Base: show, isvalid, isempty
|
||||||
|
|
||||||
|
export Survey, SurveyPart, Question,
|
||||||
|
Answer, Response, update!, clear!, nonempty,
|
||||||
|
SurveyID, ResponseID,
|
||||||
|
Checkbox, TextInput, DateInput, NumberInput, IntegerInput,
|
||||||
|
TextArea, Dropdown, RadioSelect, MultiSelect, RangeSelect
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Main Types
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
# Input Field
|
||||||
|
|
||||||
|
abstract type FormField{T} end
|
||||||
|
|
||||||
|
# Question
|
||||||
|
|
||||||
|
struct Question{F <: FormField}
|
||||||
|
id::Symbol
|
||||||
|
prompt::AbstractString
|
||||||
|
field::F
|
||||||
|
validators::Vector{Function}
|
||||||
|
postprocessors::Vector{Function}
|
||||||
|
function Question(id::Symbol, prompt::AbstractString,
|
||||||
|
field::F, validators::Vector{Function},
|
||||||
|
postprocessors::Vector{Function}) where {F<:FormField}
|
||||||
|
if id in (:uid, :page)
|
||||||
|
@warn "Question uses a reserved id $id, this is likely to cause issues"
|
||||||
|
end
|
||||||
|
postprocessors = map(p -> if p == last; lastsoftfail else p end, postprocessors)
|
||||||
|
new{F}(id, prompt, field, validators, postprocessors)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function nonempty(value::AbstractString)
|
||||||
|
if ismissing(value) || (isa(value, AbstractString) && isempty(strip(value)))
|
||||||
|
"Must be answered"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function nonempty(values::Vector{<:AbstractString})
|
||||||
|
if length(values) == 0 || all(isempty, strip.(values))
|
||||||
|
"Must be answered"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
nonempty(::Bool) = nothing
|
||||||
|
|
||||||
|
lastsoftfail(v::Vector) = if length(v) > 0
|
||||||
|
last(v)
|
||||||
|
else
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
defaultpostprocessors(::FormField) = Function[last, strip]
|
||||||
|
|
||||||
|
function Question(id::Symbol, prompt::AbstractString, field::FormField;
|
||||||
|
validators::Union{Function, Vector{<:Function}}=Function[],
|
||||||
|
postprocessors::Union{Function, Vector{<:Function}}=defaultpostprocessors(field),
|
||||||
|
mandatory::Bool=true)
|
||||||
|
fullvalidators = if mandatory
|
||||||
|
vcat(nonempty, validators)
|
||||||
|
else
|
||||||
|
vcat(validators)
|
||||||
|
end
|
||||||
|
Question(id, prompt, field, fullvalidators, postprocessors)
|
||||||
|
end
|
||||||
|
|
||||||
|
function prompttoid(prompt::String)
|
||||||
|
prompt |>
|
||||||
|
p -> replace(p, r"[^A-Za-z0-9\s]" => "") |>
|
||||||
|
p -> replace(p, r"\s+" => "_") |>
|
||||||
|
lowercase |>
|
||||||
|
Symbol
|
||||||
|
end
|
||||||
|
|
||||||
|
Question(prompt::AbstractString, field::FormField; kargs...) =
|
||||||
|
Question(prompttoid(prompttoid), prompt, field; kargs...)
|
||||||
|
|
||||||
|
# Field-based question constructors
|
||||||
|
|
||||||
|
function (F::Type{<:FormField})(id::Symbol, prompt::AbstractString, args...;
|
||||||
|
validators::Union{Function, Vector{<:Function}}=Function[],
|
||||||
|
mandatory::Bool=true,
|
||||||
|
kwargs...)
|
||||||
|
Question(id, prompt, F(args...; kwargs...); validators, mandatory)
|
||||||
|
end
|
||||||
|
function (F::Type{<:FormField})(prompt::AbstractString, args...; kwargs...)
|
||||||
|
F(prompttoid(prompt), prompt, args...; kwargs...)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Survey Part
|
||||||
|
|
||||||
|
struct SurveyPart
|
||||||
|
label::Union{AbstractString, Nothing}
|
||||||
|
questions::Vector{Question}
|
||||||
|
end
|
||||||
|
|
||||||
|
Base.getindex(p::SurveyPart, id::Symbol) = findfirst(q -> q.id == id, p.questions)
|
||||||
|
Base.getindex(p::SurveyPart, index::Integer) = p.questions[index]
|
||||||
|
|
||||||
|
Base.length(p::SurveyPart) = length(p.questions)
|
||||||
|
|
||||||
|
SurveyPart(label::Union{AbstractString, Nothing}, questions::Question...) =
|
||||||
|
SurveyPart(label, questions |> collect)
|
||||||
|
SurveyPart(questions::Question...) = SurveyPart(nothing, questions |> collect)
|
||||||
|
|
||||||
|
# Survey
|
||||||
|
|
||||||
|
const SurveyID = UInt32
|
||||||
|
|
||||||
|
struct Survey
|
||||||
|
id::SurveyID
|
||||||
|
name::AbstractString
|
||||||
|
description::Union{AbstractString, Nothing}
|
||||||
|
parts::Vector{Pair{Union{AbstractString, Nothing}, Vector{Symbol}}}
|
||||||
|
questions::Dict{Symbol, Question}
|
||||||
|
end
|
||||||
|
|
||||||
|
Base.getindex(s::Survey, id::Symbol) = s.questions[id]
|
||||||
|
Base.getindex(s::Survey, part::Integer) =
|
||||||
|
SurveyPart(s.parts[part].first,
|
||||||
|
getindex.(Ref(s.questions), s.parts[part].second))
|
||||||
|
|
||||||
|
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,
|
||||||
|
description::Union{AbstractString, Nothing},
|
||||||
|
parts::SurveyPart...) =
|
||||||
|
Survey(name, description,
|
||||||
|
map(p -> p.label => getfield.(p.questions, :id), parts) |> collect,
|
||||||
|
Dict(q.id => q for q in
|
||||||
|
Iterators.flatten(getfield.(parts, :questions))))
|
||||||
|
Survey(name::AbstractString, parts::SurveyPart...) =
|
||||||
|
Survey(name, nothing, parts...)
|
||||||
|
|
||||||
|
Survey(name::AbstractString,
|
||||||
|
description::Union{AbstractString, Nothing},
|
||||||
|
questions::Question...) =
|
||||||
|
Survey(name, description,
|
||||||
|
[nothing => getfield.(questions, :id) |> collect],
|
||||||
|
Dict(q.id => q for q in questions))
|
||||||
|
Survey(name::AbstractString, questions::Question...) =
|
||||||
|
Survey(name, nothing, questions...)
|
||||||
|
|
||||||
|
# Answer to a Question
|
||||||
|
|
||||||
|
struct Answer{T}
|
||||||
|
value::Union{T, Missing}
|
||||||
|
error::Union{AbstractString, Nothing}
|
||||||
|
end
|
||||||
|
|
||||||
|
Answer{T}() where {T <: Any} = Answer{T}(missing, nothing)
|
||||||
|
isempty(a::Answer) = ismissing(a.value)
|
||||||
|
isempty(a::Answer{<:AbstractString}) = ismissing(a.value) || isempty(a.value)
|
||||||
|
|
||||||
|
# Survey Response
|
||||||
|
|
||||||
|
const ResponseID = UInt32
|
||||||
|
|
||||||
|
mutable struct Response
|
||||||
|
survey::SurveyID
|
||||||
|
id::ResponseID
|
||||||
|
page::Integer
|
||||||
|
answers::Dict{Symbol, Answer}
|
||||||
|
started::DateTime
|
||||||
|
completed::Union{DateTime, Nothing}
|
||||||
|
end
|
||||||
|
|
||||||
|
Base.getindex(r::Response, id::Symbol) = r.answers[id]
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Handling responses
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
# Response templates
|
||||||
|
|
||||||
|
Answer(::Question{<:FormField{T}}) where {T} = Answer{T}(missing, nothing)
|
||||||
|
|
||||||
|
Response(s::Survey, id::ResponseID=rand(ResponseID)) =
|
||||||
|
Response(s.id, id, 1,
|
||||||
|
Dict(q.id => Answer(q) for q in
|
||||||
|
Iterators.flatten([s[i].questions for i in 1:length(s)])),
|
||||||
|
now(), nothing)
|
||||||
|
|
||||||
|
function Response(s::Survey, oldids::Vector{ResponseID})
|
||||||
|
newid = rand(ResponseID)
|
||||||
|
while newid in oldids
|
||||||
|
newid = rand(ResponseID)
|
||||||
|
end
|
||||||
|
Response(s, newid)
|
||||||
|
end
|
||||||
|
|
||||||
|
interpret(::FormField{<:AbstractString}, value::AbstractString) = value
|
||||||
|
interpret(::FormField{Number}, value::AbstractString) =
|
||||||
|
something(tryparse(Int64, value), parse(Float64, value))
|
||||||
|
interpret(::FormField{T}, value::AbstractString) where {T} = parse(T, value)
|
||||||
|
|
||||||
|
# Response interpretation
|
||||||
|
|
||||||
|
function Answer(q::Question{<:FormField{T}}, value::Union{String, Vector{String}}) where {T}
|
||||||
|
try
|
||||||
|
processedvalue = interpret(q.field, ∘(identity, reverse(q.postprocessors)...)(value))
|
||||||
|
error = nothing
|
||||||
|
for validator in q.validators
|
||||||
|
error = validator(processedvalue)
|
||||||
|
isnothing(error) || break
|
||||||
|
end
|
||||||
|
Answer{T}(processedvalue, error)
|
||||||
|
catch e
|
||||||
|
@warn "Answer construction failure" exception=(e, catch_backtrace())
|
||||||
|
Answer{T}(missing, first(split(sprint(showerror, e), '\n')))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Answer(q::Question{<:FormField{T}}, ::Missing) where {T}
|
||||||
|
if nonempty in q.validators
|
||||||
|
Answer{T}(missing, "Must be answered")
|
||||||
|
else
|
||||||
|
Answer{T}(missing, nothing)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Response updating
|
||||||
|
|
||||||
|
function update!(r::Response, s::Survey, datum::Pair{Symbol, <:Any})
|
||||||
|
id, value = datum
|
||||||
|
if haskey(r.answers, id) && haskey(s.questions, id)
|
||||||
|
r.answers[id] = Answer(s.questions[id], value)
|
||||||
|
else
|
||||||
|
@warn "$id not in response"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function update!(r::Response, s::Survey, data::Dict{Symbol, <:Any})
|
||||||
|
foreach(data) do datum
|
||||||
|
update!(r, s, datum)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
clear!(r::Response, ids::Vector{Symbol}) =
|
||||||
|
foreach(ids) do id
|
||||||
|
r.answers[id] = Answer{typeof(r.answers[id])}()
|
||||||
|
end
|
||||||
|
|
||||||
|
clear!(r::Response, q::Question) = clear!(r, [q.id])
|
||||||
|
clear!(r::Response, p::SurveyPart) = clear!(r, keys(p.questions))
|
||||||
|
clear!(r::Response, s::Survey) = clear!(r, keys(s.questions))
|
||||||
|
clear!(r::Response) = clear!(r, keys(r.answers))
|
||||||
|
|
||||||
|
# Response validity
|
||||||
|
|
||||||
|
isvalid(a::Answer) = isnothing(a.error)
|
||||||
|
isvalid(a::Answer, q::Question) =
|
||||||
|
isvalid(a) && !(isempty(a) && nonempty in q.validators)
|
||||||
|
isvalid(r::Response, q::Question) = isvalid(r.answers[q.id], q)
|
||||||
|
isvalid(r::Response, p::SurveyPart) = all(isvalid.(Ref(r), p.questions))
|
||||||
|
isvalid(r::Response, s::Survey) =
|
||||||
|
all(isvalid.(Ref(r), Iterators.flatten([s[i].questions for i in 1:length(s)])))
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# General htmlrenderer
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
htmlcontent(::FormField, value) = ""
|
||||||
|
htmlelement(::FormField) = "?"
|
||||||
|
htmlattrs(::FormField, value) = []
|
||||||
|
htmlvoidelem(::FormField) = false
|
||||||
|
htmlpostprocess(::FormField, ::Symbol) = identity
|
||||||
|
|
||||||
|
elem(e::AbstractString, content::AbstractString="", attrs::Pair{Symbol,<:Any}...) =
|
||||||
|
Html.normal_element(content, e, [], attrs...)
|
||||||
|
elem(e::AbstractString, attrs::Pair{Symbol,<:Any}...) = elem(e, "", attrs...)
|
||||||
|
|
||||||
|
velem(e::AbstractString, attrs::Pair{Symbol,<:Any}...) =
|
||||||
|
Html.void_element(e, [], Vector{Pair{Symbol,Any}}(collect(attrs)))
|
||||||
|
|
||||||
|
function htmlrender(field::FormField, value::Any, id, mandatory, invalid)
|
||||||
|
element = htmlelement(field)
|
||||||
|
attrs = vcat(htmlattrs(field, value),
|
||||||
|
[:id => string("qn-", id),
|
||||||
|
:name => id,
|
||||||
|
Symbol("aria-invalid") => invalid,
|
||||||
|
:required => mandatory && !isa(field, Checkbox)])
|
||||||
|
if htmlvoidelem(field)
|
||||||
|
velem(element, attrs...)
|
||||||
|
else
|
||||||
|
content = htmlcontent(field, value)
|
||||||
|
elem(element, if ismissing(content) "" else string(content) end,
|
||||||
|
attrs...)
|
||||||
|
end |> htmlpostprocess(field, id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Form fields & html rendering
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
# <input>
|
||||||
|
|
||||||
|
struct FormInput{T} <: FormField{T}
|
||||||
|
FormInput{T}() where {T} =
|
||||||
|
if T <: Union{Bool, String, Integer, Number, Date}
|
||||||
|
new()
|
||||||
|
else
|
||||||
|
TypeError(:FormInput, Union{Bool, String, Integer, Number, Date}, T)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
htmlelement(::FormInput) = "input"
|
||||||
|
htmlvoidelem(::FormInput) = true
|
||||||
|
htmlattrs(::FormInput, value) =
|
||||||
|
[:value => if ismissing(value) false else string(value) end]
|
||||||
|
|
||||||
|
# <input type="checkbox">
|
||||||
|
|
||||||
|
interpret(::FormInput{Bool}, value::AbstractString) = value == "yes"
|
||||||
|
htmlattrs(::FormInput{Bool}, value::Union{Bool, Missing}) =
|
||||||
|
[:type => "checkbox", :value => "yes", :checked => !ismissing(value) && value === true]
|
||||||
|
htmlpostprocess(::FormInput{Bool}, id::Symbol) =
|
||||||
|
s -> string(input(type="hidden", name=id, value="no"), s)
|
||||||
|
|
||||||
|
# <input type="date|number|text">
|
||||||
|
|
||||||
|
function htmlattrs(::FormInput{T}, value) where {T <: Union{Date, Number, Integer, String}}
|
||||||
|
type = Dict(Date => "date",
|
||||||
|
Number => "number",
|
||||||
|
Integer => "number",
|
||||||
|
String => "text")[T]
|
||||||
|
[:type => type,
|
||||||
|
:value => if ismissing(value) false else string(value) end]
|
||||||
|
end
|
||||||
|
|
||||||
|
const Checkbox = FormInput{Bool}
|
||||||
|
const TextInput = FormInput{String}
|
||||||
|
const DateInput = FormInput{Date}
|
||||||
|
const NumberInput = FormInput{Number}
|
||||||
|
const IntegerInput = FormInput{Integer}
|
||||||
|
|
||||||
|
# <textarea>
|
||||||
|
|
||||||
|
struct TextArea <: FormField{String} end
|
||||||
|
htmlelement(::TextArea) = "textarea"
|
||||||
|
htmlcontent(::TextArea, value) = value
|
||||||
|
|
||||||
|
# <select>
|
||||||
|
|
||||||
|
struct Options
|
||||||
|
options::Vector{Pair{String, String}}
|
||||||
|
end
|
||||||
|
Options(vals::Vector{String}) = Options(map(v -> v => v, vals))
|
||||||
|
|
||||||
|
struct OptGroup
|
||||||
|
group::String
|
||||||
|
options::Options
|
||||||
|
end
|
||||||
|
OptGroup(gopt::Pair{String, Vector}) = OptGroup(gopt.first, Options(gopt.second))
|
||||||
|
|
||||||
|
struct Dropdown{O <: Union{Options, Vector{OptGroup}}} <: FormField{String}
|
||||||
|
options::O
|
||||||
|
end
|
||||||
|
Dropdown(opts::Union{Vector{String}, Vector{Pair{String, String}}}) =
|
||||||
|
Dropdown(Options(opts))
|
||||||
|
Dropdown(gopts::Vector{Pair{String, Vector}}) =
|
||||||
|
Dropdown([OptGroup(gopt) for gopt in gopts])
|
||||||
|
|
||||||
|
htmlelement(::Dropdown) = "select"
|
||||||
|
function htmlcontent(d::Dropdown{Options}, value)
|
||||||
|
string(option("Select one", value="", selected=ismissing(value),
|
||||||
|
disabled=true, hidden=true), '\n',
|
||||||
|
map(opt -> option(opt.second, value=opt.first,
|
||||||
|
selected=(!ismissing(value) && value==opt.first)) * '\n',
|
||||||
|
d.options.options)...)
|
||||||
|
end
|
||||||
|
|
||||||
|
function htmlcontent(d::Dropdown{Vector{OptGroup}}, value)
|
||||||
|
string(option("Select one", value="", selected=ismissing(value),
|
||||||
|
disabled=true, hidden=true),
|
||||||
|
map(d.options) do optgroup
|
||||||
|
elem("optgroup",
|
||||||
|
string(map(opt -> option(opt.second, value=opt.first,
|
||||||
|
selected=value==opt.first),
|
||||||
|
d.options)...),
|
||||||
|
:label => optgroup.group)
|
||||||
|
end...)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Many <input type="radio">
|
||||||
|
|
||||||
|
struct RadioSelect <: FormField{String}
|
||||||
|
options::Options
|
||||||
|
other::Bool
|
||||||
|
# Without an inner constructor an (::Any, ::Any) constructor
|
||||||
|
# is created for /some/ reason ... why!?
|
||||||
|
RadioSelect(options::Options, other::Bool) = new(options, other)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Many <input type="checkbox">
|
||||||
|
|
||||||
|
struct MultiSelect <: FormField{Vector{String}}
|
||||||
|
options::Options
|
||||||
|
other::Bool
|
||||||
|
# Without an inner constructor an (::Any, ::Any) constructor
|
||||||
|
# is created for /some/ reason ... why!?
|
||||||
|
MultiSelect(options::Options, other::Bool) = new(options, other)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Many <input type="radio|checkbox">
|
||||||
|
|
||||||
|
function (F::Type{<:Union{RadioSelect, MultiSelect}})(opts::Vector)
|
||||||
|
nosymbol = filter(o -> o isa String || o isa Pair{String, String}, opts)
|
||||||
|
F(Options(Vector{typeof(first(nosymbol))}(nosymbol)), :other in opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
interpret(::FormField{Vector{String}}, value::Vector{<:AbstractString}) =
|
||||||
|
value
|
||||||
|
|
||||||
|
defaultpostprocessors(::MultiSelect) = Function[s -> filter(!isempty, strip.(s))]
|
||||||
|
defaultpostprocessors(::RadioSelect) = Function[s -> filter(!isempty, strip.(s)), last]
|
||||||
|
|
||||||
|
# Render many <input type="radio|checkbox">
|
||||||
|
|
||||||
|
function htmlrender(q::Union{<:Question{RadioSelect}, Question{MultiSelect}},
|
||||||
|
value, invalid::Union{Bool, String})
|
||||||
|
if !ismissing(value)
|
||||||
|
value = vcat(value)
|
||||||
|
end
|
||||||
|
type = if q isa Question{RadioSelect} "radio" else "checkbox" end
|
||||||
|
string(elem("legend", q.prompt,
|
||||||
|
Symbol("data-mandatory") =>
|
||||||
|
if nonempty in q.validators "true" else false end),
|
||||||
|
'\n',
|
||||||
|
join(map(q.field.options.options |> enumerate) do (i, opt)
|
||||||
|
id = string("qn-", q.id, "-", opt.first)
|
||||||
|
elem("label",
|
||||||
|
elem("input", :type => type, :id => id,
|
||||||
|
:name => string(q.id, "[]"), :value => opt.first,
|
||||||
|
:checked => (!ismissing(value) && opt.first ∈ value),
|
||||||
|
if q.field.other
|
||||||
|
[:oninput => "document.getElementById('$(string("qn-", q.id, "--other-input"))').value = ''"]
|
||||||
|
else [] end...) *
|
||||||
|
opt.second,
|
||||||
|
:for => id)
|
||||||
|
end, '\n'),
|
||||||
|
if q.field.other
|
||||||
|
othervals = if !ismissing(value)
|
||||||
|
filter(v -> v ∉ getfield.(q.field.options.options, :first), value)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
'\n' *
|
||||||
|
elem("label",
|
||||||
|
string(
|
||||||
|
elem("input", :type => type, :name => if type == "radio" string(q.id, "[]") else "" end,
|
||||||
|
:id => string("qn-", q.id, "--other"), :value => "",
|
||||||
|
:checked => length(othervals) > 0),
|
||||||
|
elem("input", :type => "text",
|
||||||
|
:id => string("qn-", q.id, "--other-input"),
|
||||||
|
:class => "other", :placeholder => "Other",
|
||||||
|
:name => string(q.id, "[]"), :value => join(othervals, ", "),
|
||||||
|
:oninput => "document.getElementById('$(string("qn-", q.id, "--other"))').checked = this.value.length > 0"
|
||||||
|
)),
|
||||||
|
:for => string("qn-", q.id, "--other"))
|
||||||
|
else "" end) |> fieldset
|
||||||
|
end
|
||||||
|
|
||||||
|
# <input type="range">
|
||||||
|
|
||||||
|
struct RangeSelect <: FormField{Number}
|
||||||
|
values::StepRange{<:Number, <:Number}
|
||||||
|
end
|
||||||
|
|
||||||
|
RangeSelect(r::UnitRange{<:Number}) = RangeSelect(StepRange(r.start, 1, r.stop))
|
||||||
|
|
||||||
|
htmlelement(::RangeSelect) = "input"
|
||||||
|
htmlvoidelem(::RangeSelect) = true
|
||||||
|
htmlattrs(r::RangeSelect, value) =
|
||||||
|
[:type => "range",
|
||||||
|
:min => r.values.start, :max => r.values.stop,
|
||||||
|
:step => r.values.step, :style => "--stops: $(length(r.values)-1); width: calc(100% - 3em)",
|
||||||
|
:oninput => "results$(hash(r)).value = this.value",
|
||||||
|
:value => if ismissing(value) false else string(value) end]
|
||||||
|
htmlpostprocess(r::RangeSelect, id::Symbol) =
|
||||||
|
s -> string(s, '\n', output(name="results$(hash(r))"), '\n',
|
||||||
|
script("""let r = document.getElementById("qn-$id")
|
||||||
|
let o = document.getElementsByName("results$(hash(r))")[0]
|
||||||
|
r.style.width = "calc(100% - 3em)"
|
||||||
|
o.value = r.value"""))
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Survey Renderer (HTML)
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
function htmlrender(q::Question, value, invalid::Union{Bool, String})
|
||||||
|
mandatory = nonempty in q.validators
|
||||||
|
rfield = htmlrender(q.field, value, q.id, mandatory, invalid)
|
||||||
|
elem("label",
|
||||||
|
if q.field isa FormInput{Bool}
|
||||||
|
rfield * span(q.prompt)
|
||||||
|
else
|
||||||
|
span(q.prompt) * rfield
|
||||||
|
end,
|
||||||
|
:for => "qn-"*string(q.id),
|
||||||
|
Symbol("data-mandatory") => string(mandatory)) |>
|
||||||
|
if q.field isa Checkbox; fieldset else identity end
|
||||||
|
end
|
||||||
|
|
||||||
|
function htmlrender(q::Question, a::Answer)
|
||||||
|
if isvalid(a)
|
||||||
|
htmlrender(q, a.value, false)
|
||||||
|
else
|
||||||
|
string(elem("small", a.error, :class => "formerror"),
|
||||||
|
htmlrender(q, a.value, string(!isnothing(a.error))))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
show(io::IO, ::MIME"text/html", q::Question) = print(io, htmlrender(q, missing), '\n')
|
||||||
|
function show(io::IO, ::MIME"text/html", qa::Pair{<:Question, <:Answer})
|
||||||
|
q, a = qa
|
||||||
|
print(io, htmlrender(q, a), '\n')
|
||||||
|
end
|
||||||
|
|
||||||
|
function Base.show(io::IO, m::MIME"text/html", part::SurveyPart)
|
||||||
|
foreach(part.questions) do q
|
||||||
|
show(io, m, q)
|
||||||
|
q === last(part.questions) || print(io, br(), '\n')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function show(io::IO, m::MIME"text/html", pr::Pair{SurveyPart, Response})
|
||||||
|
part, response = pr
|
||||||
|
foreach(part.questions) do q
|
||||||
|
show(io, m, q => response[q.id])
|
||||||
|
q === last(part.questions) || print(io, br(), '\n')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Survey Renderer (Plain)
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
function show(io::IO, ::MIME"text/plain", q::Question{T}) where {T}
|
||||||
|
printstyled(io, " Q{", bold=true, color=:blue)
|
||||||
|
printstyled(io, string(T), color=:blue)
|
||||||
|
printstyled(io, "}: ", bold=true, color=:blue)
|
||||||
|
print(io, q.prompt)
|
||||||
|
if nonempty in q.validators
|
||||||
|
printstyled(io, "*", color=:red)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function show(io::IO, ::MIME"text/plain", a::Answer)
|
||||||
|
printstyled(io, " A: ", bold=true, color=:green)
|
||||||
|
if ismissing(a.value)
|
||||||
|
printstyled(io, "missing\n", color=:light_black)
|
||||||
|
else
|
||||||
|
print(io, a.value)
|
||||||
|
end
|
||||||
|
if !isnothing(a.error)
|
||||||
|
printstyled(io, " ! ", a.error, color=:yellow)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function show(io::IO, m::MIME"text/plain", qa::Pair{<:Question, <:Answer})
|
||||||
|
q, a = qa
|
||||||
|
show(io, m, q)
|
||||||
|
print(io, '\n')
|
||||||
|
show(io, m, a)
|
||||||
|
end
|
||||||
|
show(io::IO, qa::Pair{<:Question, <:Answer}) = show(io, MIME("text/plain"), qa)
|
||||||
|
|
||||||
|
function show(io::IO, m::MIME"text/plain", part::SurveyPart)
|
||||||
|
for q in part.questions
|
||||||
|
show(io, m, q)
|
||||||
|
q === last(part.questions) || print(io, "\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function show(io::IO, m::MIME"text/plain", pr::Pair{SurveyPart, Response})
|
||||||
|
part, response = pr
|
||||||
|
foreach(part.questions) do q
|
||||||
|
show(io, m, q => response[q.id])
|
||||||
|
last(part.questions) === q || print(io, "\n\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
show(io::IO, pr::Pair{SurveyPart, Response}) = show(io, MIME("text/plain"), pr)
|
||||||
|
|
||||||
|
function show(io::IO, m::MIME"text/plain", s::Survey)
|
||||||
|
printstyled(io, " ", s.name, '\n', color=:magenta)
|
||||||
|
parts = [s[p] for p in 1:length(s)]
|
||||||
|
for part in parts
|
||||||
|
show(io, m, part)
|
||||||
|
part === last(parts) || printstyled(io, "\n ---\n", color=:yellow)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function show(io::IO, m::MIME"text/plain", sr::Pair{Survey, Response})
|
||||||
|
s, response = sr
|
||||||
|
printstyled(io, " ", s.name, "\n\n", color=:magenta)
|
||||||
|
parts = [s[p] for p in 1:length(s)]
|
||||||
|
for part in parts
|
||||||
|
show(io, m, part => response)
|
||||||
|
part === last(parts) || print(io, "\n\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
show(io::IO, sr::Pair{Survey, Response}) = show(io, MIME("text/plain"), sr)
|
||||||
|
|
||||||
|
function show(io::IO, m::MIME"text/plain", r::Response)
|
||||||
|
printstyled(io, "Response $(r.id) to survey $(r.survey)\n", color=:magenta)
|
||||||
|
print(io, " Started: ")
|
||||||
|
printstyled(string(r.started), '\n', color=:green)
|
||||||
|
print(io, " Completed: ")
|
||||||
|
if isnothing(r.completed)
|
||||||
|
printstyled(io, "no", '\n', color=:light_black)
|
||||||
|
print(io, " Page: ")
|
||||||
|
printstyled(io, r.page, '\n', color=:blue)
|
||||||
|
else
|
||||||
|
printstyled(io, string(r.completed), '\n', color=:blue)
|
||||||
|
end
|
||||||
|
print("Answers: ")
|
||||||
|
show(io, m, r.answers)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -1,3 +1,37 @@
|
||||||
hr.strong {
|
hr.strong {
|
||||||
border: .125rem solid var(--primary);
|
border: .125rem solid var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[type="range"] {
|
||||||
|
&::-moz-range-progress {
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
height: 0.25rem;
|
||||||
|
&:hover, &:focus {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&::-moz-range-track,
|
||||||
|
&::-webkit-slider-runnable-track
|
||||||
|
{
|
||||||
|
background: linear-gradient(90deg, var(--background-color) 0.3em, var(--range-border-color) 0px);
|
||||||
|
background-size: calc(100% / var(--stops)) 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[type="text"].other {
|
||||||
|
width: auto !important;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0.5rem !important;
|
||||||
|
height: calc(1.4rem * var(--line-height)) !important;
|
||||||
|
transform: translate(0em, 0.4em);
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
float: right;
|
||||||
|
transform: translate(0em, calc(var(--spacing) * 0.15));
|
||||||
|
}
|
||||||
|
|
|
@ -1801,3 +1801,29 @@ textarea,
|
||||||
|
|
||||||
hr.strong {
|
hr.strong {
|
||||||
border: 0.125rem solid var(--primary); }
|
border: 0.125rem solid var(--primary); }
|
||||||
|
|
||||||
|
[type="range"]::-moz-range-progress {
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
height: 0.25rem; }
|
||||||
|
[type="range"]::-moz-range-progress:hover, [type="range"]::-moz-range-progress:focus {
|
||||||
|
background-color: var(--primary-hover); }
|
||||||
|
|
||||||
|
[type="range"]::-moz-range-track, [type="range"]::-webkit-slider-runnable-track {
|
||||||
|
background: linear-gradient(90deg, var(--background-color) 0.3em, var(--range-border-color) 0px);
|
||||||
|
background-size: calc(100% / var(--stops)) 100%; }
|
||||||
|
|
||||||
|
[type="text"].other {
|
||||||
|
width: auto !important;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0.5rem !important;
|
||||||
|
height: calc(1.4rem * var(--line-height)) !important;
|
||||||
|
transform: translate(0em, 0.4em); }
|
||||||
|
|
||||||
|
output {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
float: right;
|
||||||
|
transform: translate(0em, calc(var(--spacing) * 0.15)); }
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
<header>
|
|
||||||
<hgroup>
|
|
||||||
<h1>Emacs User Survey — 2022</h1>
|
|
||||||
<h2>Help the community have a better understanding of itself and its
|
|
||||||
own diversity in Emacs usage. Discover and show how people are using
|
|
||||||
this versatile tool for everything, from software engineering to
|
|
||||||
academia and journalism.</h2>
|
|
||||||
</hgroup>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<form method="get" action="/survey">
|
|
||||||
<button type="submit">Take the survey</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
29
routes.jl
29
routes.jl
|
@ -1,5 +1,30 @@
|
||||||
using Genie.Router
|
using Genie.Router, Genie.Requests, Genie.Renderers.Html
|
||||||
|
|
||||||
|
using SurveysController
|
||||||
|
using ResultsController
|
||||||
|
|
||||||
route("/") do
|
route("/") do
|
||||||
serve_static_file("welcome.html")
|
SurveysController.index()
|
||||||
|
end
|
||||||
|
|
||||||
|
route("/survey") do
|
||||||
|
SurveysController.serve(getpayload())
|
||||||
|
end
|
||||||
|
|
||||||
|
route("/submit", method=POST) do
|
||||||
|
SurveysController.submit(postpayload())
|
||||||
|
end
|
||||||
|
|
||||||
|
route("/submit-backpage", method=POST) do
|
||||||
|
SurveysController.submit(postpayload(), backpage=true)
|
||||||
|
end
|
||||||
|
|
||||||
|
route("/results(?:/(?:[A-Za-z0-9]+(?:\\.[a-z]+)?)?)?\$") do
|
||||||
|
survey, format = match(r"([A-Za-z0-9]+)?(?:\.([a-z]+))?$", currenturl()).captures
|
||||||
|
surveyid = tryparse(UInt32, survey, base=10)
|
||||||
|
if !isnothing(format)
|
||||||
|
ResultsController.resultsfile(surveyid, format)
|
||||||
|
else
|
||||||
|
ResultsController.resultsindex(surveyid)
|
||||||
|
end
|
||||||
end
|
end
|
Loading…
Reference in New Issue