[TikiWiki-commits] [Git][tikiwiki/tiki][master] [FIX] User Selector: fix drag and drop and value handling in the tracker field

"ushindi bienvenu \(@usbbush\) via TikiWiki-cvs" <[email protected]>
Newsgroups gmane.comp.cms.tiki.cvs
Message-ID <6a957b7dcb0df_389480cc7390@gitlab-sidekiq-low-urgency-cpu-bound-v2-74dc959445-ndhqp.mail>

ushindi bienvenu pushed to branch master at Tiki Wiki CMS Groupware / Tiki


Commits:
7d0a0bbd by Landry Bitege at 2026-08-31T12:47:00+00:00
[FIX] User Selector: fix drag and drop and value handling in the tracker field
---
* [FIX] User Selector: fix drag and drop and value handling in the tracker field

See merge request tikiwiki/tiki!11007

- - - - -


7 changed files:

- lib/core/Tracker/Field/DynamicList.php
- lib/smarty_tiki/FunctionHandler/UserSelector.php
- src/js/vue-widgets/element-plus-ui/src/components/Transfer/Transfer.vue
- src/js/vue-widgets/element-plus-ui/src/helpers/select/sortable.js
- src/js/vue-widgets/element-plus-ui/src/tests/components/Transfer.test.js
- src/js/vue-widgets/element-plus-ui/src/tests/helpers/select/sortable.test.js
- templates/trackerinput/userselector_grouped.tpl


Changes:

=====================================
lib/core/Tracker/Field/DynamicList.php
=====================================
@@ -329,9 +329,7 @@ $("body").on("change", "input[name=\'' . $filterFieldHereName . '\'], select[nam
 
                 const elementPlusTransfer = document.querySelector("el-transfer[field-name=\'" + data.request.insertId + "\']");
                 if (elementPlusTransfer) {
-                    const elementPlusTransferCopy = elementPlusTransfer.cloneNode(true);
-                    elementPlusTransferCopy.setAttribute("data", JSON.stringify(transferData));
-                    elementPlusTransfer.replaceWith(elementPlusTransferCopy);
+                    elementPlusTransfer.setAttribute("data", JSON.stringify(transferData));
                 }
                     
                 if (data.request.originalValue) {


=====================================
lib/smarty_tiki/FunctionHandler/UserSelector.php
=====================================
@@ -213,10 +213,18 @@ class UserSelector extends Base implements TikiSmartyExtensionInterface
             ], $template);
         }
 
