diff --git a/tools/cldr-apps/js/src/esm/cldrForum.mjs b/tools/cldr-apps/js/src/esm/cldrForum.mjs index 04e544726ce..447485d2216 100644 --- a/tools/cldr-apps/js/src/esm/cldrForum.mjs +++ b/tools/cldr-apps/js/src/esm/cldrForum.mjs @@ -10,9 +10,13 @@ import * as cldrForumFilter from "./cldrForumFilter.mjs"; import * as cldrForumPanel from "./cldrForumPanel.mjs"; import * as cldrForumType from "./cldrForumType.mjs"; import * as cldrLoad from "./cldrLoad.mjs"; +import * as cldrNotify from "./cldrNotify.mjs"; import * as cldrStatus from "./cldrStatus.mjs"; import * as cldrSurvey from "./cldrSurvey.mjs"; import * as cldrText from "./cldrText.mjs"; +import * as cldrVue from "./cldrVue.mjs"; + +import ForumButton from "../views/ForumButton.vue"; class PostInfo { constructor(postType) { @@ -25,23 +29,25 @@ class PostInfo { this.willFlag = false; this.replyTo = -1; this.parentPost = null; + this.isOpen = true; // may change in setReplyTo } /** * Set the parent data for a reply. * - * @param {Object} post - the data (as received from the back end) for the post to which + * @param {Object} rootPost - the data (as received from the back end) for the post to which * this is a reply. It is NOT a PostInfo object! */ - setReplyTo(post) { - this.replyTo = post.id; - this.parentPost = post; + setReplyTo(rootPost) { + this.replyTo = rootPost.id; + this.parentPost = rootPost; + this.isOpen = rootPost.open; // Copy these values from the parent. this.setLocalePathValueSubject( - post.locale, - post.xpath, - post.value, - post.subject + rootPost.locale, + rootPost.xpath, + rootPost.value, + rootPost.subject ); } @@ -57,11 +63,6 @@ class PostInfo { } } -/** - * Encapsulate this class name -- caution: it's used literally in surveytool.css - */ -const FORUM_DIV_CLASS = "forumDiv"; - const SUMMARY_CLASS = "getForumSummary"; const FORUM_DEBUG = false; @@ -236,112 +237,15 @@ function setUserCanPost(canPost) { userCanPost = canPost ? true : false; } -/** - * Make a new forum post or a reply. - * - * @param {PostInfo} pi - */ -function openPostOrReply(pi) { - const isReply = pi.replyTo > 0; - const rootPost = isReply ? getThreadRootPost(pi.parentPost) : null; - const subject = makePostSubject(isReply, rootPost, pi.subject); - const root = isReply ? rootPost.id : -1; - const open = isReply ? rootPost.open : true; - const typeLabel = makePostTypeLabel(pi.postType, isReply); - const html = makePostHtml( - pi.postType, - typeLabel, - pi.locale, - pi.xpstrid, - subject, - pi.replyTo, - root, - open, - pi.value, - pi.willFlag - ); - const text = prefillPostText(pi.postType, pi.value); - - openPostWindow(html, text, pi.parentPost); -} - -/** - * Assemble the form and related html elements for creating a forum post - * - * @param postType the post type, such as 'Discuss' - * @param typeLabel the post type label, such as 'Comment' - * @param locale the locale string - * @param xpath the xpath string - * @param subject the subject string (path-header) - * @param replyTo the post id of the post being replied to, or -1 - * @param root the post id of the original post in the thread, or -1 - * @param open true or false, is this thread open - * @param value the value that was requested in the root post, or null - * @param willFlag {Boolean} if true, this post will cause the item to be flagged - * @return the html - */ -function makePostHtml( - postType, - typeLabel, - locale, - xpath, - subject, - replyTo, - root, - open, - value, - willFlag -) { - const reminder = willFlag ? "flag_must_have_reason" : "forum_remember_vote"; - let html = ""; - - html += '
' + subject + "
\n"; - html += "
" + cldrText.get(reminder) + "
\n"; - html += '
' + typeLabel + "
\n"; - html += '
\n'; - html += '
\n'; - html += - '\n'; - html += - '\n'; - html += '\n'; - html += '\n'; - html += '\n'; - html += '\n'; - html += '\n'; - html += '\n'; - html += '\n'; - html += '\n'; - html += "\n"; - - html += '
\n'; - html += '
\n'; - return html; -} - -/** - * Make the subject string for a forum post - * - * @param isReply is this a reply? True or false - * @param rootPost the original post in the thread, or null - * @param subjectParam the subject for this post supplied in parameters - * @return the string - */ -function makePostSubject(isReply, rootPost, subjectParam) { - if (isReply && rootPost) { - return post2text(rootPost.subject); - } - return subjectParam; -} - /** * Make the text (body) string for a forum post * - * @param postType the verb such as 'Request', 'Discuss', ... - * @param value the value that was requested in the root post, or null + * @param {PostInfo} pi * @return the string */ -function prefillPostText(postType, value) { +function prefillPostText(pi) { + const postType = pi.postType; // the verb such as 'Request', 'Discuss', ... + const value = pi.value; // the value that was requested in the root post, or null if (postType === cldrForumType.CLOSE) { return cldrText.get("forum_prefill_close"); } else if (postType === cldrForumType.REQUEST) { @@ -356,101 +260,20 @@ function prefillPostText(postType, value) { return ""; } -/** - * Open a window displaying the form for creating a post - * - * @param html the main html for the form - * @param text the pre-filled user-editable text for the form - * @param parentPost the post object, if any, to which this is a reply, for display at the bottom of the window - * - * Reference: Bootstrap.js post-modal: https://getbootstrap.com/docs/4.1/components/modal/ - */ -function openPostWindow(html, text, parentPost) { - const postModal = $("#post-modal"); - postModal.find(".modal-body").html(html); - $("#post-form textarea[name=text]").val(text); - if (parentPost) { - const div = parseContent([parentPost], "parent"); - const postHolder = postModal - .find(".modal-body") - .find("." + FORUM_DIV_CLASS); - postHolder[0].appendChild(div); - } - postModal.modal(); - autosize(postModal.find("textarea")); - postModal.find(".submit-post").click(submitPost); - setTimeout(function () { - postModal.find("textarea").focus(); - }, 1000 /* one second */); -} - -/** - * Submit a forum post - * - * @param event - */ -function submitPost(event) { - event.preventDefault(); - event.stopPropagation(); - const form = getFormValues(); - if (formIsAcceptable(form)) { - $("#post-form button").fadeOut(); - cldrForumPanel.clearCache(); - sendPostRequest(form); - } else { - // Call the user's attention to the bogus text area by winking it - $("#post-form textarea").fadeTo(1000, 0).fadeTo(1000, 1); - } -} - -/** - * Is the given form data acceptable? - * - * @param {Object} form - * @returns {Boolean} true if acceptable - */ -function formIsAcceptable(form) { - if (!form.text.trim()) { - // the text field is empty or all whitespace - return false; - } - if (form.postType === cldrForumType.REQUEST) { - const prefill = cldrText.sub("forum_prefill_request", [form.value]); - if (form.text.trim() === prefill.trim()) { - // the text field for a Request matches the pre-fill - return false; - } - } - return true; -} - -function getFormValues() { - return { - text: $("#post-form textarea[name=text]").val(), - locale: $("#post-form input[name=_]").val(), - open: $("#post-form input[name=open]").val(), - postType: $("#post-form input[name=postType]").val(), - replyTo: $("#post-form input[name=replyTo]").val(), - root: $("#post-form input[name=root]").val(), - value: $("#post-form input[name=value]").val(), - xpath: $("#post-form input[name=xpath]").val(), - }; -} - -function sendPostRequest(form) { +function sendPostRequest(pi, text) { const url = cldrStatus.getContextPath() + "/SurveyAjax"; const postData = { what: "forum_post", s: cldrStatus.getSessionId(), - subj: document.getElementById("postSubject").innerHTML, - _: form.locale, - open: form.open, - postType: form.postType, - replyTo: form.replyTo, - root: form.root, - text: form.text, - value: form.value, - xpath: form.xpath, + subj: pi.subject, + _: pi.locale, + open: pi.isOpen, + postType: pi.postType, + replyTo: pi.replyTo, + root: pi.replyTo, // root and replyTo are always the same + text: text, + value: pi.value, + xpath: pi.xpstrid, }; const xhrArgs = { url: url, @@ -464,11 +287,8 @@ function sendPostRequest(form) { function loadHandlerForSubmit(data) { if (data.err) { - const post = $(".post").first(); - post.before("

error: " + data.err + "

"); + cldrNotify.error("Error posting to Forum", data.err); } else if (data.ret && data.ret.length > 0) { - const postModal = $("#post-modal"); - postModal.modal("hide"); if (cldrStatus.getCurrentSpecial() === "forum") { cldrLoad.reloadV(); // main Forum page } else { @@ -476,16 +296,14 @@ function loadHandlerForSubmit(data) { cldrSurvey.expediteStatusUpdate(); // update forum icons (👁️‍🗨️, 💬) in the main table } } else { - const post = $(".post").first(); - post.before( - "Your post was added, #" + data.postId + " but could not be shown." - ); + const message = + "Your post was added, #" + data.postId + " but could not be shown."; + cldrNotify.error("Error posting to Forum", message); } } function errorHandlerForSubmit(err) { - const post = $(".post").first(); - post.before("

error! " + err + "

"); + cldrNotify.error("Error posting to Forum", err); } /** @@ -496,14 +314,12 @@ function errorHandlerForSubmit(err) { * * @return new DOM object * - * TODO: shorten this function by moving code into subroutines. Also, postpone creating - * DOM elements until finished constructing the filtered list of threads, to make the code - * cleaner, faster, and more testable. If context is 'summary', all DOM element creation here - * is a waste of time. + * NOTE: this method is too long and has tech debt. Probably it will be replaced completely + * by modern code using Vue components rather than direct DOM manipulation. * - * Threading has been revised, so that the same locale+path can have multiple distinct threads, - * rather than always combining posts with the same locale+path into a single "thread". - * Reference: https://unicode-org.atlassian.net/browse/CLDR-13695 + * If context is 'summary', all DOM element creation here is a waste of time. + * + * The same locale+path can have multiple distinct threads. */ function parseContent(posts, context) { const opts = getOptionsForContext(context); @@ -516,8 +332,9 @@ function parseContent(posts, context) { /* * create the topic (thread) divs -- populate topicDivs with DOM elements * - * TODO: skip this loop if opts.createDomElements is false. Currently we have to do this even - * if opts.createDomElements if false, since filterAndAssembleForumThreads depends on topicDivs. + * If this code were to be refactored, ideally it would skip this loop if opts.createDomElements is false. + * Currently we have to do this even if opts.createDomElements is false, since filterAndAssembleForumThreads + * depends on topicDivs. */ for (let num in posts) { const post = posts[num]; @@ -878,16 +695,27 @@ function addNewPostButtons(el, locale, couldFlag, xpstrid, code, value) { el.appendChild(document.createElement("p")); } Object.keys(options).forEach(function (postType) { - el.appendChild( - makeOneNewPostButton( - postType, - options[postType], - locale, - couldFlag, - xpstrid, - code, - value - ) + const label = makePostTypeLabel(postType, false /* isReply */); + // A "new post" button has type cldrForumType.REQUEST or cldrForumType.DISCUSS. + // REQUEST is only enabled if there is a non-null value (which the user voted for). + const disabled = postType === cldrForumType.REQUEST && value === null; + // Only an enabled REQUEST button can really cause a path to be flagged. + const willFlag = + couldFlag && postType === cldrForumType.REQUEST && !disabled; + const pi = new PostInfo(postType); + const xpathMap = cldrSurvey.getXpathMap(); + xpathMap.get( + { + hex: xpstrid, + }, + function (o) { + const subject = makeSubject(code, xpstrid, o, xpathMap, willFlag); + pi.setLocalePathValueSubject(locale, xpstrid, value, subject); + if (willFlag) { + pi.setWillFlagTrue(); + } + addPostVueButton(el, pi, label, disabled); + } ); }); } @@ -909,75 +737,42 @@ function addReplyButtons(el, rootPost) { ); Object.keys(options).forEach(function (postType) { - el.appendChild(makeOneReplyButton(rootPost, postType, options[postType])); + const pi = new PostInfo(postType); + pi.setReplyTo(rootPost); + const typeLabel = makePostTypeLabel(postType, true /* isReply */); + addPostVueButton(el, pi, typeLabel, false /* not disabled */); }); } -function makeOneNewPostButton( - postType, - label, - locale, - couldFlag, - xpstrid, - code, - value -) { - // A "new post" button has type cldrForumType.REQUEST or cldrForumType.DISCUSS. - // REQUEST is only enabled if there is a non-null value (which the user voted for). - const disabled = postType === cldrForumType.REQUEST && value === null; - // Only an enabled REQUEST button can really cause a path to be flagged. - const willFlag = couldFlag && postType === cldrForumType.REQUEST && !disabled; - const buttonClass = willFlag - ? "addPostButton forumNewPostFlagButton btn btn-default btn-sm" - : "addPostButton forumNewButton btn btn-default btn-sm"; - - const newButton = forumCreateChunk(label, "button", buttonClass); - if (disabled) { - newButton.disabled = true; - } else { - cldrDom.listenFor(newButton, "click", function (e) { - const xpathMap = cldrSurvey.getXpathMap(); - xpathMap.get( - { - hex: xpstrid, - }, - function (o) { - let subj = code + " " + xpstrid; - if (o.result && o.result.ph) { - subj = xpathMap.formatPathHeader(o.result.ph); - } - if (willFlag) { - subj += " (Flag for review)"; - } - const pi = new PostInfo(postType); - pi.setLocalePathValueSubject(locale, xpstrid, value, subj); - if (willFlag) { - pi.setWillFlagTrue(); - } - openPostOrReply(pi); - } - ); - cldrEvent.stopPropagation(e); - return false; - }); +function makeSubject(code, xpstrid, o, xpathMap, willFlag) { + let subj = code + " " + xpstrid; + if (o.result && o.result.ph) { + subj = xpathMap.formatPathHeader(o.result.ph); + } + if (willFlag) { + subj += " (Flag for review)"; } - return newButton; + return subj; } -function makeOneReplyButton(post, postType, label) { - const replyButton = forumCreateChunk( - label, - "button", - "addPostButton btn btn-default btn-sm" - ); - cldrDom.listenFor(replyButton, "click", function (e) { - const pi = new PostInfo(postType); - pi.setReplyTo(post); - openPostOrReply(pi); - cldrEvent.stopPropagation(e); - return false; - }); - return replyButton; +function addPostVueButton(containerEl, pi, label, disabled) { + try { + const wrapper = cldrVue.mount(ForumButton, containerEl); + wrapper.setPostInfo(pi); + wrapper.setLabel(label); + const reminder = cldrText.get( + pi.willFlag ? "flag_must_have_reason" : "forum_remember_vote" + ); + wrapper.setReminder(reminder); + if (disabled) { + wrapper.setDisabled(); + } + } catch (e) { + console.error( + "Error loading Post Button vue " + e.message + " / " + e.name + ); + cldrNotify.exception(e, "while loading Post Button"); + } } /** @@ -1508,16 +1303,29 @@ function parseHash(pieces) { } } +let formIsVisible = false; + +function isFormVisible() { + return formIsVisible; +} + +function setFormIsVisible(visible) { + formIsVisible = visible; +} + export { - FORUM_DIV_CLASS, SUMMARY_CLASS, addNewPostButtons, handleIdChanged, + isFormVisible, load, parseContent, parseHash, + prefillPostText, refreshSummary, reload, + sendPostRequest, + setFormIsVisible, setUserCanPost, /* * The following are meant to be accessible for unit testing only: diff --git a/tools/cldr-apps/js/src/esm/cldrForumPanel.mjs b/tools/cldr-apps/js/src/esm/cldrForumPanel.mjs index 3f2c58c5ac0..ac2efa9e598 100644 --- a/tools/cldr-apps/js/src/esm/cldrForumPanel.mjs +++ b/tools/cldr-apps/js/src/esm/cldrForumPanel.mjs @@ -12,6 +12,11 @@ import * as cldrSurvey from "./cldrSurvey.mjs"; import * as cldrTable from "./cldrTable.mjs"; import * as cldrText from "./cldrText.mjs"; +/** + * Encapsulate this class name -- caution: it's used literally in surveytool.css + */ +const FORUM_DIV_CLASS = "forumDiv"; + const forumCache = new cldrCache.LRU(); /** @@ -38,7 +43,7 @@ function loadInfo(frag, tr, theRow) { cldrForum.setUserCanPost(tr.theTable.json.canModify); addTopButtons(theRow, frag); const div = document.createElement("div"); - div.className = cldrForum.FORUM_DIV_CLASS; + div.className = FORUM_DIV_CLASS; const cachedData = forumCache.get(makeCacheKey(theRow.xpstrid)); if (cachedData) { setPostsFromData(frag, div, cachedData, theRow.xpstrid); @@ -174,9 +179,9 @@ function updatePosts(tr) { const content = getForumContent(posts, theRow.xpstrid); /* - * Update the first element whose class is cldrForum.FORUM_DIV_CLASS. + * Update the first element whose class is FORUM_DIV_CLASS. */ - $("." + cldrForum.FORUM_DIV_CLASS) + $("." + FORUM_DIV_CLASS) .first() .html(content); } diff --git a/tools/cldr-apps/js/src/esm/cldrInfo.mjs b/tools/cldr-apps/js/src/esm/cldrInfo.mjs index 7f9012f9aa4..a19d87dd73b 100644 --- a/tools/cldr-apps/js/src/esm/cldrInfo.mjs +++ b/tools/cldr-apps/js/src/esm/cldrInfo.mjs @@ -55,6 +55,7 @@ const INFO_VOTE_TICKET_ID = "info-panel-vote-and-ticket"; const INFO_REGIONAL_ID = "info-panel-regional"; const INFO_FORUM_ID = "info-panel-forum"; const INFO_XPATH_ID = "info-panel-xpath"; +const INFO_BOTTOM_ID = "info-panel-bottom"; /** * Initialize the Info Panel @@ -118,6 +119,7 @@ function insertLegacyElement(containerEl) { appendDiv(el, INFO_REGIONAL_ID); appendDiv(el, INFO_FORUM_ID); appendDiv(el, INFO_XPATH_ID); + appendDiv(el, INFO_BOTTOM_ID); } function appendDiv(el, id) { @@ -247,6 +249,7 @@ function show(str, tr, hideIfLast, fn) { addRegionalSidewaysMenu(tr); addForumPanel(tr); addXpath(tr); + addBottom(); addVoterInfoHover(); } @@ -452,6 +455,19 @@ function addForumPanel(tr) { } } +/** + * An empty paragraph at the bottom of the info panel enables scrolling + * to bring the bottom content fully into view without being overlapped + * by the xpath shown by addXpath + */ +function addBottom() { + const el = document.getElementById(INFO_BOTTOM_ID); + if (!el) { + return; + } + el.innerHTML = "

 

"; +} + function addXpath(tr) { const el = document.getElementById(INFO_XPATH_ID); if (!el) { diff --git a/tools/cldr-apps/js/src/esm/cldrSurvey.mjs b/tools/cldr-apps/js/src/esm/cldrSurvey.mjs index 6d6af3e180e..e7e0a0e706f 100644 --- a/tools/cldr-apps/js/src/esm/cldrSurvey.mjs +++ b/tools/cldr-apps/js/src/esm/cldrSurvey.mjs @@ -6,6 +6,7 @@ import * as cldrCoverage from "./cldrCoverage.mjs"; import * as cldrCoverageReset from "./cldrCoverageReset.mjs"; import * as cldrDom from "./cldrDom.mjs"; import * as cldrEvent from "./cldrEvent.mjs"; +import * as cldrForum from "./cldrForum.mjs"; import * as cldrGui from "./cldrGui.mjs"; import * as cldrLoad from "./cldrLoad.mjs"; import * as cldrMenu from "./cldrMenu.mjs"; @@ -103,20 +104,16 @@ function getXpathMap() { /** * Is the keyboard or input widget 'busy'? i.e., it's a bad time to change the DOM * - * @return true if window.getSelection().anchorNode.className contains "dijitInp" or "popover-content", - * else false - * - * "popover-content" identifies the little input window, created using bootstrap, that appears when the - * user clicks an add ("+") button. Added "popover-content" per https://unicode.org/cldr/trac/ticket/11265. - * - * Called only from CldrSurveyVettingLoader.js + * @return true if busy */ function isInputBusy() { - if (!window.getSelection) { - return false; + if (cldrForum.isFormVisible()) { + return true; } - var sel = window.getSelection(); - if (sel && sel.anchorNode && sel.anchorNode.className) { + const sel = window.getSelection ? window.getSelection() : null; + if (sel?.anchorNode?.className) { + // "popover-content" identifies the little input window, created using bootstrap, that appears when the + // user clicks an add ("+") button. if (sel.anchorNode.className.indexOf("popover-content") != -1) { return true; } diff --git a/tools/cldr-apps/js/src/views/ForumButton.vue b/tools/cldr-apps/js/src/views/ForumButton.vue new file mode 100644 index 00000000000..8c77147368b --- /dev/null +++ b/tools/cldr-apps/js/src/views/ForumButton.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/tools/cldr-apps/js/src/views/ForumForm.vue b/tools/cldr-apps/js/src/views/ForumForm.vue new file mode 100644 index 00000000000..06c226aba08 --- /dev/null +++ b/tools/cldr-apps/js/src/views/ForumForm.vue @@ -0,0 +1,140 @@ + + + + +