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

1"""Sortable / drag-n-drop record reorder component. 

2 

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). 

6 

7Usage:: 

8 

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() 

20 

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:: 

23 

24 {"order": [2, 1]} 

25""" 

26 

27from __future__ import annotations 

28 

29from typing import Any 

30 

31from lexigram.ui import Component, el 

32 

33 

34class SortableRecordList(Component): 

35 """Drag-n-drop sortable list for reordering records. 

36 

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 """ 

47 

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 

69 

70 # ------------------------------------------------------------------ 

71 

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 ) 

83 

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 ) 

94 

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 ) 

115 

116 # Alpine.js controller that initialises SortableJS and tracks changes 

117 alpine_init = self._alpine_script() 

118 

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 ) 

130 

131 # ------------------------------------------------------------------ 

132 # Helpers 

133 # ------------------------------------------------------------------ 

134 

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 ) 

159 

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) 

165 

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")