tina4ruby 3.13.148 → 3.13.149
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/CHANGELOG.md +16 -0
- data/lib/tina4/auto_crud.rb +49 -0
- data/lib/tina4/cli.rb +79 -20
- data/lib/tina4/crud.rb +411 -659
- data/lib/tina4/dev_admin.rb +53 -1
- data/lib/tina4/dispatch_pipeline.rb +3 -0
- data/lib/tina4/messenger.rb +52 -2
- data/lib/tina4/migration.rb +132 -16
- data/lib/tina4/templates/crud/form.twig +26 -0
- data/lib/tina4/templates/crud/modals.twig +31 -0
- data/lib/tina4/templates/crud/page.twig +401 -0
- data/lib/tina4/templates/crud/table.twig +69 -0
- data/lib/tina4/version.rb +1 -1
- metadata +6 -2
data/lib/tina4/crud.rb
CHANGED
|
@@ -6,315 +6,358 @@
|
|
|
6
6
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
7
|
|
|
8
8
|
require "json"
|
|
9
|
+
require "uri"
|
|
9
10
|
|
|
10
11
|
module Tina4
|
|
11
|
-
# Crud
|
|
12
|
+
# Crud (ADR-0094) — a FRONTEND over AutoCrud.
|
|
13
|
+
#
|
|
14
|
+
# `to_crud` renders a complete server-rendered admin UI (searchable, sortable,
|
|
15
|
+
# paginated table + create/edit/delete modals) for an ORM model. It owns NO
|
|
16
|
+
# backend routes: the entire REST backend (GET list, GET /{id}, POST, PUT,
|
|
17
|
+
# DELETE, secure-by-default) is delegated to `Tina4::AutoCrud`, and the UI's
|
|
18
|
+
# JavaScript talks to those routes over fetch(). The HTML comes from four
|
|
19
|
+
# app-overridable Frond templates under `crud/` (page, table, form, modals),
|
|
20
|
+
# rendered through `Tina4::Template.render` — the same app-first-then-gem
|
|
21
|
+
# resolution the error pages use — so an app restyles the admin by dropping its
|
|
22
|
+
# own `templates/crud/<name>.twig`, no framework fork.
|
|
12
23
|
#
|
|
13
24
|
# Usage:
|
|
14
25
|
# Tina4.get "/admin/users" do |request, response|
|
|
15
|
-
# response.html(Tina4::Crud.to_crud(request,
|
|
16
|
-
# sql: "SELECT id, name, email FROM users",
|
|
17
|
-
# title: "User Management",
|
|
18
|
-
# primary_key: "id"
|
|
19
|
-
# }))
|
|
26
|
+
# response.html(Tina4::Crud.to_crud(request, model: User, title: "Users"))
|
|
20
27
|
# end
|
|
21
28
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# model: User,
|
|
26
|
-
# title: "User Management"
|
|
27
|
-
# }))
|
|
28
|
-
# end
|
|
29
|
+
# A custom `sql:` only shapes the LISTING grid (a filter/join/projection); the
|
|
30
|
+
# model still drives columns, the primary key, and every write path, so a
|
|
31
|
+
# custom listing can never create an unauthenticated or divergent write route.
|
|
29
32
|
module Crud
|
|
30
33
|
class << self
|
|
31
|
-
#
|
|
32
|
-
# with create, edit, and delete modals. Also registers the supporting
|
|
33
|
-
# REST API routes on first call per table.
|
|
34
|
+
# Render the CRUD admin page for +model+ and register its AutoCrud routes.
|
|
34
35
|
#
|
|
35
36
|
# @param request [Tina4::Request] the current request
|
|
36
|
-
# @param options [Hash]
|
|
37
|
-
# @option options [
|
|
38
|
-
# @option options [
|
|
39
|
-
#
|
|
40
|
-
# @option options [String] :
|
|
41
|
-
# @option options [String] :prefix
|
|
42
|
-
# @option options [Integer] :limit
|
|
43
|
-
# @return [String]
|
|
44
|
-
def to_crud(request, options =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
37
|
+
# @param options [Hash, nil] options as a Hash (also accepted as keywords)
|
|
38
|
+
# @option options [Class] :model REQUIRED — the ORM model class
|
|
39
|
+
# @option options [String] :sql optional listing query (inferred from
|
|
40
|
+
# the model when omitted); shapes only the displayed grid
|
|
41
|
+
# @option options [String] :title page title (default "CRUD")
|
|
42
|
+
# @option options [String] :prefix AutoCrud route prefix (default "/api")
|
|
43
|
+
# @option options [Integer] :limit records per page (default 10)
|
|
44
|
+
# @return [String] the rendered crud/page template
|
|
45
|
+
def to_crud(request, options = nil, **kwargs)
|
|
46
|
+
opts = {}
|
|
47
|
+
opts.merge!(options) if options.is_a?(Hash)
|
|
48
|
+
opts.merge!(kwargs)
|
|
49
|
+
|
|
50
|
+
model = opts[:model]
|
|
51
|
+
raise ArgumentError, "Crud.to_crud requires a :model (an ORM class)" unless model
|
|
52
|
+
|
|
53
|
+
sql = opts[:sql]
|
|
54
|
+
title = (opts[:title] || "CRUD").to_s
|
|
55
|
+
prefix = (opts[:prefix] || "/api").to_s
|
|
56
|
+
limit = (opts[:limit] || 10).to_i
|
|
57
|
+
limit = 10 if limit <= 0
|
|
58
|
+
|
|
59
|
+
table_name = model.table_name.to_s
|
|
60
|
+
pk = (model.primary_key_field || :id).to_s
|
|
61
|
+
columns = model.field_definitions.keys.map(&:to_s)
|
|
62
|
+
|
|
63
|
+
# Backend: delegate 100% to AutoCrud (idempotent — register once).
|
|
64
|
+
register_backend(model, prefix)
|
|
65
|
+
|
|
66
|
+
# Pagination / search / safe-sort from the query string.
|
|
67
|
+
query_params = request.respond_to?(:query) ? (request.query || {}) : {}
|
|
68
|
+
page = [(query_params["page"] || 1).to_i, 1].max
|
|
69
|
+
search = query_params["search"].to_s.strip
|
|
70
|
+
sort_col = crud_sort_column(model, sql, query_params["sort"], pk)
|
|
71
|
+
sort_dir = query_params["sort_dir"] == "desc" ? "desc" : "asc"
|
|
72
|
+
offset = (page - 1) * limit
|
|
73
|
+
|
|
74
|
+
if sql
|
|
75
|
+
records, total = fetch_sql_data(sql, search: search, sort: sort_col,
|
|
76
|
+
sort_dir: sort_dir, limit: limit, offset: offset)
|
|
60
77
|
else
|
|
61
|
-
raise ArgumentError, "Crud.to_crud requires either :sql or :model option"
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# Parse request params for pagination, search, and sorting
|
|
65
|
-
query_params = request.respond_to?(:query) ? request.query : {}
|
|
66
|
-
page = [(query_params["page"] || 1).to_i, 1].max
|
|
67
|
-
search = query_params["search"].to_s.strip
|
|
68
|
-
sort_col = crud_sort_column(model, sql, query_params["sort"], pk)
|
|
69
|
-
sort_dir = query_params["sort_dir"] == "desc" ? "desc" : "asc"
|
|
70
|
-
offset = (page - 1) * limit
|
|
71
|
-
|
|
72
|
-
# Build the data query
|
|
73
|
-
if model
|
|
74
78
|
records, total = fetch_model_data(model, search: search, sort: sort_col,
|
|
75
79
|
sort_dir: sort_dir, limit: limit, offset: offset)
|
|
76
|
-
else
|
|
77
|
-
records, total = fetch_sql_data(sql, search: search, sort: sort_col,
|
|
78
|
-
sort_dir: sort_dir, limit: limit, offset: offset)
|
|
79
80
|
end
|
|
80
81
|
|
|
81
|
-
total_pages
|
|
82
|
-
api_path
|
|
83
|
-
|
|
84
|
-
# Register supporting CRUD API routes (idempotent)
|
|
85
|
-
register_crud_routes(model, table_name, pk, prefix) unless crud_routes_registered?(table_name, prefix)
|
|
82
|
+
total_pages = total > 0 ? (total.to_f / limit).ceil : 1
|
|
83
|
+
api_path = "#{prefix}/#{table_name}"
|
|
84
|
+
request_path = request.respond_to?(:path) ? request.path.to_s : "/"
|
|
86
85
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
limit: limit, search: search, sort_col: sort_col,
|
|
93
|
-
sort_dir: sort_dir, api_path: api_path,
|
|
94
|
-
request_path: request.path
|
|
86
|
+
render_page(
|
|
87
|
+
title: title, table_name: table_name, pk: pk, columns: columns,
|
|
88
|
+
records: records, page: page, total_pages: total_pages, total: total,
|
|
89
|
+
limit: limit, search: search, sort_col: sort_col, sort_dir: sort_dir,
|
|
90
|
+
api_path: api_path, request_path: request_path, model: model
|
|
95
91
|
)
|
|
96
92
|
end
|
|
97
93
|
|
|
98
|
-
#
|
|
94
|
+
# Render an HTML table fragment from an array of record hashes via
|
|
95
|
+
# crud/table.twig. Inline-editable (contenteditable cells + Save/Delete
|
|
96
|
+
# buttons wired through the template's delegated listener).
|
|
99
97
|
def generate_table(records, table_name: "data", primary_key: "id", editable: true)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
columns.each do |col|
|
|
111
|
-
html += "<th>#{col}</th>"
|
|
112
|
-
end
|
|
113
|
-
html += "<th>Actions</th>" if editable
|
|
114
|
-
html += "</tr></thead><tbody>"
|
|
115
|
-
|
|
116
|
-
records.each do |row|
|
|
117
|
-
pk_value = row[primary_key.to_sym] || row[primary_key.to_s]
|
|
118
|
-
html += "<tr data-id=\"#{pk_value}\">"
|
|
119
|
-
columns.each do |col|
|
|
120
|
-
value = row[col]
|
|
121
|
-
if editable
|
|
122
|
-
html += "<td contenteditable=\"true\" data-field=\"#{col}\">#{value}</td>"
|
|
123
|
-
else
|
|
124
|
-
html += "<td>#{value}</td>"
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
if editable
|
|
128
|
-
html += "<td>"
|
|
129
|
-
html += "<button class=\"btn btn-sm btn-primary me-1\" data-crud-inline=\"save\" data-table=\"#{h(table_name)}\" data-id=\"#{h(pk_value)}\">Save</button>"
|
|
130
|
-
html += "<button class=\"btn btn-sm btn-danger\" data-crud-inline=\"delete\" data-table=\"#{h(table_name)}\" data-id=\"#{h(pk_value)}\">Delete</button>"
|
|
131
|
-
html += "</td>"
|
|
132
|
-
end
|
|
133
|
-
html += "</tr>"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
html += "</tbody></table></div>"
|
|
137
|
-
|
|
138
|
-
if editable
|
|
139
|
-
html += inline_crud_javascript(table_name)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
html
|
|
98
|
+
records = records || []
|
|
99
|
+
columns = records.empty? ? [] : records.first.keys.map(&:to_s)
|
|
100
|
+
|
|
101
|
+
Tina4::Template.render("crud/table.twig",
|
|
102
|
+
table_data(columns: columns, records: records, pk: primary_key.to_s,
|
|
103
|
+
table_name: table_name.to_s, editable: editable, sortable: false,
|
|
104
|
+
inline_script: editable, request_path: nil, search: "",
|
|
105
|
+
sort_col: nil, sort_dir: "asc", page: 1, limit: 10,
|
|
106
|
+
table_id: "crud-#{table_name}"))
|
|
143
107
|
end
|
|
144
108
|
|
|
145
|
-
#
|
|
109
|
+
# Render an HTML form from a field-definition array via crud/form.twig.
|
|
110
|
+
# +fields+ is an array of { name:, type:, label:, value:, required:, options: }.
|
|
146
111
|
def generate_form(fields, action: "/", method: "POST", table_name: "data")
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
case type.to_sym
|
|
161
|
-
when :text
|
|
162
|
-
html += "<textarea class=\"form-control\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>#{value}</textarea>"
|
|
163
|
-
when :boolean
|
|
164
|
-
checked = value ? "checked" : ""
|
|
165
|
-
html += "<div class=\"form-check\">"
|
|
166
|
-
html += "<input class=\"form-check-input\" type=\"checkbox\" id=\"#{name}\" name=\"#{name}\" #{checked}>"
|
|
167
|
-
html += "</div>"
|
|
168
|
-
when :select
|
|
169
|
-
html += "<select class=\"form-select\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>"
|
|
170
|
-
(field[:options] || []).each do |opt|
|
|
171
|
-
selected = opt[:value].to_s == value.to_s ? "selected" : ""
|
|
172
|
-
html += "<option value=\"#{opt[:value]}\" #{selected}>#{opt[:label]}</option>"
|
|
173
|
-
end
|
|
174
|
-
html += "</select>"
|
|
175
|
-
when :date
|
|
176
|
-
html += "<input type=\"date\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
|
|
177
|
-
when :integer, :number
|
|
178
|
-
html += "<input type=\"number\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
|
|
179
|
-
else
|
|
180
|
-
html += "<input type=\"text\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
|
|
181
|
-
end
|
|
182
|
-
html += "</div>"
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
html += "<button type=\"submit\" class=\"btn btn-primary\">Submit</button>"
|
|
186
|
-
html += "</form>"
|
|
187
|
-
html
|
|
112
|
+
verb = method.to_s.upcase
|
|
113
|
+
Tina4::Template.render("crud/form.twig", {
|
|
114
|
+
"wrap" => true,
|
|
115
|
+
"form_id_attr" => "",
|
|
116
|
+
"action" => h(action),
|
|
117
|
+
"form_method" => h(verb),
|
|
118
|
+
"method_override" => (%w[PUT PATCH DELETE].include?(verb) ? verb : nil),
|
|
119
|
+
"edit" => false,
|
|
120
|
+
"modal_footer" => false,
|
|
121
|
+
"submit_button" => true,
|
|
122
|
+
"fields" => (fields || []).map { |f| build_custom_field(f) }
|
|
123
|
+
})
|
|
188
124
|
end
|
|
189
125
|
|
|
190
126
|
private
|
|
191
127
|
|
|
192
|
-
# Track which
|
|
128
|
+
# Track which (prefix, model) pairs have had their AutoCrud routes built.
|
|
193
129
|
def registered_tables
|
|
194
130
|
@registered_tables ||= {}
|
|
195
131
|
end
|
|
196
132
|
|
|
197
|
-
|
|
198
|
-
|
|
133
|
+
# Delegate the ENTIRE backend to AutoCrud. Idempotent: register + generate
|
|
134
|
+
# once per (prefix, model); Router.add replaces a re-registered route in
|
|
135
|
+
# place, so a second call is harmless either way.
|
|
136
|
+
def register_backend(model, prefix)
|
|
137
|
+
key = "#{prefix}::#{model.name || model.object_id}"
|
|
138
|
+
return if registered_tables[key]
|
|
139
|
+
|
|
140
|
+
# Register only if the app has not already done so (e.g. a scaffolded
|
|
141
|
+
# admin route that registered it `public: true`) — re-registering would
|
|
142
|
+
# reset that public flag back to secure. generate_routes is idempotent
|
|
143
|
+
# (Router.add replaces a route in place).
|
|
144
|
+
Tina4::AutoCrud.register(model) unless Tina4::AutoCrud.models.include?(model)
|
|
145
|
+
Tina4::AutoCrud.generate_routes(prefix: prefix)
|
|
146
|
+
registered_tables[key] = true
|
|
199
147
|
end
|
|
200
148
|
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
def
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
# If we have a model, use AutoCrud for full ORM-backed routes
|
|
209
|
-
if model
|
|
210
|
-
Tina4::AutoCrud.register(model)
|
|
211
|
-
Tina4::AutoCrud.generate_routes(prefix: prefix)
|
|
212
|
-
return
|
|
213
|
-
end
|
|
149
|
+
# Build the page template data and render page + table + modals (each via
|
|
150
|
+
# Tina4::Template.render, so every sub-template is independently
|
|
151
|
+
# app-overridable).
|
|
152
|
+
def render_page(title:, table_name:, pk:, columns:, records:, page:,
|
|
153
|
+
total_pages:, total:, limit:, search:, sort_col:, sort_dir:,
|
|
154
|
+
api_path:, request_path:, model:)
|
|
155
|
+
editable_columns = columns.reject { |c| c.to_s == pk.to_s }
|
|
214
156
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
157
|
+
table_html = Tina4::Template.render("crud/table.twig",
|
|
158
|
+
table_data(columns: columns, records: records, pk: pk,
|
|
159
|
+
table_name: table_name, editable: false, sortable: true,
|
|
160
|
+
inline_script: false, request_path: request_path,
|
|
161
|
+
search: search, sort_col: sort_col, sort_dir: sort_dir,
|
|
162
|
+
page: page, limit: limit, table_id: nil, model: model))
|
|
163
|
+
|
|
164
|
+
modals_html = render_modals(editable_columns, pk)
|
|
165
|
+
|
|
166
|
+
Tina4::Template.render("crud/page.twig", {
|
|
167
|
+
"title" => h(title),
|
|
168
|
+
"search" => h(search),
|
|
169
|
+
"request_path" => h(request_path),
|
|
170
|
+
"info_count" => records.length,
|
|
171
|
+
"info_total" => total,
|
|
172
|
+
"info_page" => page,
|
|
173
|
+
"info_total_pages" => total_pages,
|
|
174
|
+
"table_html" => table_html,
|
|
175
|
+
"modals_html" => modals_html,
|
|
176
|
+
"show_pagination" => total_pages > 1,
|
|
177
|
+
"controls" => page_controls(page, total_pages, request_path, search, sort_col, sort_dir, limit),
|
|
178
|
+
"config_json" => js_config(
|
|
179
|
+
api_path: api_path, pk: pk, columns: columns, editable: editable_columns,
|
|
180
|
+
model: model, limit: limit, search: search, sort_col: sort_col,
|
|
181
|
+
sort_dir: sort_dir, page: page
|
|
182
|
+
)
|
|
228
183
|
})
|
|
184
|
+
end
|
|
229
185
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
db.update(table_name, data, { pk => id })
|
|
237
|
-
res.json({ data: data, message: "Updated" })
|
|
238
|
-
rescue => e
|
|
239
|
-
res.json({ error: e.message }, status: 500)
|
|
240
|
-
end
|
|
186
|
+
# Render the create/edit/delete modal shell (crud/modals.twig), with the
|
|
187
|
+
# create and edit forms rendered through crud/form.twig.
|
|
188
|
+
def render_modals(editable_columns, pk)
|
|
189
|
+
Tina4::Template.render("crud/modals.twig", {
|
|
190
|
+
"create_form" => render_modal_form("create", editable_columns, pk, edit: false),
|
|
191
|
+
"edit_form" => render_modal_form("edit", editable_columns, pk, edit: true)
|
|
241
192
|
})
|
|
193
|
+
end
|
|
242
194
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
195
|
+
# A modal's create/edit form — fields + the Cancel/Save footer — via
|
|
196
|
+
# crud/form.twig.
|
|
197
|
+
def render_modal_form(mode, columns, pk, edit:)
|
|
198
|
+
fields = columns.map do |col|
|
|
199
|
+
label = pretty_label(col)
|
|
200
|
+
{
|
|
201
|
+
"id" => "#{mode}-#{col}",
|
|
202
|
+
"name" => h(col),
|
|
203
|
+
"label" => h(label),
|
|
204
|
+
"value" => "",
|
|
205
|
+
"placeholder" => h("Enter #{label.downcase}"),
|
|
206
|
+
"type" => "text",
|
|
207
|
+
"required_attr" => "",
|
|
208
|
+
"input" => true
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
Tina4::Template.render("crud/form.twig", {
|
|
213
|
+
"wrap" => true,
|
|
214
|
+
"form_id_attr" => " id=\"form-#{mode}\"",
|
|
215
|
+
"action" => "",
|
|
216
|
+
"form_method" => "POST",
|
|
217
|
+
"method_override" => nil,
|
|
218
|
+
"edit" => edit,
|
|
219
|
+
"mode" => mode,
|
|
220
|
+
"pk" => h(pk),
|
|
221
|
+
"modal_footer" => true,
|
|
222
|
+
"submit_button" => false,
|
|
223
|
+
"fields" => fields
|
|
252
224
|
})
|
|
253
225
|
end
|
|
254
226
|
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
#
|
|
260
|
-
def
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
227
|
+
# Build the data hash crud/table.twig consumes: escaped headers (with sort
|
|
228
|
+
# links + indicator + per-column alignment when sortable), rows carrying
|
|
229
|
+
# pre-joined escaped+aligned <td> cell HTML, and the mutually-exclusive
|
|
230
|
+
# editable/readonly flags. Numeric columns align right (text-end), text
|
|
231
|
+
# columns align left (text-start); the Actions column is always text-end.
|
|
232
|
+
def table_data(columns:, records:, pk:, table_name:, editable:, sortable:,
|
|
233
|
+
inline_script:, request_path:, search:, sort_col:, sort_dir:,
|
|
234
|
+
page:, limit:, table_id:, model: nil)
|
|
235
|
+
aligns = columns.map { |col| column_alignment(model, col) }
|
|
236
|
+
|
|
237
|
+
headers = columns.each_with_index.map do |col, index|
|
|
238
|
+
header = { "label" => h(pretty_label(col)), "align" => aligns[index] }
|
|
239
|
+
if sortable
|
|
240
|
+
next_dir = (sort_col.to_s == col.to_s && sort_dir == "asc") ? "desc" : "asc"
|
|
241
|
+
header["sortable"] = true
|
|
242
|
+
header["col"] = h(col)
|
|
243
|
+
header["next_dir"] = next_dir
|
|
244
|
+
header["url"] = sort_url(request_path, col, next_dir, page, search, limit)
|
|
245
|
+
header["indicator"] = sort_indicator(sort_col, col, sort_dir)
|
|
246
|
+
else
|
|
247
|
+
header["plain"] = true
|
|
248
|
+
end
|
|
249
|
+
header
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
rows = records.map do |record|
|
|
253
|
+
{ "id" => h(record_pk(record, pk)),
|
|
254
|
+
"cells" => build_cells(columns, record, editable, aligns) }
|
|
270
255
|
end
|
|
256
|
+
|
|
257
|
+
{
|
|
258
|
+
"headers" => headers,
|
|
259
|
+
"rows" => rows,
|
|
260
|
+
"empty" => records.empty?,
|
|
261
|
+
"colspan" => columns.length + 1,
|
|
262
|
+
"editable" => editable,
|
|
263
|
+
"readonly" => !editable,
|
|
264
|
+
"inline_script" => inline_script,
|
|
265
|
+
"table_name" => h(table_name),
|
|
266
|
+
"table_id_attr" => (table_id ? " id=\"#{h(table_id)}\"" : "")
|
|
267
|
+
}
|
|
271
268
|
end
|
|
272
269
|
|
|
273
|
-
#
|
|
274
|
-
#
|
|
275
|
-
#
|
|
276
|
-
#
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
270
|
+
# The pre-joined <td> cells for one row, every value HTML-escaped and
|
|
271
|
+
# carrying its column alignment class. The <td> tag is the smallest
|
|
272
|
+
# fragment the limited template engine cannot iterate itself (it has no
|
|
273
|
+
# nested-loop support), so it is assembled here; the table structure,
|
|
274
|
+
# headers and action buttons all live in crud/table.twig.
|
|
275
|
+
def build_cells(columns, record, editable, aligns)
|
|
276
|
+
columns.each_with_index.map do |col, index|
|
|
277
|
+
value = h(cell_value(record, col))
|
|
278
|
+
css = aligns[index]
|
|
279
|
+
if editable
|
|
280
|
+
"<td class=\"#{css}\" contenteditable=\"true\" data-field=\"#{h(col)}\">#{value}</td>"
|
|
281
|
+
else
|
|
282
|
+
"<td class=\"#{css}\">#{value}</td>"
|
|
283
|
+
end
|
|
284
|
+
end.join
|
|
285
|
+
end
|
|
280
286
|
|
|
281
|
-
|
|
287
|
+
# Column alignment from the model's declared field type: numeric columns
|
|
288
|
+
# (integer/numeric/float/decimal) align right, everything else left. With
|
|
289
|
+
# no model (the generate_table fragment), every column aligns left.
|
|
290
|
+
def column_alignment(model, col)
|
|
291
|
+
return "text-start" unless model&.respond_to?(:field_definitions)
|
|
292
|
+
|
|
293
|
+
type = model.field_definitions.dig(col.to_sym, :type)
|
|
294
|
+
%i[integer numeric float decimal].include?(type) ? "text-end" : "text-start"
|
|
282
295
|
end
|
|
283
296
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
# any input (the regex it replaces was flagged as polynomial by CodeQL).
|
|
289
|
-
# Same result: a keyword is removed with the rest of its line only when at
|
|
290
|
-
# least one character follows it, and the line break is kept.
|
|
291
|
-
def strip_order_and_limit(sql)
|
|
292
|
-
sql.to_s.each_line.map do |line|
|
|
293
|
-
cut_from_keyword(cut_from_keyword(line, /ORDER BY /i), /LIMIT /i)
|
|
294
|
-
end.join.strip
|
|
297
|
+
def cell_value(record, col)
|
|
298
|
+
return record[col.to_sym] if record.respond_to?(:key?) && record.key?(col.to_sym)
|
|
299
|
+
return record[col.to_s] if record.respond_to?(:key?) && record.key?(col.to_s)
|
|
300
|
+
record[col.to_sym] || record[col.to_s] || (record[col] rescue nil)
|
|
295
301
|
end
|
|
296
302
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
content = ending.empty? ? line : line[0...-1]
|
|
301
|
-
at = content.index(keyword)
|
|
302
|
-
return line if at.nil? || content.length <= at + keyword.source.length
|
|
303
|
+
def record_pk(record, pk)
|
|
304
|
+
cell_value(record, pk)
|
|
305
|
+
end
|
|
303
306
|
|
|
304
|
-
|
|
307
|
+
# A field hash for crud/form.twig built from a generate_form field def.
|
|
308
|
+
def build_custom_field(field)
|
|
309
|
+
name = field[:name].to_s
|
|
310
|
+
label = field[:label] || name.capitalize
|
|
311
|
+
value = field[:value]
|
|
312
|
+
required = field[:required] ? " required" : ""
|
|
313
|
+
|
|
314
|
+
base = {
|
|
315
|
+
"id" => h(name),
|
|
316
|
+
"name" => h(name),
|
|
317
|
+
"label" => h(label.to_s),
|
|
318
|
+
"value" => h(value.to_s),
|
|
319
|
+
"placeholder" => "",
|
|
320
|
+
"required_attr" => required
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
case (field[:type] || :string).to_sym
|
|
324
|
+
when :text
|
|
325
|
+
base.merge("textarea" => true)
|
|
326
|
+
when :boolean
|
|
327
|
+
base.merge("checkbox" => true, "checked_attr" => (value ? " checked" : ""))
|
|
328
|
+
when :select
|
|
329
|
+
base.merge("select" => true, "options_html" => build_options(field[:options], value))
|
|
330
|
+
when :date
|
|
331
|
+
base.merge("input" => true, "type" => "date")
|
|
332
|
+
when :integer, :number, :float, :decimal
|
|
333
|
+
base.merge("input" => true, "type" => "number")
|
|
334
|
+
else
|
|
335
|
+
base.merge("input" => true, "type" => "text")
|
|
336
|
+
end
|
|
305
337
|
end
|
|
306
338
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
339
|
+
def build_options(options, selected_value)
|
|
340
|
+
(options || []).map do |opt|
|
|
341
|
+
selected = opt[:value].to_s == selected_value.to_s ? " selected" : ""
|
|
342
|
+
"<option value=\"#{h(opt[:value])}\"#{selected}>#{h(opt[:label])}</option>"
|
|
343
|
+
end.join
|
|
344
|
+
end
|
|
311
345
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
346
|
+
# tina4: ADR-0069 - ?sort reaches ORDER BY only as a column the source
|
|
347
|
+
# itself declares: a model's declared field (resolved to its DB column) or
|
|
348
|
+
# a column of the SQL query's own result set. Anything else falls back to
|
|
349
|
+
# the primary key - this is a rendered page, not an API, so a bad sort is
|
|
350
|
+
# ignored, never an error.
|
|
351
|
+
def crud_sort_column(model, sql, requested, pk)
|
|
352
|
+
return pk if requested.nil? || requested.empty?
|
|
353
|
+
return model.resolve_field_column(requested) || pk if model && sql.nil?
|
|
354
|
+
return model.resolve_field_column(requested) || pk if model && !sql_result_columns(sql).include?(requested)
|
|
355
|
+
|
|
356
|
+
sql ? (sql_result_columns(sql).include?(requested) ? requested : pk) : pk
|
|
315
357
|
end
|
|
316
358
|
|
|
317
|
-
# Fetch
|
|
359
|
+
# Fetch a page of records from the model (ADR-0069 safe search across the
|
|
360
|
+
# model's string/text columns).
|
|
318
361
|
def fetch_model_data(model, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
|
|
319
362
|
order_by = "#{sort} #{sort_dir.upcase}"
|
|
320
363
|
|
|
@@ -322,71 +365,79 @@ module Tina4
|
|
|
322
365
|
records = model.all(limit: limit, offset: offset, order_by: order_by)
|
|
323
366
|
total = model.count
|
|
324
367
|
else
|
|
325
|
-
# Build search across all string/text fields
|
|
326
368
|
searchable = model.field_definitions.select { |_, opts|
|
|
327
|
-
[
|
|
369
|
+
%i[string text].include?(opts[:type])
|
|
328
370
|
}.keys
|
|
329
371
|
if searchable.empty?
|
|
330
372
|
records = model.all(limit: limit, offset: offset, order_by: order_by)
|
|
331
373
|
total = model.count
|
|
332
374
|
else
|
|
333
|
-
|
|
334
|
-
where_clause = where_parts.join(" OR ")
|
|
375
|
+
where_clause = searchable.map { |col| "#{col} LIKE ?" }.join(" OR ")
|
|
335
376
|
params = searchable.map { "%#{search}%" }
|
|
336
|
-
|
|
337
|
-
total =
|
|
338
|
-
records =
|
|
377
|
+
all_matches = model.where(where_clause, params, order_by: order_by)
|
|
378
|
+
total = all_matches.length
|
|
379
|
+
records = all_matches.slice(offset, limit) || []
|
|
339
380
|
end
|
|
340
381
|
end
|
|
341
382
|
|
|
342
|
-
|
|
343
|
-
[record_hashes, total]
|
|
383
|
+
[records.map(&:to_h), total]
|
|
344
384
|
end
|
|
345
385
|
|
|
346
|
-
# Fetch
|
|
386
|
+
# Fetch a page of rows for a custom listing SQL. The SQL shapes the DISPLAY
|
|
387
|
+
# only; writes/GET always go through AutoCrud.
|
|
347
388
|
def fetch_sql_data(sql, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
|
|
348
389
|
db = Tina4.database
|
|
349
390
|
return [[], 0] unless db
|
|
350
391
|
|
|
351
|
-
# Wrap the original SQL for sorting
|
|
352
392
|
base = strip_order_and_limit(sql)
|
|
353
|
-
query = "#{base} ORDER BY #{sort} #{sort_dir.upcase}"
|
|
354
393
|
|
|
355
|
-
if
|
|
356
|
-
|
|
357
|
-
|
|
394
|
+
if search.empty?
|
|
395
|
+
query = "#{base} ORDER BY #{sort} #{sort_dir.upcase}"
|
|
396
|
+
count_sql = "SELECT COUNT(*) as cnt FROM (#{base}) AS _crud_cnt"
|
|
397
|
+
count_result = db.fetch_one(count_sql)
|
|
398
|
+
total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
|
|
399
|
+
results = db.fetch(query, [], limit: limit, offset: offset)
|
|
400
|
+
else
|
|
358
401
|
columns = extract_columns(sql)
|
|
359
402
|
search_parts = columns.map { |col| "CAST(#{col} AS TEXT) LIKE ?" }
|
|
360
|
-
wrapped
|
|
361
|
-
wrapped += " ORDER BY #{sort} #{sort_dir.upcase}"
|
|
403
|
+
wrapped = "SELECT * FROM (#{base}) AS _crud_sub WHERE #{search_parts.join(' OR ')} ORDER BY #{sort} #{sort_dir.upcase}"
|
|
362
404
|
params = columns.map { "%#{search}%" }
|
|
363
|
-
|
|
364
|
-
# Get total count
|
|
365
405
|
count_sql = "SELECT COUNT(*) as cnt FROM (#{base}) AS _crud_cnt WHERE #{search_parts.join(' OR ')}"
|
|
366
406
|
count_result = db.fetch_one(count_sql, params)
|
|
367
407
|
total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
|
|
368
|
-
|
|
369
408
|
results = db.fetch(wrapped, params, limit: limit, offset: offset)
|
|
370
|
-
else
|
|
371
|
-
# Get total count
|
|
372
|
-
count_sql = "SELECT COUNT(*) as cnt FROM (#{base}) AS _crud_cnt"
|
|
373
|
-
count_result = db.fetch_one(count_sql)
|
|
374
|
-
total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
|
|
375
|
-
|
|
376
|
-
results = db.fetch(query, [], limit: limit, offset: offset)
|
|
377
409
|
end
|
|
378
410
|
|
|
379
411
|
records = results.respond_to?(:records) ? results.records : results.to_a
|
|
380
412
|
[records, total]
|
|
381
413
|
end
|
|
382
414
|
|
|
383
|
-
#
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
415
|
+
# The query with any ORDER BY / LIMIT clause removed, line by line with
|
|
416
|
+
# plain string operations so it stays linear on any input.
|
|
417
|
+
def strip_order_and_limit(sql)
|
|
418
|
+
sql.to_s.each_line.map do |line|
|
|
419
|
+
cut_from_keyword(cut_from_keyword(line, /ORDER BY /i), /LIMIT /i)
|
|
420
|
+
end.join.strip
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def cut_from_keyword(line, keyword)
|
|
424
|
+
ending = line.end_with?("\n") ? "\n" : ""
|
|
425
|
+
content = ending.empty? ? line : line[0...-1]
|
|
426
|
+
at = content.index(keyword)
|
|
427
|
+
return line if at.nil? || content.length <= at + keyword.source.length
|
|
428
|
+
|
|
429
|
+
content[0...at] + ending
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def sql_result_columns(sql)
|
|
433
|
+
db = Tina4.database
|
|
434
|
+
return [] unless db
|
|
435
|
+
|
|
436
|
+
base = strip_order_and_limit(sql)
|
|
437
|
+
row = db.fetch("SELECT * FROM (#{base}) AS _crud_sub", [], limit: 1).first
|
|
438
|
+
row ? row.keys.map(&:to_s) : []
|
|
387
439
|
end
|
|
388
440
|
|
|
389
|
-
# Extract column names from a SQL SELECT statement
|
|
390
441
|
def extract_columns(sql)
|
|
391
442
|
match = sql.match(/SELECT\s+(.+?)\s+FROM/im)
|
|
392
443
|
return ["*"] unless match
|
|
@@ -394,405 +445,106 @@ module Tina4
|
|
|
394
445
|
cols_str = match[1].strip
|
|
395
446
|
return ["*"] if cols_str == "*"
|
|
396
447
|
|
|
397
|
-
cols_str.split(",").map
|
|
448
|
+
cols_str.split(",").map do |c|
|
|
398
449
|
c = c.strip
|
|
399
|
-
# Handle "table.column AS alias" or "column AS alias"
|
|
400
450
|
if c =~ /\bAS\s+(\w+)/i
|
|
401
|
-
|
|
451
|
+
Regexp.last_match(1)
|
|
402
452
|
elsif c.include?(".")
|
|
403
453
|
c.split(".").last.strip
|
|
404
454
|
else
|
|
405
|
-
c
|
|
406
|
-
end
|
|
407
|
-
}
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
# Escape HTML special characters. Delegates to the framework's one
|
|
411
|
-
# canonical escaper (Frond.escape_html, single-pass, byte-identical
|
|
412
|
-
# & < > " ' -> & < > " ') so CRUD and the error
|
|
413
|
-
# overlay never carry their own private copy of the HTML escape table.
|
|
414
|
-
def h(text)
|
|
415
|
-
Tina4::Frond.escape_html(text)
|
|
416
|
-
end
|
|
417
|
-
|
|
418
|
-
# Pretty label from a column name: "user_name" => "User Name"
|
|
419
|
-
def pretty_label(col)
|
|
420
|
-
col.to_s.split("_").map(&:capitalize).join(" ")
|
|
421
|
-
end
|
|
422
|
-
|
|
423
|
-
# Determine input type from column name or ORM field type
|
|
424
|
-
def input_type_for(col, model = nil)
|
|
425
|
-
if model && model.respond_to?(:field_definitions)
|
|
426
|
-
opts = model.field_definitions[col.to_sym]
|
|
427
|
-
if opts
|
|
428
|
-
case opts[:type]
|
|
429
|
-
when :integer then return "number"
|
|
430
|
-
when :float, :decimal then return "number"
|
|
431
|
-
when :boolean then return "checkbox"
|
|
432
|
-
when :date then return "date"
|
|
433
|
-
when :datetime, :timestamp then return "datetime-local"
|
|
434
|
-
when :text then return "textarea"
|
|
435
|
-
end
|
|
455
|
+
c
|
|
436
456
|
end
|
|
437
457
|
end
|
|
438
|
-
# Guess from column name
|
|
439
|
-
return "email" if col.to_s.include?("email")
|
|
440
|
-
return "date" if col.to_s.end_with?("_at", "_date")
|
|
441
|
-
return "number" if col.to_s.end_with?("_id") && col.to_s != "id"
|
|
442
|
-
"text"
|
|
443
458
|
end
|
|
444
459
|
|
|
445
|
-
#
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
<meta charset="UTF-8">
|
|
457
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
458
|
-
<title>#{h(title)}</title>
|
|
459
|
-
<link rel="stylesheet" href="/css/tina4.min.css">
|
|
460
|
-
<style nonce="#{Tina4::Csp.current_nonce}">
|
|
461
|
-
.crud-container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
|
|
462
|
-
.crud-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
|
463
|
-
.crud-search { max-width: 300px; }
|
|
464
|
-
.crud-actions { display: flex; gap: 0.5rem; align-items: center; }
|
|
465
|
-
.crud-info { color: var(--text-muted, #6c757d); font-size: 0.875rem; margin-bottom: 0.5rem; }
|
|
466
|
-
.crud-pagination { display: flex; justify-content: center; gap: 0.25rem; margin-top: 1rem; }
|
|
467
|
-
.sort-link { text-decoration: none; color: inherit; cursor: pointer; }
|
|
468
|
-
.sort-link:hover { text-decoration: underline; }
|
|
469
|
-
.sort-indicator { font-size: 0.75rem; }
|
|
470
|
-
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
|
471
|
-
background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
|
|
472
|
-
.modal-overlay.active { display: flex; }
|
|
473
|
-
.modal-box { background: var(--bg, #fff); border-radius: 0.5rem; padding: 1.5rem;
|
|
474
|
-
width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto;
|
|
475
|
-
box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
|
|
476
|
-
.modal-box h3 { margin-top: 0; }
|
|
477
|
-
.modal-footer { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
|
|
478
|
-
.alert { padding: 0.75rem 1rem; border-radius: 0.25rem; margin-bottom: 1rem; display: none; }
|
|
479
|
-
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
|
480
|
-
.alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
|
481
|
-
</style>
|
|
482
|
-
</head>
|
|
483
|
-
<body>
|
|
484
|
-
<div class="crud-container">
|
|
485
|
-
<div id="crud-alert" class="alert"></div>
|
|
486
|
-
<div class="crud-header">
|
|
487
|
-
<h2>#{h(title)}</h2>
|
|
488
|
-
<div class="crud-actions">
|
|
489
|
-
<form method="GET" action="#{h(request_path)}" style="display:flex;gap:0.5rem;">
|
|
490
|
-
<input type="text" name="search" value="#{h(search)}" placeholder="Search..."
|
|
491
|
-
class="form-control crud-search">
|
|
492
|
-
<button type="submit" class="btn btn-secondary">Search</button>
|
|
493
|
-
</form>
|
|
494
|
-
<button class="btn btn-primary" data-crud-action="create">+ New</button>
|
|
495
|
-
</div>
|
|
496
|
-
</div>
|
|
497
|
-
<div class="crud-info">
|
|
498
|
-
Showing #{records.length} of #{total} records (page #{page} of #{total_pages})
|
|
499
|
-
</div>
|
|
500
|
-
<div class="table-responsive">
|
|
501
|
-
<table class="table table-striped table-hover">
|
|
502
|
-
<thead class="table-dark"><tr>
|
|
503
|
-
HTML
|
|
504
|
-
|
|
505
|
-
# Table headers with sort links
|
|
506
|
-
columns.each do |col|
|
|
507
|
-
next_dir = (sort_col == col.to_s && sort_dir == "asc") ? "desc" : "asc"
|
|
508
|
-
indicator = ""
|
|
509
|
-
if sort_col == col.to_s
|
|
510
|
-
indicator = sort_dir == "asc" ? " <span class=\"sort-indicator\">▲</span>" : " <span class=\"sort-indicator\">▼</span>"
|
|
511
|
-
end
|
|
512
|
-
sort_params = "sort=#{h(col)}&sort_dir=#{next_dir}&page=#{page}&search=#{URI.encode_www_form_component(search)}&limit=#{limit}"
|
|
513
|
-
html += "<th><a class=\"sort-link\" href=\"#{h(request_path)}?#{sort_params}\">#{pretty_label(col)}#{indicator}</a></th>"
|
|
460
|
+
# One flat list of pagination controls (Prev, numbered pages, Next) for
|
|
461
|
+
# crud/page.twig. Each carries exactly one of active/inactive, so the
|
|
462
|
+
# template renders them with a single loop and no nested if/else (the
|
|
463
|
+
# limited engine does not support a nested conditional).
|
|
464
|
+
def page_controls(page, total_pages, request_path, search, sort_col, sort_dir, limit)
|
|
465
|
+
return [] if total_pages <= 1
|
|
466
|
+
|
|
467
|
+
controls = []
|
|
468
|
+
if page > 1
|
|
469
|
+
controls << { "label" => "Prev", "page" => page - 1, "active" => false, "inactive" => true,
|
|
470
|
+
"url" => page_url(request_path, page - 1, search, sort_col, sort_dir, limit) }
|
|
514
471
|
end
|
|
515
|
-
html += "<th>Actions</th></tr></thead><tbody>"
|
|
516
472
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
html += "<tr>"
|
|
524
|
-
columns.each do |col|
|
|
525
|
-
value = row[col.to_sym] || row[col.to_s] || row[col]
|
|
526
|
-
html += "<td>#{h(value)}</td>"
|
|
527
|
-
end
|
|
528
|
-
html += "<td>"
|
|
529
|
-
html += "<button class=\"btn btn-sm btn-primary me-1\" data-crud-action=\"edit\" data-id=\"#{h(pk_value)}\">Edit</button>"
|
|
530
|
-
html += "<button class=\"btn btn-sm btn-danger\" data-crud-action=\"delete\" data-id=\"#{h(pk_value)}\">Delete</button>"
|
|
531
|
-
html += "</td></tr>"
|
|
532
|
-
end
|
|
473
|
+
start_page = [page - 3, 1].max
|
|
474
|
+
end_page = [start_page + 6, total_pages].min
|
|
475
|
+
start_page = [end_page - 6, 1].max
|
|
476
|
+
(start_page..end_page).each do |p|
|
|
477
|
+
controls << { "label" => p, "page" => p, "active" => (p == page), "inactive" => (p != page),
|
|
478
|
+
"url" => page_url(request_path, p, search, sort_col, sort_dir, limit) }
|
|
533
479
|
end
|
|
534
|
-
html += "</tbody></table></div>"
|
|
535
480
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
if page > 1
|
|
540
|
-
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>"
|
|
541
|
-
end
|
|
542
|
-
# Show page numbers (max 7)
|
|
543
|
-
start_page = [page - 3, 1].max
|
|
544
|
-
end_page = [start_page + 6, total_pages].min
|
|
545
|
-
start_page = [end_page - 6, 1].max
|
|
546
|
-
(start_page..end_page).each do |p|
|
|
547
|
-
active = p == page ? " btn-primary" : " btn-secondary"
|
|
548
|
-
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>"
|
|
549
|
-
end
|
|
550
|
-
if page < total_pages
|
|
551
|
-
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>"
|
|
552
|
-
end
|
|
553
|
-
html += "</div>"
|
|
481
|
+
if page < total_pages
|
|
482
|
+
controls << { "label" => "Next", "page" => page + 1, "active" => false, "inactive" => true,
|
|
483
|
+
"url" => page_url(request_path, page + 1, search, sort_col, sort_dir, limit) }
|
|
554
484
|
end
|
|
555
|
-
|
|
556
|
-
# Create modal
|
|
557
|
-
html += build_modal("create", "Create New Record", editable_columns, pk, api_path, request_path)
|
|
558
|
-
|
|
559
|
-
# Edit modal
|
|
560
|
-
html += build_modal("edit", "Edit Record", editable_columns, pk, api_path, request_path, edit: true)
|
|
561
|
-
|
|
562
|
-
# Delete confirmation modal
|
|
563
|
-
html += <<~HTML
|
|
564
|
-
<div class="modal-overlay" id="modal-delete">
|
|
565
|
-
<div class="modal-box">
|
|
566
|
-
<h3>Confirm Delete</h3>
|
|
567
|
-
<p>Are you sure you want to delete this record? This action cannot be undone.</p>
|
|
568
|
-
<input type="hidden" id="delete-pk-value">
|
|
569
|
-
<div class="modal-footer">
|
|
570
|
-
<button class="btn btn-secondary" data-crud-action="close" data-crud-modal="delete">Cancel</button>
|
|
571
|
-
<button class="btn btn-danger" data-crud-action="confirm-delete">Delete</button>
|
|
572
|
-
</div>
|
|
573
|
-
</div>
|
|
574
|
-
</div>
|
|
575
|
-
HTML
|
|
576
|
-
|
|
577
|
-
# JavaScript
|
|
578
|
-
html += build_crud_javascript(api_path, pk, editable_columns, request_path)
|
|
579
|
-
|
|
580
|
-
html += "</div></body></html>"
|
|
581
|
-
html
|
|
485
|
+
controls
|
|
582
486
|
end
|
|
583
487
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
html += "<form id=\"form-#{id}\" data-crud-form=\"1\">"
|
|
590
|
-
html += "<input type=\"hidden\" id=\"#{id}-pk-value\" name=\"#{pk}\">" if edit
|
|
591
|
-
|
|
592
|
-
columns.each do |col|
|
|
593
|
-
label = pretty_label(col)
|
|
594
|
-
field_id = "#{id}-#{col}"
|
|
595
|
-
html += "<div class=\"mb-3\">"
|
|
596
|
-
html += "<label for=\"#{field_id}\" class=\"form-label\">#{label}</label>"
|
|
597
|
-
html += "<input type=\"text\" class=\"form-control\" id=\"#{field_id}\" name=\"#{col}\" placeholder=\"Enter #{label.downcase}\">"
|
|
598
|
-
html += "</div>"
|
|
599
|
-
end
|
|
600
|
-
|
|
601
|
-
html += "<div class=\"modal-footer\">"
|
|
602
|
-
html += "<button type=\"button\" class=\"btn btn-secondary\" data-crud-action=\"close\" data-crud-modal=\"#{h(id)}\">Cancel</button>"
|
|
603
|
-
html += "<button type=\"button\" class=\"btn btn-primary\" data-crud-action=\"save\" data-crud-mode=\"#{edit ? 'edit' : 'create'}\">Save</button>"
|
|
604
|
-
html += "</div></form></div></div>"
|
|
605
|
-
html
|
|
488
|
+
def page_url(request_path, p, search, sort_col, sort_dir, limit)
|
|
489
|
+
query = "page=#{p}&search=#{URI.encode_www_form_component(search.to_s)}" \
|
|
490
|
+
"&sort=#{URI.encode_www_form_component(sort_col.to_s)}" \
|
|
491
|
+
"&sort_dir=#{sort_dir}&limit=#{limit}"
|
|
492
|
+
h("#{request_path}?#{query}")
|
|
606
493
|
end
|
|
607
494
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
var CRUD_API = '#{api_path}';
|
|
614
|
-
var CRUD_PK = '#{pk}';
|
|
615
|
-
var CRUD_COLUMNS = #{columns_json};
|
|
616
|
-
|
|
617
|
-
function crudShowAlert(message, type) {
|
|
618
|
-
var el = document.getElementById('crud-alert');
|
|
619
|
-
el.className = 'alert alert-' + type;
|
|
620
|
-
el.textContent = message;
|
|
621
|
-
el.style.display = 'block';
|
|
622
|
-
setTimeout(function() { el.style.display = 'none'; }, 3000);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function crudShowCreate() {
|
|
626
|
-
var form = document.getElementById('form-create');
|
|
627
|
-
form.reset();
|
|
628
|
-
document.getElementById('modal-create').classList.add('active');
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
function crudShowEdit(id) {
|
|
632
|
-
fetch(CRUD_API + '/' + id)
|
|
633
|
-
.then(function(r) { return r.json(); })
|
|
634
|
-
.then(function(result) {
|
|
635
|
-
var data = result.data || result;
|
|
636
|
-
document.getElementById('edit-pk-value').value = id;
|
|
637
|
-
CRUD_COLUMNS.forEach(function(col) {
|
|
638
|
-
var input = document.getElementById('edit-' + col);
|
|
639
|
-
if (input) input.value = data[col] != null ? data[col] : '';
|
|
640
|
-
});
|
|
641
|
-
document.getElementById('modal-edit').classList.add('active');
|
|
642
|
-
})
|
|
643
|
-
.catch(function(e) { crudShowAlert('Failed to load record: ' + e, 'danger'); });
|
|
644
|
-
}
|
|
495
|
+
def sort_url(request_path, col, next_dir, page, search, limit)
|
|
496
|
+
query = "sort=#{URI.encode_www_form_component(col.to_s)}&sort_dir=#{next_dir}" \
|
|
497
|
+
"&page=#{page}&search=#{URI.encode_www_form_component(search.to_s)}&limit=#{limit}"
|
|
498
|
+
h("#{request_path}?#{query}")
|
|
499
|
+
end
|
|
645
500
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
document.getElementById('modal-delete').classList.add('active');
|
|
649
|
-
}
|
|
501
|
+
def sort_indicator(sort_col, col, sort_dir)
|
|
502
|
+
return "" unless sort_col.to_s == col.to_s
|
|
650
503
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
function crudSaveCreate() {
|
|
656
|
-
var data = {};
|
|
657
|
-
CRUD_COLUMNS.forEach(function(col) {
|
|
658
|
-
var input = document.getElementById('create-' + col);
|
|
659
|
-
if (input) data[col] = input.value;
|
|
660
|
-
});
|
|
661
|
-
fetch(CRUD_API, {
|
|
662
|
-
method: 'POST',
|
|
663
|
-
headers: { 'Content-Type': 'application/json' },
|
|
664
|
-
body: JSON.stringify(data)
|
|
665
|
-
})
|
|
666
|
-
.then(function(r) { return r.json(); })
|
|
667
|
-
.then(function(result) {
|
|
668
|
-
if (result.error) { crudShowAlert(result.error, 'danger'); return; }
|
|
669
|
-
crudCloseModal('create');
|
|
670
|
-
crudShowAlert('Record created successfully', 'success');
|
|
671
|
-
setTimeout(function() { window.location.reload(); }, 500);
|
|
672
|
-
})
|
|
673
|
-
.catch(function(e) { crudShowAlert('Failed to create: ' + e, 'danger'); });
|
|
674
|
-
}
|
|
504
|
+
arrow = sort_dir == "asc" ? "▲" : "▼"
|
|
505
|
+
" <span class=\"sort-indicator\">#{arrow}</span>"
|
|
506
|
+
end
|
|
675
507
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (result.error) { crudShowAlert(result.error, 'danger'); return; }
|
|
691
|
-
crudCloseModal('edit');
|
|
692
|
-
crudShowAlert('Record updated successfully', 'success');
|
|
693
|
-
setTimeout(function() { window.location.reload(); }, 500);
|
|
694
|
-
})
|
|
695
|
-
.catch(function(e) { crudShowAlert('Failed to update: ' + e, 'danger'); });
|
|
696
|
-
}
|
|
508
|
+
# JSON config injected into the page's nonce'd <script>. The grid's whole
|
|
509
|
+
# state (search/sort/sort_dir/page), the display columns, their alignment
|
|
510
|
+
# and labels, and the editable columns all travel here so the client can
|
|
511
|
+
# drive the AutoCrud list endpoint and re-render the table over fetch().
|
|
512
|
+
# The < > & characters are unicode-escaped so the literal is safe inside
|
|
513
|
+
# the <script> element.
|
|
514
|
+
def js_config(api_path:, pk:, columns:, editable:, model:, limit:, search:,
|
|
515
|
+
sort_col:, sort_dir:, page:)
|
|
516
|
+
aligns = {}
|
|
517
|
+
labels = {}
|
|
518
|
+
columns.each do |col|
|
|
519
|
+
aligns[col.to_s] = column_alignment(model, col)
|
|
520
|
+
labels[col.to_s] = pretty_label(col)
|
|
521
|
+
end
|
|
697
522
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
523
|
+
config = {
|
|
524
|
+
"api" => api_path,
|
|
525
|
+
"pk" => pk,
|
|
526
|
+
"columns" => columns.map(&:to_s),
|
|
527
|
+
"editable" => editable.map(&:to_s),
|
|
528
|
+
"aligns" => aligns,
|
|
529
|
+
"labels" => labels,
|
|
530
|
+
"limit" => limit,
|
|
531
|
+
"search" => search.to_s,
|
|
532
|
+
"sort" => sort_col.to_s,
|
|
533
|
+
"sort_dir" => sort_dir,
|
|
534
|
+
"page" => page
|
|
535
|
+
}
|
|
536
|
+
JSON.generate(config).gsub("<", "\\u003c").gsub(">", "\\u003e").gsub("&", "\\u0026")
|
|
537
|
+
end
|
|
710
538
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
event.preventDefault();
|
|
716
|
-
}
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
// CSP-clean action wiring: a nonce authorises this <script> element
|
|
720
|
-
// but never an inline on*= attribute, so every button carries
|
|
721
|
-
// data-crud-action and binds through one delegated listener.
|
|
722
|
-
document.addEventListener('click', function (event) {
|
|
723
|
-
var button = event.target.closest('[data-crud-action]');
|
|
724
|
-
if (!button) return;
|
|
725
|
-
var action = button.dataset.crudAction;
|
|
726
|
-
if (action === 'create') {
|
|
727
|
-
crudShowCreate();
|
|
728
|
-
} else if (action === 'edit') {
|
|
729
|
-
crudShowEdit(button.dataset.id);
|
|
730
|
-
} else if (action === 'delete') {
|
|
731
|
-
crudShowDelete(button.dataset.id);
|
|
732
|
-
} else if (action === 'close') {
|
|
733
|
-
crudCloseModal(button.dataset.crudModal);
|
|
734
|
-
} else if (action === 'confirm-delete') {
|
|
735
|
-
crudConfirmDelete();
|
|
736
|
-
} else if (action === 'save') {
|
|
737
|
-
if (button.dataset.crudMode === 'edit') { crudSaveEdit(); }
|
|
738
|
-
else { crudSaveCreate(); }
|
|
739
|
-
}
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
// Close modal on overlay click
|
|
743
|
-
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
|
|
744
|
-
overlay.addEventListener('click', function(e) {
|
|
745
|
-
if (e.target === overlay) overlay.classList.remove('active');
|
|
746
|
-
});
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
// Close modal on Escape key
|
|
750
|
-
document.addEventListener('keydown', function(e) {
|
|
751
|
-
if (e.key === 'Escape') {
|
|
752
|
-
document.querySelectorAll('.modal-overlay.active').forEach(function(m) {
|
|
753
|
-
m.classList.remove('active');
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
});
|
|
757
|
-
</script>
|
|
758
|
-
HTML
|
|
539
|
+
# Escape HTML special characters. Delegates to the framework's one
|
|
540
|
+
# canonical escaper so CRUD never carries its own copy of the escape table.
|
|
541
|
+
def h(text)
|
|
542
|
+
Tina4::Frond.escape_html(text.to_s)
|
|
759
543
|
end
|
|
760
544
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
function crudSave(table, id) {
|
|
765
|
-
const row = document.querySelector(`tr[data-id="${id}"]`);
|
|
766
|
-
const cells = row.querySelectorAll('td[data-field]');
|
|
767
|
-
const data = {};
|
|
768
|
-
cells.forEach(cell => { data[cell.dataset.field] = cell.textContent; });
|
|
769
|
-
fetch(`/api/${table}/${id}`, {
|
|
770
|
-
method: 'PUT',
|
|
771
|
-
headers: { 'Content-Type': 'application/json' },
|
|
772
|
-
body: JSON.stringify(data)
|
|
773
|
-
}).then(r => r.json()).then(d => { alert('Saved!'); }).catch(e => alert('Error: ' + e));
|
|
774
|
-
}
|
|
775
|
-
function crudDelete(table, id) {
|
|
776
|
-
if (!confirm('Delete this record?')) return;
|
|
777
|
-
fetch(`/api/${table}/${id}`, { method: 'DELETE' })
|
|
778
|
-
.then(r => r.json())
|
|
779
|
-
.then(d => { document.querySelector(`tr[data-id="${id}"]`).remove(); })
|
|
780
|
-
.catch(e => alert('Error: ' + e));
|
|
781
|
-
}
|
|
782
|
-
// CSP-clean wiring: a nonce authorises this <script> element but never
|
|
783
|
-
// an inline on*= attribute, so the Save/Delete buttons carry
|
|
784
|
-
// data-crud-inline + data-* and bind through one delegated listener.
|
|
785
|
-
document.addEventListener('click', function (event) {
|
|
786
|
-
var button = event.target.closest('[data-crud-inline]');
|
|
787
|
-
if (!button) return;
|
|
788
|
-
if (button.dataset.crudInline === 'save') {
|
|
789
|
-
crudSave(button.dataset.table, button.dataset.id);
|
|
790
|
-
} else if (button.dataset.crudInline === 'delete') {
|
|
791
|
-
crudDelete(button.dataset.table, button.dataset.id);
|
|
792
|
-
}
|
|
793
|
-
});
|
|
794
|
-
</script>
|
|
795
|
-
JS
|
|
545
|
+
# Pretty label from a column name: "user_name" => "User Name".
|
|
546
|
+
def pretty_label(col)
|
|
547
|
+
col.to_s.split("_").map(&:capitalize).join(" ")
|
|
796
548
|
end
|
|
797
549
|
end
|
|
798
550
|
end
|