[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