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.
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 — Auto-generate a complete HTML CRUD interface from a SQL query or ORM model.
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
- # Or with an ORM model class:
23
- # Tina4.get "/admin/users" do |request, response|
24
- # response.html(Tina4::Crud.to_crud(request, {
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
- # Generate a complete CRUD HTML interface: searchable/paginated table
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] configuration options
37
- # @option options [String] :sql SQL query for listing records
38
- # @option options [Class] :model ORM model class (alternative to :sql)
39
- # @option options [String] :title page title (default: table name)
40
- # @option options [String] :primary_key primary key column (default: "id")
41
- # @option options [String] :prefix API route prefix (default: "/api")
42
- # @option options [Integer] :limit records per page (default: 10)
43
- # @return [String] complete HTML page
44
- def to_crud(request, options = {})
45
- sql = options[:sql]
46
- model = options[:model]
47
- title = options[:title] || "CRUD"
48
- pk = options[:primary_key] || "id"
49
- prefix = options[:prefix] || "/api"
50
- limit = (options[:limit] || 10).to_i
51
-
52
- # Determine table name and columns from SQL or model
53
- if model
54
- table_name = model.table_name.to_s
55
- pk = (model.primary_key_field || :id).to_s
56
- columns = model.field_definitions.keys.map(&:to_s)
57
- elsif sql
58
- table_name = extract_table_name(sql)
59
- columns = extract_columns(sql)
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 = total > 0 ? (total.to_f / limit).ceil : 1
82
- api_path = "#{prefix}/#{table_name}"
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
- # Build the HTML
88
- build_crud_html(
89
- title: title, table_name: table_name, pk: pk,
90
- columns: columns, records: records,
91
- page: page, total_pages: total_pages, total: total,
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
- # Generate an HTML table from an array of record hashes.
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
- return "<p>No records found.</p>" if records.nil? || records.empty?
101
-
102
- columns = records.first.keys
103
-
104
- html = <<~HTML
105
- <div class="table-responsive">
106
- <table class="table table-striped table-hover" id="crud-#{table_name}">
107
- <thead class="table-dark"><tr>
108
- HTML
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
- # Generate an HTML form from a field definition array.
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
- html = "<form action=\"#{action}\" method=\"#{method}\" class=\"needs-validation\" novalidate>"
148
- html += "<input type=\"hidden\" name=\"_method\" value=\"#{method}\">" if %w[PUT PATCH DELETE].include?(method.upcase)
149
-
150
- fields.each do |field|
151
- name = field[:name]
152
- type = field[:type] || :string
153
- label = field[:label] || name.to_s.capitalize
154
- value = field[:value] || ""
155
- required = field[:required] || false
156
-
157
- html += "<div class=\"mb-3\">"
158
- html += "<label for=\"#{name}\" class=\"form-label\">#{label}</label>"
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 tables have had CRUD routes registered
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
- def crud_routes_registered?(table_name, prefix)
198
- registered_tables["#{prefix}/#{table_name}"]
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
- # Register REST API routes for the CRUD interface when using :sql mode.
202
- # When using :model mode, the caller should use AutoCrud.register instead
203
- # for full ORM-backed routes. These routes provide basic SQL-backed CRUD.
204
- def register_crud_routes(model, table_name, pk, prefix)
205
- api_path = "#{prefix}/#{table_name}"
206
- registered_tables[api_path] = true
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
- db = Tina4.database
216
- return unless db
217
-
218
- # GET list (already handled by the page itself)
219
- # POST create
220
- Tina4::Router.add("POST", api_path, proc { |req, res|
221
- begin
222
- data = table_column_attributes(db, table_name, req.body_parsed)
223
- db.insert(table_name, data)
224
- res.json({ data: data, message: "Created" }, status: 201)
225
- rescue => e
226
- res.json({ error: e.message }, status: 500)
227
- end
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
- # PUT update
231
- Tina4::Router.add("PUT", "#{api_path}/{id}", proc { |req, res|
232
- begin
233
- id = req.params["id"]
234
- # The row is addressed by the URL id only, never by a body pk.
235
- data = table_column_attributes(db, table_name, req.body_parsed, strip: pk)
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
- # DELETE
244
- Tina4::Router.add("DELETE", "#{api_path}/{id}", proc { |req, res|
245
- begin
246
- id = req.params["id"]
247
- db.delete(table_name, { pk => id })
248
- res.json({ message: "Deleted" })
249
- rescue => e
250
- res.json({ error: e.message }, status: 500)
251
- end
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
- # tina4: ADR-0069 - a SQL-mode write body is allow-listed against the
256
- # table's REAL columns (matched case-insensitively, written in the
257
- # introspected spelling); unknown keys are dropped, is_deleted is never
258
- # client-writable, and +strip+ (the pk on update) is removed. Same write
259
- # rule as AutoCrud (CRUD-DEC-02).
260
- def table_column_attributes(db, table_name, data, strip: nil)
261
- return {} unless data.is_a?(Hash)
262
-
263
- columns = db.columns(table_name).to_h { |column| [column[:name].to_s.downcase, column[:name].to_s] }
264
- blocked = ["is_deleted", strip.to_s.downcase]
265
- data.each_with_object({}) do |(key, value), allowed|
266
- column = columns[key.to_s.downcase]
267
- next if column.nil? || blocked.include?(column.downcase)
268
-
269
- allowed[column] = value
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
- # tina4: ADR-0069 - ?sort reaches ORDER BY only as a column the source
274
- # itself declares: a model's declared field (resolved to its DB column) or
275
- # a column of the SQL query's own result set. Anything else falls back to
276
- # the primary key - this is a rendered page, not an API, so no error.
277
- def crud_sort_column(model, sql, requested, pk)
278
- return pk if requested.nil? || requested.empty?
279
- return model.resolve_field_column(requested) || pk if model
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
- sql_result_columns(sql).include?(requested) ? requested : pk
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
- # The query with any ORDER BY / LIMIT clause removed, then stripped.
285
- #
286
- # Line by line with plain string operations - a case-insensitive search
287
- # for the keyword, then a cut to the end of that line - so it is linear on
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
- # +keyword+ is a fixed literal with no quantifier, so the search is linear.
298
- def cut_from_keyword(line, keyword)
299
- ending = line.end_with?("\n") ? "\n" : ""
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
- content[0...at] + ending
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
- # Column names of the SQL query's result set, read from one probe row.
308
- def sql_result_columns(sql)
309
- db = Tina4.database
310
- return [] unless db
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
- base = strip_order_and_limit(sql)
313
- row = db.fetch("SELECT * FROM (#{base}) AS _crud_sub", [], limit: 1).first
314
- row ? row.keys.map(&:to_s) : []
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 data using an ORM model class
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
- [:string, :text].include?(opts[:type])
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
- where_parts = searchable.map { |col| "#{col} LIKE ?" }
334
- where_clause = where_parts.join(" OR ")
375
+ where_clause = searchable.map { |col| "#{col} LIKE ?" }.join(" OR ")
335
376
  params = searchable.map { "%#{search}%" }
336
- records = model.where(where_clause, params)
337
- total = records.length
338
- records = records.slice(offset, limit) || []
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
- record_hashes = records.map { |r| r.to_h }
343
- [record_hashes, total]
383
+ [records.map(&:to_h), total]
344
384
  end
345
385
 
346
- # Fetch data using a raw SQL query
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 !search.empty?
356
- # Wrap in a subquery to add search filtering
357
- wrapped = "SELECT * FROM (#{base}) AS _crud_sub WHERE "
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 += search_parts.join(" OR ")
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
- # Extract table name from a SQL SELECT statement
384
- def extract_table_name(sql)
385
- match = sql.match(/FROM\s+(\w+)/i)
386
- match ? match[1] : "data"
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 { |c|
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
- $1
451
+ Regexp.last_match(1)
402
452
  elsif c.include?(".")
403
453
  c.split(".").last.strip
404
454
  else
405
- c.strip
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
- # & < > " ' -> &amp; &lt; &gt; &quot; &#39;) 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
- # Build the complete CRUD HTML page
446
- def build_crud_html(title:, table_name:, pk:, columns:, records:,
447
- page:, total_pages:, total:, limit:, search:,
448
- sort_col:, sort_dir:, api_path:, request_path:)
449
- # Filter out auto-increment PK from editable columns
450
- editable_columns = columns.reject { |c| c.to_s == pk.to_s }
451
-
452
- html = <<~HTML
453
- <!DOCTYPE html>
454
- <html lang="en">
455
- <head>
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\">&#9650;</span>" : " <span class=\"sort-indicator\">&#9660;</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
- # Table body
518
- if records.empty?
519
- html += "<tr><td colspan=\"#{columns.length + 1}\" style=\"text-align:center;padding:2rem;\">No records found.</td></tr>"
520
- else
521
- records.each do |row|
522
- pk_value = row[pk.to_sym] || row[pk.to_s] || row[pk]
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
- # Pagination
537
- if total_pages > 1
538
- html += "<div class=\"crud-pagination\">"
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
- # Build a create or edit modal
585
- def build_modal(id, title, columns, pk, api_path, request_path, edit: false)
586
- html = "<div class=\"modal-overlay\" id=\"modal-#{id}\">"
587
- html += "<div class=\"modal-box\">"
588
- html += "<h3>#{h(title)}</h3>"
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
- # Build the JavaScript for the CRUD interface
609
- def build_crud_javascript(api_path, pk, columns, request_path)
610
- columns_json = JSON.generate(columns.map(&:to_s))
611
- <<~HTML
612
- <script nonce="#{Tina4::Csp.current_nonce}">
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
- function crudShowDelete(id) {
647
- document.getElementById('delete-pk-value').value = id;
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
- function crudCloseModal(name) {
652
- document.getElementById('modal-' + name).classList.remove('active');
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" ? "&#9650;" : "&#9660;"
505
+ " <span class=\"sort-indicator\">#{arrow}</span>"
506
+ end
675
507
 
676
- function crudSaveEdit() {
677
- var id = document.getElementById('edit-pk-value').value;
678
- var data = {};
679
- CRUD_COLUMNS.forEach(function(col) {
680
- var input = document.getElementById('edit-' + col);
681
- if (input) data[col] = input.value;
682
- });
683
- fetch(CRUD_API + '/' + id, {
684
- method: 'PUT',
685
- headers: { 'Content-Type': 'application/json' },
686
- body: JSON.stringify(data)
687
- })
688
- .then(function(r) { return r.json(); })
689
- .then(function(result) {
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
- function crudConfirmDelete() {
699
- var id = document.getElementById('delete-pk-value').value;
700
- fetch(CRUD_API + '/' + id, { method: 'DELETE' })
701
- .then(function(r) { return r.json(); })
702
- .then(function(result) {
703
- if (result.error) { crudShowAlert(result.error, 'danger'); return; }
704
- crudCloseModal('delete');
705
- crudShowAlert('Record deleted successfully', 'success');
706
- setTimeout(function() { window.location.reload(); }, 500);
707
- })
708
- .catch(function(e) { crudShowAlert('Failed to delete: ' + e, 'danger'); });
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
- // Replaces the modal forms' inline submit handler (a CSP-blocked on*=
712
- // attribute): they post over fetch(), so stop the native submit here.
713
- document.addEventListener('submit', function (event) {
714
- if (event.target.closest('form[data-crud-form]')) {
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
- def inline_crud_javascript(table_name)
762
- <<~JS
763
- <script nonce="#{Tina4::Csp.current_nonce}">
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