-        $ret .= '<select name="' . $params['name'] . '" id="' . $params['id'] . '"' . $sz . $ed . $mt . ' style="' . $params['style'] . '" class="form-control">';
-        if ($params['allowNone'] === 'y') {
+        $placeholder = '';
+        if ($params['allowNone'] === 'y' && $params['multiple'] === 'true' && $params['noneLabel'] !== '') {
+            // When selecting several users, "None" is not a value: the hidden input below already
+            // submits the empty value. Render it as a placeholder, which clears itself once a user
+            // is picked, instead of an option that can be selected alongside real users.
+            $placeholder = ' placeholder="' . htmlspecialchars(tra($params['noneLabel'])) . '"';
+        }
+
+        $ret .= '<select name="' . $params['name'] . '" id="' . $params['id'] . '"' . $sz . $ed . $mt . $placeholder . ' style="' . $params['style'] . '" class="form-control">';
+        if ($params['allowNone'] === 'y' && $params['multiple'] !== 'true') {
             $noneOptionAttributes = (empty($params['user']) ? ' selected="selected"' : '');
-            if ($params['multiple'] !== 'true' && $params['noneSelectable'] !== 'y') {
+            if ($params['noneSelectable'] !== 'y') {
                 $noneOptionAttributes .= ' disabled="disabled" hidden';
             }
 


=====================================
src/js/vue-widgets/element-plus-ui/src/components/Transfer/Transfer.vue
=====================================
@@ -1,16 +1,25 @@
 <script setup>
-import { ref, onMounted, computed } from 'vue';
+import { ref, onMounted, computed, watch } from 'vue';
 import { Menu, Edit, Delete } from "@element-plus/icons-vue";
 import Sortable from "sortablejs";
 import ConfigWrapper from '../ConfigWrapper.vue';
 
 const props = defineProps(['data', 'fieldName', 'filterable', 'defaultValue', 'sourceListTitle', 'targetListTitle', 'filterPlaceholder', 'ordering', 'minItems', 'maxItems', 'helperText', 'emitValueChange', 'isInvalid', 'language', 'showEdit', '_emit']);
-const data = typeof props.data === 'string' ? JSON.parse(props.data) : props.data;
-const defaultValue = typeof props.defaultValue === 'string' ? JSON.parse(props.defaultValue) : props.defaultValue;
+const parseProp = (value) => typeof value === 'string' ? JSON.parse(value) : value;
 
-const selected = ref(defaultValue ? [...defaultValue]: []);
+const selected = ref([...(parseProp(props.defaultValue) ?? [])]);
 
-const arrayData = Object.entries(data).map(([key, value]) => ({ key, label: value }));
+const arrayData = computed(() => Object.entries(parseProp(props.data) ?? {}).map(([key, value]) => ({ key, label: value })));
+
+/*
+    Callers update the `data` and `default-value` attributes of the custom element to change
+    the lists on the fly, so both have to be watched. Emitting the new value is what keeps the
+    select that gets submitted with the form up to date, as it is only fed by the change event.
+*/
+watch(() => props.defaultValue, (newValue) => {
+    selected.value = [...(parseProp(newValue) ?? [])];
+    props.emitValueChange?.({ value: selected.value });
+});
 
 const elTransferContainer = ref(null);
 


=====================================
src/js/vue-widgets/element-plus-ui/src/helpers/select/sortable.js
=====================================
@@ -9,13 +9,23 @@
 export function sortOptions(wrapperElement, options) {
     const elementPlusId = wrapperElement.getRootNode().host.id;
     const select = document.querySelector(`select[element-plus-ref="${elementPlusId}"]`);
+    if (!select) {
+        return;
+    }
     const tags = wrapperElement.querySelectorAll(".el-select__tags-text");
 
     tags.forEach((tag, index) => {
         const label = tag.textContent;
         const item = options.find((item) => item.label === label);
-        const option = select.querySelector(`option[value="${item.value}"]`);
-        select.options.add(option.cloneNode(true), index);
-        option.remove();
+        const option = item ? select.querySelector(`option[value="${item.value}"]`) : null;
+        if (!option) {
+            return;
+        }
+        /*
+            Move the existing option instead of inserting a copy: cloneNode() only copies HTML
+            attributes, and el-select applies its value through the `selected` property, which a
+            copy does not carry over. The copy would come back unselected and lose its value.
+        */
+        select.insertBefore(option, select.options[index] ?? null);
     });
 }


=====================================
src/js/vue-widgets/element-plus-ui/src/tests/components/Transfer.test.js
=====================================
@@ -387,6 +387,25 @@ describe("Transfer", () => {
         expect(consoleErrorSpy).not.toHaveBeenCalled();
         expect(consoleWarnSpy).not.toHaveBeenCalled();
     });
+
+    test("should update the lists and the hidden select when the data and defaultValue props change", async () => {
+        const emitValueChange = vi.fn();
+        const { rerender } = render(Transfer, { props: { ...props, emitValueChange } });
+
+        const selectElement = screen.getByTestId(DATA_TEST_ID.HIDDEN_SELECT);
+        assertSelectElementToHaveOptions(selectElement, props.defaultValue);
+
+        const givenNewData = { b: "Item B", d: "Item D" };
+        await rerender({ data: givenNewData, defaultValue: ["b"] });
+
+        assertElTransferToBeCalledWith({ ...props, data: givenNewData });
+        assertSelectElementToHaveOptions(selectElement, ["b"]);
+        // The select that gets submitted is only fed by this event, so it has to be emitted
+        expect(emitValueChange).toHaveBeenCalledWith({ value: ["b"] });
+
+        expect(consoleErrorSpy).not.toHaveBeenCalled();
+        expect(consoleWarnSpy).not.toHaveBeenCalled();
+    });
 });
 
 function assertElTransferToBeCalledWith(props) {


=====================================
src/js/vue-widgets/element-plus-ui/src/tests/helpers/select/sortable.test.js
=====================================
@@ -36,4 +36,43 @@ describe("Select sortable helper functions", () => {
 
         expect(Array.from(givenSelect.options)).toEqual(expectedReorderedOptions);
     });
+
+    test("sortOptions when called, keeps the options selected through the `selected` property", () => {
+        const givenWrapperElement = document.createElement("div");
+        givenWrapperElement.getRootNode = () => ({ host: { id: "selected-property-id" } });
+        document.body.append(givenWrapperElement);
+
+        const givenSelect = document.createElement("select");
+        givenSelect.multiple = true;
+        givenSelect.setAttribute("element-plus-ref", "selected-property-id");
+        const givenOptions = Array.from({ length: 3 }, (_, i) => {
+            const option = document.createElement("option");
+            option.value = i;
+            option.textContent = `Option ${i}`;
+            return option;
+        });
+        givenSelect.append(...givenOptions);
+        givenWrapperElement.append(givenSelect);
+
+        // Select through the property only, without the `selected` attribute, the way el-select
+        // applies its value to the select element it mirrors.
+        givenOptions[0].selected = true;
+        givenOptions[2].selected = true;
+
+        const givenOrderedTags = [givenOptions[2], givenOptions[0]].map((option) => {
+            const tag = document.createElement("div");
+            tag.classList.add("el-select__tags-text");
+            tag.textContent = option.textContent;
+            return tag;
+        });
+
+        givenWrapperElement.append(...givenOrderedTags);
+
+        sortOptions(
+            givenWrapperElement,
+            givenOptions.map((option) => ({ label: option.textContent, value: option.value }))
+        );
+
+        expect(Array.from(givenSelect.selectedOptions).map((option) => option.value)).toEqual(["2", "0"]);
+    });
 });


