Coverage for src / lexigram / admin / ui / molecules / inline_edit_cell.py: 25%

32 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 02:25 +0800

1"""Inline table cell editing component. 

2 

3Renders a cell value that becomes an input field when clicked. 

4Saves automatically on blur or Enter, cancels on Escape. 

5Uses HTMX PATCH to update the record without a full page reload. 

6 

7Usage:: 

8 

9 InlineEditCell( 

10 value="Alice", 

11 resource_url="/admin/users/42", 

12 field_name="name", 

13 cell_type="text", 

14 ) 

15""" 

16 

17from __future__ import annotations 

18 

19from typing import Any 

20 

21from lexigram.ui import Component, el 

22 

23 

24class InlineEditCell(Component): 

25 """A table cell whose value can be edited in place. 

26 

27 On click the cell switches to an ``<input>`` (or ``<select>`` / ``<textarea>``). 

28 On blur / Enter it fires ``PATCH {resource_url}`` with ``{field_name}=<new_value>``. 

29 On Escape it discards the change and reverts. 

30 

31 Args: 

32 value: Current display value. 

33 resource_url: URL to PATCH, e.g. ``"/admin/users/42"``. 

34 field_name: Form field name to send in the PATCH body. 

35 cell_type: ``"text"``, ``"number"``, ``"select"``, or ``"textarea"``. 

36 options: For ``cell_type="select"`` — list of ``{"value": …, "label": …}`` dicts. 

37 placeholder: Placeholder text for the input. 

38 css_class: Additional Tailwind classes on the outer container. 

39 editable: When ``False`` renders a plain non-editable cell. 

40 """ 

41 

42 def __init__( 

43 self, 

44 value: str, 

45 resource_url: str, 

46 field_name: str, 

47 *, 

48 cell_type: str = "text", 

49 options: list[dict[str, str]] | None = None, 

50 placeholder: str = "", 

51 css_class: str = "", 

52 editable: bool = True, 

53 ) -> None: 

54 super().__init__() 

55 self.value = value 

56 self.resource_url = resource_url 

57 self.field_name = field_name 

58 self.cell_type = cell_type 

59 self.options = options or [] 

60 self.placeholder = placeholder 

61 self.css_class = css_class 

62 self.editable = editable 

63 

64 # ------------------------------------------------------------------ 

65 # Helpers 

66 # ------------------------------------------------------------------ 

67 

68 def _input_el(self) -> Any: 

69 """Return the el() element for the editable input.""" 

70 base_cls = "w-full px-2 py-1 text-sm rounded border border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 bg-muted dark:text-foreground" 

71 escape_js = "if(event.key==='Escape'){this.closest('[data-inline-cell]').querySelector('[data-display]').classList.remove('hidden');this.closest('[data-inline-cell]').querySelector('[data-edit]').classList.add('hidden');}" 

72 common: dict[str, Any] = { 

73 "name": self.field_name, 

74 "class": base_cls, 

75 "hx-patch": self.resource_url, 

76 "hx-target": "closest [data-inline-cell]", 

77 "hx-swap": "outerHTML", 

78 "onkeydown": escape_js, 

79 } 

80 

81 if self.cell_type == "select": 

82 opts = [ 

83 el( 

84 "option", 

85 o["label"], 

86 value=o["value"], 

87 **{"selected": "true"} if o["value"] == self.value else {}, 

88 ) 

89 for o in self.options 

90 ] 

91 return el("select", *opts, **{**common, "hx-trigger": "change"}) 

92 

93 if self.cell_type == "textarea": 

94 return el( 

95 "textarea", 

96 self.value, 

97 rows="2", 

98 placeholder=self.placeholder, 

99 **{**common, "hx-trigger": "blur"}, 

100 ) 

101 

102 input_type = "number" if self.cell_type == "number" else "text" 

103 return el( 

104 "input", 

105 type=input_type, 

106 value=self.value, 

107 placeholder=self.placeholder, 

108 **{**common, "hx-trigger": "blur, keyup[key=='Enter']"}, 

109 ) 

110 

111 # ------------------------------------------------------------------ 

112 # Render 

113 # ------------------------------------------------------------------ 

114 

115 def render(self) -> object: 

116 """Render the inline-edit cell wrapper.""" 

117 if not self.editable: 

118 return el( 

119 "span", 

120 self.value, 

121 class_=f"text-sm text-foreground {self.css_class}".strip(), 

122 ) 

123 

124 display_el = el( 

125 "span", 

126 self.value or "—", 

127 **{ 

128 "data-display": "true", 

129 "class": "cursor-pointer text-sm text-foreground hover:text-primary-600 dark:hover:text-primary-400 hover:underline", 

130 "onclick": "this.closest('[data-inline-cell]').querySelector('[data-display]').classList.add('hidden');this.closest('[data-inline-cell]').querySelector('[data-edit]').classList.remove('hidden');this.closest('[data-inline-cell]').querySelector('input,select,textarea').focus();", 

131 }, 

132 ) 

133 edit_el = el( 

134 "span", 

135 self._input_el(), 

136 **{ 

137 "data-edit": "true", 

138 "class": "hidden", 

139 }, 

140 ) 

141 return el( 

142 "span", 

143 display_el, 

144 edit_el, 

145 **{ 

146 "data-inline-cell": "true", 

147 "class": f"inline-flex items-center min-w-0 w-full {self.css_class}".strip(), 

148 }, 

149 ) 

150 

151 

152__all__ = ["InlineEditCell"]