web: Re-add 'balanced transaction' validation to add form

This commit is contained in:
Jakub Zárybnický 2019-02-20 14:29:52 +01:00 committed by Simon Michael
parent ba850f3871
commit 861baadb2b
3 changed files with 100 additions and 53 deletions

View File

@ -9,11 +9,13 @@ module Hledger.Web.Handler.AddR
, postAddR , postAddR
) where ) where
import qualified Data.Text as T
import Text.Blaze.Html (preEscapedToHtml)
import Hledger import Hledger
import Hledger.Cli.Commands.Add (appendToJournalFileOrStdout) import Hledger.Cli.Commands.Add (appendToJournalFileOrStdout)
import Hledger.Web.Import import Hledger.Web.Import
import Hledger.Web.Widget.AddForm (addForm) import Hledger.Web.Widget.AddForm (addForm)
import Hledger.Web.Widget.Common (fromFormSuccess)
getAddR :: Handler () getAddR :: Handler ()
getAddR = postAddR getAddR = postAddR
@ -24,12 +26,18 @@ postAddR = do
when (CapAdd `notElem` caps) (permissionDenied "Missing the 'add' capability") when (CapAdd `notElem` caps) (permissionDenied "Missing the 'add' capability")
((res, view), enctype) <- runFormPost $ addForm j today ((res, view), enctype) <- runFormPost $ addForm j today
t <- txnTieKnot <$> fromFormSuccess (showForm view enctype) res case res of
-- XXX(?) move into balanceTransaction FormSuccess res' -> do
liftIO $ ensureJournalFileExists (journalFilePath j) let t = txnTieKnot res'
liftIO $ appendToJournalFileOrStdout (journalFilePath j) (showTransaction t) -- XXX(?) move into balanceTransaction
setMessage "Transaction added." liftIO $ ensureJournalFileExists (journalFilePath j)
redirect JournalR liftIO $ appendToJournalFileOrStdout (journalFilePath j) (showTransaction t)
setMessage "Transaction added."
redirect JournalR
FormMissing -> showForm view enctype
FormFailure errs -> do
mapM_ (setMessage . preEscapedToHtml . T.replace "\n" "<br>") errs
showForm view enctype
where where
showForm view enctype = showForm view enctype =
sendResponse =<< defaultLayout [whamlet| sendResponse =<< defaultLayout [whamlet|

View File

@ -21,7 +21,7 @@ import qualified Data.Text as T
import Data.Time (Day) import Data.Time (Day)
import Text.Blaze.Internal (Markup, preEscapedString) import Text.Blaze.Internal (Markup, preEscapedString)
import Text.JSON import Text.JSON
import Text.Megaparsec (eof, errorBundlePretty, runParser) import Text.Megaparsec (bundleErrors, eof, parseErrorTextPretty, runParser)
import Yesod import Yesod
import Hledger import Hledger
@ -67,13 +67,7 @@ addForm j today = identifyForm "add" $ \extra -> do
(descRes, descView) <- mreq textField descFS Nothing (descRes, descView) <- mreq textField descFS Nothing
(acctRes, _) <- mreq listField acctFS Nothing (acctRes, _) <- mreq listField acctFS Nothing
(amtRes, _) <- mreq listField amtFS Nothing (amtRes, _) <- mreq listField amtFS Nothing
let (postRes, displayRows) = validatePostings acctRes amtRes
let (msgs', postRes) = case validatePostings <$> acctRes <*> amtRes of
FormSuccess (Left es) -> (es, FormFailure ["Postings validation failed"])
FormSuccess (Right xs) -> ([], FormSuccess xs)
FormMissing -> ([], FormMissing)
FormFailure es -> ([], FormFailure es)
msgs = zip [(1 :: Int)..] $ msgs' ++ replicate (4 - length msgs') ("", "", Nothing, Nothing)
let descriptions = sort $ nub $ tdescription <$> jtxns j let descriptions = sort $ nub $ tdescription <$> jtxns j
escapeJSSpecialChars = regexReplaceCI "</script>" "<\\/script>" -- #236 escapeJSSpecialChars = regexReplaceCI "</script>" "<\\/script>" -- #236
@ -81,11 +75,8 @@ addForm j today = identifyForm "add" $ \extra -> do
encode . JSArray . fmap (\a -> JSObject $ toJSObject [("value", showJSON a)]) encode . JSArray . fmap (\a -> JSObject $ toJSObject [("value", showJSON a)])
journals = fst <$> jfiles j journals = fst <$> jfiles j
pure (makeTransaction <$> dateRes <*> descRes <*> postRes, $(widgetFile "add-form")) pure (validateTransaction dateRes descRes postRes, $(widgetFile "add-form"))
where where
makeTransaction date desc postings =
nulltransaction {tdate = date, tdescription = desc, tpostings = postings}
dateFS = FieldSettings "date" Nothing Nothing (Just "date") dateFS = FieldSettings "date" Nothing Nothing (Just "date")
[("class", "form-control input-lg"), ("placeholder", "Date")] [("class", "form-control input-lg"), ("placeholder", "Date")]
descFS = FieldSettings "desc" Nothing Nothing (Just "description") descFS = FieldSettings "desc" Nothing Nothing (Just "description")
@ -103,42 +94,90 @@ addForm j today = identifyForm "add" $ \extra -> do
, fieldEnctype = UrlEncoded , fieldEnctype = UrlEncoded
} }
validatePostings :: [Text] -> [Text] -> Either [(Text, Text, Maybe Text, Maybe Text)] [Posting] validateTransaction ::
validatePostings a b = FormResult Day
case traverse id $ (\(_, _, x) -> x) <$> postings of -> FormResult Text
Left _ -> Left $ foldr catPostings [] postings -> FormResult [Posting]
Right [] -> Left -> FormResult Transaction
[ ("", "", Just "Missing account", Just "Missing amount") validateTransaction dateRes descRes postingsRes =
, ("", "", Just "Missing account", Nothing) case makeTransaction <$> dateRes <*> descRes <*> postingsRes of
] FormSuccess txn -> case balanceTransaction Nothing txn of
Right [p] -> Left Left e -> FormFailure [T.pack e]
[ (paccount p, T.pack . showMixedAmountWithoutPrice $ pamount p, Nothing, Nothing) Right txn' -> FormSuccess txn'
, ("", "", Just "Missing account", Nothing) x -> x
]
Right xs -> Right xs
where where
postings = unfoldr go (True, a, b) makeTransaction date desc postings =
nulltransaction {tdate = date, tdescription = desc, tpostings = postings}
go (_, x:xs, y:ys) = Just ((x, y, zipPosting (validateAccount x) (validateAmount y)), (True, xs, ys))
go (True, x:y:xs, []) = Just ((x, "", zipPosting (validateAccount x) (Left "Missing amount")), (True, y:xs, []))
go (True, x:xs, []) = Just ((x, "", zipPosting (validateAccount x) (Right missingamt)), (False, xs, []))
go (False, x:xs, []) = Just ((x, "", zipPosting (validateAccount x) (Left "Missing amount")), (False, xs, []))
go (_, [], y:ys) = Just (("", y, zipPosting (Left "Missing account") (validateAmount y)), (False, [], ys))
go (_, [], []) = Nothing
zipPosting = zipEither (\acc amt -> nullposting {paccount = acc, pamount = Mixed [amt]}) -- | Parse a list of postings out of a list of accounts and a corresponding list
-- of amounts
validatePostings ::
FormResult [Text]
-> FormResult [Text]
-> (FormResult [Posting], [(Int, (Text, Text, Maybe Text, Maybe Text))])
validatePostings acctRes amtRes = let
catPostings (t, t', Left (e, e')) xs = (t, t', e, e') : xs -- Zip accounts and amounts, fill in missing values and drop empty rows.
catPostings (t, t', Right _) xs = (t, t', Nothing, Nothing) : xs rows :: [(Text, Text)]
rows = filter (/= ("", "")) $ zipDefault "" (formSuccess [] acctRes) (formSuccess [] amtRes)
errorToFormMsg = first (("Invalid value: " <>) . T.pack . errorBundlePretty) -- Parse values and check for incomplete rows with only an account or an amount.
validateAccount = errorToFormMsg . runParser (accountnamep <* eof) "" . T.strip -- The boolean in unfoldr state is for special handling of 'missingamt', where
validateAmount = errorToFormMsg . runParser (evalStateT (amountp <* eof) mempty) "" . T.strip -- one row may have only an account and not an amount.
postings :: [(Text, Text, Either (Maybe Text, Maybe Text) Posting)]
postings = unfoldr go (True, rows)
go (True, (x, ""):y:xs) = Just ((x, "", zipRow (checkAccount x) (Left "Missing amount")), (True, y:xs))
go (True, (x, ""):xs) = Just ((x, "", zipRow (checkAccount x) (Right missingamt)), (False, xs))
go (False, (x, ""):xs) = Just ((x, "", zipRow (checkAccount x) (Left "Missing amount")), (False, xs))
go (_, ("", y):xs) = Just (("", y, zipRow (Left "Missing account") (checkAmount y)), (False, xs))
go (_, (x, y):xs) = Just ((x, y, zipRow (checkAccount x) (checkAmount y)), (True, xs))
go (_, []) = Nothing
-- Modification of Align, from the `these` package zipRow (Left e) (Left e') = Left (Just e, Just e')
zipEither :: (a -> a' -> r) -> Either e a -> Either e' a' -> Either (Maybe e, Maybe e') r zipRow (Left e) (Right _) = Left (Just e, Nothing)
zipEither f a b = case (a, b) of zipRow (Right _) (Left e) = Left (Nothing, Just e)
(Right a', Right b') -> Right (f a' b') zipRow (Right acct) (Right amt) = Right (nullposting {paccount = acct, pamount = Mixed [amt]})
(Left a', Right _) -> Left (Just a', Nothing)
(Right _, Left b') -> Left (Nothing, Just b') errorToFormMsg = first (("Invalid value: " <>) . T.pack .
(Left a', Left b') -> Left (Just a', Just b') foldl (\s a -> s <> parseErrorTextPretty a) "" .
bundleErrors)
checkAccount = errorToFormMsg . runParser (accountnamep <* eof) "" . T.strip
checkAmount = errorToFormMsg . runParser (evalStateT (amountp <* eof) mempty) "" . T.strip
-- Add errors to forms with zero or one rows if the form is not a FormMissing
result :: [(Text, Text, Either (Maybe Text, Maybe Text) Posting)]
result = case (acctRes, amtRes) of
(FormMissing, FormMissing) -> postings
_ -> case postings of
[] -> [ ("", "", Left (Just "Missing account", Just "Missing amount"))
, ("", "", Left (Just "Missing account", Nothing))
]
[x] -> [x, ("", "", Left (Just "Missing account", Nothing))]
xs -> xs
-- Prepare rows for rendering - resolve Eithers into error messages and pad to
-- at least four rows
display' = flip fmap result $ \(acc, amt, res) -> case res of
Left (mAccountErr, mAmountErr) -> (acc, amt, mAccountErr, mAmountErr)
Right _ -> (acc, amt, Nothing, Nothing)
display = display' ++ replicate (4 - length display') ("", "", Nothing, Nothing)
-- And finally prepare the final FormResult [Posting]
formResult = case traverse (\(_, _, x) -> x) result of
Left _ -> FormFailure ["Postings validation failed"]
Right xs -> FormSuccess xs
in (formResult, zip [(1 :: Int)..] display)
zipDefault :: a -> [a] -> [a] -> [(a, a)]
zipDefault def (b:bs) (c:cs) = (b, c):(zipDefault def bs cs)
zipDefault def (b:bs) [] = (b, def):(zipDefault def bs [])
zipDefault def [] (c:cs) = (def, c):(zipDefault def [] cs)
zipDefault _ _ _ = []
formSuccess :: a -> FormResult a -> a
formSuccess def res = case res of
FormSuccess x -> x
_ -> def

View File

@ -40,7 +40,7 @@
<div .col-md-9 .col-xs-6 .col-sm-6> <div .col-md-9 .col-xs-6 .col-sm-6>
<div .account-postings> <div .account-postings>
$forall (n, (acc, amt, accE, amtE)) <- msgs $forall (n, (acc, amt, accE, amtE)) <- displayRows
<div .form-group .row .account-group> <div .form-group .row .account-group>
<div .col-md-8 .col-xs-8 .col-sm-8 :isJust accE:.has-error> <div .col-md-8 .col-xs-8 .col-sm-8 :isJust accE:.has-error>
<input .account-input.form-control.input-lg.typeahead type=text <input .account-input.form-control.input-lg.typeahead type=text