TodoComponent.vala 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. using Astralis;
  2. using Invercargill;
  3. using Invercargill.DataStructures;
  4. using Inversion;
  5. using Spry;
  6. /**
  7. * TodoComponent Example
  8. *
  9. * Demonstrates using the Spry.Component class with IoC composition.
  10. * Shows how to:
  11. * - Build a complete CRUD interface with Components
  12. * - Use <spry-component name="MyComponent"> for declarative child components
  13. * - Use get_component_child<T>(sid) to access child components
  14. * - Use <spry-outlet> for dynamic lists (multiple items from data)
  15. * - Use inject<T>() for dependency injection
  16. * - Use spry-action and spry-target for declarative HTMX interactions
  17. * - Use handle_action() for action handling
  18. *
  19. * Usage: todo-component [port]
  20. */
  21. /**
  22. * TodoItem - Simple task model for our todo list.
  23. */
  24. class TodoItem : Object {
  25. public int id { get; set; }
  26. public string title { get; set; }
  27. public bool completed { get; set; }
  28. public TodoItem(int id, string title, bool completed = false) {
  29. this.id = id;
  30. this.title = title;
  31. this.completed = completed;
  32. }
  33. }
  34. /**
  35. * TodoStore - In-memory todo store registered as singleton.
  36. */
  37. class TodoStore : Object {
  38. private Series<TodoItem> items = new Series<TodoItem>();
  39. private int next_id = 1;
  40. public TodoStore() {
  41. // Add some initial items
  42. add("Learn Spry.Component");
  43. add("Build dynamic HTML pages");
  44. add("Handle form submissions");
  45. }
  46. public void add(string title) {
  47. items.add(new TodoItem(next_id++, title));
  48. }
  49. public void toggle(int id) {
  50. items.to_immutable_buffer().iterate((item) => {
  51. if (item.id == id) {
  52. item.completed = !item.completed;
  53. }
  54. });
  55. }
  56. public void remove(int id) {
  57. var new_items = items.to_immutable_buffer()
  58. .where(item => item.id != id)
  59. .to_series();
  60. items = new_items;
  61. }
  62. public ImmutableBuffer<TodoItem> all() {
  63. return items.to_immutable_buffer();
  64. }
  65. public int count() {
  66. return (int)items.to_immutable_buffer().count();
  67. }
  68. public int completed_count() {
  69. return (int)items.to_immutable_buffer()
  70. .count(item => item.completed);
  71. }
  72. }
  73. /**
  74. * EmptyListComponent - Shown when no items exist.
  75. */
  76. class EmptyListComponent : Component {
  77. public override string markup { get {
  78. return """
  79. <div class="empty">
  80. No tasks yet! Add one below.
  81. </div>
  82. """;
  83. }}
  84. }
  85. /**
  86. * TodoItemComponent - A single todo item with HTMX actions.
  87. */
  88. class TodoItemComponent : Component {
  89. private TodoStore todo_store = inject<TodoStore>();
  90. private ComponentFactory factory = inject<ComponentFactory>();
  91. private HttpContext http_context = inject<HttpContext>();
  92. private HeaderComponent header = inject<HeaderComponent>();
  93. private int _item_id;
  94. public int item_id {
  95. set {
  96. _item_id = value;
  97. }
  98. get {
  99. return _item_id;
  100. }
  101. }
  102. public override string markup { get {
  103. return """
  104. <div class="todo-item" sid="item">
  105. <button type="button" class="btn-toggle" sid="toggle-btn" spry-action=":Toggle" spry-target="item" hx-swap="outerHTML"></button>
  106. <span class="title" sid="title"></span>
  107. <button type="button" class="btn-delete" sid="delete-btn" spry-action=":Delete" spry-target="item" hx-swap="delete">Delete</button>
  108. </div>
  109. """;
  110. }}
  111. // Called before serialization to prepare template with data from store
  112. public override async void prepare() throws Error {
  113. var item = todo_store.all().first_or_default(i => i.id == _item_id);
  114. if (item == null) return;
  115. this["title"].text_content = item.title;
  116. this["toggle-btn"].text_content = item.completed ? "Undo" : "Done";
  117. if (item.completed) {
  118. this["item"].add_class("completed");
  119. }
  120. // Set hx-vals on parent div - inherited by child buttons
  121. this["item"].set_attribute("hx-vals", @"{\"id\":$_item_id}");
  122. }
  123. public async override void handle_action(string action) throws Error {
  124. // Get the item id from query parameters (passed via hx-vals)
  125. var id_str = http_context.request.query_params.get_any_or_default("id");
  126. if (id_str == null) return;
  127. var id = int.parse(id_str);
  128. _item_id = id; // Set for prepare()
  129. switch (action) {
  130. case "Toggle":
  131. todo_store.toggle(id);
  132. // prepare() will be called automatically before serialization
  133. // Add header globals for OOB swap (header's prepare() fetches stats)
  134. add_globals_from(header);
  135. break;
  136. case "Delete":
  137. todo_store.remove(id);
  138. // With hx-swap="delete", we still need to return the header update
  139. add_globals_from(header);
  140. return;
  141. }
  142. }
  143. }
  144. /**
  145. * TodoListComponent - Container for todo items.
  146. */
  147. class TodoListComponent : Component {
  148. private TodoStore todo_store = inject<TodoStore>();
  149. private ComponentFactory factory = inject<ComponentFactory>();
  150. private HttpContext http_context = inject<HttpContext>();
  151. private HeaderComponent header = inject<HeaderComponent>();
  152. public void set_items(Enumerable<Renderable> items) {
  153. set_outlet_children("items", items);
  154. }
  155. public override string markup { get {
  156. return """
  157. <div class="card" id="todo-list" sid="todo-list">
  158. <h2>Todo List</h2>
  159. <spry-outlet sid="items"/>
  160. </div>
  161. """;
  162. }}
  163. public async override void handle_action(string action) throws Error {
  164. if (action == "Add") {
  165. var title = http_context.request.query_params.get_any_or_default("title");
  166. if (title != null && title.strip() != "") {
  167. todo_store.add(title.strip());
  168. }
  169. }
  170. // Rebuild the list - only need to set item_id, prepare() handles the rest
  171. var count = todo_store.count();
  172. if (count == 0) {
  173. set_outlet_children("items", Iterate.single<Renderable>(factory.create<EmptyListComponent>()));
  174. } else {
  175. var items = new Series<Renderable>();
  176. todo_store.all().iterate((item) => {
  177. var component = factory.create<TodoItemComponent>();
  178. component.item_id = item.id;
  179. items.add(component);
  180. });
  181. set_outlet_children("items", items);
  182. }
  183. // Add header globals for OOB swap (header's prepare() fetches stats)
  184. add_globals_from(header);
  185. }
  186. }
  187. /**
  188. * HeaderComponent - Page header with stats.
  189. * Uses prepare() to fetch data from store automatically.
  190. */
  191. class HeaderComponent : Component {
  192. private TodoStore todo_store = inject<TodoStore>();
  193. public override string markup { get {
  194. return """
  195. <div class="card" id="header" spry-global="header">
  196. <h1>Todo Component Example</h1>
  197. <p>This page demonstrates <code>Spry.Component</code> with IoC composition.</p>
  198. <div class="stats">
  199. <div class="stat">
  200. <div class="stat-value" sid="total"></div>
  201. <div class="stat-label">Total Tasks</div>
  202. </div>
  203. <div class="stat">
  204. <div class="stat-value" sid="completed"></div>
  205. <div class="stat-label">Completed</div>
  206. </div>
  207. </div>
  208. </div>
  209. """;
  210. }}
  211. // Called before serialization to populate stats from store
  212. public override async void prepare() throws Error {
  213. this["total"].text_content = todo_store.count().to_string();
  214. this["completed"].text_content = todo_store.completed_count().to_string();
  215. }
  216. }
  217. /**
  218. * AddFormComponent - Form to add new todos with HTMX.
  219. */
  220. class AddFormComponent : Component {
  221. private TodoStore todo_store = inject<TodoStore>();
  222. private ComponentFactory factory = inject<ComponentFactory>();
  223. private HttpContext http_context = inject<HttpContext>();
  224. public override string markup { get {
  225. return """
  226. <div class="card">
  227. <h2>Add New Task</h2>
  228. <form sid="form" spry-action="TodoListComponent:Add" hx-target="#todo-list" hx-swap="outerHTML" style="display: flex; gap: 10px;">
  229. <input type="text" name="title" placeholder="Enter a new task..." required="required" sid="title-input"/>
  230. <button type="submit" class="btn-primary">Add Task</button>
  231. </form>
  232. </div>
  233. """;
  234. }}
  235. }
  236. /**
  237. * FeaturesComponent - Shows Component features used.
  238. */
  239. class FeaturesComponent : Component {
  240. public override string markup { get {
  241. return """
  242. <div class="card">
  243. <h2>Component Features Used</h2>
  244. <div class="feature">
  245. <strong>Defining Components:</strong>
  246. <code>class MyComponent : Component { public override string markup { get { return "..."; } } }</code>
  247. </div>
  248. <div class="feature">
  249. <strong>Declarative Child Components:</strong>
  250. <code>&lt;spry-component name="HeaderComponent" sid="header"/&gt;</code>
  251. </div>
  252. <div class="feature">
  253. <strong>Accessing Child Components:</strong>
  254. <code>var header = get_component_child&lt;HeaderComponent&gt;("header");</code>
  255. </div>
  256. <div class="feature">
  257. <strong>Using Outlets (for lists):</strong>
  258. <code>&lt;spry-outlet sid="items"/&gt;</code>
  259. </div>
  260. <div class="feature">
  261. <strong>Preparing Templates:</strong>
  262. <code>public override void prepare() { this["title"].text_content = "..."; }</code>
  263. </div>
  264. <div class="feature">
  265. <strong>Declarative HTMX Actions:</strong>
  266. <code>&lt;button spry-action=":Toggle" hx-vals='{"id":1}'&gt;Toggle&lt;/button&gt;</code>
  267. </div>
  268. <div class="feature">
  269. <strong>IoC Injection:</strong>
  270. <code>private ComponentFactory factory = inject&lt;ComponentFactory&gt;();</code>
  271. </div>
  272. </div>
  273. """;
  274. }}
  275. }
  276. /**
  277. * FooterComponent - Page footer.
  278. */
  279. class FooterComponent : Component {
  280. public override string markup { get {
  281. return """
  282. <div class="card" style="text-align: center; color: #666;">
  283. <p>Built with <code>Spry.Component</code> |
  284. <a href="/api/todos">View JSON API</a>
  285. </p>
  286. </div>
  287. """;
  288. }}
  289. }
  290. /**
  291. * TodoPage - The main page structure.
  292. *
  293. * Inherits from PageComponent to act as both a Component and Endpoint.
  294. * Uses <spry-component name="..."> syntax for declarative child components.
  295. * Child components are accessed via get_component_child<T>(sid) in prepare().
  296. */
  297. class TodoPage : PageComponent {
  298. private TodoStore todo_store = inject<TodoStore>();
  299. private ComponentFactory factory = inject<ComponentFactory>();
  300. public override string markup { get {
  301. return """
  302. <!DOCTYPE html>
  303. <html>
  304. <head>
  305. <meta charset="UTF-8"/>
  306. <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  307. <title>Todo Component Example</title>
  308. <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
  309. <style>
  310. body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
  311. .card { background: white; border-radius: 8px; padding: 20px; margin: 10px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
  312. h1 { color: #333; margin-top: 0; }
  313. h2 { color: #555; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; }
  314. .todo-item { display: flex; align-items: center; padding: 10px; margin: 5px 0; background: #fafafa; border-radius: 4px; border-left: 4px solid #4CAF50; }
  315. .todo-item.completed { border-left-color: #ccc; opacity: 0.7; }
  316. .todo-item.completed .title { text-decoration: line-through; color: #888; }
  317. .todo-item .title { flex: 1; margin: 0 10px; }
  318. .stats { display: flex; gap: 20px; margin: 10px 0; }
  319. .stat { background: #e8f5e9; padding: 10px 20px; border-radius: 4px; }
  320. .stat-value { font-size: 24px; font-weight: bold; color: #4CAF50; }
  321. .stat-label { font-size: 12px; color: #666; }
  322. input[type="text"] { padding: 10px; border: 1px solid #ddd; border-radius: 4px; flex: 1; }
  323. button { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; }
  324. .btn-primary { background: #4CAF50; color: white; }
  325. .btn-primary:hover { background: #45a049; }
  326. .btn-toggle { background: #2196F3; color: white; padding: 5px 10px; font-size: 12px; }
  327. .btn-delete { background: #f44336; color: white; padding: 5px 10px; font-size: 12px; }
  328. code { background: #e8e8e8; padding: 2px 6px; border-radius: 4px; font-size: 14px; }
  329. pre { background: #263238; color: #aed581; padding: 15px; border-radius: 4px; overflow-x: auto; }
  330. .feature { margin: 15px 0; }
  331. .feature code { display: block; background: #f5f5f5; padding: 10px; margin: 5px 0; }
  332. a { color: #2196F3; text-decoration: none; }
  333. a:hover { text-decoration: underline; }
  334. .empty { color: #999; font-style: italic; padding: 20px; text-align: center; }
  335. </style>
  336. </head>
  337. <body>
  338. <spry-component name="HeaderComponent" sid="header"/>
  339. <spry-component name="TodoListComponent" sid="todo-list"/>
  340. <spry-component name="AddFormComponent" sid="add-form"/>
  341. <spry-component name="FeaturesComponent" sid="features"/>
  342. <spry-component name="FooterComponent" sid="footer"/>
  343. </body>
  344. </html>
  345. """;
  346. }}
  347. // Called before serialization to populate the todo list
  348. public override async void prepare() throws Error {
  349. // Get the TodoListComponent child and populate it
  350. var todo_list = get_component_child<TodoListComponent>("todo-list");
  351. var count = todo_store.count();
  352. if (count == 0) {
  353. todo_list.set_items(Iterate.single<Renderable>(factory.create<EmptyListComponent>()));
  354. } else {
  355. var items = new Series<Renderable>();
  356. todo_store.all().iterate((item) => {
  357. var component = factory.create<TodoItemComponent>();
  358. component.item_id = item.id;
  359. items.add(component);
  360. });
  361. todo_list.set_items(items);
  362. }
  363. }
  364. }
  365. // API endpoint that returns JSON representation of todos
  366. class TodoJsonEndpoint : Object, Endpoint {
  367. private TodoStore todo_store = inject<TodoStore>();
  368. public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
  369. var json_parts = new Series<string>();
  370. json_parts.add("{\"todos\": [");
  371. bool first = true;
  372. todo_store.all().iterate((item) => {
  373. if (!first) json_parts.add(",");
  374. var completed_str = item.completed ? "true" : "false";
  375. var escaped_title = item.title.replace("\"", "\\\"");
  376. json_parts.add(@"{\"id\":$(item.id),\"title\":\"$escaped_title\",\"completed\":$completed_str}");
  377. first = false;
  378. });
  379. json_parts.add("]}");
  380. var json = json_parts.to_immutable_buffer()
  381. .aggregate<string>("", (acc, s) => acc + s);
  382. return new HttpStringResult(json)
  383. .set_header("Content-Type", "application/json");
  384. }
  385. }
  386. void main(string[] args) {
  387. int port = args.length > 1 ? int.parse(args[1]) : 8080;
  388. print("═══════════════════════════════════════════════════════════════\n");
  389. print(" Spry TodoComponent Example (IoC)\n");
  390. print("═══════════════════════════════════════════════════════════════\n");
  391. print(" Port: %d\n", port);
  392. print("═══════════════════════════════════════════════════════════════\n");
  393. print(" Endpoints:\n");
  394. print(" / - Todo list (Component-based)\n");
  395. print(" /api/todos - JSON API for todos\n");
  396. print("═══════════════════════════════════════════════════════════════\n");
  397. print(" HTMX Actions (via SpryModule):\n");
  398. print(" Add, Toggle, Delete - Dynamic updates\n");
  399. print("═══════════════════════════════════════════════════════════════\n");
  400. print("\nPress Ctrl+C to stop the server\n\n");
  401. try {
  402. var application = new WebApplication(port);
  403. // Register compression components (optional, for better performance)
  404. application.use_compression();
  405. // Add Spry module for automatic HTMX endpoint registration
  406. application.add_module<SpryModule>();
  407. // Register the todo store as singleton
  408. application.add_singleton<TodoStore>();
  409. // Configure Spry components and pages
  410. var spry_cfg = application.configure_with<SpryConfigurator>();
  411. // Register child components
  412. spry_cfg.add_component<EmptyListComponent>();
  413. spry_cfg.add_component<TodoItemComponent>();
  414. spry_cfg.add_component<TodoListComponent>();
  415. spry_cfg.add_component<HeaderComponent>();
  416. spry_cfg.add_component<AddFormComponent>();
  417. spry_cfg.add_component<FeaturesComponent>();
  418. spry_cfg.add_component<FooterComponent>();
  419. // Register the page (acts as both Component and Endpoint)
  420. spry_cfg.add_page<TodoPage>(new EndpointRoute("/"));
  421. // Register JSON API endpoint
  422. application.add_endpoint<TodoJsonEndpoint>(new EndpointRoute("/api/todos"));
  423. application.run();
  424. } catch (Error e) {
  425. printerr("Error: %s\n", e.message);
  426. Process.exit(1);
  427. }
  428. }