diff --git a/css/buttons.css b/css/buttons.css new file mode 100644 index 0000000..f2bbf80 --- /dev/null +++ b/css/buttons.css @@ -0,0 +1,94 @@ +/* Buttons + ------------------------------------ + The website's own buttons, for any page of the Symfony site. Put them in an + element with the class submit-buttons: button1 for the main action, button2 + for the others. Inputs, buttons and links look the same. + + Taken from the team tools, which had them on their own pages only. + Linked from base.html.twig, not imported through index.css, so the old + site's copy of the assets does not shadow it. +*/ + +#main .submit-buttons { + text-align: center; + margin: 0.8em 0; +} + +#main .submit-buttons .button1, +#main .submit-buttons .button2 { + display: inline-block; + box-sizing: border-box; + vertical-align: middle; + margin: 0 4px; + padding: 0 14px; + height: 28px; + border: 1px solid #B4BAC0; + border-radius: 4px; + background: linear-gradient(#FFFFFF, #E9EBEE); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.08); + color: #333333; + /* Longhands, not the font shorthand: that resets line-height, which a + link needs to centre its text; an input centres on its own. */ + font-family: "Lucida Grande", Verdana, Helvetica, Arial, sans-serif; + font-size: 12px; + font-weight: normal; + /* Height less the two border pixels, so the text sits in the middle. */ + line-height: 26px; + text-decoration: none; + cursor: pointer; +} + +#main .submit-buttons .button1 { + font-weight: bold; + border-color: #0F5C95; + background: linear-gradient(#2D7FBD, #105289); + color: #FFFFFF; +} + +#main .submit-buttons .button2:hover, +#main .submit-buttons .button2:focus { + border-color: #0F5C95; + color: #105289; + text-decoration: none; +} + +#main .submit-buttons .button1:hover, +#main .submit-buttons .button1:focus { + background: linear-gradient(#3A8FD0, #17629F); + text-decoration: none; +} + +#main .submit-buttons .button1:focus-visible, +#main .submit-buttons .button2:focus-visible { + outline: 2px solid #5A9BD4; + outline-offset: 1px; +} + +/* A row of buttons next to other content, such as in a table cell. */ +#main .submit-buttons.submit-buttons-inline { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 0; + text-align: left; +} + +#main .submit-buttons.submit-buttons-inline .button1, +#main .submit-buttons.submit-buttons-inline .button2 { + margin: 0; +} + +/* A square button holding only an icon; give it an aria-label. */ +#main .submit-buttons .button-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + padding: 0; +} + +#main .submit-buttons .button-icon svg { + display: block; + width: 14px; + height: 14px; +} diff --git a/css/srt.css b/css/srt.css index 20ef800..16da12f 100644 --- a/css/srt.css +++ b/css/srt.css @@ -47,6 +47,18 @@ body.support-template .gen-info .error { margin-top: 0.3em; } +/* A warning under its question, in the style of the messages at the top of + a step. */ +body.support-template .gen-info .srt-warning { + margin: 0 0 0.8em; + padding: 5px 10px; + text-align: left; +} + +body.support-template .gen-info .srt-warning p { + margin: 0; +} + /* Help: an icon that shows the text on hover, or on a tap on touch screens. The inputs point at the text with aria-describedby, so screen readers read it with the field and the icon needs no place in the tab order. */ @@ -116,18 +128,6 @@ body.support-template .gen-info .error { margin-bottom: 0; } -/* Previous and next together, starting over set apart. */ -.srt-buttons { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; -} - -.srt-buttons .srt-start-over { - margin-left: auto; -} - /* The form's default button, so Enter moves forward; never shown. */ .srt-default-submit { position: absolute; @@ -163,34 +163,28 @@ body.support-template .gen-info .error { margin: 0.6em 0; } -.srt-admin .markdown-field .CodeMirror, +/* Smaller editors than EasyMDE's default: they start low, grow with the + text, and scroll once it is long. */ +.srt-admin .markdown-field .CodeMirror { + height: auto; + min-height: 0; +} + +/* EasyMDE sets its 300px minimum as an inline style. */ .srt-admin .markdown-field .CodeMirror-scroll { - min-height: 100px; - height: 140px; + min-height: 100px !important; + max-height: 360px; } .srt-admin table.srt-options input.inputbox { width: 95%; } -.srt-admin table.srt-options td.srt-order input.inputbox { - width: 4em; -} - -.srt-admin td.srt-order { - white-space: nowrap; - width: 1%; -} - /* The same column widths in every step, so the tables line up. */ .srt-admin table.srt-questions { table-layout: fixed; } -.srt-admin table.srt-questions .srt-order { - width: 7em; -} - .srt-admin table.srt-questions th:nth-child(3), .srt-admin table.srt-questions td:nth-child(3) { width: 9em; @@ -201,13 +195,13 @@ body.support-template .gen-info .error { width: 11em; } -.srt-admin form.srt-move { - display: inline; +.srt-admin table.srt-outcomes td:first-child, +.srt-admin table.srt-texts td:first-child { + width: 12em; } -.srt-admin form.srt-move .button2 { - padding: 0 4px; - font-size: 0.9em; +.srt-admin table.srt-outcomes td:last-child { + width: 11em; } .srt-admin .srt-branches label { @@ -215,27 +209,105 @@ body.support-template .gen-info .error { white-space: nowrap; } -.srt-admin .submit-buttons .srt-cancel { - margin-left: 0.5em; +/* Parts of the question form that do not apply to the chosen type. prosilver + gives dl and fieldset a display of their own, which would win over the + hidden attribute. */ +.srt-admin [hidden] { + display: none !important; } -/* Drag and drop, which js/support/srt-admin.js switches on. */ -.srt-admin .srt-drag-handle { +.srt-admin .panel h3 { + margin-top: 0; +} + +.srt-admin .panel h4 { + margin: 1.2em 0 0.4em; + padding-bottom: 2px; + border-bottom: 1px solid #CCCCCC; + color: #115098; + font-size: 1.05em; +} + +.srt-admin .srt-versions { + margin-bottom: 0.8em; +} + +/* A yes or no question: its two answers are fixed, so only what they do is + shown. */ +.srt-admin table.srt-boolean .srt-col-order, +.srt-admin table.srt-boolean .srt-col-value, +.srt-admin table.srt-boolean .srt-col-remove, +.srt-admin table.srt-boolean tr.srt-row-extra { display: none; - padding: 0 6px 0 2px; - color: #999999; +} + +.srt-admin table.srt-options input[readonly] { + border-color: transparent; + background: transparent; + box-shadow: none; + font-weight: bold; +} + +/* Read out, not shown. */ +.srt-admin .srt-sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* Drag and drop, and the arrow keys, through js/support/srt-admin.js. */ +.srt-admin .srt-drag-handle { + display: inline-block; + padding: 4px 6px; + border-radius: 4px; + color: #536482; cursor: grab; user-select: none; + vertical-align: middle; } -.srt-admin.srt-sortable-on .srt-drag-handle { - display: inline; +.srt-admin .srt-drag-handle svg { + display: block; + width: 10px; + height: 16px; + fill: currentColor; +} + +.srt-admin .srt-drag-handle:hover, +.srt-admin .srt-drag-handle:focus-visible { + color: #105289; + background-color: #C7D9E9; +} + +/* The order column only holds the handles. */ +.srt-admin table.srt-options .srt-col-order, +.srt-admin table.srt-questions .srt-order { + width: 36px; } .srt-admin tr.srt-dragging { opacity: 0.4; } +/* While a new order is being saved. */ +.srt-admin #srt-steps.srt-saving, +.srt-admin #srt-steps.srt-saving .srt-drag-handle { + cursor: progress; +} + +.srt-admin tr.srt-row-saving { + opacity: 0.5; +} + +/* A button waiting for the server. */ +#main .srt-admin .srt-busy { + cursor: progress; + opacity: 0.7; +} + .srt-admin tbody.srt-drop-target { outline: 2px dashed #5A9BD4; outline-offset: -2px; diff --git a/js/markdown-editor.js b/js/markdown-editor.js index cf3af26..9532595 100644 --- a/js/markdown-editor.js +++ b/js/markdown-editor.js @@ -1003,10 +1003,51 @@ }, // EasyMDE's preview is client side and only approximate; the server // renders the article that finally gets published. - previewClass: ['editor-preview', 'markdown-body'] + previewClass: ['editor-preview', 'markdown-body'], + // The textarea follows every change, so the browser checks what + // was typed when the form is submitted, not what it started with. + forceSync: true }); editor.markdownCapture = capture; + reportInvalid(textarea, editor); + } + + /** + * The editor hides its textarea, and the browser cannot point at a hidden + * field it finds invalid, such as a required one left empty: the submit + * would just not happen. Say why next to the editor, and put the cursor + * in it. + */ + function reportInvalid(textarea, editor) { + var message = document.createElement('span'); + message.className = 'error markdown-editor-error'; + message.hidden = true; + message.setAttribute('aria-live', 'polite'); + + // After the editor's container: its toolbar, text and status bar. + var container = editor.codemirror.getWrapperElement().parentNode; + container.parentNode.insertBefore(message, container.nextSibling); + + textarea.addEventListener('invalid', function (event) { + event.preventDefault(); + message.textContent = textarea.validationMessage; + message.hidden = false; + + // Every invalid field says why; only the first takes the cursor. + var first = textarea.form && textarea.form.querySelector('input:invalid, select:invalid, textarea:invalid'); + + if (!first || first === textarea) { + editor.codemirror.getWrapperElement().scrollIntoView({ block: 'center' }); + editor.codemirror.focus(); + } + }); + + editor.codemirror.on('change', function () { + if (!message.hidden && textarea.validity.valid) { + message.hidden = true; + } + }); } function init() { diff --git a/js/support/srt-admin.js b/js/support/srt-admin.js index ab2bfd6..0ca1cf4 100644 --- a/js/support/srt-admin.js +++ b/js/support/srt-admin.js @@ -1,34 +1,338 @@ /** - * Drag and drop sorting for the Support Request Template pages in the team - * tools (phpbb-website-private #29), on the jQuery every page loads. - * - * Rows are dragged by their handle. On the overview, questions can be dropped - * in any step, or in the "new step" table, and the new order is submitted at - * once through #srt-order-form. In a question's answers, dropping only - * renumbers the order fields; the answers are saved with the question. - * - * Nothing depends on this: without it the arrows on the overview and the order - * fields of the answers do the same. + * The Support Request Template pages in the team tools (phpbb-website-private + * #29), on the jQuery every page loads. The pages rely on this script. + */ + +/** + * Where the pages report what the server said, in #srt-status. + */ +function srtStatus(message, colour) { + 'use strict'; + + jQuery('#srt-status').empty().append(jQuery('
').addClass(colour).text(message)); +} + +/** + * The message in a JSON error answer, or a general one. + */ +function srtErrorMessage(xhr) { + 'use strict'; + + try { + return JSON.parse(xhr.responseText).message || 'Something went wrong. Please reload the page and try again.'; + } catch (e) { + return 'Something went wrong. Please reload the page and try again.'; + } +} + +/** + * The question form: only the parts that apply to the chosen type are shown. + * Elements carry the types they apply to in data-srt-types. A yes or no + * question shows one row per answer, Yes and No, for picking what each does. + * "Only ask when" offers only questions on steps before the one in the step + * field. The Add button adds an answer from the form's prototype row. */ jQuery(function ($) { 'use strict'; - var $admin = $('#srt-admin'); - var $bodies = $admin.find('tbody[data-srt-sortable]'); + var $form = $('#srt-question-form'); + + if (!$form.length) { + return; + } + + var $type = $form.find('[data-srt-type-select]'); + var $step = $form.find('[data-srt-step-input]'); + var $requires = $form.find('[data-srt-requires-select]'); + var $warnOutdated = $form.find('input[name$="[warnOutdated]"]'); + var $table = $form.find('table.srt-options'); + var $prototype = $('#srt-option-prototype'); + var labels = {yes: 'Yes', no: 'No'}; + + // Past the highest index in the table: a form sent back after a failed + // save can have gaps, where rows were added and taken out again. + var nextIndex = 0; + $table.find('tbody input[name$="[value]"]').each(function () { + var match = /\[options\]\[(\d+)\]/.exec(this.name); + + if (match) { + nextIndex = Math.max(nextIndex, parseInt(match[1], 10) + 1); + } + }); + + function field($row, name) { + return $row.find('input[name$="[' + name + ']"]'); + } + + function position($row) { + return parseInt(field($row, 'position').val(), 10) || 0; + } + + function isYesOrNo($row) { + return labels.hasOwnProperty(field($row, 'value').val()); + } + + // A yes or no question only shows its Yes and No rows. + function stripe() { + var boolean = $table.hasClass('srt-boolean'); + + $table.find('tbody tr').filter(function () { + return !boolean || !$(this).hasClass('srt-row-extra'); + }).each(function (index) { + $(this).toggleClass('bg1', index % 2 === 0).toggleClass('bg2', index % 2 === 1); + }); + } + + // A new answer, from the prototype, after the others. + function newRow() { + var last = 0; + + $table.find('tbody tr').each(function () { + last = Math.max(last, position($(this))); + }); + + var $row = $($.trim($prototype.html().replace(/__name__/g, String(nextIndex++)))); + + field($row, 'position').val(last + 10); + $row.appendTo($table.find('tbody')); + + return $row; + } + + // Switching a question to yes or no fills an empty row, or adds one, for + // each of the two answers it now has, if they are not there yet. + function addYesAndNo() { + $.each(labels, function (value, label) { + var $rows = $table.find('tbody tr'); + var exists = $rows.filter(function () { + return field($(this), 'value').val() === value; + }).length > 0; + + if (exists) { + return; + } + + // An empty row, but not one sent back with an error for what else + // it had filled in. + var $row = $rows.filter(function () { + var $candidate = $(this); + + return field($candidate, 'value').val() === '' && field($candidate, 'label').val() === '' + && !field($candidate, 'warn').is(':checked') && !$candidate.find('select[name$="[outcome]"]').val(); + }).first(); + + if (!$row.length) { + $row = newRow(); + } + + field($row, 'value').val(value); + field($row, 'label').val(label); + $row.attr('data-srt-auto', value); + }); + } - if (!$bodies.length || !('draggable' in document.createElement('tr'))) { + // Switching away from yes or no again takes out the rows filled above, as + // long as nobody changed them, so they do not become answers unnoticed. + function removeYesAndNo() { + $table.find('tbody tr[data-srt-auto]').each(function () { + var $row = $(this); + var value = $row.attr('data-srt-auto'); + + if (field($row, 'value').val() === value && field($row, 'label').val() === labels[value]) { + $row.remove(); + } else { + $row.removeAttr('data-srt-auto'); + } + }); + } + + function update() { + var type = $type.val(); + var boolean = type === 'boolean'; + + $form.find('[data-srt-types]').each(function () { + $(this).prop('hidden', $.inArray(type, $(this).attr('data-srt-types').split(' ')) === -1); + }); + + if (boolean) { + addYesAndNo(); + } else { + removeYesAndNo(); + } + + $table.toggleClass('srt-boolean', boolean); + $table.find('tbody tr').each(function () { + var $row = $(this); + var fixed = boolean && isYesOrNo($row); + + $row.toggleClass('srt-row-extra', boolean && !fixed); + field($row, 'value').add(field($row, 'label')).prop('readonly', fixed); + }); + stripe(); + + // A question can only depend on one asked on an earlier step. While the + // step field is being retyped it holds no number; leave the list be. + var step = parseInt($step.val(), 10); + $requires.find('option[data-srt-step]').each(function () { + if (isNaN(step)) { + return; + } + + var later = parseInt($(this).attr('data-srt-step'), 10) >= step; + + $(this).prop('disabled', later).prop('hidden', later); + }); + if (!isNaN(step) && $requires.find('option:selected').prop('disabled')) { + $requires.val(''); + } + + // The answer a condition waits for, once there is a condition. + $form.find('[data-srt-needs-requires]').prop('hidden', !$requires.val()); + + // The warning, once an answer shown in the table, or an outdated + // release, warns. + var warns = $table.find('tbody tr:not(.srt-row-extra) input[name$="[warn]"]:checked').length > 0 + || ($warnOutdated.is(':checked') && type === 'phpbb_version'); + $form.find('[data-srt-needs-warning]').prop('hidden', !warns); + + // An editor set up while hidden has no size until it is refreshed. + $form.find('.CodeMirror').each(function () { + if (this.CodeMirror && $(this).is(':visible')) { + this.CodeMirror.refresh(); + } + }); + } + + // The answers show in their order, also when the form comes back after a + // failed save, so that dragging one keeps the order of the others. + $table.find('tbody').append($table.find('tbody tr').get().sort(function (a, b) { + return position($(a)) - position($(b)); + })); + + $form.on('click', '[data-srt-add-option]', function () { + var $row = newRow(); + + stripe(); + field($row, 'value').trigger('focus'); + }); + + $type.add($requires).on('change', update); + $step.on('input change', update); + $form.on('change', 'input[name$="[warn]"], input[name$="[warnOutdated]"]', update); + update(); +}); + +/** + * A button busy with a request: disabled, with its text saying so. + */ +function srtBusy($button, busy) { + 'use strict'; + + var text = $button.is('input') ? 'val' : 'text'; + + if (busy && $button.hasClass('srt-busy')) { return; } - $admin.addClass('srt-sortable-on'); - $admin.find('.srt-new-step').prop('hidden', false); + if (busy) { + $button.attr('data-srt-label', $button[text]()); + $button[text]($button.attr('data-srt-busy') || 'Saving…'); + } else if ($button.attr('data-srt-label')) { + $button[text]($button.attr('data-srt-label')); + } + + $button.prop('disabled', busy).toggleClass('srt-busy', busy); +} + +/** + * Deleting a question or an outcome, with the page's CSRF token. Symfony + * routes it as DELETE; on the wire it is a POST with _method=DELETE, because + * the site's .htdev refuses DELETE requests. The server answers with where + * to go next, or why it did not delete. + * + * Saving a form shows that it is busy until the next page comes. + */ +jQuery(function ($) { + 'use strict'; + + var $admin = $('#srt-admin'); + + $admin.on('click', '[data-srt-delete]', function () { + var $button = $(this); + + if (!window.confirm($button.attr('data-srt-confirm'))) { + return; + } + + srtBusy($button, true); + srtStatus('Deleting…', 'yellow'); + + $.ajax({ + type: 'POST', + url: $button.attr('data-srt-delete'), + data: {_method: 'DELETE'}, + headers: {'X-CSRF-Token': $button.attr('data-srt-token')}, + dataType: 'json' + }).done(function (data) { + window.location.href = data.redirect; + }).fail(function (xhr) { + srtBusy($button, false); + srtStatus(srtErrorMessage(xhr), 'red'); + // The button is at the bottom of the form, the message at the top. + document.getElementById('srt-status').scrollIntoView(); + }); + }); + + $admin.on('submit', 'form[method="post"]', function () { + // Not Cancel, which belongs to a form of its own. + var $buttons = $(this).find('input[type="submit"], button[type="submit"]').not('[form]'); + + // Disabled only after the browser has taken the form's fields. + window.setTimeout(function () { + $buttons.each(function () { + srtBusy($(this), true); + }); + }, 0); + }); + + // Back to a page kept by the browser: its buttons are not busy any more. + $(window).on('pageshow', function () { + $admin.find('.srt-busy').each(function () { + srtBusy($(this), false); + }); + }); +}); + +/** + * Sorting the questions on the overview and a question's answers. + * + * Rows are dragged by their handle, or moved with the arrow keys while their + * handle has focus. On the overview, a question can go into any step, or into + * the "new step" table after the last one, and every move is saved at once: + * the steps are then renumbered, an emptied step disappears and a new empty + * one follows the last. A move the server refuses is undone. In a question's + * answers, a move only renumbers the hidden order fields; the answers are + * saved with the question. + */ +jQuery(function ($) { + 'use strict'; + + var $admin = $('#srt-admin'); + var $steps = $('#srt-steps'); + var $newStep = $steps.find('.srt-new-step').first().clone(); var $dragged = null; var group = null; var before = null; + var beforeSections = null; + var alone = false; var origin = null; var dropped = false; - var submitting = false; + var saving = false; + + function bodies(name) { + return $admin.find('tbody[data-srt-sortable="' + name + '"]'); + } function rows($tbody) { return $tbody.children('tr').not('.srt-placeholder'); @@ -38,13 +342,38 @@ jQuery(function ($) { return $tbody.attr('data-srt-sortable'); } - // A string that changes whenever a row of the group moves. - function snapshot(name) { - return $bodies.filter('[data-srt-sortable="' + name + '"]').map(function () { - return rows($(this)).map(function () { - return $(this).attr('data-srt-id') || $(this).find('input, select').attr('name'); - }).get().join(','); - }).get().join('|'); + // Where every row of the group is, to compare with later or to go back to. + function layout(name) { + return bodies(name).map(function () { + return {tbody: $(this), rows: rows($(this)).get()}; + }).get(); + } + + function sameLayout(a, b) { + if (a.length !== b.length) { + return false; + } + + for (var i = 0; i < a.length; i++) { + if (a[i].rows.length !== b[i].rows.length) { + return false; + } + + for (var j = 0; j < a[i].rows.length; j++) { + if (a[i].rows[j] !== b[i].rows[j]) { + return false; + } + } + } + + return true; + } + + function restore(saved) { + $.each(saved, function (index, place) { + place.tbody.prepend(place.rows); + tidy(place.tbody); + }); } function tidy($tbody) { @@ -56,37 +385,124 @@ jQuery(function ($) { }); } - function submitOrder() { - var $form = $('#srt-order-form'); + function renumber($tbody) { + rows($tbody).each(function (index) { + $(this).find('input[name$="[position]"]').val((index + 1) * 10); + }); + } - if (!$form.length || submitting) { - return; - } + // The steps as they are, to go back to. + function stepSections() { + return $steps.children('.srt-step').map(function () { + return { + element: this, + heading: $(this).find('[data-srt-step-heading]').text(), + isNew: $(this).hasClass('srt-new-step') + }; + }).get(); + } - submitting = true; + // Steps as the server numbers them: empty ones gone, and one empty "new + // step" after the last. Emptied steps are only detached, so a refused + // move can bring them back. + function renumberSteps() { + var number = 0; - $bodies.filter('[data-srt-sortable="questions"]').each(function (step) { - rows($(this)).each(function () { - $('', {type: 'hidden', name: 'order[' + step + '][]', value: $(this).attr('data-srt-id')}).appendTo($form); - }); + $steps.children('.srt-step').each(function () { + var $step = $(this); + + if (rows($step.find('tbody')).length === 0) { + $step.detach(); + return; + } + + number++; + $step.removeClass('srt-new-step').find('[data-srt-step-heading]').text('Step ' + number); }); - $form[0].submit(); + $steps.append($newStep.clone()); } - function renumber($tbody) { - rows($tbody).each(function (index) { - $(this).find('input[name$="[position]"]').val((index + 1) * 10); + function restoreSteps(sections) { + $steps.children('.srt-step').detach(); + + $.each(sections, function (index, section) { + $(section.element).toggleClass('srt-new-step', section.isNew) + .find('[data-srt-step-heading]').text(section.heading); + $steps.append(section.element); }); } - $bodies.each(function () { - tidy($(this)); - }); + // Sends the order of every question. The steps show the new order at + // once, and go back to how they were if the server refuses it. + function saveOrder(saved, sections, $row) { + var order = bodies('questions').map(function () { + return [rows($(this)).map(function () { + return $(this).attr('data-srt-id'); + }).get()]; + }).get(); + + saving = true; + renumberSteps(); + $steps.addClass('srt-saving'); + $row.addClass('srt-row-saving'); + srtStatus('Saving the order…', 'yellow'); + + $.ajax({ + type: 'POST', + url: $steps.attr('data-srt-order-url'), + headers: {'X-CSRF-Token': $steps.attr('data-srt-token')}, + data: {order: order}, + dataType: 'json' + }).done(function (data) { + srtStatus(data.message, 'green'); + }).fail(function (xhr) { + // Putting the steps back takes focus from the handle it was on. + var focused = document.activeElement; + + restoreSteps(sections); + restore(saved); + $(focused).filter('.srt-drag-handle').trigger('focus'); + srtStatus(srtErrorMessage(xhr), 'red'); + }).always(function () { + saving = false; + $steps.removeClass('srt-saving'); + $row.removeClass('srt-row-saving'); + }); + } + + // The only question of the last step cannot start a new step: that step + // would be the same one, renumbered. + var ALONE_IN_LAST_STEP = 'This question is the only one on the last step, so it already is a step of its own.'; + + function aloneInLastStep($row) { + var $all = bodies('questions'); + var $tbody = $row.parent(); + + return groupOf($tbody) === 'questions' && $all.index($tbody) === $all.length - 2 && rows($tbody).length === 1; + } + + function moved(name, $row, saved, sections) { + if (name === 'questions') { + saveOrder(saved, sections, $row); + } else { + renumber($row.parent()); + } + } + + // Back on the overview from another page: the order may have changed + // since the browser kept this copy of it, so show it as it is now. + if ($steps.length) { + $(window).on('pageshow', function (event) { + if (event.originalEvent && event.originalEvent.persisted) { + window.location.reload(); + } + }); + } // Only the handle starts a drag, so text in the answers' fields can still // be selected with the mouse. - $bodies.on('mousedown', '.srt-drag-handle', function () { + $admin.on('mousedown', 'tbody[data-srt-sortable] .srt-drag-handle', function () { $(this).closest('tr').attr('draggable', 'true'); }); @@ -96,17 +512,19 @@ jQuery(function ($) { $admin.find('tr[draggable="true"]').not($dragged).removeAttr('draggable'); }); - $bodies.on('dragstart', 'tr', function (event) { + $admin.on('dragstart', 'tbody[data-srt-sortable] > tr', function (event) { var $row = $(this); - if (submitting || $row.attr('draggable') !== 'true') { + if (saving || $row.attr('draggable') !== 'true') { event.preventDefault(); return; } $dragged = $row; group = groupOf($row.parent()); - before = snapshot(group); + before = layout(group); + beforeSections = stepSections(); + alone = aloneInLastStep($row); origin = {parent: $row.parent(), next: $row.next()}; dropped = false; @@ -116,16 +534,27 @@ jQuery(function ($) { event.originalEvent.dataTransfer.setData('text/plain', ''); }); - $bodies.on('dragover', function (event) { + $admin.on('dragover', 'tbody[data-srt-sortable]', function (event) { var $tbody = $(this); if (!$dragged || groupOf($tbody) !== group) { return; } + // Not a place it can be dropped; the browser shows as much, and the + // page says why, once per drag. + if (alone && $tbody.closest('.srt-new-step').length) { + if (alone !== 'told') { + srtStatus(ALONE_IN_LAST_STEP, 'yellow'); + alone = 'told'; + } + + return; + } + event.preventDefault(); event.originalEvent.dataTransfer.dropEffect = 'move'; - $bodies.removeClass('srt-drop-target'); + $admin.find('tbody.srt-drop-target').removeClass('srt-drop-target'); $tbody.addClass('srt-drop-target'); var $source = $dragged.parent(); @@ -140,7 +569,10 @@ jQuery(function ($) { $dragged.insertBefore($over); } } else if (!$source.is($tbody) && (!$over.length || $over.hasClass('srt-placeholder'))) { - $dragged.appendTo($tbody); + $dragged.insertBefore($tbody.children('.srt-placeholder').first()); + if (!$dragged.parent().is($tbody)) { + $dragged.appendTo($tbody); + } } tidy($tbody); @@ -149,7 +581,7 @@ jQuery(function ($) { } }); - $bodies.on('drop', function (event) { + $admin.on('drop', 'tbody[data-srt-sortable]', function (event) { event.preventDefault(); dropped = true; }); @@ -160,40 +592,85 @@ jQuery(function ($) { } var $row = $dragged; - var $moved = $row.parent(); var name = group; $dragged = null; group = null; $row.removeClass('srt-dragging').removeAttr('draggable'); - $bodies.removeClass('srt-drop-target'); + $admin.find('tbody.srt-drop-target').removeClass('srt-drop-target'); // Cancelled with Escape or let go outside the tables: the row moved // while it was dragged over them, so put it back. if (!dropped) { - if (origin.next.length) { - $row.insertBefore(origin.next); + restore(before); + return; + } + + if (!sameLayout(before, layout(name))) { + moved(name, $row, before, beforeSections); + } + }); + + // The arrow keys on a handle move its row one place up or down; a question + // at the edge of its step moves on into the step before or after. + $admin.on('keydown', 'tbody[data-srt-sortable] .srt-drag-handle', function (event) { + var up = event.which === 38; + + if (saving || $dragged || (!up && event.which !== 40) + || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + + event.preventDefault(); + + var $handle = $(this); + var $row = $handle.closest('tr'); + var $tbody = $row.parent(); + var name = groupOf($tbody); + var saved = layout(name); + var sections = stepSections(); + var $rows = rows($tbody); + var index = $rows.index($row); + var $sibling = up ? (index > 0 ? $rows.eq(index - 1) : $()) : $rows.eq(index + 1); + + if ($sibling.length) { + if (up) { + $row.insertBefore($sibling); } else { - $row.appendTo(origin.parent); + $row.insertAfter($sibling); } + } else if (name === 'questions') { + var $all = bodies('questions'); + var target = $all.index($tbody) + (up ? -1 : 1); - tidy(origin.parent); - if (!$moved.is(origin.parent)) { - tidy($moved); + if (target < 0 || target >= $all.length) { + return; } - return; - } + if (!up && aloneInLastStep($row)) { + srtStatus(ALONE_IN_LAST_STEP, 'yellow'); + return; + } - if (snapshot(name) === before) { - return; - } + var $target = $all.eq(target); - if (name === 'questions') { - submitOrder(); + if (up) { + $row.insertBefore($target.children('.srt-placeholder').first()); + if (!$row.parent().is($target)) { + $row.appendTo($target); + } + } else { + $row.prependTo($target); + } + + tidy($target); } else { - renumber($row.parent()); + return; } + + tidy($tbody); + $handle.trigger('focus'); + moved(name, $row, saved, sections); }); }); diff --git a/js/support/srt.js b/js/support/srt.js new file mode 100644 index 0000000..367a8ac --- /dev/null +++ b/js/support/srt.js @@ -0,0 +1,35 @@ +/** + * The Support Request Template generator (phpbb-website-private #29), on the + * jQuery every page loads. + * + * An answer that warns shows its warning under its question as soon as it is + * picked, and hides it again when another answer is. + */ +jQuery(function ($) { + 'use strict'; + + var $form = $('#srt_gen form[method="post"]'); + + if (!$form.length) { + return; + } + + $form.find('.srt-warning[data-srt-warn-for]').each(function () { + var $warning = $(this); + var $fields = $form.find('[name="' + $warning.attr('data-srt-warn-for') + '"]'); + var values = $.map(JSON.parse($warning.attr('data-srt-warn-values')), String); + + function answer() { + var $field = $fields.filter('select, textarea, input:not([type="radio"]), input[type="radio"]:checked').first(); + + return $field.length ? String($field.val()) : ''; + } + + function update() { + $warning.prop('hidden', $.inArray(answer(), values) === -1); + } + + $fields.on('change', update); + update(); + }); +});