From b0d2a5e071082648694cd5e78cb68d5a44d69608 Mon Sep 17 00:00:00 2001 From: Artyom Kazak Date: Sat, 1 Sep 2018 14:29:32 +0200 Subject: [PATCH] Use Vue for (some) text editors --- README.md | 13 +++ src/Guide/JS.hs | 8 ++ src/Guide/Types/Core.hs | 6 +- src/Guide/Views.hs | 3 + src/Guide/Views/Category.hs | 8 +- src/Guide/Views/Item.hs | 26 +++--- src/Guide/Views/Utils.hs | 86 +++++++++---------- static/components/.eslintrc.js | 11 +++ static/components/AEditor.js | 146 +++++++++++++++++++++++++++++++++ 9 files changed, 244 insertions(+), 63 deletions(-) create mode 100644 static/components/.eslintrc.js create mode 100644 static/components/AEditor.js diff --git a/README.md b/README.md index 2c713fa..1358cc1 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,19 @@ $ stack test * `favicon` – code used to generate a favicon * `guidejs` – client side JavaScript +### Frontend + +To lint Vue.js components, you need to do: + +```bash +npm install --global eslint eslint-plugin-vue@next +cd static/components +eslint . +``` + +Maybe there's a better way than `npm install --global`, I don't know. I'm +not a Node.js guy. + ### Notes When you see something like diff --git a/src/Guide/JS.hs b/src/Guide/JS.hs index 28a70a6..4b549e6 100644 --- a/src/Guide/JS.hs +++ b/src/Guide/JS.hs @@ -134,6 +134,14 @@ instance JSParams a => JSFunction (a -> JS) where let paramList = T.intercalate "," (map fromJS (jsParams args)) in JS $ format "{}({});" fName paramList +-- This also produces a Javascript function call, but prefixes the function +-- with "this."; this is needed for event handlers in Vue for some reason +newtype WithThis a = WithThis { withThis :: a } + +instance JSFunction a => JSFunction (WithThis a) where + makeJSFunction fName fParams fDef = WithThis $ + makeJSFunction ("this." <> fName) fParams fDef + -- This isn't a standalone function and so it doesn't have to be listed in -- 'allJSFunctions'. assign :: ToJS x => JS -> x -> JS diff --git a/src/Guide/Types/Core.hs b/src/Guide/Types/Core.hs index ff536f0..6a2750e 100644 --- a/src/Guide/Types/Core.hs +++ b/src/Guide/Types/Core.hs @@ -126,13 +126,13 @@ hackageName _ Other = pure Other instance A.ToJSON ItemKind where toJSON (Library x) = A.object [ - "tag" A..= ("Library" :: Text), + "tag" A..= ("Library" :: Text), "contents" A..= x ] toJSON (Tool x) = A.object [ - "tag"   A..= ("Tool" :: Text), + "tag" A..= ("Tool" :: Text), "contents" A..= x ] toJSON Other = A.object [ - "tag" A..= ("Other" :: Text) ] + "tag" A..= ("Other" :: Text) ] data ItemKind_v2 = Library_v2 (Maybe Text) diff --git a/src/Guide/Views.hs b/src/Guide/Views.hs index ce2d484..ad5ce8a 100644 --- a/src/Guide/Views.hs +++ b/src/Guide/Views.hs @@ -643,6 +643,9 @@ wrapPage pageTitle' page = doctypehtml_ $ do return false; }; |] includeJS "/js/bundle.js" + -- TODO: don't use development build in production! + includeJS "https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js" + includeJS "/components/AEditor.js" -- for modal dialogs includeJS "/magnific-popup.js" includeCSS "/magnific-popup.css" diff --git a/src/Guide/Views/Category.hs b/src/Guide/Views/Category.hs index aed88d9..062a009 100644 --- a/src/Guide/Views/Category.hs +++ b/src/Guide/Views/Category.hs @@ -179,9 +179,9 @@ renderCategoryNotes category = cached (CacheCategoryNotes (category^.uid)) $ do T.readFile "static/category-notes-template.md" else return (category^.notes) markdownEditor - [rows_ "10", class_ " editor "] + 10 -- rows contents - (\val -> JS.submitCategoryNotes - (this, category^.uid, category^.notes.mdSource, val)) - (JS.switchSection (this, "normal" :: Text)) + (\val -> JS.withThis JS.submitCategoryNotes + (this, category^.uid, category^.notes.mdSource, val)) + (JS.withThis JS.switchSection (this, "normal" :: Text)) "or press Ctrl+Enter to save" diff --git a/src/Guide/Views/Item.hs b/src/Guide/Views/Item.hs index 28d1e3d..0dfe12a 100644 --- a/src/Guide/Views/Item.hs +++ b/src/Guide/Views/Item.hs @@ -147,7 +147,7 @@ renderItemDescription item = cached (CacheItemDescription (item^.uid)) $ mustache "item-description" $ A.object [ "item" A..= item ] --- | Render the “ecosystem” secion.. +-- | Render the “ecosystem” section. renderItemEcosystem :: MonadIO m => Item -> HtmlT m () renderItemEcosystem item = cached (CacheItemEcosystem (item^.uid)) $ do let thisId = "item-ecosystem-" <> uidToText (item^.uid) @@ -174,11 +174,11 @@ renderItemEcosystem item = cached (CacheItemEcosystem (item^.uid)) $ do [style_ "width:12px;opacity:0.5", class_ " edit-item-ecosystem "] $ JS.switchSection (this, "normal" :: Text) markdownEditor - [rows_ "3", class_ " editor "] + 3 -- rows (item^.ecosystem) - (\val -> JS.submitItemEcosystem - (this, item^.uid, item^.ecosystem.mdSource, val)) - (JS.switchSection (this, "normal" :: Text)) + (\val -> JS.withThis JS.submitItemEcosystem + (this, item^.uid, item^.ecosystem.mdSource, val)) + (JS.withThis JS.switchSection (this, "normal" :: Text)) "or press Ctrl+Enter to save" -- | Render the “traits” section. @@ -205,12 +205,14 @@ renderItemTraits item = cached (CacheItemTraits (item^.uid)) $ do mapM_ (renderTrait (item^.uid)) (item^.pros) section "editable" [] $ do smallMarkdownEditor - [rows_ "3", placeholder_ "add pro"] + 3 -- rows (toMarkdownInline "") - (\val -> JS.addPro (JS.selectUid listUid, item^.uid, val) <> - JS.assign val ("" :: Text)) + -- TODO: clearing the editor should be moved into 'addPro' and + -- done only if the request succeeds + (\val -> JS.withThis JS.addPro (JS.selectUid listUid, item^.uid, val)) Nothing "press Ctrl+Enter or Enter to add" + (Just "add pro") -- placeholder textButton "edit off" $ JS.switchSectionsEverywhere(this, "normal" :: Text) @@ -231,12 +233,14 @@ renderItemTraits item = cached (CacheItemTraits (item^.uid)) $ do mapM_ (renderTrait (item^.uid)) (item^.cons) section "editable" [] $ do smallMarkdownEditor - [rows_ "3", placeholder_ "add con"] + 3 -- rows (toMarkdownInline "") - (\val -> JS.addCon (JS.selectUid listUid, item^.uid, val) <> - JS.assign val ("" :: Text)) + -- TODO: clearing the editor should be moved into 'addCon' and + -- done only if the request succeeds + (\val -> JS.withThis JS.addCon (JS.selectUid listUid, item^.uid, val)) Nothing "press Ctrl+Enter or Enter to add" + (Just "add con") -- placeholder textButton "edit off" $ JS.switchSectionsEverywhere(this, "normal" :: Text) diff --git a/src/Guide/Views/Utils.hs b/src/Guide/Views/Utils.hs index 526af52..61235f5 100644 --- a/src/Guide/Views/Utils.hs +++ b/src/Guide/Views/Utils.hs @@ -75,6 +75,7 @@ import Text.Digestive (View) -- import NeatInterpolation -- Web import Lucid hiding (for_) +import Lucid.Base (makeAttribute) -- Files import qualified System.FilePath.Find as F -- -- Network @@ -179,64 +180,59 @@ checkedIf p x = if p then with x [checked_] else x hiddenIf :: With w => Bool -> w -> w hiddenIf p x = if p then with x [style_ "display:none;"] else x +-- | @v-bind@ from Vue.js +vBind :: JS.ToJS a => Text -> a -> Attribute +vBind x val = makeAttribute (":" <> x) (fromJS (JS.toJS val)) + +-- | @v-on@ from Vue.js +-- +-- You can access the event payload with @$event@. +vOn :: Text -> JS -> Attribute +vOn x js = makeAttribute ("@" <> x) (fromJS js) + markdownEditor :: MonadIO m - => [Attribute] + => Int -- ^ How many rows the editor should have -> MarkdownBlock -- ^ Default text - -> (JS -> JS) -- ^ “Submit” handler, receiving the contents of the editor + -> (JS -> JS) -- ^ “Submit” handler, receiving a variable with the + -- contents of the editor -> JS -- ^ “Cancel” handler -> Text -- ^ Instruction (e.g. “press Ctrl+Enter to save”) -> HtmlT m () -markdownEditor attr (view mdSource -> s) submit cancel instr = do - textareaUid <- randomLongUid - let val = JS $ "document.getElementById(\""+|textareaUid|+"\").value" - -- Autocomplete has to be turned off thanks to - -- . - textarea_ ([uid_ textareaUid, - autocomplete_ "off", - class_ "big fullwidth", - onCtrlEnter (submit val), - onEscape (JS.assign val s <> cancel) ] - ++ attr) $ - toHtml s - button "Save" [class_ " save "] $ - submit val - emptySpan "6px" - button "Cancel" [class_ " cancel "] $ - JS.assign val s <> - cancel - emptySpan "6px" - span_ [class_ "edit-field-instruction"] (toHtml instr) - a_ [href_ "/markdown", target_ "_blank"] $ - img_ [src_ "/markdown.svg", alt_ "markdown supported", - class_ " markdown-supported "] +markdownEditor rows (view mdSource -> src) submit cancel instr = do + editorUid <- randomLongUid + term "a-editor" [uid_ editorUid, + vBind "init-content" src, + vBind "instruction" instr, + vBind "rows" rows, + vOn "submit-edit" (submit (JS "$event")), + vOn "cancel-edit" cancel] + (pure ()) + script_ (format "new Vue({el: '#{}'});" editorUid) smallMarkdownEditor :: MonadIO m - => [Attribute] + => Int -- ^ How many rows the editor should have -> MarkdownInline -- ^ Default text - -> (JS -> JS) -- ^ “Submit” handler, receiving the contents of the editor + -> (JS -> JS) -- ^ “Submit” handler, receiving a variable with the + -- contents of the editor -> Maybe JS -- ^ “Cancel” handler (if “Cancel” is needed) -> Text -- ^ Instruction (e.g. “press Enter to add”) + -> Maybe Text -- ^ Placeholder -> HtmlT m () -smallMarkdownEditor attr (view mdSource -> s) submit mbCancel instr = do - textareaId <- randomLongUid - let val = JS $ "document.getElementById(\""+|textareaId|+"\").value" - textarea_ ([class_ "fullwidth", uid_ textareaId, autocomplete_ "off"] ++ - [onEnter (submit val)] ++ - [onEscape cancel | Just cancel <- [mbCancel]] ++ - attr) $ - toHtml s - br_ [] - for_ mbCancel $ \cancel -> do - textButton "cancel" $ - JS.assign val s <> - cancel - span_ [style_ "float:right"] $ do - span_ [class_ "edit-field-instruction"] (toHtml instr) - a_ [href_ "/markdown", target_ "_blank"] $ - img_ [src_ "/markdown.svg", alt_ "markdown supported", - class_ " markdown-supported "] +smallMarkdownEditor rows (view mdSource -> src) submit mbCancel instr mbPlaceholder = do + editorUid <- randomLongUid + term "a-editor-mini" ([uid_ editorUid, + vBind "init-content" src, + vBind "instruction" instr, + vBind "rows" rows] ++ + map (vBind "placeholder") (maybeToList mbPlaceholder) ++ + [vOn "submit-edit" (submit (JS "$event"))] ++ + case mbCancel of { + Nothing -> [vBind "allow-cancel" False]; + Just cancel -> [vOn "cancel-edit" cancel]; }) + (pure ()) + script_ (format "new Vue({el: '#{}'});" editorUid) thisNode :: MonadIO m => HtmlT m JQuerySelector thisNode = do diff --git a/static/components/.eslintrc.js b/static/components/.eslintrc.js new file mode 100644 index 0000000..771d8db --- /dev/null +++ b/static/components/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + extends: ['eslint:recommended', 'plugin:vue/recommended'], + 'env': { + 'es6': true, + 'jquery': true, + }, + plugins: ['vue'], + globals: { + 'Vue': true, + }, +}; diff --git a/static/components/AEditor.js b/static/components/AEditor.js new file mode 100644 index 0000000..dcf0c04 --- /dev/null +++ b/static/components/AEditor.js @@ -0,0 +1,146 @@ +//////////////////////////////////////////////////////////////////////////// +// A multiline text editor with "Submit" and "Cancel" buttons. +// +// Available keybindings: +// * Ctrl+Enter Submit +// * Escape Cancel +// +// Events: +// * submit-edit(String) Some text should be saved +// * cancel-edit The edit has been cancelled +// +//////////////////////////////////////////////////////////////////////////// + +// TODO: check that it's okay that editors have been wrapped into
+ +// TODO: I can recall 'section' being removed from item-info, maybe that's important + +Vue.component('AEditor', { + props: { + // Text to populate the editor with + initContent: {type: String, default: ""}, + // Instruction for the user + instruction: {type: String, required: true}, + // How many rows the editor should have + rows: {type: Number, required: true}, + // Whether editor content should be reset on cancel + resetOnCancel: {type: Boolean, default: true}, + }, + + data: function() { return { + content: this.initContent, + }}, + + methods: { + onSubmitEdit() { + this.$emit('submit-edit', this.content); }, + onCancelEdit() { + if (this.resetOnCancel) this.content = this.initContent; + this.$emit('cancel-edit'); }, + }, + + // TODO: another messy template; also, can use