tina4ruby 3.10.2 → 3.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b49886a65ba68f1102f4c03b3054c6f0efd4cc576a1083e627981a8832986902
4
- data.tar.gz: b5a5cf59633ba153be413a974bd32d6128d6e56e6f901e4a16afce9380f30e94
3
+ metadata.gz: deb48b3053a6cde8d4d7fef6645b5e4a25eaff05c1d11b8b255c9c5ff42e7759
4
+ data.tar.gz: 5c7a65aa85b1a3d39676cf90b404cae1fbb3731c4e5c1fbbbbbae02ed97b468f
5
5
  SHA512:
6
- metadata.gz: 85339a87b7bd1c5769cde431f56111d787d80091ba96872aba477b160084bdd18c416a469f1cd05677f0caec667a897e4908503914fc969fe536eb1f26e9a095
7
- data.tar.gz: ae31dff4f46df762ca97ba74e26077b75885809e094848cbaf37cdc65fc9035e1fdf0543c7220d49707cf4d11f883ef38f60644ed1edf6d7d0efa8d54036aa50
6
+ metadata.gz: a1d8a2d31f0b1be83d4974190a6e7bf55aad671f7b8945444fc39335f14ddb537b98c887f975fba1f1d3f020f461703936000f33855845b3058de00c5147a377
7
+ data.tar.gz: 5b1c9e819c1b771c3d6308e23b9fbd1246906d0f96fce21b2a54ea38c27e5bb4af3a0032313101529960498b70f8be6283137d50ee5f172326f9070d681a1ee2
data/lib/tina4/crud.rb CHANGED
@@ -1,8 +1,95 @@
1
1
  # frozen_string_literal: true
2
+ require "json"
2
3
 
3
4
  module Tina4
5
+ # Crud — Auto-generate a complete HTML CRUD interface from a SQL query or ORM model.
6
+ #
7
+ # Usage:
8
+ # Tina4.get "/admin/users" do |request, response|
9
+ # response.html(Tina4::Crud.to_crud(request, {
10
+ # sql: "SELECT id, name, email FROM users",
11
+ # title: "User Management",
12
+ # primary_key: "id"
13
+ # }))
14
+ # end
15
+ #
16
+ # Or with an ORM model class:
17
+ # Tina4.get "/admin/users" do |request, response|
18
+ # response.html(Tina4::Crud.to_crud(request, {
19
+ # model: User,
20
+ # title: "User Management"
21
+ # }))
22
+ # end
4
23
  module Crud
5
24
  class << self
