edit submissions (with tags)

This commit is contained in:
Filip Gralinski 2017-02-25 19:13:55 +01:00
parent b7fc462c14
commit d52b149d04
10 changed files with 562 additions and 35 deletions

View File

@ -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)}

View File

@ -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

40
Handler/SubmissionView.hs Normal file
View File

@ -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

View File

@ -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)

View File

@ -47,6 +47,7 @@ library
Handler.Presentation
Handler.Tags
Handler.EditSubmission
Handler.SubmissionView
if flag(dev) || flag(library-only)
cpp-options: -DDEVELOPMENT

212
static/css/tagify.css Normal file
View File

@ -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);
}
}

238
static/js/tagify.js Normal file
View File

@ -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 = '<div><input list="tagsSuggestions'+ this.id +'" class="placeholder"/><span>'+ input.placeholder +'</span></div>';
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 = "<datalist id='tagsSuggestions"+ this.id +"'> \
<label> \
select from the list: \
<select> \
<option value=''></option> \
[OPTIONS] \
</select> \
</label> \
</datalist>";
for( i=this.settings.whitelist.length; i--; )
OPTIONS += "<option>"+ this.settings.whitelist[i] +"</option>";
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 = "<x></x><div><span title='"+ v +"'>"+ v +" </span></div>";
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(', ');
}
}

View File

@ -18,6 +18,9 @@ $newline never
integrity="sha384-fLW2N01lMqjakBkx3l/M9EahuwpSfeNvV63J5ezn3uZzapT0u7EYsXMjQV+0En5r" crossorigin="anonymous">
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js" integrity="sha384-0mSbJDEHialfmuBBQP6A4Qrprq5OVfW37PRR3j5ELqxss1yVqOtnepnHVP9aJ7xS" crossorigin="anonymous">
<link rel="stylesheet" href="/static/css/tagify.css">
<script src="/static/js/tagify.js">
<meta name="viewport" content="width=device-width,initial-scale=1">
^{pageHead pc}

View File

@ -1,4 +1,4 @@
<p>#{show $ submissionStamp $ submission}
^{view}
<form method=post action=@{EditSubmissionR submissionId}#form enctype=#{formEnctype}>
^{formWidget}

View File

@ -0,0 +1,6 @@
var input = document.querySelector('input[data-role=tagsinput]'),
tagify = new Tagify( input, {
whitelist: #{tagsAvailableAsJSON},
autocomplete: true,
enforeWhitelist: true});
input.style.display = 'none';