yesodweb / yesod

A RESTful Haskell web framework built on WAI.
http://www.yesodweb.com/
MIT License
2.63k stars 369 forks source link

yesod-form cannot distinguish fresh inputs from empty inputs #1264

Open bitemyapp opened 8 years ago

bitemyapp commented 8 years ago

Even for optional inputs.

This means I cannot use val from the arguments of the fieldView function (Either Text a) to vary input rendering based on error status.

If I add a janky Left "" -> Nothing case for my error guard, it doesn't mark the input invalid when the error is that the value was required.

screenshot from 2016-08-19 16-00-26

Example widget reproducing the above:

-- | Creates a input with @type="text"@, accepts Text inputs.
abideEmailField :: (Monad m, RenderMessage (HandlerSite m) FormMessage)
            => Text
            -> Maybe Text
            -> Field m EmailAddress
abideEmailField labelText maybeHelpText =
  Field
    { fieldParse = parseHelper $ emailFromField
    , fieldView = \theId name attrs val isReq ->
        let helpTextId = theId ++ "HelpText"
            maybeError = case val of
              Left "" -> Nothing -- TODO: Fix val/forms stuff.
              Left err -> Just err
              Right _ -> Nothing
            hasError = isJust maybeError
        in [whamlet|
$newline never
<div.small-12.columns>
  <div>
    Val was: &nbsp; #{show val}
  <label>
    #{labelText}
    <input type="text" :hasError:.input-error id="#{theId}" name="#{name}" *{attrs} aria-describedby="#{helpTextId}" :isReq:required value="#{either id emailToText val}">
    $maybe helpText <- maybeHelpText
      <p class="help-text" id="#{helpTextId}">
      #{helpText}
|]
    , fieldEnctype = UrlEncoded
    }

As it stands, if I don't do the Left "" -> Nothing hack, a fresh form renders with .input-error for all inputs, even ones marked optional via aopt.

Here's what it looks like without the Left "" -> Nothing hack, on a fresh GET of my form:

screenshot from 2016-08-19 16-06-21

As it stands, only the form renderer knows if there are actual errors, AFAICT, but the class was meant to go to the input. Now I'll need to figure out some kind of passthrough div to hoist the CSS target.

bitemyapp commented 8 years ago

There's a documentation task implicit in this, if this is not fixed. The docs currently say:

http://hackage.haskell.org/package/yesod-form-1.4.7.1/docs/Yesod-Form-Types.html#t:FieldViewFunc

Either (invalid text) or (legitimate result)

What it doesn't say is that "invalid text" doesn't distinguish from a form that has attempted validation against an actual POST vs. one that is merely being rendered fresh, making it useless to the author of the Field definition save as a means of injecting it into the value="" component of the input tag.

Either is probably inappropriate for this. The real state of each input is something like:

data FieldValue a =
  Fresh
  | Invalid Text
  | Valid a

There's also the matter of errors being all string based, but I'm leaving that dragon be for now.

bitemyapp commented 8 years ago

I was able to workaround this by hoisting the CSS rule to a span conditionally injected by the form renderer, rather than attempting to condition error-styling on the presence of an error in the field input itself.

renderAbide :: Monad m => FormRender m a
renderAbide aform fragment = do
    (res, views') <- aFormToForm aform
    let views = views' []
        hasError view = isJust (fvErrors view)
    let widget = [whamlet|
                $newline never
                \#{fragment}
                $forall view <- views
                  <div.row>
                    $if (hasError view)
                      <span.input-error>
                        ^{fvInput view}
                    $else
                      ^{fvInput view}
                    $maybe err <- fvErrors view
                      <small.form-error.is-visible>#{err}
                |]
    return (res, widget)

This still leaves a lot of fairly ordinary things I'd like to do out of reach and feels silly as Hell, but I needed for this "just work" so I could move on to other things.

snoyberg commented 8 years ago

Sorry, I'm not following the report. I know that we have the ability to distinguish between missing and invalid in general, since most fields work that way. Can you explain what's different in this case from, say, textField?

nicolashery commented 4 years ago

I think I've run into a similar issue. Bootstrap v4 requires you to add the .is-invalid class directly on the <input> element (docs).

My first attempt was to create my own textField function and use that instead of the one from yesod-form:

textField ::
  (Monad m, RenderMessage (HandlerSite m) FormMessage) =>
  Field m Text
textField = Field
  { fieldParse = parseHelper $ Right,
    fieldView = \theId name attrs val isReq ->
      [whamlet|
$newline never
<input id="#{theId}" name="#{name}" *{addInputClasses attrs val} type="text" :isReq:required value="#{either id id val}">
|],
    fieldEnctype = UrlEncoded
  }

addInputClasses :: [(Text, Text)] -> Either Text a -> [(Text, Text)]
addInputClasses attrs val = addClass "form-control" $
  case val of
    Left _ -> addClass "is-invalid" attrs
    Right _ -> attrs

(Notice I'm matching on the Either Text a to add the .is-invalid class when we have a Left Text).

Although that gives us the intended behavior when a form is submitted (here the "Edit project" page):

Screen Shot 2019-12-27 at 10 50 59 AM

The problem is on a "fresh form" with no values filled out (here the "Create project" page), the input also shows up as invalid (although the form hasn't been submitted yet):

Screen Shot 2019-12-27 at 10 50 17 AM

My workaround for now is the same as described earlier in this thread, I added some CSS rules to be able to apply the .is-invalid class on the parent element:

/* With `yesod-form` can't add a class directly to the `<input>` element
  so need to add it to the parent `.form-group`, copy the Bootstrap
  styles and change a bit the way the CSS selectors work. */
.is-invalid .form-control {
  border-color: #dc3545;
  padding-right: calc(1.5em + 0.75rem);
  background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");
  background-repeat: no-repeat;
  background-position: right calc(0.375em + 0.1875rem) center;
  background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
}
.is-invalid .invalid-feedback {
  display: block;
}
renderDefaultForm :: Monad m => FormRender m a
renderDefaultForm aform fragment = do
  (res, views') <- aFormToForm aform
  let views = views' []
      hasErrors v = isJust $ fvErrors v
      widget =
        [whamlet|
\#{fragment}
$forall view <- views
  <div class="form-group" :hasErrors view:.is-invalid>
    <label for="#{fvId view}">
      ^{fvLabel view}
    ^{fvInput view}
    $maybe err <- fvErrors view
      <p class="invalid-feedback">#{err}
|]
  return (res, widget)