25
+ # Generate a complete CRUD HTML interface: searchable/paginated table
26
+ # with create, edit, and delete modals. Also registers the supporting
27
+ # REST API routes on first call per table.
28
+ #
29
+ # @param request [Tina4::Request] the current request
30
+ # @param options [Hash] configuration options
31
+ # @option options [String] :sql SQL query for listing records
32
+ # @option options [Class] :model ORM model class (alternative to :sql)
33
+ # @option options [String] :title page title (default: table name)
34
+ # @option options [String] :primary_key primary key column (default: "id")
35
+ # @option options [String] :prefix API route prefix (default: "/api")
36
+ # @option options [Integer] :limit records per page (default: 10)
37
+ # @return [String] complete HTML page
38
+ def to_crud(request, options = {})
39
+ sql = options[:sql]
40
+ model = options[:model]
41
+ title = options[:title] || "CRUD"
42
+ pk = options[:primary_key] || "id"
43
+ prefix = options[:prefix] || "/api"
44
+ limit = (options[:limit] || 10).to_i
45
+
46
+ # Determine table name and columns from SQL or model
47
+ if model
48
+ table_name = model.table_name.to_s
49
+ pk = (model.primary_key_field || :id).to_s
50
+ columns = model.field_definitions.keys.map(&:to_s)
51
+ elsif sql
52
+ table_name = extract_table_name(sql)
53
+ columns = extract_columns(sql)
54
+ else
55
+ raise ArgumentError, "Crud.to_crud requires either :sql or :model option"
56
+ end
57
+
58
+ # Parse request params for pagination, search, and sorting
59
+ query_params = request.respond_to?(:query) ? request.query : {}
60
+ page = [(query_params["page"] || 1).to_i, 1].max
61
+ search = query_params["search"].to_s.strip
62
+ sort_col = query_params["sort"] || pk
63
+ sort_dir = query_params["sort_dir"] == "desc" ? "desc" : "asc"
64
+ offset = (page - 1) * limit
65
+
66
+ # Build the data query
67
+ if model
68
+ records, total = fetch_model_data(model, search: search, sort: sort_col,
69
+ sort_dir: sort_dir, limit: limit, offset: offset)
70
+ else
71
+ records, total = fetch_sql_data(sql, search: search, sort: sort_col,
72
+ sort_dir: sort_dir, limit: limit, offset: offset)
73
+ end
74
+
75
+ total_pages = total > 0 ? (total.to_f / limit).ceil : 1
76
+ api_path = "#{prefix}/#{table_name}"
77
+
78
+ # Register supporting CRUD API routes (idempotent)
79
+ register_crud_routes(model, table_name, pk, prefix) unless crud_routes_registered?(table_name, prefix)
80
+
81
+ # Build the HTML
82
+ build_crud_html(
83
+ title: title, table_name: table_name, pk: pk,
84
+ columns: columns, records: records,
85
+ page: page, total_pages: total_pages, total: total,
86
+ limit: limit, search: search, sort_col: sort_col,
87
+ sort_dir: sort_dir, api_path: api_path,
88
+ request_path: request.path
89
+ )
90
+ end
91
+
92
+ # Generate an HTML table from an array of record hashes.
6
93
  def generate_table(records, table_name: "data", primary_key: "id", editable: true)
7
94
  return "<p>No records found.</p>" if records.nil? || records.empty?
8
95
 
@@ -43,12 +130,13 @@ module Tina4
43
130
  html += "</tbody></table></div>"
44
131
 
45
132
  if editable
46
- html += crud_javascript(table_name)
133
+ html += inline_crud_javascript(table_name)
47
134
  end
48
135
 
49
136
  html
50
137
  end
51
138
 
139
+ # Generate an HTML form from a field definition array.
52
140
  def generate_form(fields, action: "/", method: "POST", table_name: "data")
53
141
  html = "<form action=\"#{action}\" method=\"#{method}\" class=\"needs-validation\" novalidate>"
54
142
  html += "<input type=\"hidden\" name=\"_method\" value=\"#{method}\">" if %w[PUT PATCH DELETE].include?(method.upcase)
@@ -95,7 +183,484 @@ module Tina4
95
183
 
96
184
  private
97
185
 
