tina4ruby 3.10.3 → 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 +4 -4
- data/lib/tina4/crud.rb +570 -2
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: deb48b3053a6cde8d4d7fef6645b5e4a25eaff05c1d11b8b255c9c5ff42e7759
|
|
4
|
+
data.tar.gz: 5c7a65aa85b1a3d39676cf90b404cae1fbb3731c4e5c1fbbbbbae02ed97b468f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 +=
|
|
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
|
-
|
|
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("&", "&")
|
|
345
|
+
.gsub("<", "<")
|
|
346
|
+
.gsub(">", ">")
|
|
347
|
+
.gsub('"', """)
|
|
348
|
+
.gsub("'", "'")
|
|
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\">▲</span>" : " <span class=\"sort-indicator\">▼</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
|
data/lib/tina4/version.rb
CHANGED
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__)
|