Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
max_line_length = 120
max_line_length = 130

[**.{yml,yaml,md,tex,txt}]
max_line_length = 160
Expand Down
1 change: 1 addition & 0 deletions .github/actions/spelling/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ hasdata
img
lindex
lucius
optgroup
RWS
syb
subdirs
Expand Down
1 change: 1 addition & 0 deletions flex-tasks/src/FlexTask/Generic/Form.hs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ module FlexTask.Generic.Form (
, formifyInstanceBasicField
, formifyInstanceOptionalField
, formifyInstanceSingleChoice
, formifyInstanceOptionalSingleChoice
, formifyInstanceMultiChoice
) where

Expand Down
128 changes: 115 additions & 13 deletions flex-tasks/src/FlexTask/Generic/FormInternal.hs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import Yesod (
multiSelectField,
optionsPairs,
renderMessage,
selectField,
textareaField,
textField,
)
Expand All @@ -45,6 +44,7 @@ import FlexTask.Widgets
, radioField
, joinWidgets
, renderForm
, selectField
)
import FlexTask.YesodConfig (FlexForm(..), Handler, Rendered, Widget)

Expand Down Expand Up @@ -208,6 +208,9 @@ Use if both of the following is true:
Choose one
</label>
<select id="flexident1" ...>
<option value="" selected disabled>
&lt;None&gt;
</option>
<option value="1">
First Option
</option>
Expand All @@ -222,7 +225,7 @@ Use if both of the following is true:
</div>
-}
newtype SingleChoiceSelection = SingleChoiceSelection
{getAnswer :: Maybe Int -- ^ Retrieve the selected option. @Nothing@ if none.
{getAnswer :: Int -- ^ Retrieve the selected option.
} deriving (Show,Eq,Generic)
{- |
Same as `SingleChoiceSelection`, but for multiple choice input.
Expand Down Expand Up @@ -254,18 +257,20 @@ Use if both of the following is true:
</div>
-}
newtype MultipleChoiceSelection = MultipleChoiceSelection
{ getAnswers :: [Int] -- ^ Retrieve the list of selected options. @[]@ if none.
{ getAnswers :: [Int] -- ^ Retrieve the list of selected options. @[]@ if none are selected.
} deriving (Show,Eq,Generic)


{-# DEPRECATED singleChoiceEmpty
"This function only existed to satisfy a legacy interface in Autotool. It will be removed in a future version."
#-}
-- | Value with no option selected.
singleChoiceEmpty :: SingleChoiceSelection
singleChoiceEmpty = SingleChoiceSelection Nothing

singleChoiceEmpty = singleChoiceAnswer 0

-- | Value with given number option selected.
singleChoiceAnswer :: Int -> SingleChoiceSelection
singleChoiceAnswer = SingleChoiceSelection . Just
singleChoiceAnswer = SingleChoiceSelection


-- | Value with no options selected.
Expand Down Expand Up @@ -476,7 +481,7 @@ instance Formify [String] where
formifyImplementation = formifyInstanceList


instance (BaseForm a, Formify a) => Formify (Maybe a) where
instance {-# Overlappable #-} (BaseForm a, Formify a) => Formify (Maybe a) where
formifyImplementation = formifyInstanceOptionalField


Expand All @@ -485,13 +490,14 @@ instance Formify (Maybe a) => Formify [Maybe a] where


instance Formify SingleChoiceSelection where
formifyImplementation = renderNextSingleChoiceField (`zip` [1..]) . (=<<) getAnswer
formifyImplementation = renderNextSingleChoiceField (`zip` [1..]) . fmap getAnswer


instance Formify MultipleChoiceSelection where
formifyImplementation = renderNextMultipleChoiceField (`zip` [1..]) . fmap getAnswers


instance Formify (Maybe SingleChoiceSelection) where
formifyImplementation = renderNextOptionalSingleChoiceField (`zip` [1..]) . fmap (fmap getAnswer)

{- |
This is the main way to build generic forms.
Expand Down Expand Up @@ -742,15 +748,15 @@ that cannot use a bodyless `Formify` instance.
<div>
<span id="flexident1">
<label>
<input id="flexident1-1" type="radio" ... value="1" ...>
<input id="flexident1-1" type="radio" ... value="1" required...>
One
</label>
<label>
<input id="flexident1-2" type="radio" ... value="2" checked ...>
<input id="flexident1-2" type="radio" ... value="2" checked required...>
Two
</label>
<label>
<input id="flexident1-3" type="radio" ... value="3" ...>
<input id="flexident1-3" type="radio" ... value="3" required...>
Three
</label>
</span>
Expand All @@ -765,6 +771,9 @@ that cannot use a bodyless `Formify` instance.
Choose one
</label>
<select id="flexident1" ...>
<option value="" selected disabled>
&lt;None&gt;
</option>
<option value="1">
One
</option>
Expand All @@ -785,6 +794,75 @@ formifyInstanceSingleChoice
-> ([[FieldInfo]], Rendered [[Widget]])
formifyInstanceSingleChoice = renderNextSingleChoiceField zipWithEnum


{- |
Same as `formifyInstanceSingleChoice`, but makes the choice optional.
The resulting form will include a \<None\> option to make no selection.
This option will be initially selected if no default is given.

=== __Example__

>>> instance Formify (Maybe MyType) where formifyImplementation = formifyInstanceOptionalSingleChoice
>>> printWidget "en" $ formify (Just $ Just Two) [[buttonsEnum Horizontal "Choose one or abstain" (showToUniversalLabel @MyType)]]
...
<div class="flex-form-div form-group">
...
<label for="flexident1">
Choose one or abstain
</label>
<div>
<span id="flexident1">
<label>
<input id="flexident1-none" type="radio" ... value="None">
&lt;None&gt;
</label>
<label>
<input id="flexident1-1" type="radio" ... value="1">
One
</label>
<label>
<input id="flexident1-2" type="radio" ... value="2" checked>
Two
</label>
<label>
<input id="flexident1-3" type="radio" ... value="3">
Three
</label>
</span>
</div>
...
</div>

>>> printWidget "en" $ formify (Nothing @(Maybe MyType)) [[dropdownEnum "Choose one or abstain" (showToUniversalLabel @MyType)]]
<div class="flex-form-div form-group">
...
<label for="flexident1">
Choose one or abstain
</label>
<select id="flexident1" ...>
<option value="None" selected>
Copy link
Copy Markdown
Contributor

@horriblename horriblename Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should probably be value=""

so I can make use of <select required> to get the browser's own pre-submit checks like this:

Image

https://stackoverflow.com/a/6048891

Copy link
Copy Markdown
Member Author

@patritzenfeld patritzenfeld Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should already be the case if you use SingleChoiceSelection. This value="None" is only offered for form type Maybe SingleChoiceSelection. If the field is required, then this is used instead:

$newline never
<select ##{theId} name=#{name} :req:required *{attrs}>
$if req
<option value="" selected disabled>_{MsgSelectNone}
^{inside}

But I guess I should revise my custom selectField function to be less confusing. I just did a quick copy and edit on the standard Yesod version which resulted in messy code.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently, with formify (Nothing @SingleChoiceSelection), I'll get a dropdown where the value defaults to the first real value, and the None option is disabled:

Image

I want it to default to None instead (the table is big so making it default to None makes it easy for students to see which dropdown they have not yet answered), but I also want the required check to take effect.

For this to work, perhaps what I need is just removing disabled from https://github.com/fmidue/flex-tasks/pull/222/changes#diff-26588820d6d6d6eb9930e0b89192489214299acf12c3b5043f1b362e02333b7bR774? what do you think ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I guess the behaviour is different between browsers. It did initially select the None option when I tried it on my setup, but did not allow reselecting it. I I will just remove the disabled attribute then.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What browser are you using? I'd like to take a look at this again on Tuesday.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

firefox

&lt;None&gt;
</option>
<option value="1">
One
</option>
<option value="2">
Two
</option>
<option value="3">
Three
</option>
</select>
...
</div>
-}
formifyInstanceOptionalSingleChoice
:: (Bounded a, Enum a, Eq a)
=> Maybe (Maybe a)
-> [[FieldInfo]]
-> ([[FieldInfo]], Rendered [[Widget]])
formifyInstanceOptionalSingleChoice = renderNextOptionalSingleChoiceField zipWithEnum

renderNextSingleChoiceField
:: Eq a
=> ([SomeMessage FlexForm] -> [(SomeMessage FlexForm, a)])
Expand All @@ -796,7 +874,7 @@ renderNextSingleChoiceField pairsWith =
(\case
ChoicesDropdown fs opts ->
( fs
, areq $ selectField $ withOptions opts
, areq $ selectField True $ withOptions opts
)
ChoicesButtons align fs opts ->
( fs
Expand All @@ -809,6 +887,30 @@ renderNextSingleChoiceField pairsWith =
)
where withOptions = optionsPairs . pairsWith

renderNextOptionalSingleChoiceField
:: Eq a
=> ([SomeMessage FlexForm] -> [(SomeMessage FlexForm, a)])
-> Maybe (Maybe a)
-> [[FieldInfo]]
-> ([[FieldInfo]], Rendered [[Widget]])
renderNextOptionalSingleChoiceField pairsWith =
renderNextField
(\case
ChoicesDropdown fs opts ->
( fs
, aopt $ selectField False $ withOptions opts
)
ChoicesButtons align fs opts ->
( fs
, aopt $ case align of
Vertical -> radioField True
Horizontal -> radioField False
$ withOptions opts
)
_ -> error "Incorrect FieldInfo for an optional single choice field! Use one of the 'buttons' or 'dropdown' functions."
)
where withOptions = optionsPairs . pairsWith

renderNextMultipleChoiceField
:: Eq a
=> ([SomeMessage FlexForm] -> [(SomeMessage FlexForm, a)])
Expand Down
5 changes: 2 additions & 3 deletions flex-tasks/src/FlexTask/Generic/ParseInternal.hs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ import FlexTask.Generic.FormInternal
, SingleInputList(..)
, multipleChoiceAnswer
, singleChoiceAnswer
, singleChoiceEmpty
)


Expand Down Expand Up @@ -233,7 +232,7 @@ instance Parse a => Parse (Maybe a) where


instance Parse SingleChoiceSelection where
formParser = maybe singleChoiceEmpty singleChoiceAnswer <$> formParser
formParser = singleChoiceAnswer <$> formParser


instance Parse MultipleChoiceSelection where
Expand Down Expand Up @@ -414,7 +413,7 @@ the input form is "infallible" since only constructed from String text fields, s
>>> import Control.OutputCapable.Blocks.Debug (run)
>>> run German $ parseInfallibly (formParser @SingleChoiceSelection) $ asSubmission [["1"]]
Just (SingleChoiceSelection {getAnswer = Just 1})
Just (SingleChoiceSelection {getAnswer = 1})
>>> run English $ parseInfallibly (formParser @(SingleInputList Double)) $ asSubmission [["Wrong input"]]
*** Exception: The impossible happened: (line 1, column 3):
Expand Down
47 changes: 46 additions & 1 deletion flex-tasks/src/FlexTask/Widgets.hs
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,32 @@ joinWidgets = mapM_ (insertDiv . sequence_)


radioField :: Eq a => Bool -> Handler (OptionList a) -> Field Handler a
radioField isVertical = selectFieldHelper outside (\_ _ _ -> pure ()) inside Nothing
radioField isVertical = selectFieldHelper outside onOpt inside Nothing
where
outside theId _name _attrs inside' =
toWidget horizontalRBStyle >> [whamlet|
$newline never
<div>
<span ##{theId}>^{inside'}
|]
onOpt theId name isSel = nothingFun theId [whamlet|
$newline never
<input id=#{theId}-none type=radio name=#{name} value="None" :isSel:checked>
|]
nothingFun _ optionWidget =
let emptyRadio = [whamlet|
$newline never
<label>
^{optionWidget}
_{MsgSelectNone}
|]
in [whamlet|
$newline never
$if isVertical
<div>
^{emptyRadio}
$else
^{emptyRadio}
|]
inside theId name attrs value isSel display =
let radio = [whamlet|
Expand Down Expand Up @@ -110,3 +129,29 @@ checkboxField isVertical optList = (multiSelectField optList)
^{box}
|]
}


selectField
:: (Eq a, RenderMessage site FormMessage)
=> Bool
-> HandlerFor site (OptionList a)
-> Field (HandlerFor site) a
selectField req = selectFieldHelper
(\theId name attrs inside -> [whamlet|
$newline never
<select ##{theId} name=#{name} :req:required *{attrs}>
$if req
<option value="" selected disabled>_{MsgSelectNone}
^{inside}
|]) -- outside
(\_theId _name isSel -> [whamlet|
$newline never
<option value="None" :isSel:selected>_{MsgSelectNone}
|]) -- when optional
(\_theId _name _attrs value isSel text -> toWidget [whamlet|
$newline never
<option value=#{value} :isSel:selected>#{text}
|]) -- inside
(Just $ \label -> [whamlet|
<optgroup label=#{label}>
|]) -- group label
Loading