slash_migrate 0.1.0

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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +74 -0
  5. data/Rakefile +6 -0
  6. data/app/controllers/slash_migrate/application_controller.rb +15 -0
  7. data/app/controllers/slash_migrate/assets_controller.rb +26 -0
  8. data/app/controllers/slash_migrate/columns_controller.rb +106 -0
  9. data/app/controllers/slash_migrate/indexes_controller.rb +54 -0
  10. data/app/controllers/slash_migrate/migrations_controller.rb +41 -0
  11. data/app/controllers/slash_migrate/models_controller.rb +49 -0
  12. data/app/controllers/slash_migrate/tables_controller.rb +19 -0
  13. data/app/helpers/slash_migrate/application_helper.rb +168 -0
  14. data/app/jobs/slash_migrate/application_job.rb +4 -0
  15. data/app/mailers/slash_migrate/application_mailer.rb +6 -0
  16. data/app/models/slash_migrate/application_record.rb +5 -0
  17. data/app/services/slash_migrate/add_columns_migration.rb +59 -0
  18. data/app/services/slash_migrate/add_index_migration.rb +68 -0
  19. data/app/services/slash_migrate/column.rb +198 -0
  20. data/app/services/slash_migrate/drop_column_migration.rb +35 -0
  21. data/app/services/slash_migrate/drop_index_migration.rb +61 -0
  22. data/app/services/slash_migrate/edit_column_migration.rb +110 -0
  23. data/app/services/slash_migrate/migration_builder.rb +115 -0
  24. data/app/services/slash_migrate/migration_file_writer.rb +71 -0
  25. data/app/services/slash_migrate/migration_runner.rb +91 -0
  26. data/app/services/slash_migrate/schema_inspector.rb +55 -0
  27. data/app/views/layouts/slash_migrate/application.html.erb +40 -0
  28. data/app/views/slash_migrate/columns/_preview.html.erb +10 -0
  29. data/app/views/slash_migrate/columns/_update_preview.html.erb +17 -0
  30. data/app/views/slash_migrate/columns/edit.html.erb +83 -0
  31. data/app/views/slash_migrate/columns/new.html.erb +33 -0
  32. data/app/views/slash_migrate/columns/preview.html.erb +3 -0
  33. data/app/views/slash_migrate/columns/update_preview.html.erb +3 -0
  34. data/app/views/slash_migrate/indexes/_preview.html.erb +10 -0
  35. data/app/views/slash_migrate/indexes/new.html.erb +57 -0
  36. data/app/views/slash_migrate/indexes/preview.html.erb +3 -0
  37. data/app/views/slash_migrate/migrations/index.html.erb +71 -0
  38. data/app/views/slash_migrate/models/_name_help.html.erb +13 -0
  39. data/app/views/slash_migrate/models/_preview.html.erb +21 -0
  40. data/app/views/slash_migrate/models/_row.html.erb +37 -0
  41. data/app/views/slash_migrate/models/new.html.erb +38 -0
  42. data/app/views/slash_migrate/models/preview.html.erb +9 -0
  43. data/app/views/slash_migrate/shared/_breadcrumbs.html.erb +12 -0
  44. data/app/views/slash_migrate/shared/_callout.html.erb +12 -0
  45. data/app/views/slash_migrate/shared/_code_file.html.erb +24 -0
  46. data/app/views/slash_migrate/shared/_column_builder.html.erb +37 -0
  47. data/app/views/slash_migrate/shared/_flow_arrow.html.erb +6 -0
  48. data/app/views/slash_migrate/shared/_terminal.html.erb +18 -0
  49. data/app/views/slash_migrate/tables/index.html.erb +36 -0
  50. data/app/views/slash_migrate/tables/show.html.erb +81 -0
  51. data/config/routes.rb +33 -0
  52. data/lib/slash_migrate/assets/controllers.js +250 -0
  53. data/lib/slash_migrate/assets/slash_migrate.css +381 -0
  54. data/lib/slash_migrate/assets/stimulus.min.js +2588 -0
  55. data/lib/slash_migrate/assets/turbo.min.js +7298 -0
  56. data/lib/slash_migrate/configuration.rb +17 -0
  57. data/lib/slash_migrate/engine.rb +33 -0
  58. data/lib/slash_migrate/pending_migration_check_proxy.rb +32 -0
  59. data/lib/slash_migrate/version.rb +3 -0
  60. data/lib/slash_migrate.rb +16 -0
  61. data/lib/tasks/slash_migrate_tasks.rake +4 -0
  62. metadata +121 -0
