Coverage for src / lexigram / admin / ui / organisms / sortable_list.py: 27%
37 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1"""Sortable / drag-n-drop record reorder component.
3Provides :class:`SortableRecordList` — a table that lets users drag rows
4into a new order and persists the new order via an HTMX PATCH request
5(FilamentPHP: Y, Django Admin: E).
7Usage::
9 rows = [
10 {"id": 1, "title": "First post"},
11 {"id": 2, "title": "Second post"},
12 ]
13 widget = SortableRecordList(
14 rows=rows,
15 id_field="id",
16 label_field="title",
17 reorder_url="/admin/posts/reorder",
18 )
19 html = widget.render()
21The client-side uses SortableJS (loaded via CDN) and an Alpine.js controller
22that fires an HTMX PATCH to *reorder_url* with a JSON body::
24 {"order": [2, 1]}
25"""
27from __future__ import annotations
29from typing import Any
31from lexigram.ui import Component, el
34class SortableRecordList(Component):
35 """Drag-n-drop sortable list for reordering records.
37 Args:
38 rows: Sequence of record dicts (or objects with ``__getitem__``).
39 id_field: Key used to identify each record (default ``"id"``).
40 label_field: Key used as the visible label (default ``"title"``).
41 reorder_url: URL for the HTMX PATCH request carrying the new order.
42 hx_target: HTMX swap target (default ``"this"``).
43 hx_swap: HTMX swap strategy (default ``"none"``).
44 handle_class: CSS class to add to the drag handle icon.
45 empty_label: Text shown when *rows* is empty.
46 """
48 def __init__(
49 self,
50 rows: list[Any],
51 id_field: str = "id",
52 label_field: str = "title",
53 reorder_url: str = "",
54 hx_target: str = "this",
55 hx_swap: str = "none",
56 handle_class: str = "",
57 empty_label: str = "No records to reorder.",
58 **props: Any,
59 ) -> None:
60 super().__init__(**props)
61 self.rows = rows
62 self.id_field = id_field
63 self.label_field = label_field
64 self.reorder_url = reorder_url
65 self.hx_target = hx_target
66 self.hx_swap = hx_swap
67 self.handle_class = handle_class
68 self.empty_label = empty_label
70 # ------------------------------------------------------------------
72 def render(self) -> Any:
73 if not self.rows:
74 return el(
75 "div",
76 self.empty_label,
77 class_=(
78 "flex items-center justify-center h-20 "
79 "text-sm text-muted-foreground dark:text-muted-foreground "
80 "border border-dashed border-border rounded-lg"
81 ),
82 )
84 row_els = [self._row_el(row, idx) for idx, row in enumerate(self.rows)]
85 list_el = el(
86 "ul",
87 *row_els,
88 id="sortable-list",
89 class_=(
90 "divide-y divide-border rounded-lg border border-border overflow-hidden"
91 ),
92 **{"x-ref": "sortableList"},
93 )
95 # Save button — fires HTMX PATCH with current order
96 save_btn = el(
97 "button",
98 "Save order",
99 type="button",
100 class_=(
101 "mt-3 inline-flex items-center px-4 py-2 text-sm font-medium rounded-md "
102 "bg-primary text-primary-foreground hover:bg-primary/90 "
103 "disabled:opacity-50 transition-colors"
104 ),
105 **{
106 "@click": "saveOrder()",
107 "hx-patch": self.reorder_url,
108 "hx-target": self.hx_target,
109 "hx-swap": self.hx_swap,
110 "hx-ext": "json-enc",
111 "x-bind:disabled": "!dirty",
112 ":class": "{'opacity-50 cursor-not-allowed': !dirty}",
113 },
114 )
116 # Alpine.js controller that initialises SortableJS and tracks changes
117 alpine_init = self._alpine_script()
119 return el(
120 "div",
121 alpine_init,
122 list_el,
123 save_btn,
124 **{
125 "x-data": "sortableRecords()",
126 "x-init": "init()",
127 "class": "space-y-2",
128 },
129 )
131 # ------------------------------------------------------------------
132 # Helpers
133 # ------------------------------------------------------------------
135 def _row_el(self, row: Any, idx: int) -> Any:
136 record_id = self._get(row, self.id_field, str(idx))
137 label = self._get(row, self.label_field, f"Record {idx + 1}")
138 handle_cls = f"cursor-grab active:cursor-grabbing text-muted-foreground mr-3 {self.handle_class}".strip()
139 handle = el("span", "⠿", class_=handle_cls, **{"aria-hidden": "true"})
140 label_el = el(
141 "span",
142 str(label),
143 class_="text-sm text-foreground",
144 )
145 return el(
146 "li",
147 handle,
148 label_el,
149 **{
150 "data-id": str(record_id),
151 "class": (
152 "flex items-center px-4 py-3 "
153 "bg-card "
154 "hover:bg-muted dark:hover:bg-muted "
155 "select-none"
156 ),
157 },
158 )
160 @staticmethod
161 def _get(row: Any, key: str, default: Any = "") -> Any:
162 if isinstance(row, dict):
163 return row.get(key, default)
164 return getattr(row, key, default)
166 def _alpine_script(self) -> Any:
167 """Render the inline Alpine.js + SortableJS setup script."""
168 js = (
169 "function sortableRecords() {"
170 " return {"
171 " dirty: false,"
172 " sortable: null,"
173 " init() {"
174 " if (typeof Sortable === 'undefined') return;"
175 " this.sortable = new Sortable(this.$refs.sortableList, {"
176 " animation: 150,"
177 " handle: 'span[aria-hidden]',"
178 " onEnd: () => { this.dirty = true; }"
179 " });"
180 " },"
181 " saveOrder() {"
182 " const items = this.$refs.sortableList.querySelectorAll('li');"
183 " const order = Array.from(items).map(el => el.dataset.id);"
184 " htmx.ajax('PATCH', '" + self.reorder_url + "', {"
185 " target: '" + self.hx_target + "',"
186 " swap: '" + self.hx_swap + "',"
187 " values: { order: order }"
188 " });"
189 " this.dirty = false;"
190 " }"
191 " };"
192 "}"
193 )
194 return el("script", js, type="text/javascript")