98
- def crud_javascript(table_name)
186
+ # Track which tables have had CRUD routes registered
187
+ def registered_tables
188
+ @registered_tables ||= {}
189
+ end
190
+
191
+ def crud_routes_registered?(table_name, prefix)
192
+ registered_tables["#{prefix}/#{table_name}"]
193
+ end
194
+
195
+ # Register REST API routes for the CRUD interface when using :sql mode.
196
+ # When using :model mode, the caller should use AutoCrud.register instead
197
+ # for full ORM-backed routes. These routes provide basic SQL-backed CRUD.
198
+ def register_crud_routes(model, table_name, pk, prefix)
199
+ api_path = "#{prefix}/#{table_name}"
200
+ registered_tables[api_path] = true
201
+
202
+ # If we have a model, use AutoCrud for full ORM-backed routes
203
+ if model
204
+ Tina4::AutoCrud.register(model)
205
+ Tina4::AutoCrud.generate_routes(prefix: prefix)
206
+ return
207
+ end
208
+
209
+ db = Tina4.database
210
+ return unless db
211
+
212
+ # GET list (already handled by the page itself)
213
+ # POST create
214
+ Tina4::Router.add_route("POST", api_path, proc { |req, res|
215
+ begin
216
+ data = req.body_parsed
217
+ result = db.insert(table_name, data)
218
+ res.json({ data: data, message: "Created" }, status: 201)
219
+ rescue => e
220
+ res.json({ error: e.message }, status: 500)
221
+ end
222
+ })
223
+
224
+ # PUT update
225
+ Tina4::Router.add_route("PUT", "#{api_path}/{id}", proc { |req, res|
226
+ begin
227
+ id = req.params["id"]
228
+ data = req.body_parsed
229
+ db.update(table_name, data, { pk => id })
230
+ res.json({ data: data, message: "Updated" })
231
+ rescue => e
232
+ res.json({ error: e.message }, status: 500)
233
+ end
234
+ })
235
+
236
+ # DELETE
237
+ Tina4::Router.add_route("DELETE", "#{api_path}/{id}", proc { |req, res|
238
+ begin
239
+ id = req.params["id"]
240
+ db.delete(table_name, { pk => id })
241
+ res.json({ message: "Deleted" })
242
+ rescue => e
243
+ res.json({ error: e.message }, status: 500)
244
+ end
245
+ })
246
+ end
247
+
248
+ # Fetch data using an ORM model class
249
+ def fetch_model_data(model, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
250
+ order_by = "#{sort} #{sort_dir.upcase}"
251
+
252
+ if search.empty?
253
+ records = model.all(limit: limit, offset: offset, order_by: order_by)
254
+ total = model.count
255
+ else
256
+ # Build search across all string/text fields
257
+ searchable = model.field_definitions.select { |_, opts|
258
+ [:string, :text].include?(opts[:type])
259
+ }.keys
260
+ if searchable.empty?
261
+ records = model.all(limit: limit, offset: offset, order_by: order_by)
262
+ total = model.count
263
+ else
264
+ where_parts = searchable.map { |col| "#{col} LIKE ?" }
265
+ where_clause = where_parts.join(" OR ")
266
+ params = searchable.map { "%#{search}%" }
267
+ records = model.where(where_clause, params)
268
+ total = records.length
269
+ records = records.slice(offset, limit) || []
270
+ end
271
+ end
272
+
273
+ record_hashes = records.map { |r| r.to_h }
274
+ [record_hashes, total]
275
+ end
276
+
277
+ # Fetch data using a raw SQL query
278
+ def fetch_sql_data(sql, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
279
+ db = Tina4.database
280
+ return [[], 0] unless db
281
+
282
+ # Wrap the original SQL for sorting
283
+ query = sql.gsub(/ORDER BY .+$/i, "").gsub(/LIMIT .+$/i, "").strip
284
+ query += " ORDER BY #{sort} #{sort_dir.upcase}"
285
+
286
+ if !search.empty?
287
+ # Wrap in a subquery to add search filtering
288
+ wrapped = "SELECT * FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_sub WHERE "
289
+ columns = extract_columns(sql)
290
+ search_parts = columns.map { |col| "CAST(#{col} AS TEXT) LIKE ?" }
291
+ wrapped += search_parts.join(" OR ")
292
+ wrapped += " ORDER BY #{sort} #{sort_dir.upcase}"
293
+ params = columns.map { "%#{search}%" }
294
+
295
+ # Get total count
296
+ count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt WHERE #{search_parts.join(' OR ')}"
297
+ count_result = db.fetch_one(count_sql, params)
298
+ total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
299
+
300
+ results = db.fetch(wrapped, params, limit: limit, offset: offset)
301
+ else
302
+ # Get total count
303
+ count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt"
304
+ count_result = db.fetch_one(count_sql)
305
+ total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
306
+
307
+ results = db.fetch(query, [], limit: limit, offset: offset)
308
+ end
309
+
310
+ records = results.respond_to?(:records) ? results.records : results.to_a
311
+ [records, total]
312
+ end
313
+
314
+ # Extract table name from a SQL SELECT statement
315
+ def extract_table_name(sql)
316
+ match = sql.match(/FROM\s+(\w+)/i)
317
+ match ? match[1] : "data"
318
+ end
319
+
320
+ # Extract column names from a SQL SELECT statement
321
+ def extract_columns(sql)
322
+ match = sql.match(/SELECT\s+(.+?)\s+FROM/im)
323
+ return ["*"] unless match
324
+
325
+ cols_str = match[1].strip
326
+ return ["*"] if cols_str == "*"
327
+
328
+ cols_str.split(",").map { |c|
329
+ c = c.strip
330
+ # Handle "table.column AS alias" or "column AS alias"
331
+ if c =~ /\bAS\s+(\w+)/i
332
+ $1
333
+ elsif c.include?(".")
334
+ c.split(".").last.strip
335
+ else
336
+ c.strip
337
+ end
338
+ }
339
+ end
340
+
341
+ # Escape HTML special characters
342
+ def h(text)
343
+ text.to_s
344
+ .gsub("&", "&amp;")
345
+ .gsub("<", "&lt;")
346
+ .gsub(">", "&gt;")
347
+ .gsub('"', "&quot;")
348
+ .gsub("'", "&#39;")
349
+ end
350
+
351
+ # Pretty label from a column name: "user_name" => "User Name"
352
+ def pretty_label(col)
353
+ col.to_s.split("_").map(&:capitalize).join(" ")
354
+ end
355
+
356
+ # Determine input type from column name or ORM field type
357
+ def input_type_for(col, model = nil)
358
+ if model && model.respond_to?(:field_definitions)
359
+ opts = model.field_definitions[col.to_sym]
360
+ if opts
361
+ case opts[:type]
362
+ when :integer then return "number"
363
+ when :float, :decimal then return "number"
364
+ when :boolean then return "checkbox"
365
+ when :date then return "date"
366
+ when :datetime, :timestamp then return "datetime-local"
367
+ when :text then return "textarea"
368
+ end
369
+ end
370
+ end
371
+ # Guess from column name
372
+ return "email" if col.to_s.include?("email")
373
+ return "date" if col.to_s.end_with?("_at", "_date")
374
+ return "number" if col.to_s.end_with?("_id") && col.to_s != "id"
375
+ "text"
376
+ end
377
+
378
+ # Build the complete CRUD HTML page
379
+ def build_crud_html(title:, table_name:, pk:, columns:, records:,
380
+ page:, total_pages:, total:, limit:, search:,
381
+ sort_col:, sort_dir:, api_path:, request_path:)
382
+ # Filter out auto-increment PK from editable columns
383
+ editable_columns = columns.reject { |c| c.to_s == pk.to_s }
384
+
385
+ html = <<~HTML
386
+ <!DOCTYPE html>
387
+ <html lang="en">
388
+ <head>
389
+ <meta charset="UTF-8">
390
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
391
+ <title>#{h(title)}</title>
392
+ <link rel="stylesheet" href="/css/tina4.min.css">
393
+ <style>
394
+ .crud-container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
395
+ .crud-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
396
+ .crud-search { max-width: 300px; }
397
+ .crud-actions { display: flex; gap: 0.5rem; align-items: center; }
398
+ .crud-info { color: var(--text-muted, #6c757d); font-size: 0.875rem; margin-bottom: 0.5rem; }
399
+ .crud-pagination { display: flex; justify-content: center; gap: 0.25rem; margin-top: 1rem; }
400
+ .sort-link { text-decoration: none; color: inherit; cursor: pointer; }
401
+ .sort-link:hover { text-decoration: underline; }
402
+ .sort-indicator { font-size: 0.75rem; }
403
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
404
+ background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
405
+ .modal-overlay.active { display: flex; }
406
+ .modal-box { background: var(--bg, #fff); border-radius: 0.5rem; padding: 1.5rem;
407
+ width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto;
408
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
409
+ .modal-box h3 { margin-top: 0; }
410
+ .modal-footer { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
411
+ .alert { padding: 0.75rem 1rem; border-radius: 0.25rem; margin-bottom: 1rem; display: none; }
412
+ .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
413
+ .alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
414
+ </style>
415
+ </head>
416
+ <body>
417
+ <div class="crud-container">
418
+ <div id="crud-alert" class="alert"></div>
419
+ <div class="crud-header">
420
+ <h2>#{h(title)}</h2>
421
+ <div class="crud-actions">
422
+ <form method="GET" action="#{h(request_path)}" style="display:flex;gap:0.5rem;">
423
+ <input type="text" name="search" value="#{h(search)}" placeholder="Search..."
424
+ class="form-control crud-search">
425
+ <button type="submit" class="btn btn-secondary">Search</button>
426
+ </form>
427
+ <button class="btn btn-primary" onclick="crudShowCreate()">+ New</button>
428
+ </div>
429
+ </div>
430
+ <div class="crud-info">
431
+ Showing #{records.length} of #{total} records (page #{page} of #{total_pages})
432
+ </div>
433
+ <div class="table-responsive">
434
+ <table class="table table-striped table-hover">
435
+ <thead class="table-dark"><tr>
436
+ HTML
437
+
438
+ # Table headers with sort links
439
+ columns.each do |col|
440
+ next_dir = (sort_col == col.to_s && sort_dir == "asc") ? "desc" : "asc"
441
+ indicator = ""
442
+ if sort_col == col.to_s
443
+ indicator = sort_dir == "asc" ? " <span class=\"sort-indicator\">&#9650;</span>" : " <span class=\"sort-indicator\">&#9660;</span>"
444
+ end
445
+ sort_params = "sort=#{h(col)}&sort_dir=#{next_dir}&page=#{page}&search=#{URI.encode_www_form_component(search)}&limit=#{limit}"
446
+ html += "<th><a class=\"sort-link\" href=\"#{h(request_path)}?#{sort_params}\">#{pretty_label(col)}#{indicator}</a></th>"
447
+ end
448
+ html += "<th>Actions</th></tr></thead><tbody>"
449
+
450
+ # Table body
451
+ if records.empty?
452
+ html += "<tr><td colspan=\"#{columns.length + 1}\" style=\"text-align:center;padding:2rem;\">No records found.</td></tr>"
453
+ else
454
+ records.each do |row|
455
+ pk_value = row[pk.to_sym] || row[pk.to_s] || row[pk]
456
+ html += "<tr>"
457
+ columns.each do |col|
458
+ value = row[col.to_sym] || row[col.to_s] || row[col]
459
+ html += "<td>#{h(value)}</td>"
460
+ end
461
+ html += "<td>"
462
+ html += "<button class=\"btn btn-sm btn-primary me-1\" onclick=\"crudShowEdit(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Edit</button>"
463
+ html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudShowDelete(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Delete</button>"
464
+ html += "</td></tr>"
465
+ end
466
+ end
467
+ html += "</tbody></table></div>"
468
+
469
+ # Pagination
470
+ if total_pages > 1
471
+ html += "<div class=\"crud-pagination\">"
472
+ if page > 1
473
+ html += "<a class=\"btn btn-sm btn-secondary\" href=\"#{h(request_path)}?page=#{page - 1}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">Prev</a>"
474
+ end
475
+ # Show page numbers (max 7)
476
+ start_page = [page - 3, 1].max
477
+ end_page = [start_page + 6, total_pages].min
478
+ start_page = [end_page - 6, 1].max
479
+ (start_page..end_page).each do |p|
480
+ active = p == page ? " btn-primary" : " btn-secondary"
481
+ html += "<a class=\"btn btn-sm#{active}\" href=\"#{h(request_path)}?page=#{p}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">#{p}</a>"
482
+ end
483
+ if page < total_pages
484
+ html += "<a class=\"btn btn-sm btn-secondary\" href=\"#{h(request_path)}?page=#{page + 1}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">Next</a>"
485
+ end
486
+ html += "</div>"
487
+ end
488
+
489
+ # Create modal
490
+ html += build_modal("create", "Create New Record", editable_columns, pk, api_path, request_path)
491
+
492
+ # Edit modal
493
+ html += build_modal("edit", "Edit Record", editable_columns, pk, api_path, request_path, edit: true)
494
+
495
+ # Delete confirmation modal
496
+ html += <<~HTML
497
+ <div class="modal-overlay" id="modal-delete">
498
+ <div class="modal-box">
499
+ <h3>Confirm Delete</h3>
500
+ <p>Are you sure you want to delete this record? This action cannot be undone.</p>
501
+ <input type="hidden" id="delete-pk-value">
502
+ <div class="modal-footer">
503
+ <button class="btn btn-secondary" onclick="crudCloseModal('delete')">Cancel</button>
504
+ <button class="btn btn-danger" onclick="crudConfirmDelete()">Delete</button>
505
+ </div>
506
+ </div>
507
+ </div>
508
+ HTML
509
+
510
+ # JavaScript
511
+ html += build_crud_javascript(api_path, pk, editable_columns, request_path)
512
+
513
+ html += "</div></body></html>"
514
+ html
515
+ end
516
+
517
+ # Build a create or edit modal
518
+ def build_modal(id, title, columns, pk, api_path, request_path, edit: false)
519
+ html = "<div class=\"modal-overlay\" id=\"modal-#{id}\">"
520
+ html += "<div class=\"modal-box\">"
521
+ html += "<h3>#{h(title)}</h3>"
522
+ html += "<form id=\"form-#{id}\" onsubmit=\"return false;\">"
523
+ html += "<input type=\"hidden\" id=\"#{id}-pk-value\" name=\"#{pk}\">" if edit
524
+
525
+ columns.each do |col|
526
+ label = pretty_label(col)
527
+ field_id = "#{id}-#{col}"
528
+ html += "<div class=\"mb-3\">"
529
+ html += "<label for=\"#{field_id}\" class=\"form-label\">#{label}</label>"
530
+ html += "<input type=\"text\" class=\"form-control\" id=\"#{field_id}\" name=\"#{col}\" placeholder=\"Enter #{label.downcase}\">"
531
+ html += "</div>"
532
+ end
533
+
534
+ html += "<div class=\"modal-footer\">"
535
+ html += "<button type=\"button\" class=\"btn btn-secondary\" onclick=\"crudCloseModal('#{id}')\">Cancel</button>"
536
+ html += "<button type=\"button\" class=\"btn btn-primary\" onclick=\"crudSave#{edit ? 'Edit' : 'Create'}()\">Save</button>"
537
+ html += "</div></form></div></div>"
538
+ html
539
+ end
540
+
541
+ # Build the JavaScript for the CRUD interface
542
+ def build_crud_javascript(api_path, pk, columns, request_path)
543
+ columns_json = JSON.generate(columns.map(&:to_s))
544
+ <<~HTML
545
+ <script>
546
+ var CRUD_API = '#{api_path}';
547
+ var CRUD_PK = '#{pk}';
548
+ var CRUD_COLUMNS = #{columns_json};
549
+
550
+ function crudShowAlert(message, type) {
551
+ var el = document.getElementById('crud-alert');
552
+ el.className = 'alert alert-' + type;
553
+ el.textContent = message;
554
+ el.style.display = 'block';
555
+ setTimeout(function() { el.style.display = 'none'; }, 3000);
556
+ }
557
+
558
+ function crudShowCreate() {
559
+ var form = document.getElementById('form-create');
560
+ form.reset();
561
+ document.getElementById('modal-create').classList.add('active');
562
+ }
563
+
564
+ function crudShowEdit(id) {
565
+ fetch(CRUD_API + '/' + id)
566
+ .then(function(r) { return r.json(); })
567
+ .then(function(result) {
568
+ var data = result.data || result;
569
+ document.getElementById('edit-pk-value').value = id;
570
+ CRUD_COLUMNS.forEach(function(col) {
571
+ var input = document.getElementById('edit-' + col);
572
+ if (input) input.value = data[col] != null ? data[col] : '';
573
+ });
574
+ document.getElementById('modal-edit').classList.add('active');
575
+ })
576
+ .catch(function(e) { crudShowAlert('Failed to load record: ' + e, 'danger'); });
577
+ }
578
+
579
+ function crudShowDelete(id) {
580
+ document.getElementById('delete-pk-value').value = id;
581
+ document.getElementById('modal-delete').classList.add('active');
582
+ }
583
+
584
+ function crudCloseModal(name) {
585
+ document.getElementById('modal-' + name).classList.remove('active');
586
+ }
587
+
588
+ function crudSaveCreate() {
589
+ var data = {};
590
+ CRUD_COLUMNS.forEach(function(col) {
591
+ var input = document.getElementById('create-' + col);
592
+ if (input) data[col] = input.value;
593
+ });
594
+ fetch(CRUD_API, {
595
+ method: 'POST',
596
+ headers: { 'Content-Type': 'application/json' },
597
+ body: JSON.stringify(data)
598
+ })
599
+ .then(function(r) { return r.json(); })
600
+ .then(function(result) {
601
+ if (result.error) { crudShowAlert(result.error, 'danger'); return; }
602
+ crudCloseModal('create');
603
+ crudShowAlert('Record created successfully', 'success');
604
+ setTimeout(function() { window.location.reload(); }, 500);
605
+ })
606
+ .catch(function(e) { crudShowAlert('Failed to create: ' + e, 'danger'); });
607
+ }
608
+
609
+ function crudSaveEdit() {
610
+ var id = document.getElementById('edit-pk-value').value;
611
+ var data = {};
612
+ CRUD_COLUMNS.forEach(function(col) {
613
+ var input = document.getElementById('edit-' + col);
614
+ if (input) data[col] = input.value;
615
+ });
616
+ fetch(CRUD_API + '/' + id, {
617
+ method: 'PUT',
618
+ headers: { 'Content-Type': 'application/json' },
619
+ body: JSON.stringify(data)
620
+ })
621
+ .then(function(r) { return r.json(); })
622
+ .then(function(result) {
623
+ if (result.error) { crudShowAlert(result.error, 'danger'); return; }
624
+ crudCloseModal('edit');
625
+ crudShowAlert('Record updated successfully', 'success');
626
+ setTimeout(function() { window.location.reload(); }, 500);
627
+ })
628
+ .catch(function(e) { crudShowAlert('Failed to update: ' + e, 'danger'); });
629
+ }
630
+
631
+ function crudConfirmDelete() {
632
+ var id = document.getElementById('delete-pk-value').value;
633
+ fetch(CRUD_API + '/' + id, { method: 'DELETE' })
634
+ .then(function(r) { return r.json(); })
635
+ .then(function(result) {
636
+ if (result.error) { crudShowAlert(result.error, 'danger'); return; }
637
+ crudCloseModal('delete');
638
+ crudShowAlert('Record deleted successfully', 'success');
639
+ setTimeout(function() { window.location.reload(); }, 500);
640
+ })
641
+ .catch(function(e) { crudShowAlert('Failed to delete: ' + e, 'danger'); });
642
+ }
643
+
644
+ // Close modal on overlay click
645
+ document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
646
+ overlay.addEventListener('click', function(e) {
647
+ if (e.target === overlay) overlay.classList.remove('active');
648
+ });
649
+ });
650
+
651
+ // Close modal on Escape key
652
+ document.addEventListener('keydown', function(e) {
653
+ if (e.key === 'Escape') {
654
+ document.querySelectorAll('.modal-overlay.active').forEach(function(m) {
655
+ m.classList.remove('active');
656
+ });
657
+ }
658
+ });
659
+ </script>
660
+ HTML
661
+ end
662
+
663
+ def inline_crud_javascript(table_name)
99
664
  <<~JS
100
665
  <script>
101
666
  function crudSave(table, id) {
@@ -121,4 +686,7 @@ module Tina4
121
686
  end
122
687
  end
123
688
  end
689
+
690
+ # Uppercase alias for convenience: Tina4::CRUD.to_crud(...)
691
+ CRUD = Crud
124
692
  end
@@ -431,24 +431,60 @@ module Tina4
431
431
  value
432
432
  end
433
433
 
434
- # Split expression on dots, but not dots inside parentheses
434
+ # Split expression on dots, respecting quotes, parentheses and brackets.
435
+ # Dots inside quoted strings or nested parens/brackets are NOT separators.
436
+ # Bracket access like foo["bar"] is emitted as a separate part.
435
437
  def split_dot_parts(expr)
436
438
  parts = []
437
439
  current = +""
438
- depth = 0
439
- expr.each_char do |ch|
440
- if ch == "("
441
- depth += 1
440
+ paren_depth = 0
441
+ bracket_depth = 0
442
+ in_quote = nil
443
+
444
+ i = 0
445
+ chars = expr.chars
446
+ while i < chars.length
447
+ ch = chars[i]
448
+
449
+ if in_quote
450
+ current << ch
451
+ # End quote only when matching unescaped closer
452
+ in_quote = nil if ch == in_quote && (i == 0 || chars[i - 1] != "\\")
453
+ elsif ch == '"' || ch == "'"
454
+ in_quote = ch
455
+ current << ch
456
+ elsif ch == "("
457
+ paren_depth += 1
442
458
  current << ch
443
459
  elsif ch == ")"
444
- depth -= 1
460
+ paren_depth -= 1
461
+ current << ch
462
+ elsif ch == "[" && paren_depth == 0
463
+ if bracket_depth == 0 && !current.empty?
464
+ # Start of bracket access on an existing part -- split here
465
+ parts << current
466
+ current = +""
467
+ end
468
+ bracket_depth += 1
445
469
  current << ch
446
- elsif ch == "." && depth == 0
447
- parts << current
470
+ elsif ch == "]"
471
+ bracket_depth -= 1
472
+ current << ch
473
+ if bracket_depth == 0 && paren_depth == 0
474
+ # End of top-level bracket access -- emit as its own part
475
+ parts << current
476
+ current = +""
477
+ # Skip a trailing dot that merely chains the next segment
478
+ i += 1 if i + 1 < chars.length && chars[i + 1] == "."
479
+ end
480
+ elsif ch == "." && paren_depth == 0 && bracket_depth == 0
481
+ parts << current unless current.empty?
448
482
  current = +""
449
483
  else
450
484
  current << ch
451
485
  end
486
+
487
+ i += 1
452
488
  end
453
489
  parts << current unless current.empty?
454
490
  parts
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.10.2"
4
+ VERSION = "3.10.4"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -82,6 +82,7 @@ module Tina4
82
82
  # ── Lazy-loaded: optional modules ─────────────────────────────────────
83
83
  autoload :Swagger, File.expand_path("tina4/swagger", __dir__)
84
84
  autoload :Crud, File.expand_path("tina4/crud", __dir__)
85
+ autoload :CRUD, File.expand_path("tina4/crud", __dir__)
85
86
  autoload :API, File.expand_path("tina4/api", __dir__)
86
87
  autoload :APIResponse, File.expand_path("tina4/api", __dir__)
87
88
  autoload :GraphQLType, File.expand_path("tina4/graphql", __dir__)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.2
4
+ version: 3.10.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team