From d52b149d04e085abffd4fe677325545997c5f20f Mon Sep 17 00:00:00 2001 From: Filip Gralinski Date: Sat, 25 Feb 2017 19:13:55 +0100 Subject: [PATCH] edit submissions (with tags) --- Handler/EditSubmission.hs | 62 +++++- Handler/Query.hs | 28 +-- Handler/SubmissionView.hs | 40 ++++ config/models | 5 + gonito.cabal | 1 + static/css/tagify.css | 212 +++++++++++++++++++++ static/js/tagify.js | 238 ++++++++++++++++++++++++ templates/default-layout-wrapper.hamlet | 3 + templates/edit-submission.hamlet | 2 +- templates/edit-submission.julius | 6 + 10 files changed, 562 insertions(+), 35 deletions(-) create mode 100644 Handler/SubmissionView.hs create mode 100644 static/css/tagify.css create mode 100644 static/js/tagify.js create mode 100644 templates/edit-submission.julius diff --git a/Handler/EditSubmission.hs b/Handler/EditSubmission.hs index 0083fa4..5b99cca 100644 --- a/Handler/EditSubmission.hs +++ b/Handler/EditSubmission.hs @@ -3,28 +3,76 @@ module Handler.EditSubmission where import Import +import Handler.Common (checkIfCanEdit) +import Handler.SubmissionView import Yesod.Form.Bootstrap3 (BootstrapFormLayout (..), renderBootstrap3, bfs) +import Data.Text as T + getEditSubmissionR :: SubmissionId -> Handler Html getEditSubmissionR submissionId = do - (formWidget, formEnctype) <- generateFormPost editSubmissionForm + submission <- runDB $ get404 submissionId + tags <- runDB $ getTags submissionId + let mTagsAsText = case tags of + [] -> Nothing + _ -> Just $ T.intercalate ", " $ Import.map tagName $ Import.catMaybes tags + (formWidget, formEnctype) <- generateFormPost $ editSubmissionForm (submissionDescription submission) mTagsAsText doEditSubmission formWidget formEnctype submissionId postEditSubmissionR :: SubmissionId -> Handler Html postEditSubmissionR submissionId = do - ((result, formWidget), formEnctype) <- runFormPost editSubmissionForm - doEditSubmission formWidget formEnctype submissionId + submission <- runDB $ get404 submissionId + ((result, _), _) <- runFormPost $ editSubmissionForm (submissionDescription submission) Nothing + let FormSuccess (description, tags) = result + isEditable <- checkIfCanEdit submissionId + if isEditable + then + runDB $ do + update submissionId [SubmissionDescription =. description] + + sts <- selectList [SubmissionTagSubmission ==. submissionId] [] + let currentTagIds = Import.map (submissionTagTag . entityVal) sts + + addTags submissionId tags currentTagIds + + return () + else + do + setMessage $ toHtml ("Only owner can edit a submission!!!" :: Text) + return () + getEditSubmissionR submissionId + + +addTags submissionId tagsAsText existingOnes = do + let newTags = case tagsAsText of + Just tags' -> Import.map T.strip $ T.split (== ',') tags' + Nothing -> [] + mTs <- mapM (\t -> getBy $ UniqueTagName t) newTags + let tids = Import.map entityKey $ Import.catMaybes mTs + + deleteWhere [SubmissionTagSubmission ==. submissionId, SubmissionTagTag /<-. tids] + + _ <- mapM (\tid -> insert $ SubmissionTag submissionId tid Nothing) (Import.filter (not . (`elem` existingOnes)) tids) + return () + + doEditSubmission formWidget formEnctype submissionId = do submission <- runDB $ get404 submissionId + submissionFull <- getFullInfo (Entity submissionId submission) + let view = queryResult submissionFull + + tagsAvailable <- runDB $ selectList [] [Asc TagName] + let tagsAvailableAsJSON = toJSON $ Import.map (tagName . entityVal) tagsAvailable + defaultLayout $ do setTitle "Edit a submission" $(widgetFile "edit-submission") -editSubmissionForm :: Form (Text, Maybe Text) -editSubmissionForm = renderBootstrap3 BootstrapBasicForm $ (,) - <$> areq textField (bfs MsgSubmissionDescription) Nothing - <*> aopt textField (tagsfs MsgSubmissionTags) Nothing +editSubmissionForm :: Text -> Maybe Text -> Form (Text, Maybe Text) +editSubmissionForm description mTags = renderBootstrap3 BootstrapBasicForm $ (,) + <$> areq textField (bfs MsgSubmissionDescription) (Just description) + <*> aopt textField (tagsfs MsgSubmissionTags) (Just mTags) tagsfs :: RenderMessage site msg => msg -> FieldSettings site tagsfs msg = attrs { fsAttrs = ("data-role"::Text,"tagsinput"::Text):(fsAttrs attrs)} diff --git a/Handler/Query.hs b/Handler/Query.hs index a851aee..859eb2d 100644 --- a/Handler/Query.hs +++ b/Handler/Query.hs @@ -3,6 +3,7 @@ module Handler.Query where import Import import Handler.Shared +import Handler.SubmissionView import PersistSHA1 import Database.Persist.Sql @@ -11,24 +12,6 @@ import Data.Text as T(pack) import Yesod.Form.Bootstrap3 (BootstrapFormLayout (..), renderBootstrap3, withSmallInput) -data FullSubmissionInfo = FullSubmissionInfo { - fsiSubmissionId :: SubmissionId, - fsiSubmission :: Submission, - fsiUser :: User, - fsiRepo :: Repo, - fsiChallenge :: Challenge } - -getFullInfo :: Entity Submission -> Handler FullSubmissionInfo -getFullInfo (Entity submissionId submission) = do - repo <- runDB $ get404 $ submissionRepo submission - user <- runDB $ get404 $ submissionSubmitter submission - challenge <- runDB $ get404 $ submissionChallenge submission - return $ FullSubmissionInfo { - fsiSubmissionId = submissionId, - fsiSubmission = submission, - fsiUser = user, - fsiRepo = repo, - fsiChallenge = challenge } findSubmissions :: Text -> Handler [FullSubmissionInfo] findSubmissions sha1Prefix = do @@ -69,14 +52,5 @@ processQuery query = do setTitle "query results" $(widgetFile "query-results") -queryResult submission = do - $(widgetFile "query-result") - where commitSha1AsText = fromSHA1ToText $ submissionCommit $ fsiSubmission submission - submitter = formatSubmitter $ fsiUser submission - publicSubmissionBranch = getPublicSubmissionBranch $ fsiSubmissionId submission - publicSubmissionRepo = getReadOnlySubmissionUrl $ challengeName $ fsiChallenge submission - browsableUrl = browsableGitRepoBranch (challengeName $ fsiChallenge submission) publicSubmissionBranch - stamp = T.pack $ show $ submissionStamp $ fsiSubmission submission - queryForm :: Form Text queryForm = renderBootstrap3 BootstrapBasicForm $ areq textField (fieldSettingsLabel MsgGitCommitSha1) Nothing diff --git a/Handler/SubmissionView.hs b/Handler/SubmissionView.hs new file mode 100644 index 0000000..2392e89 --- /dev/null +++ b/Handler/SubmissionView.hs @@ -0,0 +1,40 @@ +module Handler.SubmissionView where + +import Import +import Handler.Shared +import PersistSHA1 + +import Data.Text as T(pack) + +data FullSubmissionInfo = FullSubmissionInfo { + fsiSubmissionId :: SubmissionId, + fsiSubmission :: Submission, + fsiUser :: User, + fsiRepo :: Repo, + fsiChallenge :: Challenge } + +getFullInfo :: Entity Submission -> Handler FullSubmissionInfo +getFullInfo (Entity submissionId submission) = do + repo <- runDB $ get404 $ submissionRepo submission + user <- runDB $ get404 $ submissionSubmitter submission + challenge <- runDB $ get404 $ submissionChallenge submission + return $ FullSubmissionInfo { + fsiSubmissionId = submissionId, + fsiSubmission = submission, + fsiUser = user, + fsiRepo = repo, + fsiChallenge = challenge } + +queryResult submission = do + $(widgetFile "query-result") + where commitSha1AsText = fromSHA1ToText $ submissionCommit $ fsiSubmission submission + submitter = formatSubmitter $ fsiUser submission + publicSubmissionBranch = getPublicSubmissionBranch $ fsiSubmissionId submission + publicSubmissionRepo = getReadOnlySubmissionUrl $ challengeName $ fsiChallenge submission + browsableUrl = browsableGitRepoBranch (challengeName $ fsiChallenge submission) publicSubmissionBranch + stamp = T.pack $ show $ submissionStamp $ fsiSubmission submission + +getTags submissionId = do + sts <- selectList [SubmissionTagSubmission ==. submissionId] [] + tags <- mapM get $ Import.map (submissionTagTag . entityVal) sts + return tags diff --git a/config/models b/config/models index fe4716d..95e5005 100644 --- a/config/models +++ b/config/models @@ -78,4 +78,9 @@ Tag name Text description Text Maybe UniqueTagName name +SubmissionTag + submission SubmissionId + tag TagId + accepted Bool Maybe + UniqueSubmissionTag submission tag -- By default this file is used in Model.hs (which is imported by Foundation.hs) diff --git a/gonito.cabal b/gonito.cabal index ef07cba..8a9a8b4 100644 --- a/gonito.cabal +++ b/gonito.cabal @@ -47,6 +47,7 @@ library Handler.Presentation Handler.Tags Handler.EditSubmission + Handler.SubmissionView if flag(dev) || flag(library-only) cpp-options: -DDEVELOPMENT diff --git a/static/css/tagify.css b/static/css/tagify.css new file mode 100644 index 0000000..9c962f5 --- /dev/null +++ b/static/css/tagify.css @@ -0,0 +1,212 @@ +tags { + display: block; + border: 1px solid #DDD; + padding-right: 0.3em 0.5em; + cursor: text; + overflow: hidden; +} + +tags:hover { + border-color: #CCC; +} + +tags tag { + display: inline-block; + margin: 5px 0 5px 5px; + vertical-align: top; + position: relative; + cursor: default; + -webkit-transition: .13s ease-out; + transition: .13s ease-out; + -webkit-animation: .3s tags--bump 1 ease-out; + animation: .3s tags--bump 1 ease-out; +} + +tags tag > div { + vertical-align: top; + position: relative; + box-sizing: border-box; + max-width: 100%; + padding: 0.3em 0.5em; + color: black; + -webkit-transition: .13s ease-out; + transition: .13s ease-out; + padding-right: 1.5em; +} + +tags tag > div > span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + vertical-align: top; + width: 100%; + -webkit-transition: .1s; + transition: .1s; +} + +tags tag > div::before { + content: ''; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + background: #E5E5E5; + border-radius: 3px; + z-index: -1; + pointer-events: none; + -webkit-transition: 80ms ease; + transition: 80ms ease; +} + +tags tag:hover div::before { + top: -2px; + right: -2px; + bottom: -2px; + left: -2px; + background: #D3E2E2; + box-shadow: 0 0 0 0 #D39494 inset; +} + +tags tag.tagify--noAnim { + -webkit-animation: none; + animation: none; +} + +tags tag.tagify--hide { + width: 0 !important; + padding-left: 0; + padding-right: 0; + margin-left: 0; + margin-right: 0; + opacity: 0; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-transition: .3s; + transition: .3s; + pointer-events: none; +} + +tags tag.tagify--mark div::before { + -webkit-animation: .3s tagify--pulse 2 ease-out; + animation: .3s tagify--pulse 2 ease-out; +} + +tags tag.tagify--notAllowed div > span { + opacity: .5; +} + +tags tag.tagify--notAllowed div::before { + background: rgba(211, 148, 148, 0.44); + -webkit-transition: .2s; + transition: .2s; +} + +tags tag x { + font: 14px/14px Serif; + width: 14px; + height: 14px; + text-align: center; + border-radius: 50px; + position: absolute; + z-index: 1; + right: -webkit-calc(0.5em - 2px); + right: calc(0.5em - 2px); + top: 50%; + cursor: pointer; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); + -webkit-transition: .2s ease-out; + transition: .2s ease-out; +} + +tags tag x::after { + content: "\00D7"; +} + +tags tag x:hover { + color: white; + background: #c77777; +} + +tags tag x:hover + div > span { + opacity: .5; +} + +tags tag x:hover + div::before { + background: rgba(211, 148, 148, 0.44); + -webkit-transition: .2s; + transition: .2s; +} + +tags input,tags textarea { + border: 0; + display: none; +} + +tags > div { + display: inline-block; + min-width: 10px; + margin: 5px; + padding: 0.3em 0.5em; + position: relative; + vertical-align: top; +} + +tags > div > input { + display: block; + min-width: 130px; +} + +tags > div > input:focus { + outline: none; +} + +tags > div > input.placeholder ~ span { + opacity: .5; + -webkit-transform: none; + -ms-transform: none; + transform: none; +} + +tags > div > span { + opacity: 0; + line-height: 1.8; + position: absolute; + top: 0; + z-index: 1; + white-space: nowrap; + pointer-events: none; + -webkit-transform: translatex(6px); + -ms-transform: translatex(6px); + transform: translatex(6px); + -webkit-transition: .15s ease-out; + transition: .15s ease-out; +} + +@-webkit-keyframes tags--bump { + 30% { + box-shadow: 0 0 0 4px #E5E5E5; + } +} + +@keyframes tags--bump { + 30% { + box-shadow: 0 0 0 4px #E5E5E5; + } +} + +@-webkit-keyframes tagify--pulse { + 25% { + background: rgba(211, 148, 148, 0.6); + } +} + +@keyframes tagify--pulse { + 25% { + background: rgba(211, 148, 148, 0.6); + } +} diff --git a/static/js/tagify.js b/static/js/tagify.js new file mode 100644 index 0000000..2b3ea4e --- /dev/null +++ b/static/js/tagify.js @@ -0,0 +1,238 @@ +/** + * Tagify - jQuery tags input plugin + * By Yair Even-Or (2016) + * Don't sell this code. (c) + * https://github.com/yairEO/tagify + */ +function Tagify( input, settings ){ + // protection + if( !input ){ + console.warn('Tagify: ', 'invalid input element ', input) + return this; + } + + settings = typeof settings == 'object' ? settings : {}; // make sure settings is an 'object' + + this.settings = { + duplicates : settings.duplicates || false, // flag - allow tuplicate tags + enforeWhitelist : settings.enforeWhitelist || false, // flag - should ONLY use tags allowed in whitelist + autocomplete : settings.autocomplete || true, // flag - show native suggeestions list as you type + whitelist : settings.whitelist || [], // is this list has any items, then only allow tags from this list + blacklist : settings.blacklist || [] // a list of non-allowed tags + }; + + this.id = Math.random().toString(36).substr(2,9), // almost-random ID (because, fuck it) + this.value = []; // An array holding all the (currently used) tags + this.DOM = {}; // Store all relevant DOM elements in an Object + this.build(input); + this.events(); +} + +Tagify.prototype = { + build : function( input ){ + var that = this, + value = input.value; + + this.DOM.originalInput = input; + this.DOM.scope = document.createElement('tags'); + this.DOM.scope.innerHTML = '
'+ input.placeholder +'
'; + + this.DOM.input = this.DOM.scope.querySelector('input'); + input.parentNode.insertBefore(this.DOM.scope, input); + this.DOM.scope.appendChild(input); + + // if "autocomplete" flag on toggeled & "whitelist" has items, build suggestions list + if( this.settings.autocomplete && this.settings.whitelist.length ) + this.buildDataList(); + + // if the original input already had any value (tags) + if( value ) + this.addTag(value).forEach(function(tag){ + tag && tag.classList.add('tagify--noAnim'); + }); + }, + + /** + * DOM events binding + */ + events : function(){ + var events = { + // event name / event callback / element to be listening to + focus : ['onFocusBlur' , 'input'], + blur : ['onFocusBlur' , 'input'], + input : ['onInput' , 'input'], + keydown : ['onKeydown' , 'input'], + click : ['onClickScope' , 'scope'] + }; + + for( var e in events ) + this.DOM[events[e][1]].addEventListener(e, this.callbacks[events[e][0]].bind(this)); + }, + + /** + * DOM events callbacks + */ + callbacks : { + onFocusBlur : function(e){ + var text = e.target.value.trim(); + + if( e.type == "focus" ) + e.target.className = 'input'; + else if( e.type == "blur" && text == "" ){ + e.target.className = 'input placeholder'; + this.DOM.input.removeAttribute('style'); + } + }, + + onKeydown : function(e){ + var s = e.target.value; + if( e.key == "Backspace" && (s == "" || s.charCodeAt(0) == 8203) ){ + this.removeTag( this.DOM.scope.querySelectorAll('tag:not(.tagify--hide)').length - 1 ); + } + if( e.key == "Escape" ){ + e.target.value = ''; + e.target.blur(); + } + if( e.key == "Enter" ){ + e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380 + if( this.addTag(s) ) + e.target.value = ''; + return false; + } + }, + + onInput : function(e){ + var value = e.target.value, + lastChar = value[value.length - 1]; + + e.target.style.width = ((e.target.value.length + 1) * 7) + 'px'; + + if( value.indexOf(',') != -1 ){ + this.addTag( value ); + e.target.value = ''; // clear the input field's value + } + }, + + onClickScope : function(e){ + if( e.target.tagName == "TAGS" ) + this.DOM.input.focus(); + if( e.target.tagName == "X" ){ + this.removeTag( this.getNodeIndex(e.target.parentNode) ); + } + } + }, + + /** + * Build tags suggestions using HTML datalist + * @return {[type]} [description] + */ + buildDataList : function(){ + var OPTIONS = "", + i, + datalist = " \ + \ + "; + + for( i=this.settings.whitelist.length; i--; ) + OPTIONS += ""; + + datalist = datalist.replace('[OPTIONS]', OPTIONS); // inject the options string in the right place + this.DOM.input.insertAdjacentHTML('afterend', datalist); // append the datalist HTML string in the Tags + + return datalist; + }, + + getNodeIndex : function( node ){ + var index = 0; + while( (node = node.previousSibling) ) + if (node.nodeType != 3 || !/^\s*$/.test(node.data)) + index++; + return index; + }, + + markTagByValue : function(value){ + var tagIdx = this.value.findIndex(function(item){ return value.toLowerCase() === item.toLowerCase() }), + tag = this.DOM.scope.querySelectorAll('tag')[tagIdx]; + + if( tag ){ + tag.classList.add('tagify--mark'); + setTimeout(function(){ tag.classList.remove('tagify--mark') }, 2000); + return true; + } + return false; + }, + + /** + * make sure the tag, or words in it, is not in the blacklist + */ + isTagBlacklisted : function(v){ + v = v.split(' '); + return this.settings.blacklist.filter(function(x){ return v.indexOf(x) != -1 }).length; + }, + + /** + * make sure the tag, or words in it, is not in the blacklist + */ + isTagWhitelisted : function(v){ + return this.settings.whitelist.indexOf(v) != -1; + }, + + addTag : function( value ){ + var that = this; + + this.DOM.input.removeAttribute('style'); + + value = value.trim(); + if( !value ) return; + + return value.split(',').filter(function(v){ return !!v }).map(function(v){ + var tagElm = document.createElement('tag'); + v = v.trim(); + + if( !that.settings.duplicates && that.markTagByValue(v) ) + return false; + + // check against blacklist & whitelist (if enforced) + if( that.isTagBlacklisted(v) || (that.settings.enforeWhitelist && !that.isTagWhitelisted(v)) ){ + tagElm.classList.add('tagify--notAllowed'); + setTimeout(function(){ that.removeTag(that.getNodeIndex(tagElm)) }, 1000); + } + + // the space below is important - http://stackoverflow.com/a/19668740/104380 + tagElm.innerHTML = "
"+ v +"
"; + that.DOM.scope.insertBefore(tagElm, that.DOM.input.parentNode); + + that.value.push(v); + that.update(); + return tagElm; + }); + }, + + removeTag : function( idx ){ + var tagElm = this.DOM.scope.children[idx]; + if( !tagElm) return; + + tagElm.style.width = parseFloat(window.getComputedStyle(tagElm).width) + 'px'; + document.body.clientTop; // force repaint for the width to take affect before the "hide" class below + tagElm.classList.add('tagify--hide'); + + // manual timeout (hack, since transitionend cannot be used because of hover) + setTimeout(function(){ + tagElm.parentNode.removeChild(tagElm); + }, 400); + + this.value.splice(idx, 1); + this.update(); + }, + + // update the origianl (hidden) input field's value + update : function(){ + this.DOM.originalInput.value = this.value.join(', '); + } +} diff --git a/templates/default-layout-wrapper.hamlet b/templates/default-layout-wrapper.hamlet index d197068..d804aee 100644 --- a/templates/default-layout-wrapper.hamlet +++ b/templates/default-layout-wrapper.hamlet @@ -18,6 +18,9 @@ $newline never integrity="sha384-fLW2N01lMqjakBkx3l/M9EahuwpSfeNvV63J5ezn3uZzapT0u7EYsXMjQV+0En5r" crossorigin="anonymous">