=====================================
templates/trackerinput/userselector_grouped.tpl
=====================================
@@ -20,7 +20,10 @@
                     <option value="{$data.selected_users[ix]}" selected>{if ($field.showRealname == 'y')}{$data.selected_users[ix]|username}{else}{$data.selected_users[ix]}{/if}</option>
                 {/section}
             </select>
-            <input type="hidden" name="{$field.html_name}" id="hidden_{$field.fieldId}" value="{$data.selected_users|implode:','}">
+            {*NOTE: submitted with an empty value so the field can be cleared. It must NOT carry the
+              selected users: sharing the select name, its value would be submitted as an extra
+              entry that matches no login.*}
+            <input type="hidden" name="{$field.html_name}" value="">
         </div>
     {/if}
 </div>
@@ -81,20 +84,14 @@
             $selector.val(selected);
             const fieldName = "{{$field.html_name}}";
             const elementPlusTransfer = document.querySelector("el-transfer[field-name=\'" + fieldName + "\']");
-            if (elementPlusTransfer?.shadowRoot) {
-                const selectedOptions = elementPlusTransfer.shadowRoot.querySelector("select[name=\'" + fieldName + "\']").selectedOptions;
-                const elementPlusTransferCopy = elementPlusTransfer.cloneNode(true);
-                elementPlusTransferCopy.setAttribute("data", JSON.stringify(group_users));
-                elementPlusTransferCopy.setAttribute("default-value", JSON.stringify([...selectedOptions].map(option => option.value).filter(value => group_users[value])));
-                elementPlusTransfer.replaceWith(elementPlusTransferCopy);
-            } else if (elementPlusTransfer) { // when the inner content hasn't been rendered yet by the scipt
+            if (elementPlusTransfer) {
                 elementPlusTransfer.setAttribute("data", JSON.stringify(group_users));
+                // Nothing to carry over when the inner content hasn't been rendered yet by the script
+                const innerSelect = elementPlusTransfer.shadowRoot?.querySelector("select[name=\'" + fieldName + "\']");
+                if (innerSelect) {
+                    elementPlusTransfer.setAttribute("default-value", JSON.stringify([...innerSelect.selectedOptions].map(option => option.value).filter(value => group_users[value])));
+                }
             }
         }
     }).trigger('change');
-
-    $("#user_selector_{{$field.fieldId}}").on("change", function() {
-        var selectedUsers = $(this).val() || [];
-        $("#hidden_{{$field.fieldId}}").val(selectedUsers.join(","));
-    }).trigger('change');
 {/jq}



View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/7d0a0bbdfa1b2fed63ebdd378071e5b716ed0b9f

-- 
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/7d0a0bbdfa1b2fed63ebdd378071e5b716ed0b9f
You're receiving this email because of your account on gitlab.com. Manage all notifications: https://gitlab.com/-/profile/notifications | Help: https://gitlab.com/help

_______________________________________________
TikiWiki-cvs mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/tikiwiki-cvs
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.