From 05e2a298febf5396ec53a490876fdb7730b7e1dc Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 10 Mar 2026 15:40:28 +0100 Subject: [PATCH] feat: add accessible drag&drop table component --- assets/css/app.css | 31 +++++ assets/js/app.js | 136 +++++++++++++++++++++ assets/vendor/sortable.js | 2 + lib/mv_web/components/core_components.ex | 118 ++++++++++++++++++ lib/mv_web/live/global_settings_live.ex | 54 +++++++- priv/gettext/de/LC_MESSAGES/default.po | 18 ++- priv/gettext/default.pot | 20 +++ priv/gettext/en/LC_MESSAGES/default.po | 18 ++- test/membership/setting_join_form_test.exs | 4 +- 9 files changed, 386 insertions(+), 15 deletions(-) create mode 100644 assets/vendor/sortable.js diff --git a/assets/css/app.css b/assets/css/app.css index 4b28fb7..28ea24b 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -656,3 +656,34 @@ } /* This file is for your main application CSS */ + +/* ============================================ + SortableList: drag-and-drop table rows + ============================================ */ + +/* Ghost row: placeholder showing where the dragged item will be dropped. + Background fills the gap; text invisible so layout matches original row. */ +.sortable-ghost { + background-color: var(--color-base-300) !important; + opacity: 0.5; +} +.sortable-ghost td { + border-color: transparent !important; +} + +/* Chosen row: the row being actively dragged (follows the cursor). */ +.sortable-chosen { + background-color: var(--color-base-200); + box-shadow: 0 4px 16px -2px oklch(0 0 0 / 0.18); + cursor: grabbing !important; +} + +/* Drag handle button: only grab cursor, no hover effect for mouse users. + Keyboard outline is handled via JS outline style. */ +[data-sortable-handle] button { + cursor: grab; +} +[data-sortable-handle] button:hover { + background-color: transparent !important; + color: inherit; +} diff --git a/assets/js/app.js b/assets/js/app.js index b7d1a45..3c4e0f9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -21,6 +21,7 @@ import "phoenix_html" import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" +import Sortable from "../vendor/sortable" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") @@ -120,6 +121,141 @@ Hooks.TabListKeydown = { } } +// SortableList hook: Accessible reorderable table/list. +// Mouse drag: SortableJS (smooth animation, ghost row, items push apart). +// Keyboard: Space = grab/drop, Arrow up/down = move, Escape = cancel, matching the Salesforce a11y pattern. +// Container must have data-reorder-event and data-list-id. +// Each row (tr) must have data-row-index; locked rows have data-locked="true". +// Pushes event with { from_index, to_index } (both integers) on reorder. +Hooks.SortableList = { + mounted() { + this.reorderEvent = this.el.dataset.reorderEvent + this.listId = this.el.dataset.listId + // Keyboard state: store grabbed row id so it survives LiveView re-renders + this.grabbedRowId = null + + this.announcementEl = this.listId ? document.getElementById(this.listId + "-announcement") : null + const announce = (msg) => { + if (!this.announcementEl) return + // Clear then re-set to force screen reader re-read + this.announcementEl.textContent = "" + setTimeout(() => { if (this.announcementEl) this.announcementEl.textContent = msg }, 50) + } + + const tbody = this.el.querySelector("tbody") + if (!tbody) return + + this.getRows = () => Array.from(tbody.querySelectorAll("tr")) + this.getRowIndex = (tr) => { + const idx = tr.getAttribute("data-row-index") + return idx != null ? parseInt(idx, 10) : -1 + } + this.isLocked = (tr) => tr.getAttribute("data-locked") === "true" + + // SortableJS for mouse drag-and-drop with animation + this.sortable = new Sortable(tbody, { + animation: 150, + handle: "[data-sortable-handle]", + // Disable sorting for locked rows (first row = email) + filter: "[data-locked='true']", + preventOnFilter: true, + // Ghost (placeholder showing where the item will land) + ghostClass: "sortable-ghost", + // The item being dragged + chosenClass: "sortable-chosen", + // Cursor while dragging + dragClass: "sortable-drag", + // Don't trigger on handle area clicks (only actual drag) + delay: 0, + onEnd: (e) => { + if (e.oldIndex === e.newIndex) return + this.pushEvent(this.reorderEvent, { from_index: e.oldIndex, to_index: e.newIndex }) + announce(`Dropped. Position ${e.newIndex + 1} of ${this.getRows().length}.`) + // LiveView will reconcile the DOM order after re-render + } + }) + + // Keyboard handler (Salesforce a11y pattern: Space=grab/drop, Arrows=move, Escape=cancel) + this.handleKeyDown = (e) => { + // Don't intercept Space on interactive elements (checkboxes, buttons, inputs) + const tag = e.target.tagName + if (tag === "INPUT" || tag === "BUTTON" || tag === "SELECT" || tag === "TEXTAREA") return + + const tr = e.target.closest("tr") + if (!tr || this.isLocked(tr)) return + const rows = this.getRows() + const idx = this.getRowIndex(tr) + if (idx < 0) return + const total = rows.length + + if (e.key === " ") { + e.preventDefault() + const rowId = tr.id + if (this.grabbedRowId === rowId) { + // Drop + this.grabbedRowId = null + tr.style.outline = "" + announce(`Dropped. Position ${idx + 1} of ${total}.`) + } else { + // Grab + this.grabbedRowId = rowId + tr.style.outline = "2px solid var(--color-primary)" + tr.style.outlineOffset = "-2px" + announce(`Grabbed. Position ${idx + 1} of ${total}. Use arrow keys to move, Space to drop, Escape to cancel.`) + } + return + } + + if (e.key === "Escape") { + if (this.grabbedRowId != null) { + e.preventDefault() + const grabbedTr = document.getElementById(this.grabbedRowId) + if (grabbedTr) { grabbedTr.style.outline = ""; grabbedTr.style.outlineOffset = "" } + this.grabbedRowId = null + announce("Reorder cancelled.") + } + return + } + + if (this.grabbedRowId == null) return + + if (e.key === "ArrowUp" && idx > 0) { + e.preventDefault() + this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx - 1 }) + announce(`Position ${idx} of ${total}.`) + } else if (e.key === "ArrowDown" && idx < total - 1) { + e.preventDefault() + this.pushEvent(this.reorderEvent, { from_index: idx, to_index: idx + 1 }) + announce(`Position ${idx + 2} of ${total}.`) + } + } + + this.el.addEventListener("keydown", this.handleKeyDown, true) + }, + + updated() { + // Re-apply keyboard outline and restore focus after LiveView re-render. + // LiveView DOM patching loses focus; without explicit re-focus the next keypress + // goes to document.body (Space scrolls the page instead of triggering our handler). + if (this.grabbedRowId) { + const tr = document.getElementById(this.grabbedRowId) + if (tr) { + tr.style.outline = "2px solid var(--color-primary)" + tr.style.outlineOffset = "-2px" + tr.focus() + } else { + // Row no longer exists (removed while grabbed), clear state + this.grabbedRowId = null + } + } + }, + + destroyed() { + if (this.sortable) this.sortable.destroy() + this.el.removeEventListener("keydown", this.handleKeyDown, true) + } +} + // SidebarState hook: Manages sidebar expanded/collapsed state Hooks.SidebarState = { mounted() { diff --git a/assets/vendor/sortable.js b/assets/vendor/sortable.js new file mode 100644 index 0000000..95423a6 --- /dev/null +++ b/assets/vendor/sortable.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY "join-field-\#{f.id}" end} + locked_ids={["join-field-email"]} + reorder_event="reorder_join_form_field" + > + <:col :let={field} label={gettext("Field")} class="min-w-[14rem]">{field.label} + <:col :let={field} label={gettext("Required")}>... + <:action :let={field}><.button>Remove + + """ + attr :id, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, required: true + attr :locked_ids, :list, default: [] + attr :reorder_event, :string, required: true + attr :row_item, :any, default: &Function.identity/1 + + slot :col, required: true do + attr :label, :string, required: true + attr :class, :string + end + + slot :action + + def sortable_table(assigns) do + assigns = assign(assigns, :locked_set, MapSet.new(assigns.locked_ids)) + + ~H""" +
+ + + + + + + + + + + + + + + + +
{col[:label]}{gettext("Actions")}
+ + + Enum.join(" ")} + > + {render_slot(col, @row_item.(row))} + + <%= for action <- @action do %> + {render_slot(action, @row_item.(row))} + <% end %> +
+
+ """ + end + @doc """ Renders a data list. diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 651afc0..3c75fa8 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -160,7 +160,10 @@ defmodule MvWeb.GlobalSettingsLive do type="button" variant="primary" phx-click="toggle_add_field_dropdown" - disabled={Enum.empty?(@available_join_form_member_fields) and Enum.empty?(@available_join_form_custom_fields)} + disabled={ + Enum.empty?(@available_join_form_member_fields) and + Enum.empty?(@available_join_form_custom_fields) + } aria-haspopup="listbox" aria-expanded={to_string(@show_add_field_dropdown)} > @@ -190,7 +193,15 @@ defmodule MvWeb.GlobalSettingsLive do {field.label} -
+
{gettext("Individual fields")}
@@ -213,12 +224,13 @@ defmodule MvWeb.GlobalSettingsLive do {gettext("No fields selected. Add at least the email field.")}