@@ -0,0 +1,38 @@
1
+ <% content_for :page_class, "page-form" %>
2
+
3
+ <div class="page-head">
4
+ <div>
5
+ <h1 class="page-title">New table</h1>
6
+ <p class="page-sub">Pick a name and add columns. The migration and model files appear live below as you type.</p>
7
+ </div>
8
+ </div>
9
+
10
+ <%= form_with url: models_path, method: :post, class: "stack",
11
+ data: {
12
+ controller: "model-form",
13
+ action: "input->model-form#scheduleRefresh change->model-form#scheduleRefresh",
14
+ "model-form-preview-url-value": preview_models_path
15
+ } do |form| %>
16
+
17
+ <div class="panel panel-pad">
18
+ <div class="field">
19
+ <label class="label" for="model_name">Model name</label>
20
+ <%= form.text_field :model_name, id: "model_name", placeholder: "e.g. Article", class: "input mono", autocomplete: "off", data: {action: "input->model-form#modelNameInput"} %>
21
+ <div id="model-name-help"><%= render "name_help" %></div>
22
+ </div>
23
+
24
+ <h2 class="section-title">Columns</h2>
25
+ <%= render "slash_migrate/shared/column_builder", tables: @existing_tables, self_option: true, auto_columns: true %>
26
+ </div>
27
+
28
+ <%= render "slash_migrate/shared/flow_arrow" %>
29
+
30
+ <div id="sm-preview">
31
+ <%= render "preview" %>
32
+ </div>
33
+
34
+ <div class="actions-stack">
35
+ <%= form.submit "Create model & migration", class: "btn btn-accent" %>
36
+ <%= link_to "Reset", new_model_path, class: "btn btn-ghost" %>
37
+ </div>
38
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <%# Rendered as a Turbo Stream message and applied client-side via
2
+ Turbo.renderStreamMessage. We emit the <turbo-stream> element by hand so the
3
+ engine needs neither the turbo-rails gem nor a registered MIME type. %>
4
+ <turbo-stream action="update" target="sm-preview">
5
+ <template><%= render "preview" %></template>
6
+ </turbo-stream>
7
+ <turbo-stream action="update" target="model-name-help">
8
+ <template><%= render "name_help" %></template>
9
+ </turbo-stream>
@@ -0,0 +1,12 @@
1
+ <%# trail: array of {label:, url:} hashes, in order. An entry without :url renders
2
+ as the current (non-link) page. %>
3
+ <nav class="crumbs" aria-label="Breadcrumb">
4
+ <% trail.each_with_index do |item, i| %>
5
+ <% if i > 0 %><span class="crumb-sep"><%= sm_icon(:chevron) %></span><% end %>
6
+ <% if item[:url] %>
7
+ <%= link_to item[:label], item[:url], class: "crumb-link" %>
8
+ <% else %>
9
+ <span class="crumb-current" aria-current="page"><%= item[:label] %></span>
10
+ <% end %>
11
+ <% end %>
12
+ </nav>
@@ -0,0 +1,12 @@
1
+ <%#
2
+ Wrap content with: render layout: "slash_migrate/shared/callout", locals: { kind: "warn", title: "…" } do … end
3
+ kind: "info" | "warn" | "ok" | "err". title: optional.
4
+ %>
5
+ <% icon = {"info" => :info, "warn" => :warn, "ok" => :check, "err" => :x}.fetch(kind.to_s) %>
6
+ <div class="callout callout-<%= kind %>">
7
+ <span class="callout-ico"><%= sm_icon(icon) %></span>
8
+ <div class="callout-body">
9
+ <% if local_assigns[:title].present? %><p class="callout-title"><%= title %></p><% end %>
10
+ <div class="callout-text"><%= yield %></div>
11
+ </div>
12
+ </div>
@@ -0,0 +1,24 @@
1
+ <%#
2
+ A light, syntax-highlighted code panel — the hero of every form screen.
3
+ Locals: path: (dim dir, e.g. "db/migrate/"), name: (filename or, when timestamped,
4
+ the part after the version), kind: (meta label), source: (raw Ruby string).
5
+ Optional: foot:, foot_kind: ("ok" | "warn" | "info"), timestamped: (bool — prepends
6
+ the live migration version + "_" before name).
7
+ %>
8
+ <% foot_kind = local_assigns.fetch(:foot_kind, "ok") %>
9
+ <div class="code-card">
10
+ <div class="code-head">
11
+ <span class="code-filename">
12
+ <%= sm_icon(:file) %>
13
+ <span><span class="dim"><%= path %></span><% if local_assigns[:timestamped] %><span class="code-ts" data-timestamp title="The current UTC time as YYYYMMDDHHMMSS — this becomes the migration's version number"><%= migration_version %></span>_<% end %><%= name %></span>
14
+ </span>
15
+ <span class="code-meta"><%= kind %></span>
16
+ </div>
17
+ <pre class="code-body"><%= ruby_code_body(source) %></pre>
18
+ <% if local_assigns[:foot].present? %>
19
+ <div class="code-foot is-<%= foot_kind %>">
20
+ <%= sm_icon(foot_kind == "warn" ? :warn : foot_kind == "info" ? :info : :check) %>
21
+ <span><%= foot %></span>
22
+ </div>
23
+ <% end %>
24
+ </div>
@@ -0,0 +1,37 @@
1
+ <%# The repeating column-row builder, shared by New model and Add columns.
2
+ Locals: tables: (existing table names for reference pickers),
3
+ self_option: (offer a "(this table)" self-reference option — new tables only). %>
4
+ <% self_option = local_assigns.fetch(:self_option, false) %>
5
+ <% auto_columns = local_assigns.fetch(:auto_columns, false) %>
6
+ <div class="col-heads">
7
+ <div class="col-head">Name</div>
8
+ <div class="col-head">Type</div>
9
+ <div class="col-head">Null</div>
10
+ <div class="col-head" data-model-form-target="optionHeader">Default</div>
11
+ <div class="col-head">Index</div>
12
+ <div></div>
13
+ </div>
14
+ <% if auto_columns %>
15
+ <%# id and the timestamps come with every create_table, so show them filled in
16
+ but locked — students see what Rails adds without us spelling it out. These
17
+ controls are disabled and unnamed: never submitted, and the builder writes
18
+ id and t.timestamps regardless. %>
19
+ <% [["id", "bigint", "primary key"], ["created_at", "datetime", "no index"], ["updated_at", "datetime", "no index"]].each do |name, type, index_note| %>
20
+ <div class="col-row is-locked">
21
+ <input type="text" class="input mono" value="<%= name %>" disabled>
22
+ <select class="select" disabled><option><%= type %></option></select>
23
+ <select class="select" disabled><option>NOT NULL</option></select>
24
+ <input type="text" class="input mono" value="—" disabled>
25
+ <select class="select" disabled><option><%= index_note %></option></select>
26
+ <span class="lock-cell" title="Added automatically by Rails"><%= sm_icon(:lock) %></span>
27
+ </div>
28
+ <% end %>
29
+ <% end %>
30
+ <div data-model-form-target="rows">
31
+ <%= render "slash_migrate/models/row", tables: tables, self_option: self_option %>
32
+ </div>
33
+ <template data-model-form-target="template">
34
+ <%= render "slash_migrate/models/row", tables: tables, self_option: self_option %>
35
+ </template>
36
+ <button type="button" class="add-row-btn" data-action="model-form#addRow"><%= sm_icon(:plus) %> Add another column</button>
37
+ <p class="help-text is-warn" data-model-form-target="idWarning" hidden></p>
@@ -0,0 +1,6 @@
1
+ <%# Animated three-chevron indicator pointing from the form down to the live preview. %>
2
+ <div class="stack-link" aria-hidden="true">
3
+ <span class="chev"></span>
4
+ <span class="chev"></span>
5
+ <span class="chev"></span>
6
+ </div>
@@ -0,0 +1,18 @@
1
+ <%#
2
+ A dark terminal panel for real command output — deliberately plain (no syntax
3
+ tinting), the way `rails db:migrate` actually prints. Locals: command:, output:.
4
+ Optional: meta:, meta_style:.
5
+ %>
6
+ <div class="code-card is-terminal">
7
+ <div class="code-head">
8
+ <span class="code-filename">
9
+ <%= sm_icon(:terminal) %>
10
+ <span class="code-prompt">$</span>
11
+ <strong><%= command %></strong>
12
+ </span>
13
+ <% if local_assigns[:meta].present? %>
14
+ <span class="code-meta" style="<%= local_assigns[:meta_style] %>"><%= meta %></span>
15
+ <% end %>
16
+ </div>
17
+ <pre class="code-body"><%= terminal_body(output) %></pre>
18
+ </div>
@@ -0,0 +1,36 @@
1
+ <div class="page-head">
2
+ <div>
3
+ <h1 class="page-title">Tables</h1>
4
+ <p class="page-sub">The tables in this app's database. Open any one to inspect its columns, indexes, and foreign keys.</p>
5
+ </div>
6
+ <%= link_to new_model_path, class: "btn btn-accent" do %><%= sm_icon(:plus) %> New table<% end %>
7
+ </div>
8
+
9
+ <% if @table_names.empty? %>
10
+ <div class="panel">
11
+ <div class="empty">
12
+ <div class="empty-art"><%= sm_icon(:database) %></div>
13
+ <h3>No tables yet</h3>
14
+ <p>Once you create one, it shows up here. Start by giving it a name and a few columns.</p>
15
+ <%= link_to new_model_path, class: "btn btn-accent" do %><%= sm_icon(:plus) %> Create your first table<% end %>
16
+ </div>
17
+ </div>
18
+ <% else %>
19
+ <div class="panel">
20
+ <table class="table">
21
+ <thead>
22
+ <tr><th>Table</th><th></th></tr>
23
+ </thead>
24
+ <tbody>
25
+ <% @table_names.each do |name| %>
26
+ <tr class="is-link">
27
+ <td><span class="mono"><%= name %></span></td>
28
+ <td class="col-actions">
29
+ <%= link_to table_path(name), class: "row-go", aria: {label: "Inspect #{name}"} do %><%= sm_icon(:chevron) %><% end %>
30
+ </td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ </div>
36
+ <% end %>
@@ -0,0 +1,81 @@
1
+ <%
2
+ columns = @table.columns
3
+ indexes = @table.indexes
4
+ foreign_keys = @table.foreign_keys
5
+ primary_key = @table.primary_key
6
+ fk_targets = foreign_keys.to_h { |fk| [fk.column, fk.to_table] }
7
+ %>
8
+
9
+ <div class="page-head">
10
+ <div>
11
+ <%= render "slash_migrate/shared/breadcrumbs", trail: [{label: "Tables", url: root_path}, {label: @table.name}] %>
12
+ <h1 class="page-title"><span class="mono"><%= @table.name %></span></h1>
13
+ <p class="page-sub">
14
+ <%= columns.size %> column<%= "s" unless columns.size == 1 %> ·
15
+ <%= indexes.size %> index<%= "es" unless indexes.size == 1 %> ·
16
+ <%= foreign_keys.size %> foreign key<%= "s" unless foreign_keys.size == 1 %>
17
+ </p>
18
+ </div>
19
+ <%= link_to new_table_column_path(@table.name), class: "btn btn-ghost" do %><%= sm_icon(:plus) %> Add columns<% end %>
20
+ </div>
21
+
22
+ <h2 class="section-title">Columns</h2>
23
+ <div class="panel">
24
+ <table class="table">
25
+ <thead>
26
+ <tr><th>Name</th><th>Type</th><th>Null</th><th>Default</th><th>Notes</th><th></th></tr>
27
+ </thead>
28
+ <tbody>
29
+ <% columns.each do |column| %>
30
+ <tr>
31
+ <td><span class="mono"><%= column.name %></span></td>
32
+ <td class="col-type"><%= column.type %> <span class="sql">(<%= column.sql_type %>)</span></td>
33
+ <td>
34
+ <% if column.null %><span class="muted">—</span><% else %><span class="badge is-notnull no-dot">NOT NULL</span><% end %>
35
+ </td>
36
+ <td class="col-default">
37
+ <% if column.default.nil? %><em>—</em><% else %><span class="mono"><%= column.default %></span><% end %>
38
+ </td>
39
+ <td>
40
+ <div class="badges">
41
+ <% if column.name == primary_key %><span class="badge is-pk">primary key</span><% end %>
42
+ <% if fk_targets[column.name] %><span class="badge is-fk">foreign key (<%= fk_targets[column.name] %>)</span><% end %>
43
+ </div>
44
+ </td>
45
+ <td class="col-actions">
46
+ <% unless column.name == primary_key %>
47
+ <%= link_to "Edit", edit_table_column_path(@table.name, column.name), class: "row-action" %>
48
+ <% end %>
49
+ </td>
50
+ </tr>
51
+ <% end %>
52
+ </tbody>
53
+ </table>
54
+ </div>
55
+
56
+ <h2 class="section-title">Indexes</h2>
57
+ <div class="panel">
58
+ <table class="table">
59
+ <thead>
60
+ <tr><th>Name</th><th>Columns</th><th>Unique</th><th></th></tr>
61
+ </thead>
62
+ <tbody>
63
+ <% indexes.each do |index| %>
64
+ <tr>
65
+ <td><span class="mono"><%= index.name %></span></td>
66
+ <td><span class="mono"><%= Array(index.columns).join(", ") %></span></td>
67
+ <td><% if index.unique %><span class="badge is-unique">unique</span><% else %><span class="muted">—</span><% end %></td>
68
+ <td class="col-actions">
69
+ <%= button_to drop_table_index_path(@table.name, index.name), class: "row-action is-danger",
70
+ data: {turbo_confirm: "Generate a migration to drop #{index.name}?"} do %>Drop<% end %>
71
+ </td>
72
+ </tr>
73
+ <% end %>
74
+ <tr>
75
+ <td colspan="4">
76
+ <%= link_to new_table_index_path(@table.name), class: "row-action" do %><%= sm_icon(:plus) %> Add index<% end %>
77
+ </td>
78
+ </tr>
79
+ </tbody>
80
+ </table>
81
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,33 @@
1
+ SlashMigrate::Engine.routes.draw do
2
+ root to: "tables#index"
3
+
4
+ resources :tables, only: [:index, :show] do
5
+ resources :columns, only: [:new, :create, :edit, :update], param: :name do
6
+ post :preview, on: :collection
7
+ member do
8
+ post :drop
9
+ post :update_preview
10
+ end
11
+ end
12
+
13
+ resources :indexes, only: [:new, :create], param: :name do
14
+ post :preview, on: :collection
15
+ member do
16
+ post :drop
17
+ end
18
+ end
19
+ end
20
+
21
+ resources :models, only: [:new, :create] do
22
+ post :preview, on: :collection
23
+ end
24
+
25
+ get "migrations", to: "migrations#index", as: :migrations
26
+ post "migrations/run", to: "migrations#run", as: :run_migrations
27
+ post "migrations/rollback", to: "migrations#rollback", as: :rollback_migrations
28
+ delete "migrations/:version", to: "migrations#destroy", as: :migration, constraints: {version: /\d+/}
29
+
30
+ # The engine serves its own JS/CSS so it never depends on the host app's asset
31
+ # pipeline (importmap / esbuild / Sprockets / Propshaft). See AssetsController.
32
+ get "assets/:name", to: "assets#show", as: :asset_file, constraints: {name: /[\w.-]+/}
33
+ end
@@ -0,0 +1,250 @@
1
+ // Self-contained Stimulus controllers for the engine. Loaded as a plain script
2
+ // after the vendored Stimulus + Turbo UMD builds, so it relies only on the
3
+ // `Stimulus` and `Turbo` globals — no bundler, importmap, or host JS.
4
+ (function () {
5
+ "use strict";
6
+
7
+ var application = Stimulus.Application.start();
8
+
9
+ // Drives the "new model" form: add/remove column rows and stream a live
10
+ // preview of the migration the form would generate.
11
+ var ModelFormController = class extends Stimulus.Controller {
12
+ static targets = ["rows", "template", "preview", "optionHeader", "idWarning"];
13
+ static values = { previewUrl: String };
14
+
15
+ connect() {
16
+ this.timer = null;
17
+ this.syncRows();
18
+ this.refreshHints();
19
+ this.refresh();
20
+ }
21
+
22
+ addRow() {
23
+ this.rowsTarget.appendChild(this.templateTarget.content.cloneNode(true));
24
+ this.syncRows();
25
+ this.refreshHints();
26
+ this.refresh();
27
+ }
28
+
29
+ removeRow(event) {
30
+ var row = event.target.closest("[data-row]");
31
+ if (row) row.remove();
32
+ this.refreshHints();
33
+ this.refresh();
34
+ }
35
+
36
+ // A reference column points at a table (and takes no default), so swap the
37
+ // default input for the "references table" picker on reference rows.
38
+ typeChanged(event) {
39
+ var row = event.target.closest("[data-row]");
40
+ if (row) this.syncRow(row);
41
+ this.refreshHints();
42
+ }
43
+
44
+ // As a column name is typed, point its foreign key at the table Rails would
45
+ // infer — but only if that table actually exists.
46
+ nameInput(event) {
47
+ var row = event.target.closest("[data-row]");
48
+ if (row) this.autoTarget(row);
49
+ this.updateIdWarning();
50
+ }
51
+
52
+ // Once the student picks a target themselves, stop auto-steering it.
53
+ pickerTouched(event) {
54
+ event.target.dataset.touched = "1";
55
+ }
56
+
57
+ // The "→ this table" self-reference option mirrors the model name above.
58
+ modelNameInput() {
59
+ this.updateSelfSuggestions();
60
+ }
61
+
62
+ // Header, _id warning, and self-reference suggestion all derive from the
63
+ // whole row set, so refresh them together after structural changes.
64
+ refreshHints() {
65
+ this.updateOptionHeader();
66
+ this.updateIdWarning();
67
+ this.updateSelfSuggestions();
68
+ }
69
+
70
+ syncRows() {
71
+ this.rowsTarget.querySelectorAll("[data-row]").forEach((row) => this.syncRow(row));
72
+ }
73
+
74
+ syncRow(row) {
75
+ var type = row.querySelector('select[name="attributes[][type]"]').value;
76
+ var isReference = type === "references" || type === "belongs_to";
77
+ var picker = row.querySelector("[data-row-table]");
78
+ var defaultField = row.querySelector("[data-row-default]");
79
+ if (picker) picker.style.display = isReference ? "" : "none";
80
+ if (defaultField) defaultField.style.display = isReference ? "none" : "";
81
+ this.autoTarget(row);
82
+ }
83
+
84
+ // Select the existing table the column name points at (column "zebra" ->
85
+ // "zebras"), or fall back to "no foreign key" when no such table exists — so
86
+ // the default never produces a foreign key to a missing table. Leaves a
87
+ // target the student picked themselves alone.
88
+ autoTarget(row) {
89
+ var picker = row.querySelector("[data-row-table]");
90
+ if (!picker || picker.dataset.touched) return;
91
+ var type = row.querySelector('select[name="attributes[][type]"]').value;
92
+ if (type !== "references" && type !== "belongs_to") return;
93
+ var field = row.querySelector('input[name="attributes[][name]"]');
94
+ var raw = field ? field.value.trim() : "";
95
+ var inferred = raw ? this.pluralize(this.underscore(raw)) : "";
96
+ var match = inferred && Array.from(picker.options).some((option) => option.value === inferred);
97
+ picker.value = match ? inferred : "";
98
+ }
99
+
100
+ pluralize(word) {
101
+ if (/[^aeiou]y$/i.test(word)) return word.replace(/y$/i, "ies");
102
+ if (/(s|ss|sh|ch|x|z)$/i.test(word)) return word + "es";
103
+ return word + "s";
104
+ }
105
+
106
+ // The shared fourth-column header reflects the row types currently in play:
107
+ // "Default", "To table", or both when the builder mixes them.
108
+ updateOptionHeader() {
109
+ if (!this.hasOptionHeaderTarget) return;
110
+ var types = Array.from(this.rowsTarget.querySelectorAll('select[name="attributes[][type]"]')).map((select) => select.value);
111
+ var hasReference = types.some((type) => type === "references" || type === "belongs_to");
112
+ var hasOther = types.some((type) => type !== "references" && type !== "belongs_to");
113
+ this.optionHeaderTarget.textContent = hasReference ? (hasOther ? "Default / to table" : "To table") : "Default";
114
+ }
115
+
116
+ // The self-reference option ("→ this table") names the table the model maps
117
+ // to, derived from the model-name field (model "Zebra" -> table "zebras").
118
+ updateSelfSuggestions() {
119
+ var options = this.element.querySelectorAll("[data-self-suggestion]");
120
+ if (!options.length) return;
121
+ var field = this.element.querySelector('input[name="model_name"]');
122
+ var name = field ? field.value.trim() : "";
123
+ var label = name ? "→ " + this.pluralize(this.underscore(name)) : "→ this table";
124
+ options.forEach((option) => { option.textContent = label; });
125
+ }
126
+
127
+ underscore(word) {
128
+ return word.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase();
129
+ }
130
+
131
+ // references appends _id itself, so a column named foo_id becomes foo_id_id.
132
+ // Surface any such rows as inline helper text below the builder.
133
+ updateIdWarning() {
134
+ if (!this.hasIdWarningTarget) return;
135
+ var offenders = [];
136
+ this.rowsTarget.querySelectorAll("[data-row]").forEach((row) => {
137
+ var type = row.querySelector('select[name="attributes[][type]"]').value;
138
+ if (type !== "references" && type !== "belongs_to") return;
139
+ var name = (row.querySelector('input[name="attributes[][name]"]').value || "").trim();
140
+ if (/_id$/i.test(name)) offenders.push(name);
141
+ });
142
+ var el = this.idWarningTarget;
143
+ el.replaceChildren();
144
+ if (!offenders.length) {
145
+ el.hidden = true;
146
+ return;
147
+ }
148
+ el.hidden = false;
149
+ el.append("references adds ", this.code("_id"), " for you — drop it: ");
150
+ offenders.forEach((name, index) => {
151
+ if (index > 0) el.append(", ");
152
+ el.append(this.code(name), " → ", this.code(name.replace(/_id$/i, "")));
153
+ });
154
+ el.append(".");
155
+ }
156
+
157
+ code(text) {
158
+ var node = document.createElement("code");
159
+ node.textContent = text;
160
+ return node;
161
+ }
162
+
163
+ // Debounced so we don't fire a request on every keystroke.
164
+ scheduleRefresh() {
165
+ clearTimeout(this.timer);
166
+ this.timer = setTimeout(() => this.refresh(), 300);
167
+ }
168
+
169
+ refresh() {
170
+ fetch(this.previewUrlValue, {
171
+ method: "POST",
172
+ body: new FormData(this.element),
173
+ headers: {
174
+ Accept: "text/vnd.turbo-stream.html",
175
+ "X-CSRF-Token": this.csrfToken()
176
+ }
177
+ })
178
+ .then((response) => response.text())
179
+ .then((html) => Turbo.renderStreamMessage(html));
180
+ }
181
+
182
+ csrfToken() {
183
+ var meta = document.querySelector('meta[name="csrf-token"]');
184
+ return meta ? meta.content : "";
185
+ }
186
+ };
187
+
188
+ application.register("model-form", ModelFormController);
189
+
190
+ // Debounced live preview for a single form (the edit-column form): POST the
191
+ // form to its preview URL and render the returned Turbo Stream.
192
+ var LivePreviewController = class extends Stimulus.Controller {
193
+ static values = { url: String };
194
+
195
+ connect() {
196
+ this.timer = null;
197
+ this.refresh();
198
+ }
199
+
200
+ scheduleRefresh() {
201
+ clearTimeout(this.timer);
202
+ this.timer = setTimeout(() => this.refresh(), 300);
203
+ }
204
+
205
+ refresh() {
206
+ // Drop Rails' _method override so this preview stays a POST even when the
207
+ // form itself submits as PATCH.
208
+ var body = new FormData(this.element);
209
+ body.delete("_method");
210
+ fetch(this.urlValue, {
211
+ method: "POST",
212
+ body: body,
213
+ headers: {
214
+ Accept: "text/vnd.turbo-stream.html",
215
+ "X-CSRF-Token": this.csrfToken()
216
+ }
217
+ })
218
+ .then((response) => response.text())
219
+ .then((html) => Turbo.renderStreamMessage(html));
220
+ }
221
+
222
+ csrfToken() {
223
+ var meta = document.querySelector('meta[name="csrf-token"]');
224
+ return meta ? meta.content : "";
225
+ }
226
+ };
227
+
228
+ application.register("live-preview", LivePreviewController);
229
+
230
+ // Tick the would-be migration version (a UTC timestamp) once a second, so the
231
+ // 14-digit number in a preview filename visibly counts up — showing students
232
+ // exactly where that number comes from. Re-queries the DOM each tick, so it
233
+ // keeps working after the live preview swaps in fresh markup.
234
+ function pad(n) {
235
+ return String(n).padStart(2, "0");
236
+ }
237
+ function migrationVersion() {
238
+ var d = new Date();
239
+ return "" + d.getUTCFullYear() + pad(d.getUTCMonth() + 1) + pad(d.getUTCDate()) +
240
+ pad(d.getUTCHours()) + pad(d.getUTCMinutes()) + pad(d.getUTCSeconds());
241
+ }
242
+ function tickTimestamps() {
243
+ var stamp = migrationVersion();
244
+ document.querySelectorAll("[data-timestamp]").forEach(function (el) {
245
+ el.textContent = stamp;
246
+ });
247
+ }
248
+ setInterval(tickTimestamps, 1000);
249
+ tickTimestamps();
250
+ })();