TodoComponent.vala 19 KB

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