From 509f6c92c56b4f51a4d4da6090f984929f694130 Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 22:30:44 +0100 Subject: [PATCH 01/11] Tidy the Support Request Template overview For the overview in the team tools (phpbb/phpbb-website-private#226): - The move arrows are prosilver icon buttons, square and holding a chevron, in one centred row with the drag handle. - The Add buttons under the tables get room before the next heading and lose the underline the page gives its links. - The outcome and page text tables get fixed widths for their name columns. Co-Authored-By: Claude Opus 5 --- css/srt.css | 50 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/css/srt.css b/css/srt.css index 20ef800..6988c9a 100644 --- a/css/srt.css +++ b/css/srt.css @@ -188,7 +188,7 @@ body.support-template .gen-info .error { } .srt-admin table.srt-questions .srt-order { - width: 7em; + width: 8em; } .srt-admin table.srt-questions th:nth-child(3), @@ -201,13 +201,53 @@ body.support-template .gen-info .error { width: 11em; } +/* The drag handle and the two arrows, on one line and centred. */ +.srt-admin .srt-order-tools { + display: flex; + align-items: center; + gap: 3px; +} + .srt-admin form.srt-move { - display: inline; + margin: 0; } -.srt-admin form.srt-move .button2 { - padding: 0 4px; - font-size: 0.9em; +/* prosilver's icon-only button, square, holding a chevron like the + pagination's. */ +.srt-admin form.srt-move .button { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 24px; + height: 24px; + padding: 0; +} + +.srt-admin .srt-chevron { + display: block; + width: 14px; + height: 14px; +} + +/* The buttons under the tables, with room before the next heading. */ +.srt-admin .srt-actions { + margin: 0.6em 0 1.8em; +} + +/* The page links are underlined by an id rule, so this needs one too. */ +#srt-admin .srt-actions a.button, +#srt-admin .srt-actions a.button:hover { + text-decoration: none; +} + +.srt-admin table.srt-outcomes td:first-child, +.srt-admin table.srt-texts td:first-child { + width: 12em; +} + +.srt-admin table.srt-outcomes td:last-child { + width: 11em; } .srt-admin .srt-branches label { From aee1c9a39fcba97d0dff09827f9591be1d6c42b5 Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 22:56:09 +0100 Subject: [PATCH 02/11] Show only what applies to an SRT question's type For the question form in the team tools (phpbb/phpbb-website-private#226): - js/support/srt-admin.js hides the parts marked with data-srt-types that do not apply to the chosen type, the version rules until a version list is chosen, the awaited answer until there is a condition, and the warning until an answer or an outdated release warns. A yes or no question fills blank rows with Yes and No and shows only what they do. Markdown editors that become visible are refreshed, as CodeMirror has no size while hidden. - css/srt.css: [hidden] wins over prosilver's display rules, headings for the sub-sections, and the yes or no table without its order, value and remove columns. Co-Authored-By: Claude Opus 5 --- css/srt.css | 39 +++++++++++++ js/support/srt-admin.js | 123 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/css/srt.css b/css/srt.css index 6988c9a..7a759a6 100644 --- a/css/srt.css +++ b/css/srt.css @@ -255,6 +255,45 @@ body.support-template .gen-info .error { white-space: nowrap; } +/* 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; +} + +.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; +} + +.srt-admin table.srt-options input[readonly] { + border-color: transparent; + background: transparent; + box-shadow: none; + font-weight: bold; +} + .srt-admin .submit-buttons .srt-cancel { margin-left: 0.5em; } diff --git a/js/support/srt-admin.js b/js/support/srt-admin.js index ab2bfd6..ff34443 100644 --- a/js/support/srt-admin.js +++ b/js/support/srt-admin.js @@ -1,6 +1,125 @@ /** - * Drag and drop sorting for the Support Request Template pages in the team - * tools (phpbb-website-private #29), on the jQuery every page loads. + * The Support Request Template pages in the team tools (phpbb-website-private + * #29), on the jQuery every page loads. + * + * Question form: only the parts that apply to the chosen type are shown. + * Elements carry the types they apply to in data-srt-types; those marked + * data-srt-needs-versions only show once a version list is chosen. A yes or no + * question shows one row per answer, Yes and No, for picking what each does. + * Without this script the whole form shows. + */ +jQuery(function ($) { + 'use strict'; + + var $form = $('#srt-question-form'); + + if (!$form.length) { + return; + } + + var $type = $form.find('[data-srt-type-select]'); + var $versions = $form.find('[data-srt-version-select]'); + var $requires = $form.find('[data-srt-requires-select]'); + var $warnOutdated = $form.find('input[name$="[warnOutdated]"]'); + var $table = $form.find('table.srt-options'); + var labels = {yes: 'Yes', no: 'No'}; + + function field($row, name) { + return $row.find('input[name$="[' + name + ']"]'); + } + + function isYesOrNo($row) { + return labels.hasOwnProperty(field($row, 'value').val()); + } + + // Switching a question to yes or no fills blank rows with 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; + } + + var $blank = $rows.filter(function () { + return field($(this), 'value').val() === '' && field($(this), 'label').val() === ''; + }).first(); + + field($blank, 'value').val(value); + field($blank, 'label').val(label); + $blank.attr('data-srt-auto', value); + }); + } + + // Switching away from yes or no again empties 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]) { + field($row, 'value').val(''); + field($row, 'label').val(''); + } + + $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); + }); + + $form.find('[data-srt-needs-versions]').prop('hidden', !$versions.val() || (type !== 'dropdown' && type !== 'option')); + + 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); + }); + + // 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') && !$form.find('[data-srt-needs-versions]').prop('hidden')); + $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(); + } + }); + } + + $type.add($versions).add($requires).on('change', update); + $form.on('change', 'input[name$="[warn]"], input[name$="[warnOutdated]"]', update); + update(); +}); + +/** + * Drag and drop sorting of the questions and of a question's answers. * * 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 From 83d9ce283029f41059f0e18028f96a34f732bc2b Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 23:16:07 +0100 Subject: [PATCH 03/11] Shared button styles, and the SRT form follows the new version type - css/buttons.css: the website's own buttons, taken from the team tools (phpbb-website-private's internal/team/_buttons.css.twig) so every page can use them: button1 and button2 in a .submit-buttons container, for inputs, buttons and links alike, plus submit-buttons-inline for a row inside other content and button-icon for a square icon button. It is its own file, linked from base.html.twig, so the old site's copy of the assets cannot shadow it. - css/srt.css drops its own button styles in favour of those. - js/support/srt-admin.js: the version rules follow the new phpBB version question type instead of a version list on any dropdown, and "Only ask when" offers only questions on earlier steps than the step field. Co-Authored-By: Claude Opus 5 --- css/buttons.css | 94 +++++++++++++++++++++++++++++++++++++++++ css/srt.css | 52 ----------------------- js/support/srt-admin.js | 31 ++++++++++---- 3 files changed, 117 insertions(+), 60 deletions(-) create mode 100644 css/buttons.css 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 7a759a6..ed45763 100644 --- a/css/srt.css +++ b/css/srt.css @@ -116,18 +116,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; @@ -201,46 +189,10 @@ body.support-template .gen-info .error { width: 11em; } -/* The drag handle and the two arrows, on one line and centred. */ -.srt-admin .srt-order-tools { - display: flex; - align-items: center; - gap: 3px; -} - .srt-admin form.srt-move { margin: 0; } -/* prosilver's icon-only button, square, holding a chevron like the - pagination's. */ -.srt-admin form.srt-move .button { - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - width: 24px; - height: 24px; - padding: 0; -} - -.srt-admin .srt-chevron { - display: block; - width: 14px; - height: 14px; -} - -/* The buttons under the tables, with room before the next heading. */ -.srt-admin .srt-actions { - margin: 0.6em 0 1.8em; -} - -/* The page links are underlined by an id rule, so this needs one too. */ -#srt-admin .srt-actions a.button, -#srt-admin .srt-actions a.button:hover { - text-decoration: none; -} - .srt-admin table.srt-outcomes td:first-child, .srt-admin table.srt-texts td:first-child { width: 12em; @@ -294,10 +246,6 @@ body.support-template .gen-info .error { font-weight: bold; } -.srt-admin .submit-buttons .srt-cancel { - margin-left: 0.5em; -} - /* Drag and drop, which js/support/srt-admin.js switches on. */ .srt-admin .srt-drag-handle { display: none; diff --git a/js/support/srt-admin.js b/js/support/srt-admin.js index ff34443..c884b74 100644 --- a/js/support/srt-admin.js +++ b/js/support/srt-admin.js @@ -3,10 +3,10 @@ * #29), on the jQuery every page loads. * * Question form: only the parts that apply to the chosen type are shown. - * Elements carry the types they apply to in data-srt-types; those marked - * data-srt-needs-versions only show once a version list is chosen. A yes or no + * 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. - * Without this script the whole form shows. + * "Only ask when" offers only questions on steps before the one in the step + * field. Without this script the whole form shows. */ jQuery(function ($) { 'use strict'; @@ -18,7 +18,7 @@ jQuery(function ($) { } var $type = $form.find('[data-srt-type-select]'); - var $versions = $form.find('[data-srt-version-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'); @@ -79,8 +79,6 @@ jQuery(function ($) { $(this).prop('hidden', $.inArray(type, $(this).attr('data-srt-types').split(' ')) === -1); }); - $form.find('[data-srt-needs-versions]').prop('hidden', !$versions.val() || (type !== 'dropdown' && type !== 'option')); - if (boolean) { addYesAndNo(); } else { @@ -96,13 +94,29 @@ jQuery(function ($) { field($row, 'value').add(field($row, 'label')).prop('readonly', fixed); }); + // 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') && !$form.find('[data-srt-needs-versions]').prop('hidden')); + || ($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. @@ -113,7 +127,8 @@ jQuery(function ($) { }); } - $type.add($versions).add($requires).on('change', update); + $type.add($requires).on('change', update); + $step.on('input change', update); $form.on('change', 'input[name$="[warn]"], input[name$="[warnOutdated]"]', update); update(); }); From f40c71b6294111e82ad6ed8d0c78ad81d1f9a084 Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 23:28:26 +0100 Subject: [PATCH 04/11] Add SRT answers with a button, and drop the order numbers when dragging - js/support/srt-admin.js: the empty rows offered for new answers are hidden behind the Add an answer button, which shows one per click and then adds rows from the page's template. Filling in Yes and No shows the rows it uses, and emptying them again hides them. - css/srt.css: with the script, dragging sets the answers' order, so the order numbers, which stand in for it without the script, are hidden and the column only holds the handles. Co-Authored-By: Claude Opus 5 --- css/srt.css | 12 ++++++++++++ js/support/srt-admin.js | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/css/srt.css b/css/srt.css index ed45763..f7138c7 100644 --- a/css/srt.css +++ b/css/srt.css @@ -259,6 +259,18 @@ body.support-template .gen-info .error { display: inline; } +/* Dragging sets the order of the answers, so their order numbers, which + stand in for it without the script, are not shown. */ +.srt-admin.srt-sortable-on table.srt-options .srt-col-order input { + display: none; +} + +/* That column then only holds the drag handles. */ +.srt-admin.srt-sortable-on table.srt-options th.srt-col-order { + width: 1.5em; + font-size: 0; +} + .srt-admin tr.srt-dragging { opacity: 0.4; } diff --git a/js/support/srt-admin.js b/js/support/srt-admin.js index c884b74..009c5b8 100644 --- a/js/support/srt-admin.js +++ b/js/support/srt-admin.js @@ -51,7 +51,7 @@ jQuery(function ($) { field($blank, 'value').val(value); field($blank, 'label').val(label); - $blank.attr('data-srt-auto', value); + $blank.attr('data-srt-auto', value).removeClass('srt-blank-row').prop('hidden', false); }); } @@ -65,6 +65,7 @@ jQuery(function ($) { if (field($row, 'value').val() === value && field($row, 'label').val() === labels[value]) { field($row, 'value').val(''); field($row, 'label').val(''); + $row.addClass('srt-blank-row').prop('hidden', true); } $row.removeAttr('data-srt-auto'); @@ -127,6 +128,36 @@ jQuery(function ($) { }); } + // The empty rows offered for new answers wait behind the Add button, which + // shows one at a time, and adds another from the template when none are + // left. The new answer goes last. + var $prototype = $('#srt-option-prototype'); + var nextIndex = $table.find('tbody tr').length; + + $table.find('tbody tr.srt-blank-row').prop('hidden', true); + $form.find('.srt-add-option').removeAttr('hidden'); + + $form.on('click', '[data-srt-add-option]', function () { + var $row = $table.find('tbody tr.srt-blank-row[hidden]').first(); + + if (!$row.length && $prototype.length) { + $row = $($.trim($prototype.html().replace(/__name__/g, String(nextIndex++)))); + $row.addClass('srt-blank-row'); + } + + var last = 0; + $table.find('tbody tr').not('.srt-blank-row').each(function () { + last = Math.max(last, parseInt(field($(this), 'position').val(), 10) || 0); + }); + + $row.appendTo($table.find('tbody')).removeClass('srt-blank-row').prop('hidden', false); + field($row, 'position').val(last + 10); + $table.find('tbody tr').not('[hidden]').each(function (index) { + $(this).toggleClass('bg1', index % 2 === 0).toggleClass('bg2', index % 2 === 1); + }); + 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); From 3e25a81c749a12a3ddabaf8fcc53b078deb819bd Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 23:35:22 +0100 Subject: [PATCH 05/11] Keep the SRT answers' order numbers, show answers in order, and add rows for Yes and No when needed Co-Authored-By: Claude Opus 5 --- css/srt.css | 12 ------ js/support/srt-admin.js | 87 +++++++++++++++++++++++++++++++---------- 2 files changed, 66 insertions(+), 33 deletions(-) diff --git a/css/srt.css b/css/srt.css index f7138c7..ed45763 100644 --- a/css/srt.css +++ b/css/srt.css @@ -259,18 +259,6 @@ body.support-template .gen-info .error { display: inline; } -/* Dragging sets the order of the answers, so their order numbers, which - stand in for it without the script, are not shown. */ -.srt-admin.srt-sortable-on table.srt-options .srt-col-order input { - display: none; -} - -/* That column then only holds the drag handles. */ -.srt-admin.srt-sortable-on table.srt-options th.srt-col-order { - width: 1.5em; - font-size: 0; -} - .srt-admin tr.srt-dragging { opacity: 0.4; } diff --git a/js/support/srt-admin.js b/js/support/srt-admin.js index 009c5b8..20df3b5 100644 --- a/js/support/srt-admin.js +++ b/js/support/srt-admin.js @@ -32,6 +32,33 @@ jQuery(function ($) { return labels.hasOwnProperty(field($row, 'value').val()); } + // The empty rows offered for new answers wait behind the Add button. When + // none are left, another comes from the template. + var $prototype = $('#srt-option-prototype'); + var nextIndex = $table.find('tbody tr').length; + + function newRow() { + var $row = $table.find('tbody tr.srt-blank-row[hidden]').first(); + + if (!$row.length && $prototype.length) { + $row = $($.trim($prototype.html().replace(/__name__/g, String(nextIndex++)))); + $row.prop('hidden', true).appendTo($table.find('tbody')); + } + + return $row; + } + + // A yes or no question only shows its Yes and No rows. + function stripe() { + var boolean = $table.hasClass('srt-boolean'); + + $table.find('tbody tr').not('[hidden]').filter(function () { + return !boolean || !$(this).hasClass('srt-row-extra'); + }).each(function (index) { + $(this).toggleClass('bg1', index % 2 === 0).toggleClass('bg2', index % 2 === 1); + }); + } + // Switching a question to yes or no fills blank rows with the two answers // it now has, if they are not there yet. function addYesAndNo() { @@ -45,10 +72,19 @@ jQuery(function ($) { return; } + // An empty row, but not one sent back with an error for what else + // it had filled in. var $blank = $rows.filter(function () { - return field($(this), 'value').val() === '' && field($(this), 'label').val() === ''; + var $row = $(this); + + return field($row, 'value').val() === '' && field($row, 'label').val() === '' + && !field($row, 'warn').is(':checked') && !$row.find('select[name$="[outcome]"]').val(); }).first(); + if (!$blank.length) { + $blank = newRow(); + } + field($blank, 'value').val(value); field($blank, 'label').val(label); $blank.attr('data-srt-auto', value).removeClass('srt-blank-row').prop('hidden', false); @@ -94,6 +130,7 @@ jQuery(function ($) { $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. @@ -128,33 +165,41 @@ jQuery(function ($) { }); } - // The empty rows offered for new answers wait behind the Add button, which - // shows one at a time, and adds another from the template when none are - // left. The new answer goes last. - var $prototype = $('#srt-option-prototype'); - var nextIndex = $table.find('tbody tr').length; + // 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. The + // empty rows go last, out of sight. + function position($row) { + return parseInt(field($row, 'position').val(), 10) || 0; + } + var $sorted = $table.find('tbody tr').not('.srt-blank-row').get().sort(function (a, b) { + return position($(a)) - position($(b)); + }); + $table.find('tbody').prepend($sorted); $table.find('tbody tr.srt-blank-row').prop('hidden', true); $form.find('.srt-add-option').removeAttr('hidden'); + stripe(); + // The Add button shows one empty row at a time, as the last answer. $form.on('click', '[data-srt-add-option]', function () { - var $row = $table.find('tbody tr.srt-blank-row[hidden]').first(); - - if (!$row.length && $prototype.length) { - $row = $($.trim($prototype.html().replace(/__name__/g, String(nextIndex++)))); - $row.addClass('srt-blank-row'); - } - + var $row = newRow(); var last = 0; - $table.find('tbody tr').not('.srt-blank-row').each(function () { - last = Math.max(last, parseInt(field($(this), 'position').val(), 10) || 0); + + $table.find('tbody tr').not('[hidden]').each(function () { + last = Math.max(last, position($(this))); }); - $row.appendTo($table.find('tbody')).removeClass('srt-blank-row').prop('hidden', false); + var $hidden = $table.find('tbody tr[hidden]').not($row).first(); + + if ($hidden.length) { + $row.insertBefore($hidden); + } else { + $row.appendTo($table.find('tbody')); + } + + $row.removeClass('srt-blank-row').prop('hidden', false); field($row, 'position').val(last + 10); - $table.find('tbody tr').not('[hidden]').each(function (index) { - $(this).toggleClass('bg1', index % 2 === 0).toggleClass('bg2', index % 2 === 1); - }); + stripe(); field($row, 'value').trigger('focus'); }); @@ -186,7 +231,7 @@ jQuery(function ($) { } $admin.addClass('srt-sortable-on'); - $admin.find('.srt-new-step').prop('hidden', false); + $admin.find('.srt-new-step, .srt-drag-hint').prop('hidden', false); var $dragged = null; var group = null; @@ -213,7 +258,7 @@ jQuery(function ($) { } function tidy($tbody) { - var $rows = rows($tbody); + var $rows = rows($tbody).not('[hidden]'); $tbody.children('.srt-placeholder').prop('hidden', $rows.length > 0); $rows.each(function (index) { From 2b6ff913144a304a9451c438d55551a1e8bdd0d5 Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 23:41:33 +0100 Subject: [PATCH 06/11] Hide the SRT answers' order numbers when they can be dragged Co-Authored-By: Claude Opus 5 --- css/srt.css | 13 +++++++++++++ js/support/srt-admin.js | 3 +++ 2 files changed, 16 insertions(+) diff --git a/css/srt.css b/css/srt.css index ed45763..a40f222 100644 --- a/css/srt.css +++ b/css/srt.css @@ -259,6 +259,19 @@ body.support-template .gen-info .error { display: inline; } +/* Dragging sets the order of the answers, so their order numbers, which + stand in for it without the script, are not shown. */ +.srt-admin.srt-sortable-on table.srt-options .srt-col-order input, +.srt-admin.srt-sortable-on .srt-order-hint { + display: none; +} + +/* That column then only holds the drag handles. */ +.srt-admin.srt-sortable-on table.srt-options th.srt-col-order { + width: 1.5em; + font-size: 0; +} + .srt-admin tr.srt-dragging { opacity: 0.4; } diff --git a/js/support/srt-admin.js b/js/support/srt-admin.js index 20df3b5..8da7dc2 100644 --- a/js/support/srt-admin.js +++ b/js/support/srt-admin.js @@ -42,6 +42,9 @@ jQuery(function ($) { if (!$row.length && $prototype.length) { $row = $($.trim($prototype.html().replace(/__name__/g, String(nextIndex++)))); + // Like the empty rows the page offers: its order field is hidden + // while dragging is on, so it must never be left empty. + field($row, 'position').val('0'); $row.prop('hidden', true).appendTo($table.find('tbody')); } From 08bab48cfb5e521803e70d511d6208ab3bc69e5d Mon Sep 17 00:00:00 2001 From: paulsohier Date: Fri, 18 Sep 2026 23:52:41 +0100 Subject: [PATCH 07/11] Show SRT answer warnings under their question as soon as they are picked Co-Authored-By: Claude Opus 5 --- css/srt.css | 12 ++++++++++++ js/support/srt.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 js/support/srt.js diff --git a/css/srt.css b/css/srt.css index a40f222..639f252 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. */ diff --git a/js/support/srt.js b/js/support/srt.js new file mode 100644 index 0000000..2803122 --- /dev/null +++ b/js/support/srt.js @@ -0,0 +1,39 @@ +/** + * 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. The page then tells the + * server, through warnings_shown, not to repeat the warnings on the next + * step. Without this script they are shown there instead. + */ +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(); + }); + + $form.find('[data-srt-warnings-shown]').val('1'); +}); From 9330db63f13bf038eb2a97f04505b72dc432e427 Mon Sep 17 00:00:00 2001 From: paulsohier Date: Sat, 19 Sep 2026 09:31:01 +0100 Subject: [PATCH 08/11] SRT admin: clearer drag handles that also move rows with the arrow keys, arrows only without the script Co-Authored-By: Claude Opus 5 --- css/srt.css | 42 +++++++++++++++++----- js/support/srt-admin.js | 77 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/css/srt.css b/css/srt.css index 639f252..0e52d8c 100644 --- a/css/srt.css +++ b/css/srt.css @@ -261,29 +261,55 @@ body.support-template .gen-info .srt-warning p { /* Drag and drop, which js/support/srt-admin.js switches on. */ .srt-admin .srt-drag-handle { display: none; - padding: 0 6px 0 2px; - color: #999999; + padding: 4px 6px; + border-radius: 4px; + color: #536482; cursor: grab; user-select: none; + vertical-align: middle; +} + +.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; } .srt-admin.srt-sortable-on .srt-drag-handle { - display: inline; + display: inline-block; } -/* Dragging sets the order of the answers, so their order numbers, which - stand in for it without the script, are not shown. */ +/* Dragging, or the arrow keys on a handle, set the order, so the order + numbers and arrow buttons that stand in for it without the script are not + shown. */ .srt-admin.srt-sortable-on table.srt-options .srt-col-order input, .srt-admin.srt-sortable-on .srt-order-hint { display: none; } -/* That column then only holds the drag handles. */ -.srt-admin.srt-sortable-on table.srt-options th.srt-col-order { - width: 1.5em; +/* Stronger than buttons.css's #main .submit-buttons-inline. */ +#main #srt-admin.srt-sortable-on .srt-move-buttons { + display: none; +} + +/* That column then only holds the handles. */ +.srt-admin.srt-sortable-on table.srt-options th.srt-col-order, +.srt-admin.srt-sortable-on table.srt-questions .srt-order { + width: 36px; font-size: 0; } +.srt-admin.srt-sortable-on table.srt-questions td.srt-order { + font-size: inherit; +} + .srt-admin tr.srt-dragging { opacity: 0.4; } diff --git a/js/support/srt-admin.js b/js/support/srt-admin.js index 8da7dc2..8b4f698 100644 --- a/js/support/srt-admin.js +++ b/js/support/srt-admin.js @@ -362,6 +362,83 @@ jQuery(function ($) { } }); + // 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. + $bodies.on('keydown', '.srt-drag-handle', function (event) { + var up = event.which === 38; + + if (submitting || $dragged || (!up && event.which !== 40) + || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + + event.preventDefault(); + + var $row = $(this).closest('tr'); + var $tbody = $row.parent(); + var name = groupOf($tbody); + var $visible = rows($tbody).not('[hidden]'); + var $sibling = $visible.eq($visible.index($row) + (up ? -1 : 1)); + + if (up && $visible.index($row) === 0) { + $sibling = $(); + } + + if ($sibling.length) { + if (up) { + $row.insertBefore($sibling); + } else { + $row.insertAfter($sibling); + } + } else if (name === 'questions') { + var $steps = $bodies.filter('[data-srt-sortable="questions"]'); + var target = $steps.index($tbody) + (up ? -1 : 1); + + // The only question of the last step would start a new step that + // is the same one. + if (target < 0 || target >= $steps.length || (target === $steps.length - 1 && $visible.length === 1)) { + return; + } + + if (up) { + $row.appendTo($steps.eq(target)); + } else { + $row.prependTo($steps.eq(target)); + } + + tidy($steps.eq(target)); + } else { + return; + } + + tidy($tbody); + + if (name === 'questions') { + try { + window.sessionStorage.setItem('srt-focus', $row.attr('data-srt-id')); + } catch (e) { + // Focus is not kept over the reload then. + } + + submitOrder(); + } else { + renumber($tbody); + $(this).trigger('focus'); + } + }); + + // Back from moving a question with the keys: its handle gets focus again. + try { + var focus = window.sessionStorage.getItem('srt-focus'); + + window.sessionStorage.removeItem('srt-focus'); + if (focus) { + $bodies.find('tr[data-srt-id="' + focus.replace(/[^0-9]/g, '') + '"] .srt-drag-handle').trigger('focus'); + } + } catch (e) { + // Nothing to restore. + } + $bodies.on('drop', function (event) { event.preventDefault(); dropped = true; From 585e28a2c11a546df27f5b460077c5928b3604f8 Mon Sep 17 00:00:00 2001 From: paulsohier Date: Sat, 19 Sep 2026 10:40:18 +0100 Subject: [PATCH 09/11] SRT admin relies on its script: order saved and deletes sent over AJAX, no fallbacks Co-Authored-By: Claude Opus 5 --- css/srt.css | 59 ++--- js/support/srt-admin.js | 477 +++++++++++++++++++++++----------------- js/support/srt.js | 6 +- 3 files changed, 287 insertions(+), 255 deletions(-) diff --git a/css/srt.css b/css/srt.css index 0e52d8c..bd5e452 100644 --- a/css/srt.css +++ b/css/srt.css @@ -173,24 +173,11 @@ body.support-template .gen-info .srt-warning p { 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: 8em; -} - .srt-admin table.srt-questions th:nth-child(3), .srt-admin table.srt-questions td:nth-child(3) { width: 9em; @@ -201,10 +188,6 @@ body.support-template .gen-info .srt-warning p { width: 11em; } -.srt-admin form.srt-move { - margin: 0; -} - .srt-admin table.srt-outcomes td:first-child, .srt-admin table.srt-texts td:first-child { width: 12em; @@ -258,9 +241,19 @@ body.support-template .gen-info .srt-warning p { font-weight: bold; } -/* Drag and drop, which js/support/srt-admin.js switches on. */ +/* 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: none; + display: inline-block; padding: 4px 6px; border-radius: 4px; color: #536482; @@ -282,32 +275,10 @@ body.support-template .gen-info .srt-warning p { background-color: #C7D9E9; } -.srt-admin.srt-sortable-on .srt-drag-handle { - display: inline-block; -} - -/* Dragging, or the arrow keys on a handle, set the order, so the order - numbers and arrow buttons that stand in for it without the script are not - shown. */ -.srt-admin.srt-sortable-on table.srt-options .srt-col-order input, -.srt-admin.srt-sortable-on .srt-order-hint { - display: none; -} - -/* Stronger than buttons.css's #main .submit-buttons-inline. */ -#main #srt-admin.srt-sortable-on .srt-move-buttons { - display: none; -} - -/* That column then only holds the handles. */ -.srt-admin.srt-sortable-on table.srt-options th.srt-col-order, -.srt-admin.srt-sortable-on table.srt-questions .srt-order { +/* The order column only holds the handles. */ +.srt-admin table.srt-options .srt-col-order, +.srt-admin table.srt-questions .srt-order { width: 36px; - font-size: 0; -} - -.srt-admin.srt-sortable-on table.srt-questions td.srt-order { - font-size: inherit; } .srt-admin tr.srt-dragging { diff --git a/js/support/srt-admin.js b/js/support/srt-admin.js index 8b4f698..43abeda 100644 --- a/js/support/srt-admin.js +++ b/js/support/srt-admin.js @@ -1,12 +1,36 @@ /** * The Support Request Template pages in the team tools (phpbb-website-private - * #29), on the jQuery every page loads. - * - * Question form: only the parts that apply to the chosen type are shown. + * #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. Without this script the whole form shows. + * field. The Add button adds an answer from the form's prototype row. */ jQuery(function ($) { 'use strict'; @@ -22,48 +46,61 @@ jQuery(function ($) { 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 isYesOrNo($row) { - return labels.hasOwnProperty(field($row, 'value').val()); + function position($row) { + return parseInt(field($row, 'position').val(), 10) || 0; } - // The empty rows offered for new answers wait behind the Add button. When - // none are left, another comes from the template. - var $prototype = $('#srt-option-prototype'); - var nextIndex = $table.find('tbody tr').length; - - function newRow() { - var $row = $table.find('tbody tr.srt-blank-row[hidden]').first(); - - if (!$row.length && $prototype.length) { - $row = $($.trim($prototype.html().replace(/__name__/g, String(nextIndex++)))); - // Like the empty rows the page offers: its order field is hidden - // while dragging is on, so it must never be left empty. - field($row, 'position').val('0'); - $row.prop('hidden', true).appendTo($table.find('tbody')); - } - - return $row; + 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').not('[hidden]').filter(function () { + $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); }); } - // Switching a question to yes or no fills blank rows with the two answers - // it now has, if they are not there yet. + // 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'); @@ -77,24 +114,24 @@ jQuery(function ($) { // An empty row, but not one sent back with an error for what else // it had filled in. - var $blank = $rows.filter(function () { - var $row = $(this); + var $row = $rows.filter(function () { + var $candidate = $(this); - return field($row, 'value').val() === '' && field($row, 'label').val() === '' - && !field($row, 'warn').is(':checked') && !$row.find('select[name$="[outcome]"]').val(); + return field($candidate, 'value').val() === '' && field($candidate, 'label').val() === '' + && !field($candidate, 'warn').is(':checked') && !$candidate.find('select[name$="[outcome]"]').val(); }).first(); - if (!$blank.length) { - $blank = newRow(); + if (!$row.length) { + $row = newRow(); } - field($blank, 'value').val(value); - field($blank, 'label').val(label); - $blank.attr('data-srt-auto', value).removeClass('srt-blank-row').prop('hidden', false); + field($row, 'value').val(value); + field($row, 'label').val(label); + $row.attr('data-srt-auto', value); }); } - // Switching away from yes or no again empties the rows filled above, as + // 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 () { @@ -102,12 +139,10 @@ jQuery(function ($) { var value = $row.attr('data-srt-auto'); if (field($row, 'value').val() === value && field($row, 'label').val() === labels[value]) { - field($row, 'value').val(''); - field($row, 'label').val(''); - $row.addClass('srt-blank-row').prop('hidden', true); + $row.remove(); + } else { + $row.removeAttr('data-srt-auto'); } - - $row.removeAttr('data-srt-auto'); }); } @@ -169,39 +204,14 @@ jQuery(function ($) { } // 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. The - // empty rows go last, out of sight. - function position($row) { - return parseInt(field($row, 'position').val(), 10) || 0; - } - - var $sorted = $table.find('tbody tr').not('.srt-blank-row').get().sort(function (a, b) { + // 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)); - }); - $table.find('tbody').prepend($sorted); - $table.find('tbody tr.srt-blank-row').prop('hidden', true); - $form.find('.srt-add-option').removeAttr('hidden'); - stripe(); + })); - // The Add button shows one empty row at a time, as the last answer. $form.on('click', '[data-srt-add-option]', function () { var $row = newRow(); - var last = 0; - - $table.find('tbody tr').not('[hidden]').each(function () { - last = Math.max(last, position($(this))); - }); - - var $hidden = $table.find('tbody tr[hidden]').not($row).first(); - - if ($hidden.length) { - $row.insertBefore($hidden); - } else { - $row.appendTo($table.find('tbody')); - } - $row.removeClass('srt-blank-row').prop('hidden', false); - field($row, 'position').val(last + 10); stripe(); field($row, 'value').trigger('focus'); }); @@ -213,35 +223,65 @@ jQuery(function ($) { }); /** - * Drag and drop sorting of the questions and of a question's answers. - * - * 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. + * Deleting a question or an outcome: a DELETE request with the page's CSRF + * token. The server answers with where to go next, or why it did not delete. */ jQuery(function ($) { 'use strict'; - var $admin = $('#srt-admin'); - var $bodies = $admin.find('tbody[data-srt-sortable]'); + $('#srt-admin').on('click', '[data-srt-delete]', function () { + var $button = $(this); - if (!$bodies.length || !('draggable' in document.createElement('tr'))) { - return; - } + if (!window.confirm($button.attr('data-srt-confirm'))) { + return; + } + + $button.prop('disabled', true); + + $.ajax({ + type: 'DELETE', + url: $button.attr('data-srt-delete'), + headers: {'X-CSRF-Token': $button.attr('data-srt-token')}, + dataType: 'json' + }).done(function (data) { + window.location.href = data.redirect; + }).fail(function (xhr) { + $button.prop('disabled', false); + srtStatus(srtErrorMessage(xhr), 'red'); + // The button is at the bottom of the form, the message at the top. + document.getElementById('srt-status').scrollIntoView(); + }); + }); +}); + +/** + * 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'; - $admin.addClass('srt-sortable-on'); - $admin.find('.srt-new-step, .srt-drag-hint').prop('hidden', false); + 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 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'); @@ -251,17 +291,42 @@ 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) { - var $rows = rows($tbody).not('[hidden]'); + var $rows = rows($tbody); $tbody.children('.srt-placeholder').prop('hidden', $rows.length > 0); $rows.each(function (index) { @@ -269,37 +334,70 @@ 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; - } + // Steps as the server now numbers them: empty ones gone, and one empty + // "new step" after the last. + function renumberSteps() { + var number = 0; + + $steps.find('.srt-step').each(function () { + var $step = $(this); - submitting = true; + if (rows($step.find('tbody')).length === 0) { + $step.remove(); + return; + } - $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); - }); + 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); + // Sends the order of every question; undoes the move if it is refused. + function saveOrder(saved) { + var order = bodies('questions').map(function () { + return [rows($(this)).map(function () { + return $(this).attr('data-srt-id'); + }).get()]; + }).get(); + + saving = true; + + $.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) { + renumberSteps(); + srtStatus(data.message, 'green'); + }).fail(function (xhr) { + restore(saved); + srtStatus(srtErrorMessage(xhr), 'red'); + }).always(function () { + saving = false; }); } - $bodies.each(function () { - tidy($(this)); - }); + function moved(name, $tbody, saved) { + if (name === 'questions') { + saveOrder(saved); + } else { + renumber($tbody); + } + } // 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'); }); @@ -309,17 +407,17 @@ 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); origin = {parent: $row.parent(), next: $row.next()}; dropped = false; @@ -329,7 +427,7 @@ 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) { @@ -338,7 +436,7 @@ jQuery(function ($) { 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(); @@ -353,7 +451,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); @@ -362,27 +463,57 @@ jQuery(function ($) { } }); + $admin.on('drop', 'tbody[data-srt-sortable]', function (event) { + event.preventDefault(); + dropped = true; + }); + + $(document).on('dragend', function () { + if (!$dragged) { + return; + } + + var $row = $dragged; + var name = group; + + $dragged = null; + group = null; + + $row.removeClass('srt-dragging').removeAttr('draggable'); + $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) { + restore(before); + return; + } + + if (!sameLayout(before, layout(name))) { + moved(name, $row.parent(), before); + } + }); + // 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. - $bodies.on('keydown', '.srt-drag-handle', function (event) { + $admin.on('keydown', 'tbody[data-srt-sortable] .srt-drag-handle', function (event) { var up = event.which === 38; - if (submitting || $dragged || (!up && event.which !== 40) + if (saving || $dragged || (!up && event.which !== 40) || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } event.preventDefault(); - var $row = $(this).closest('tr'); + var $handle = $(this); + var $row = $handle.closest('tr'); var $tbody = $row.parent(); var name = groupOf($tbody); - var $visible = rows($tbody).not('[hidden]'); - var $sibling = $visible.eq($visible.index($row) + (up ? -1 : 1)); - - if (up && $visible.index($row) === 0) { - $sibling = $(); - } + var saved = layout(name); + 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) { @@ -391,99 +522,33 @@ jQuery(function ($) { $row.insertAfter($sibling); } } else if (name === 'questions') { - var $steps = $bodies.filter('[data-srt-sortable="questions"]'); - var target = $steps.index($tbody) + (up ? -1 : 1); + var $all = bodies('questions'); + var target = $all.index($tbody) + (up ? -1 : 1); // The only question of the last step would start a new step that // is the same one. - if (target < 0 || target >= $steps.length || (target === $steps.length - 1 && $visible.length === 1)) { + if (target < 0 || target >= $all.length || (target === $all.length - 1 && $rows.length === 1)) { return; } + var $target = $all.eq(target); + if (up) { - $row.appendTo($steps.eq(target)); + $row.insertBefore($target.children('.srt-placeholder').first()); + if (!$row.parent().is($target)) { + $row.appendTo($target); + } } else { - $row.prependTo($steps.eq(target)); + $row.prependTo($target); } - tidy($steps.eq(target)); + tidy($target); } else { return; } tidy($tbody); - - if (name === 'questions') { - try { - window.sessionStorage.setItem('srt-focus', $row.attr('data-srt-id')); - } catch (e) { - // Focus is not kept over the reload then. - } - - submitOrder(); - } else { - renumber($tbody); - $(this).trigger('focus'); - } - }); - - // Back from moving a question with the keys: its handle gets focus again. - try { - var focus = window.sessionStorage.getItem('srt-focus'); - - window.sessionStorage.removeItem('srt-focus'); - if (focus) { - $bodies.find('tr[data-srt-id="' + focus.replace(/[^0-9]/g, '') + '"] .srt-drag-handle').trigger('focus'); - } - } catch (e) { - // Nothing to restore. - } - - $bodies.on('drop', function (event) { - event.preventDefault(); - dropped = true; - }); - - $(document).on('dragend', function () { - if (!$dragged) { - return; - } - - 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'); - - // 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); - } else { - $row.appendTo(origin.parent); - } - - tidy(origin.parent); - if (!$moved.is(origin.parent)) { - tidy($moved); - } - - return; - } - - if (snapshot(name) === before) { - return; - } - - if (name === 'questions') { - submitOrder(); - } else { - renumber($row.parent()); - } + $handle.trigger('focus'); + moved(name, $tbody, saved); }); }); diff --git a/js/support/srt.js b/js/support/srt.js index 2803122..367a8ac 100644 --- a/js/support/srt.js +++ b/js/support/srt.js @@ -3,9 +3,7 @@ * 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. The page then tells the - * server, through warnings_shown, not to repeat the warnings on the next - * step. Without this script they are shown there instead. + * picked, and hides it again when another answer is. */ jQuery(function ($) { 'use strict'; @@ -34,6 +32,4 @@ jQuery(function ($) { $fields.on('change', update); update(); }); - - $form.find('[data-srt-warnings-shown]').val('1'); }); From 51d493f2b9515097c64a8e39dcd93f163f0e8d1b Mon Sep 17 00:00:00 2001 From: paulsohier Date: Sat, 19 Sep 2026 11:25:12 +0100 Subject: [PATCH 10/11] SRT admin: show when it is busy, renumber steps at once and undo a refused move, delete through a POST override Co-Authored-By: Claude Opus 5 --- css/srt.css | 16 ++++++ js/support/srt-admin.js | 121 ++++++++++++++++++++++++++++++++++------ 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/css/srt.css b/css/srt.css index bd5e452..94209d9 100644 --- a/css/srt.css +++ b/css/srt.css @@ -285,6 +285,22 @@ body.support-template .gen-info .srt-warning p { 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/support/srt-admin.js b/js/support/srt-admin.js index 43abeda..de4738a 100644 --- a/js/support/srt-admin.js +++ b/js/support/srt-admin.js @@ -223,35 +223,84 @@ jQuery(function ($) { }); /** - * Deleting a question or an outcome: a DELETE request with the page's CSRF - * token. The server answers with where to go next, or why it did not delete. + * 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; + } + + 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'; - $('#srt-admin').on('click', '[data-srt-delete]', function () { + var $admin = $('#srt-admin'); + + $admin.on('click', '[data-srt-delete]', function () { var $button = $(this); if (!window.confirm($button.attr('data-srt-confirm'))) { return; } - $button.prop('disabled', true); + srtBusy($button, true); + srtStatus('Deleting…', 'yellow'); $.ajax({ - type: 'DELETE', + 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) { - $button.prop('disabled', false); + 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); + }); + }); }); /** @@ -275,6 +324,7 @@ jQuery(function ($) { var $dragged = null; var group = null; var before = null; + var beforeSections = null; var origin = null; var dropped = false; var saving = false; @@ -340,16 +390,28 @@ jQuery(function ($) { }); } - // Steps as the server now numbers them: empty ones gone, and one empty - // "new step" after the last. + // 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(); + } + + // 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; - $steps.find('.srt-step').each(function () { + $steps.children('.srt-step').each(function () { var $step = $(this); if (rows($step.find('tbody')).length === 0) { - $step.remove(); + $step.detach(); return; } @@ -360,8 +422,19 @@ jQuery(function ($) { $steps.append($newStep.clone()); } - // Sends the order of every question; undoes the move if it is refused. - function saveOrder(saved) { + 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); + }); + } + + // 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'); @@ -369,6 +442,10 @@ jQuery(function ($) { }).get(); saving = true; + renumberSteps(); + $steps.addClass('srt-saving'); + $row.addClass('srt-row-saving'); + srtStatus('Saving the order…', 'yellow'); $.ajax({ type: 'POST', @@ -377,21 +454,27 @@ jQuery(function ($) { data: {order: order}, dataType: 'json' }).done(function (data) { - renumberSteps(); 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'); }); } - function moved(name, $tbody, saved) { + function moved(name, $row, saved, sections) { if (name === 'questions') { - saveOrder(saved); + saveOrder(saved, sections, $row); } else { - renumber($tbody); + renumber($row.parent()); } } @@ -418,6 +501,7 @@ jQuery(function ($) { $dragged = $row; group = groupOf($row.parent()); before = layout(group); + beforeSections = stepSections(); origin = {parent: $row.parent(), next: $row.next()}; dropped = false; @@ -490,7 +574,7 @@ jQuery(function ($) { } if (!sameLayout(before, layout(name))) { - moved(name, $row.parent(), before); + moved(name, $row, before, beforeSections); } }); @@ -511,6 +595,7 @@ jQuery(function ($) { 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); @@ -549,6 +634,6 @@ jQuery(function ($) { tidy($tbody); $handle.trigger('focus'); - moved(name, $tbody, saved); + moved(name, $row, saved, sections); }); }); From 05e4878c09850ff6cd017eb24fa34aa7ed92be33 Mon Sep 17 00:00:00 2001 From: paulsohier Date: Sat, 19 Sep 2026 11:41:52 +0100 Subject: [PATCH 11/11] Markdown editor reports a required field left empty; SRT admin refuses a no-op new step, reloads a kept overview, and lets its editors scroll Co-Authored-By: Claude Opus 5 --- css/srt.css | 13 ++++++++++--- js/markdown-editor.js | 43 ++++++++++++++++++++++++++++++++++++++++- js/support/srt-admin.js | 43 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/css/srt.css b/css/srt.css index 94209d9..16da12f 100644 --- a/css/srt.css +++ b/css/srt.css @@ -163,10 +163,17 @@ body.support-template .gen-info .srt-warning p { 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 { 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 de4738a..0ca1cf4 100644 --- a/js/support/srt-admin.js +++ b/js/support/srt-admin.js @@ -325,6 +325,7 @@ jQuery(function ($) { var group = null; var before = null; var beforeSections = null; + var alone = false; var origin = null; var dropped = false; var saving = false; @@ -470,6 +471,17 @@ jQuery(function ($) { }); } + // 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); @@ -478,6 +490,16 @@ jQuery(function ($) { } } + // 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. $admin.on('mousedown', 'tbody[data-srt-sortable] .srt-drag-handle', function () { @@ -502,6 +524,7 @@ jQuery(function ($) { group = groupOf($row.parent()); before = layout(group); beforeSections = stepSections(); + alone = aloneInLastStep($row); origin = {parent: $row.parent(), next: $row.next()}; dropped = false; @@ -518,6 +541,17 @@ jQuery(function ($) { 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'; $admin.find('tbody.srt-drop-target').removeClass('srt-drop-target'); @@ -610,9 +644,12 @@ jQuery(function ($) { var $all = bodies('questions'); var target = $all.index($tbody) + (up ? -1 : 1); - // The only question of the last step would start a new step that - // is the same one. - if (target < 0 || target >= $all.length || (target === $all.length - 1 && $rows.length === 1)) { + if (target < 0 || target >= $all.length) { + return; + } + + if (!up && aloneInLastStep($row)) { + srtStatus(ALONE_IN_LAST_STEP, 'yellow'); return; }