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/dev_admin.rb
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
require "json"
|
|
10
10
|
require "digest"
|
|
11
|
+
require "ipaddr"
|
|
11
12
|
require "tmpdir"
|
|
12
13
|
require "net/http"
|
|
13
14
|
require "uri"
|
|
@@ -2134,6 +2135,11 @@ module Tina4
|
|
|
2134
2135
|
# (with_mcp_gate -> 404), so the REST loopback arm skips these prefixes and
|
|
2135
2136
|
# lets the MCP gate govern (keeps a disallowed mcp/call a 404, not a 403).
|
|
2136
2137
|
DEV_MCP_PREFIXES = ["/__dev/api/mcp", "/__dev/mcp"].freeze
|
|
2138
|
+
|
|
2139
|
+
# Static dev-toolbar assets that carry no secrets and expose no actions, so
|
|
2140
|
+
# the peer gate does not cover them: wherever the toolbar is injected its
|
|
2141
|
+
# stylesheet and script must load (issue #279). Host + same-origin still apply.
|
|
2142
|
+
DEV_PUBLIC_ASSETS = ["/__dev/toolbar.css", "/__dev/toolbar.js"].freeze
|
|
2137
2143
|
# Private-key / credential basenames the file endpoints must never serve.
|
|
2138
2144
|
DEV_SECRET_BASENAMES = %w[.env .envrc id_rsa id_dsa id_ecdsa id_ed25519].freeze
|
|
2139
2145
|
DEV_SECRET_SUFFIXES = %w[.pem .key .pfx .p12 .keystore .jks].freeze
|
|
@@ -2186,9 +2192,14 @@ module Tina4
|
|
|
2186
2192
|
return dev_refused("dev-admin: refused (host not allowed)") unless dev_host_allowed?(env)
|
|
2187
2193
|
return dev_refused("dev-admin: refused (cross-origin request)") unless dev_same_origin_ok?(env)
|
|
2188
2194
|
|
|
2195
|
+
# The static toolbar assets are exempt from the peer gate (#279): they
|
|
2196
|
+
# carry no secrets and the toolbar needs them wherever it is injected.
|
|
2197
|
+
# Host + same-origin above still apply.
|
|
2198
|
+
return nil if DEV_PUBLIC_ASSETS.include?(path)
|
|
2199
|
+
|
|
2189
2200
|
unless path.start_with?(*DEV_MCP_PREFIXES)
|
|
2190
2201
|
remote_ip = (env["REMOTE_ADDR"] || "").to_s
|
|
2191
|
-
unless
|
|
2202
|
+
unless dev_peer_allowed?(remote_ip) || mcp_token_ok?(env, dedicated: true)
|
|
2192
2203
|
return dev_refused("dev-admin: refused (non-loopback peer)")
|
|
2193
2204
|
end
|
|
2194
2205
|
end
|
|
@@ -2216,6 +2227,47 @@ module Tina4
|
|
|
2216
2227
|
end
|
|
2217
2228
|
public :dev_host_allowed?
|
|
2218
2229
|
|
|
2230
|
+
# True when raw peer +ip+ falls inside +entry+ (a bare IP or a CIDR). IPv4
|
|
2231
|
+
# and IPv6; an IPv4-mapped IPv6 peer (::ffff:a.b.c.d) is matched as IPv4.
|
|
2232
|
+
def ip_in_cidr?(ip, entry)
|
|
2233
|
+
ip = ip.to_s.strip
|
|
2234
|
+
ip = ip[7..] if ip.downcase.start_with?("::ffff:") # mapped -> compare as IPv4
|
|
2235
|
+
net = IPAddr.new(entry.to_s.strip)
|
|
2236
|
+
addr = IPAddr.new(ip)
|
|
2237
|
+
return false unless net.family == addr.family # v4 peer never matches a v6 CIDR
|
|
2238
|
+
net.include?(addr)
|
|
2239
|
+
rescue IPAddr::Error, ArgumentError
|
|
2240
|
+
false
|
|
2241
|
+
end
|
|
2242
|
+
public :ip_in_cidr?
|
|
2243
|
+
|
|
2244
|
+
# True when the RAW socket peer may reach /__dev: loopback always, plus any
|
|
2245
|
+
# IP/CIDR in TINA4_DEV_ALLOWED_PEERS (comma-separated, opt-in, default none).
|
|
2246
|
+
# Reads the real socket peer ONLY, never a forwarded header, so it cannot be
|
|
2247
|
+
# spoofed by X-Forwarded-For. The documented way to reach the dev dashboard
|
|
2248
|
+
# from a Docker dev box, where requests arrive from the container-network
|
|
2249
|
+
# gateway rather than loopback (issue #279).
|
|
2250
|
+
def dev_peer_allowed?(remote_ip)
|
|
2251
|
+
return true if Tina4.is_loopback?(remote_ip)
|
|
2252
|
+
|
|
2253
|
+
configured = ENV["TINA4_DEV_ALLOWED_PEERS"].to_s.strip
|
|
2254
|
+
return false if configured.empty?
|
|
2255
|
+
|
|
2256
|
+
configured.split(",").any? { |entry| !entry.strip.empty? && ip_in_cidr?(remote_ip, entry) }
|
|
2257
|
+
end
|
|
2258
|
+
public :dev_peer_allowed?
|
|
2259
|
+
|
|
2260
|
+
# True when the toolbar may be injected for this viewer - the Host allow-list
|
|
2261
|
+
# and the raw-peer gate both pass. A viewer the /__dev gate would refuse gets
|
|
2262
|
+
# NO toolbar markup, so its stylesheet and script are never requested only to
|
|
2263
|
+
# 403 (issue #279).
|
|
2264
|
+
def dev_toolbar_allowed?(env)
|
|
2265
|
+
return false unless dev_host_allowed?(env)
|
|
2266
|
+
|
|
2267
|
+
dev_peer_allowed?((env["REMOTE_ADDR"] || "").to_s)
|
|
2268
|
+
end
|
|
2269
|
+
public :dev_toolbar_allowed?
|
|
2270
|
+
|
|
2219
2271
|
def dev_refused(message)
|
|
2220
2272
|
[403, { "content-type" => "application/json; charset=utf-8" },
|
|
2221
2273
|
[JSON.generate({ "ok" => false, "error" => message })]]
|
|
@@ -587,6 +587,9 @@ module Tina4
|
|
|
587
587
|
status, headers, body_parts = response
|
|
588
588
|
content_type = headers["content-type"] || ""
|
|
589
589
|
return nil unless content_type.include?("text/html")
|
|
590
|
+
# Do not inject the toolbar for a viewer the /__dev gate would refuse (#279):
|
|
591
|
+
# otherwise the page carries toolbar markup whose stylesheet and script 403.
|
|
592
|
+
return nil unless Tina4::DevAdmin.dev_toolbar_allowed?(ctx.env)
|
|
590
593
|
|
|
591
594
|
request_info = {
|
|
592
595
|
method: ctx.method,
|
data/lib/tina4/messenger.rb
CHANGED
|
@@ -90,14 +90,15 @@ module Tina4
|
|
|
90
90
|
|
|
91
91
|
attr_reader :host, :port, :username, :from_address, :from_name,
|
|
92
92
|
:imap_host, :imap_port, :use_tls, :encryption,
|
|
93
|
-
:imap_encryption, :imap_use_tls, :imap_username, :imap_password
|
|
93
|
+
:imap_encryption, :imap_use_tls, :imap_username, :imap_password,
|
|
94
|
+
:timeout
|
|
94
95
|
|
|
95
96
|
# Initialize with SMTP config.
|
|
96
97
|
# Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
|
|
97
98
|
def initialize(host: nil, port: nil, username: nil, password: nil,
|
|
98
99
|
from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
|
|
99
100
|
imap_host: nil, imap_port: nil, imap_encryption: nil,
|
|
100
|
-
imap_username: nil, imap_password: nil)
|
|
101
|
+
imap_username: nil, imap_password: nil, timeout: nil)
|
|
101
102
|
# Whether a host was actually CONFIGURED, which is not the same as @host being
|
|
102
103
|
# set: it falls back to "localhost", so it is never nil and cannot answer
|
|
103
104
|
# "can this messenger send?". The capture gate needs that answer, so record it
|
|
@@ -143,7 +144,55 @@ module Tina4
|
|
|
143
144
|
# account read the wrong mailbox. Explicit constructor args win (ADR-0041).
|
|
144
145
|
@imap_username = imap_username || ENV["TINA4_MAIL_IMAP_USERNAME"] || @username
|
|
145
146
|
@imap_password = imap_password || ENV["TINA4_MAIL_IMAP_PASSWORD"] || @password
|
|
147
|
+
|
|
148
|
+
# SMTP send timeout (seconds): constructor > TINA4_MAIL_TIMEOUT > 30.
|
|
149
|
+
# Whole seconds, at least 1. See resolve_timeout. Parity with the PHP
|
|
150
|
+
# master (Messenger::resolveTimeout, tina4-php#286).
|
|
151
|
+
@timeout = resolve_timeout(timeout, ENV["TINA4_MAIL_TIMEOUT"])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
#: Default socket timeout, in seconds - the value this class has always used.
|
|
155
|
+
TIMEOUT_DEFAULT = 30
|
|
156
|
+
#: Raw TINA4_MAIL_TIMEOUT values already warned about, so a bad one is said once.
|
|
157
|
+
@timeout_warned = {}
|
|
158
|
+
class << self
|
|
159
|
+
attr_reader :timeout_warned
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Resolve the SMTP send timeout to whole seconds (issue #278).
|
|
163
|
+
#
|
|
164
|
+
# An explicit constructor value is the caller's own instruction, so one below
|
|
165
|
+
# a second is a programming error and raises. A bad env value is a
|
|
166
|
+
# misconfiguration: warn once and use the default rather than guess. Zero and
|
|
167
|
+
# negative are garbage, not an opt-out - to the socket 0 means "do not wait at
|
|
168
|
+
# all", so no send could succeed.
|
|
169
|
+
def resolve_timeout(explicit, raw)
|
|
170
|
+
unless explicit.nil?
|
|
171
|
+
seconds = Integer(explicit)
|
|
172
|
+
raise ArgumentError, "Messenger timeout must be at least 1 second, got #{seconds}." if seconds < 1
|
|
173
|
+
|
|
174
|
+
return seconds
|
|
175
|
+
end
|
|
176
|
+
return TIMEOUT_DEFAULT if raw.nil? || raw.to_s.strip.empty?
|
|
177
|
+
|
|
178
|
+
seconds = begin
|
|
179
|
+
Integer(raw.to_s.strip)
|
|
180
|
+
rescue ArgumentError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
if seconds.nil? || seconds < 1
|
|
184
|
+
unless self.class.timeout_warned.key?(raw)
|
|
185
|
+
self.class.timeout_warned[raw] = true
|
|
186
|
+
Tina4::Log.warning(
|
|
187
|
+
"TINA4_MAIL_TIMEOUT must be a whole number of seconds and at least 1, got " \
|
|
188
|
+
"#{raw.inspect} - using the default of #{TIMEOUT_DEFAULT} seconds"
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
return TIMEOUT_DEFAULT
|
|
192
|
+
end
|
|
193
|
+
seconds
|
|
146
194
|
end
|
|
195
|
+
private :resolve_timeout
|
|
147
196
|
|
|
148
197
|
def normalize_encryption(value, label)
|
|
149
198
|
normalized = value.to_s.strip.downcase
|
|
@@ -486,6 +535,7 @@ module Tina4
|
|
|
486
535
|
starttls: @use_tls,
|
|
487
536
|
username: authenticate ? @username : nil,
|
|
488
537
|
password: authenticate ? @password : nil,
|
|
538
|
+
timeout: @timeout,
|
|
489
539
|
&block
|
|
490
540
|
)
|
|
491
541
|
end
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -6,42 +6,51 @@
|
|
|
6
6
|
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
7
|
|
|
8
8
|
require "fileutils"
|
|
9
|
+
require "digest"
|
|
10
|
+
require "tmpdir"
|
|
9
11
|
|
|
10
12
|
module Tina4
|
|
11
13
|
class Migration
|
|
12
14
|
TRACKING_TABLE = "tina4_migration"
|
|
15
|
+
# Lock name shared by GET_LOCK (MySQL) and sp_getapplock (MSSQL).
|
|
16
|
+
LOCK_NAME = "tina4_migration_lock"
|
|
13
17
|
|
|
14
18
|
attr_reader :db, :migrations_dir
|
|
15
19
|
|
|
16
20
|
def initialize(db, migrations_dir: nil)
|
|
17
21
|
@db = db
|
|
18
22
|
@migrations_dir = migrations_dir || resolve_migrations_dir
|
|
19
|
-
|
|
23
|
+
# Ruby boots once per process (no per-request re-entry like PHP's php -S),
|
|
24
|
+
# so there is no concurrent CONSTRUCT race here — the table is ensured
|
|
25
|
+
# eagerly as before. migrate also re-ensures it UNDER the run-wide lock
|
|
26
|
+
# (idempotent), which is where the #277 apply race is actually serialized.
|
|
27
|
+
ensure_tracking_table if @db
|
|
20
28
|
end
|
|
21
29
|
|
|
22
|
-
# Run all pending migrations
|
|
30
|
+
# Run all pending migrations under a cross-process lock.
|
|
31
|
+
#
|
|
32
|
+
# The lock serializes concurrent startup migrations (#277): the hook runs per
|
|
33
|
+
# request under php -S-style servers and once per process on a long-lived
|
|
34
|
+
# server, so several workers could reach a fresh database at once and apply
|
|
35
|
+
# the same migration more than once (a data migration then inserted its rows
|
|
36
|
+
# twice). Now the winner migrates while the rest block, then re-read the
|
|
37
|
+
# applied set and find nothing pending. Every backend auto-releases the lock
|
|
38
|
+
# when the holder's session/process dies, so a crash never deadlocks boot.
|
|
23
39
|
def migrate
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
batch = next_batch_number
|
|
31
|
-
results = []
|
|
32
|
-
pending.each do |file|
|
|
33
|
-
result = run_migration(file, batch)
|
|
34
|
-
results << result
|
|
35
|
-
# Stop on failure
|
|
36
|
-
break if result[:status] == "failed"
|
|
40
|
+
lock = acquire_migration_lock
|
|
41
|
+
begin
|
|
42
|
+
ensure_tracking_table
|
|
43
|
+
run_pending
|
|
44
|
+
ensure
|
|
45
|
+
release_migration_lock(lock)
|
|
37
46
|
end
|
|
38
|
-
results
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
alias run migrate
|
|
42
50
|
|
|
43
51
|
# Rollback last batch (or N steps)
|
|
44
52
|
def rollback(steps = 1)
|
|
53
|
+
ensure_tracking_table
|
|
45
54
|
completed = completed_migrations_with_batch
|
|
46
55
|
return [] if completed.empty?
|
|
47
56
|
|
|
@@ -60,6 +69,7 @@ module Tina4
|
|
|
60
69
|
end
|
|
61
70
|
|
|
62
71
|
def status
|
|
72
|
+
ensure_tracking_table
|
|
63
73
|
{
|
|
64
74
|
completed: completed_migrations,
|
|
65
75
|
pending: pending_migrations.map { |f| File.basename(f) }
|
|
@@ -178,6 +188,112 @@ module Tina4
|
|
|
178
188
|
|
|
179
189
|
private
|
|
180
190
|
|
|
191
|
+
# Apply every pending migration, in order. Caller holds the run-wide lock.
|
|
192
|
+
def run_pending
|
|
193
|
+
pending = pending_migrations
|
|
194
|
+
if pending.empty?
|
|
195
|
+
Tina4::Log.info("No pending migrations")
|
|
196
|
+
return []
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
batch = next_batch_number
|
|
200
|
+
results = []
|
|
201
|
+
pending.each do |file|
|
|
202
|
+
result = run_migration(file, batch)
|
|
203
|
+
results << result
|
|
204
|
+
# Stop on failure
|
|
205
|
+
break if result[:status] == "failed"
|
|
206
|
+
end
|
|
207
|
+
results
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A stable signed-64-bit key for PostgreSQL's pg_advisory_lock, derived from
|
|
211
|
+
# the lock name so it cannot collide with an application's own advisory-lock
|
|
212
|
+
# keys. It is a constant (not user input), so inlining it in the SQL is
|
|
213
|
+
# injection-safe.
|
|
214
|
+
def pg_advisory_key
|
|
215
|
+
Digest::SHA256.digest(LOCK_NAME)[0, 8].unpack1("q")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Take the run-wide migration lock, blocking until it is held.
|
|
219
|
+
#
|
|
220
|
+
# PostgreSQL/MySQL/MSSQL use a native session-scoped advisory lock; SQLite,
|
|
221
|
+
# Firebird and anything else fall back to an OS advisory file lock on a
|
|
222
|
+
# sidecar in the migrations folder. A backend that cannot lock degrades to the
|
|
223
|
+
# file lock, and finally to running unlocked (the pre-#277 behaviour) rather
|
|
224
|
+
# than blocking boot. Returns [kind, resource] for release_migration_lock.
|
|
225
|
+
def acquire_migration_lock
|
|
226
|
+
engine = (@db.respond_to?(:get_database_type) ? @db.get_database_type : "").to_s.downcase
|
|
227
|
+
|
|
228
|
+
begin
|
|
229
|
+
if engine.start_with?("postgres")
|
|
230
|
+
@db.fetch("SELECT pg_advisory_lock(#{pg_advisory_key}) AS locked")
|
|
231
|
+
return [:postgres, nil]
|
|
232
|
+
elsif engine.start_with?("mysql")
|
|
233
|
+
# -1 = wait indefinitely; GET_LOCK is connection-scoped.
|
|
234
|
+
@db.fetch("SELECT GET_LOCK('#{LOCK_NAME}', -1) AS locked")
|
|
235
|
+
return [:mysql, nil]
|
|
236
|
+
elsif engine == "mssql" || engine == "sqlserver"
|
|
237
|
+
@db.execute(
|
|
238
|
+
"DECLARE @res INT; EXEC @res = sp_getapplock @Resource = '#{LOCK_NAME}', " \
|
|
239
|
+
"@LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = -1"
|
|
240
|
+
)
|
|
241
|
+
return [:mssql, nil]
|
|
242
|
+
end
|
|
243
|
+
rescue => e
|
|
244
|
+
Tina4::Log.debug("DB migration lock unavailable (#{engine}): #{e.message}; using a file lock")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
acquire_file_lock
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# OS advisory file lock on a sidecar — the portable fallback (crash-safe: the
|
|
251
|
+
# kernel drops it when the process exits).
|
|
252
|
+
def acquire_file_lock
|
|
253
|
+
handle = File.open(file_lock_path, File::RDWR | File::CREAT, 0o644)
|
|
254
|
+
handle.flock(File::LOCK_EX)
|
|
255
|
+
[:file, handle]
|
|
256
|
+
rescue => e
|
|
257
|
+
Tina4::Log.debug("file migration lock unavailable: #{e.message}; running unlocked")
|
|
258
|
+
[:none, nil]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Where the advisory lock file lives: the system temp directory, NOT the
|
|
262
|
+
# migrations folder. The lock is a runtime artifact, not a migration — a
|
|
263
|
+
# dotfile left in the tracked migrations/ directory gets committed by accident
|
|
264
|
+
# and blocks a plain rmdir of the folder. The name is derived from the ABSOLUTE
|
|
265
|
+
# migrations directory, so every worker of the SAME app lands on the SAME file
|
|
266
|
+
# and flock() serializes them, while two different apps get two different locks.
|
|
267
|
+
# This keeps the lock scope identical to before; only the file's location
|
|
268
|
+
# changed. Mirrors the PHP reference's fileLockPath() (issue #277).
|
|
269
|
+
def file_lock_path
|
|
270
|
+
key = begin
|
|
271
|
+
File.realpath(@migrations_dir)
|
|
272
|
+
rescue StandardError
|
|
273
|
+
@migrations_dir
|
|
274
|
+
end
|
|
275
|
+
File.join(Dir.tmpdir, "tina4-migration-#{Digest::SHA256.hexdigest(key)}.lock")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def release_migration_lock(handle)
|
|
279
|
+
kind, resource = handle
|
|
280
|
+
case kind
|
|
281
|
+
when :postgres
|
|
282
|
+
@db.fetch("SELECT pg_advisory_unlock(#{pg_advisory_key}) AS released")
|
|
283
|
+
when :mysql
|
|
284
|
+
@db.fetch("SELECT RELEASE_LOCK('#{LOCK_NAME}') AS released")
|
|
285
|
+
when :mssql
|
|
286
|
+
@db.execute("EXEC sp_releaseapplock @Resource = '#{LOCK_NAME}', @LockOwner = 'Session'")
|
|
287
|
+
when :file
|
|
288
|
+
if resource
|
|
289
|
+
resource.flock(File::LOCK_UN)
|
|
290
|
+
resource.close
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
rescue => e
|
|
294
|
+
Tina4::Log.debug("migration lock release failed (#{handle&.first}): #{e.message}")
|
|
295
|
+
end
|
|
296
|
+
|
|
181
297
|
# Resolve migrations directory: prefer src/migrations, fall back to migrations/
|
|
182
298
|
def resolve_migrations_dir
|
|
183
299
|
# Canonical location is migrations/ (project root) — matches the Python reference, the CLI,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{#
|
|
2
|
+
crud/form.twig — the create/edit field inputs (and the public generate_form).
|
|
3
|
+
|
|
4
|
+
Override: drop your own templates/crud/form.twig to change how inputs render.
|
|
5
|
+
|
|
6
|
+
fields - one per input: { id, name, label, value, placeholder, required_attr,
|
|
7
|
+
and exactly ONE control flag: input (with type) | textarea | checkbox
|
|
8
|
+
(checked_attr) | select (options_html) }.
|
|
9
|
+
wrap - render the <form> wrapper; form_id_attr/action/form_method set it
|
|
10
|
+
edit - add the hidden primary-key input (edit modal)
|
|
11
|
+
modal_footer- render the Cancel/Save modal buttons; submit_button - a plain Submit
|
|
12
|
+
|
|
13
|
+
No inline styles or on*= handlers: the modal buttons carry data-crud-action and
|
|
14
|
+
are wired by the page script's delegated listener (ADR-0088).
|
|
15
|
+
#}
|
|
16
|
+
{% if wrap %}<form{{ form_id_attr|raw }} action="{{ action }}" method="{{ form_method }}" class="needs-validation" data-crud-form="1">{% endif %}
|
|
17
|
+
{% if method_override %}<input type="hidden" name="_method" value="{{ method_override }}">{% endif %}
|
|
18
|
+
{% if edit %}<input type="hidden" id="{{ mode }}-pk-value" name="{{ pk }}">{% endif %}
|
|
19
|
+
{% if modal_footer %}<div class="crud-errors" data-crud-errors hidden></div>{% endif %}
|
|
20
|
+
{% for field in fields %}<div class="mb-3">
|
|
21
|
+
<label for="{{ field.id }}" class="form-label">{{ field.label }}</label>
|
|
22
|
+
{% if field.textarea %}<textarea class="form-control" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ field.value }}</textarea>{% endif %}{% if field.checkbox %}<div class="form-check"><input class="form-check-input" type="checkbox" id="{{ field.id }}" name="{{ field.name }}"{{ field.checked_attr|raw }}></div>{% endif %}{% if field.select %}<select class="form-select" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ field.options_html|raw }}</select>{% endif %}{% if field.input %}<input type="{{ field.type }}" class="form-control" id="{{ field.id }}" name="{{ field.name }}" value="{{ field.value }}" placeholder="{{ field.placeholder }}"{{ field.required_attr|raw }}>{% endif %}
|
|
23
|
+
</div>
|
|
24
|
+
{% endfor %}
|
|
25
|
+
{% if modal_footer %}<div class="modal-footer"><button type="button" class="btn btn-secondary" data-crud-action="close" data-crud-modal="{{ mode }}">Cancel</button><button type="button" class="btn btn-primary" data-crud-action="save" data-crud-mode="{{ mode }}">Save</button></div>{% endif %}{% if submit_button %}<button type="submit" class="btn btn-primary">Submit</button>{% endif %}
|
|
26
|
+
{% if wrap %}</form>{% endif %}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{#
|
|
2
|
+
crud/modals.twig — the create, edit and delete-confirm dialogs.
|
|
3
|
+
|
|
4
|
+
Override: drop your own templates/crud/modals.twig to restyle the dialogs.
|
|
5
|
+
create_form / edit_form are the crud/form.twig renders injected here. Buttons
|
|
6
|
+
carry data-crud-action (close / confirm-delete) and are wired by the page
|
|
7
|
+
script — no inline on*= handlers (ADR-0088).
|
|
8
|
+
#}
|
|
9
|
+
<div class="modal-overlay" id="modal-create">
|
|
10
|
+
<div class="modal-box">
|
|
11
|
+
<h3>Create New Record</h3>
|
|
12
|
+
{{ create_form|raw }}
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="modal-overlay" id="modal-edit">
|
|
16
|
+
<div class="modal-box">
|
|
17
|
+
<h3>Edit Record</h3>
|
|
18
|
+
{{ edit_form|raw }}
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="modal-overlay" id="modal-delete">
|
|
22
|
+
<div class="modal-box">
|
|
23
|
+
<h3>Confirm Delete</h3>
|
|
24
|
+
<p>Are you sure you want to delete this record? This action cannot be undone.</p>
|
|
25
|
+
<input type="hidden" id="delete-pk-value">
|
|
26
|
+
<div class="modal-footer">
|
|
27
|
+
<button class="btn btn-secondary" data-crud-action="close" data-crud-modal="delete">Cancel</button>
|
|
28
|
+
<button class="btn btn-danger" data-crud-action="confirm-delete">Delete</button>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|