- <%!-- Fields table (compact width) --%> + <%!-- Fields table (compact width, reorderable) --%>
- <.table + <.sortable_table id="join-form-fields-table" rows={@join_form_fields} row_id={fn field -> "join-field-#{field.id}" end} + reorder_event="reorder_join_form_field" > <:col :let={field} label={gettext("Field")} class="min-w-[14rem]"> {field.label} @@ -250,7 +262,10 @@ defmodule MvWeb.GlobalSettingsLive do - + +

+ {gettext("The order of rows determines the field order in the join form.")} +

@@ -642,6 +657,7 @@ defmodule MvWeb.GlobalSettingsLive do custom_fields = socket.assigns.join_form_custom_fields new_fields = Enum.reject(current, &(&1.id == field_id)) new_ids = Enum.map(new_fields, & &1.id) + %{member_fields: new_member, custom_fields: new_custom} = build_available_join_form_fields(new_ids, custom_fields) @@ -670,6 +686,27 @@ defmodule MvWeb.GlobalSettingsLive do {:noreply, socket} end + @impl true + def handle_event( + "reorder_join_form_field", + %{"from_index" => from_idx, "to_index" => to_idx}, + socket + ) + when is_integer(from_idx) and is_integer(to_idx) do + fields = socket.assigns.join_form_fields + new_fields = reorder_list(fields, from_idx, to_idx) + + socket = + socket + |> assign(:join_form_fields, new_fields) + |> persist_join_form_settings() + + {:noreply, socket} + end + + # Ignore malformed reorder events (e.g. nil indices from aborted drags) + def handle_event("reorder_join_form_field", _params, socket), do: {:noreply, socket} + defp persist_join_form_settings(socket) do settings = socket.assigns.settings field_ids = Enum.map(socket.assigns.join_form_fields, & &1.id) @@ -990,6 +1027,7 @@ defmodule MvWeb.GlobalSettingsLive do required_config = settings.join_form_field_required || %{} join_form_fields = build_join_form_fields(field_ids, required_config, custom_fields) + %{member_fields: member_avail, custom_fields: custom_avail} = build_available_join_form_fields(field_ids, custom_fields) @@ -1054,6 +1092,12 @@ defmodule MvWeb.GlobalSettingsLive do defp toggle_required_if_matches(field, _field_id), do: field + defp reorder_list(list, from_index, to_index) do + item = Enum.at(list, from_index) + rest = List.delete_at(list, from_index) + List.insert_at(rest, to_index, item) + end + defp member_field_id_strings do Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 01c49f6..90bedc5 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3384,10 +3384,20 @@ msgstr "Bestätigung durch Vorstand erforderlich (in Entwicklung)" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format -msgid "Personal data" -msgstr "Persönliche Daten" +msgid "Individual fields" +msgstr "Individuelle Felder" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format -msgid "Individual fields" -msgstr "Individuelle Felder" +msgid "Personal data" +msgstr "Persönliche Daten" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reorder" +msgstr "Umordnen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "The order of rows determines the field order in the join form." +msgstr "Die Reihenfolge der Zeilen bestimmt die Reihenfolge der Felder im Beitrittsformular." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index b5ab449..70bf233 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3381,3 +3381,23 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Board approval required (in development)" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Individual fields" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Personal data" +msgstr "" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reorder" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "The order of rows determines the field order in the join form." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 5556d10..02160f9 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3384,10 +3384,20 @@ msgstr "Board approval required (in development)" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format -msgid "Personal data" -msgstr "Personal data" +msgid "Individual fields" +msgstr "Individual fields" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format -msgid "Individual fields" -msgstr "Individual fields" +msgid "Personal data" +msgstr "Personal data" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reorder" +msgstr "Reorder" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "The order of rows determines the field order in the join form." +msgstr "The order of rows determines the field order in the join form." diff --git a/test/membership/setting_join_form_test.exs b/test/membership/setting_join_form_test.exs index bcafe9f..26b5f33 100644 --- a/test/membership/setting_join_form_test.exs +++ b/test/membership/setting_join_form_test.exs @@ -13,9 +13,9 @@ defmodule Mv.Membership.SettingJoinFormTest do """ use Mv.DataCase, async: false - alias Mv.Membership - alias Mv.Helpers.SystemActor alias Mv.Constants + alias Mv.Helpers.SystemActor + alias Mv.Membership setup do {:ok, settings} = Membership.get_settings()