inline_forms_schema_edit 8.1.42

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4e38325358e9117cb9e8f4f10f2ea4f0e666309c1fc0309306a5536214f6f6bf
4
+ data.tar.gz: 94c056f121faf8216211b5d09736ea846f4014fd2aa37db743187691b05dd8e8
5
+ SHA512:
6
+ metadata.gz: 68e8cc58fd4bd736a5d10cc43ccf4b37370241c5811f2e8cf733bdf09b18051a7c55e4d42a750b6e66955dfdada53a896adc22cbea0917bcf9b21e119292236b
7
+ data.tar.gz: bd31280e01c4fb905fe1647f7d00c33bb9cf3fae703e6771b648a75d8dc0da2bb34570860cb64410c00eee33cc23b6f899f912a4f990654282e536d3b878a62b
data/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem are documented in this file. Versions are in
4
+ lockstep with inline_forms / inline_forms_installer / validation_hints.
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [8.1.42] - 2026-07-22
9
+
10
+ ### Changed
11
+
12
+ - Version bump to stay in lockstep with `inline_forms` /
13
+ `inline_forms_installer` / `validation_hints` 8.1.42. This gem packages its
14
+ own files (`{app,doc,lib}` glob) and was unaffected by the `stuff/`
15
+ gem-build leak fixed in the main gem.
16
+
17
+ ### Added
18
+
19
+ - **Batch pipeline (plan phases 1-3, and the tenant half of 4).**
20
+ - Persisted intents: `InlineForms::SchemaBatch` + `InlineForms::SchemaIntentRecord`
21
+ (`rails g inline_forms_schema_edit:install` writes the migration; the
22
+ installer runs it for `--schema-edit` apps). Drafts accumulate in a
23
+ batch (the "cart"); **submit freezes it** — intents and batch become
24
+ immutable (model-enforced) and a content digest seals the intent list.
25
+ - GUI: `/schema` index (draft cart, submit with optional apply window,
26
+ batch history), "Add to batch" on preview. Direct apply stays dev-only;
27
+ drafting in production requires the explicit
28
+ `InlineFormsSchemaEdit.production_drafting = true` opt-in (default off).
29
+ - Machine endpoints for the CI loop, token-authenticated
30
+ (`InlineFormsSchemaEdit.export_token` / `INLINE_FORMS_SCHEMA_EXPORT_TOKEN`;
31
+ 404 when unconfigured): `GET /schema/batches/:id/export.json`,
32
+ `POST /schema/batches/:id/status`.
33
+ - Export/import/replay: `rake schema_edit:export_batch[id]`,
34
+ `rake schema_edit:apply_batch[file]` (verifies the digest, re-validates
35
+ every intent against the current checkout, replays through
36
+ `SchemaApply#generate!` — codegen only, never migrates or commits),
37
+ `rake schema_edit:mark_batch[id,status]`.
38
+ - Deploy window (phase 4, tenant side): `rake schema_edit:apply_due`
39
+ migrates ready batches whose window arrived, marks them applied, runs
40
+ the optional `InlineFormsSchemaEdit.restart_command`. A commented
41
+ Forgejo Actions example ships in `doc/schema-apply-workflow.yml.example`
42
+ (copied to the app's `doc/` by the install generator).
43
+ - Routes are now drawn by `InlineFormsSchemaEdit.draw_routes(self)` (one
44
+ line in the app's routes.rb) so gem upgrades can add routes without
45
+ editing the app.
46
+
47
+ - Initial extraction of the schema-change GUI out of the inline_forms engine
48
+ into this separate mountable engine gem (phase 0 of
49
+ `stuff/2026-07-11-schema-gui-gem-and-automated-pipeline-plan.md`):
50
+ `InlineForms::SchemaController`, its views and the `inline_forms_schema`
51
+ layout. The staging services (`SchemaIntent`, `SchemaPreview`,
52
+ `SchemaApply`, `SchemaLabel`) stay in inline_forms. Apps opt in via
53
+ `inline_forms create --schema-edit` (implied by `--example`).
@@ -0,0 +1,228 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ module InlineForms
4
+ # GUI to add a field to a model through the browser, building on the
5
+ # schema-staging services in inline_forms. Two ways to act on an intent:
6
+ #
7
+ # * DIRECT APPLY (dev only, the phase-0 flow): `create` runs
8
+ # inline_forms_addto in-process (model edit + migration file) but NOT
9
+ # db:migrate; the pending-migration gate covers the window.
10
+ # * BATCH DRAFTING (the pipeline flow): `draft` persists the intent into
11
+ # the current draft SchemaBatch (the "cart"); `submit_batch` freezes it
12
+ # for the automated pull -> CI -> deploy loop. Nothing is generated in
13
+ # the request cycle.
14
+ #
15
+ # Production posture: direct apply is NEVER available in production
16
+ # (production never writes code). Drafting is also non-production unless
17
+ # the app opts in (InlineFormsSchemaEdit.production_drafting = true — the
18
+ # SaaS tenant case). The machine endpoints (export/batch_status) are
19
+ # token-authenticated and environment-independent, for the CI side.
20
+ class SchemaController < InlineFormsApplicationController
21
+ HEADER = InlineFormsSchemaEdit::IntentValidator::HEADER
22
+
23
+ layout "inline_forms_schema"
24
+
25
+ skip_before_action :verify_authenticity_token, only: :batch_status, raise: false
26
+
27
+ before_action :require_non_production, only: :create
28
+ before_action :require_drafting_allowed, except: [ :create, :export, :batch_status ]
29
+ before_action :authenticate_pipeline_token!, only: [ :export, :batch_status ]
30
+ before_action :load_form_options, only: [ :new, :preview, :create, :draft ]
31
+ before_action :require_batch_tables, only: [ :index, :draft, :remove_draft, :submit_batch ]
32
+
33
+ # Injectable so tests record instead of mutating the working tree.
34
+ cattr_accessor :generator_executor
35
+ cattr_accessor :label_writer
36
+
37
+ # The cart + batch history.
38
+ def index
39
+ @draft_batch = InlineForms::SchemaBatch.with_status(:draft).order(:id).first
40
+ @batches = InlineForms::SchemaBatch.where.not(status: "draft").order(id: :desc).limit(25)
41
+ end
42
+
43
+ def new
44
+ @intent_params = blank_intent_params
45
+ end
46
+
47
+ def preview
48
+ @intent_params = intent_params
49
+ @error = InlineFormsSchemaEdit::IntentValidator.error_for(@intent_params)
50
+ return render(:new) if @error
51
+
52
+ @intent = build_intent(@intent_params)
53
+ @is_header = @intent.form_element == :header
54
+ if @is_header
55
+ @attribute_list = InlineForms::SchemaPreview.attribute_list_for(@intent.model_class, @intent)
56
+ else
57
+ @preview, @attribute_list = InlineForms::SchemaPreview.build(@intent)
58
+ end
59
+ @batching_available = batch_tables_present?
60
+ end
61
+
62
+ # DIRECT APPLY (dev only): codegen into this checkout's tree.
63
+ def create
64
+ @intent_params = intent_params
65
+ @error = InlineFormsSchemaEdit::IntentValidator.error_for(@intent_params)
66
+ return render(:new) if @error
67
+
68
+ @intent = build_intent(@intent_params)
69
+ @is_header = @intent.form_element == :header
70
+ begin
71
+ @migration_path = InlineForms::SchemaApply.new(@intent).generate!(
72
+ destination_root: Rails.root,
73
+ executor: generator_executor || InlineForms::SchemaApply::DEFAULT_GENERATE_EXECUTOR
74
+ )
75
+ @migration_basename = @migration_path && File.basename(@migration_path)
76
+ write_label!
77
+ rescue StandardError => e
78
+ @error = "Generator failed: #{e.message}"
79
+ render(:new)
80
+ end
81
+ end
82
+
83
+ # BATCH DRAFTING: persist the intent into the current draft batch.
84
+ def draft
85
+ @intent_params = intent_params
86
+ @error = InlineFormsSchemaEdit::IntentValidator.error_for(@intent_params)
87
+ return render(:new) if @error
88
+
89
+ batch = InlineForms::SchemaBatch.current_draft
90
+ batch.intents.create!(
91
+ target_model: @intent_params[:model_name],
92
+ attr_name: @intent_params[:attribute],
93
+ form_element: @intent_params[:form_element],
94
+ after_attr: @intent_params[:after].presence,
95
+ label: @intent_params[:label].presence,
96
+ locale: @intent_params[:locale].presence
97
+ )
98
+ redirect_to inline_forms_schema_index_path
99
+ end
100
+
101
+ def remove_draft
102
+ intent = InlineForms::SchemaIntentRecord.find(params[:id])
103
+ if intent.batch&.draft?
104
+ intent.destroy
105
+ redirect_to inline_forms_schema_index_path
106
+ else
107
+ redirect_to inline_forms_schema_index_path, alert: "Batch is frozen."
108
+ end
109
+ end
110
+
111
+ def submit_batch
112
+ batch = InlineForms::SchemaBatch.current_draft
113
+ window = params[:window_at].presence && Time.zone.parse(params[:window_at])
114
+ batch.submit!(requested_by: requesting_identity, window_at: window)
115
+ redirect_to inline_forms_schema_index_path
116
+ rescue ArgumentError => e
117
+ redirect_to inline_forms_schema_index_path, alert: e.message
118
+ end
119
+
120
+ # MACHINE ENDPOINT (token): the frozen batch as JSON for the CI side.
121
+ def export
122
+ batch = InlineForms::SchemaBatch.find(params[:id])
123
+ return render(json: { error: "batch is still a draft" }, status: :conflict) if batch.draft?
124
+
125
+ render json: InlineFormsSchemaEdit::BatchExport.payload(batch)
126
+ end
127
+
128
+ # MACHINE ENDPOINT (token): CI reports progress back.
129
+ # Params: status (processing|ready|applied|failed), git_sha, error.
130
+ def batch_status
131
+ batch = InlineForms::SchemaBatch.find(params[:id])
132
+ batch.transition!(params[:status].to_s, git_sha: params[:git_sha].presence, error: params[:error].presence)
133
+ render json: { id: batch.id, status: batch.status }
134
+ rescue ArgumentError => e
135
+ render json: { error: e.message }, status: :unprocessable_entity
136
+ end
137
+
138
+ private
139
+
140
+ def require_non_production
141
+ head :not_found if Rails.env.production?
142
+ end
143
+
144
+ def require_drafting_allowed
145
+ return unless Rails.env.production?
146
+
147
+ head :not_found unless InlineFormsSchemaEdit.production_drafting
148
+ end
149
+
150
+ # 404 when unconfigured (don't advertise the endpoint), 401 on mismatch.
151
+ def authenticate_pipeline_token!
152
+ configured = InlineFormsSchemaEdit.export_token
153
+ return head(:not_found) if configured.blank?
154
+
155
+ provided = request.headers["Authorization"].to_s[/\ABearer (.+)\z/, 1] || params[:token].to_s
156
+ unless ActiveSupport::SecurityUtils.secure_compare(configured, provided.to_s)
157
+ head :unauthorized
158
+ end
159
+ end
160
+
161
+ def batch_tables_present?
162
+ InlineForms::SchemaBatch.table_exists? && InlineForms::SchemaIntentRecord.table_exists?
163
+ rescue StandardError
164
+ false
165
+ end
166
+
167
+ def require_batch_tables
168
+ return if batch_tables_present?
169
+
170
+ render plain: "Schema-GUI batch tables missing. Run: bin/rails g inline_forms_schema_edit:install && bin/rails db:migrate",
171
+ status: :service_unavailable
172
+ end
173
+
174
+ def requesting_identity
175
+ return nil unless respond_to?(:current_user, true)
176
+
177
+ current_user&.email
178
+ rescue StandardError
179
+ nil
180
+ end
181
+
182
+ def load_form_options
183
+ @form_elements = InlineForms::SchemaPreview.supported_form_elements
184
+ @models = InlineFormsSchemaEdit::IntentValidator.candidate_models
185
+ @locales = I18n.available_locales.map(&:to_s)
186
+ @default_locale = I18n.default_locale.to_s
187
+ end
188
+
189
+ def blank_intent_params
190
+ { model_name: "", attribute: "", form_element: @form_elements.first.to_s,
191
+ after: "", label: "", locale: @default_locale }
192
+ end
193
+
194
+ def intent_params
195
+ {
196
+ model_name: params[:model_name].to_s.strip,
197
+ attribute: params[:attribute].to_s.strip,
198
+ form_element: params[:form_element].to_s.strip,
199
+ after: params[:after].to_s.strip,
200
+ label: params[:label].to_s.strip,
201
+ locale: params[:locale].to_s.strip.presence || @default_locale
202
+ }
203
+ end
204
+
205
+ def build_intent(p)
206
+ InlineForms::SchemaIntent.new(
207
+ model_name: p[:model_name],
208
+ attribute: p[:attribute],
209
+ form_element: p[:form_element],
210
+ after: p[:after].presence
211
+ )
212
+ end
213
+
214
+ def write_label!
215
+ return if @intent_params[:label].blank?
216
+
217
+ writer = label_writer || InlineForms::SchemaLabel.method(:write)
218
+ @label_path = writer.call(
219
+ destination_root: Rails.root,
220
+ model_class: @intent.model_class,
221
+ attribute: @intent.attribute,
222
+ label: @intent_params[:label],
223
+ locale: @intent_params[:locale]
224
+ )
225
+ @label_basename = @label_path && File.basename(@label_path.to_s)
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,116 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ module InlineForms
4
+ # A batch of proposed schema changes (SchemaIntentRecord rows), the unit
5
+ # the automated pipeline processes. Lifecycle:
6
+ #
7
+ # draft — admin is still adding/removing intents (the "cart")
8
+ # submitted — frozen; waiting for CI to pick it up
9
+ # processing — CI is generating code / running the test gate
10
+ # ready — code committed (git_sha set); waiting for the deploy window
11
+ # applied — migrated + restarted
12
+ # failed — any step failed; error holds the diagnostics
13
+ #
14
+ # IMMUTABILITY: the moment a batch leaves draft, its intents are frozen
15
+ # (enforced in SchemaIntentRecord) and the batch itself only accepts
16
+ # status-flow updates (status, git_sha, error, applied_at). content_digest
17
+ # seals the intent list at submit time; export re-emits it and the import
18
+ # side re-verifies it, so a batch provably cannot change between "admin
19
+ # pressed submit" and "CI replayed it".
20
+ class SchemaBatch < ActiveRecord::Base
21
+ self.table_name = "inline_forms_schema_batches"
22
+
23
+ STATUSES = %w[draft submitted processing ready applied failed].freeze
24
+ TRANSITIONS = {
25
+ "draft" => %w[submitted],
26
+ "submitted" => %w[processing failed],
27
+ "processing" => %w[ready failed],
28
+ "ready" => %w[applied failed],
29
+ "failed" => %w[submitted], # resubmit after a fix
30
+ "applied" => []
31
+ }.freeze
32
+
33
+ # Columns the pipeline may still write after the batch left draft.
34
+ STATUS_FLOW_COLUMNS = %w[status git_sha error applied_at updated_at].freeze
35
+
36
+ has_many :intents,
37
+ -> { order(:position, :id) },
38
+ class_name: "InlineForms::SchemaIntentRecord",
39
+ foreign_key: :batch_id,
40
+ inverse_of: :batch,
41
+ dependent: :destroy
42
+
43
+ validates :status, inclusion: { in: STATUSES }
44
+
45
+ before_update :allow_only_status_flow_after_draft
46
+ before_destroy :only_draft_batches_are_destroyable
47
+
48
+ scope :with_status, ->(s) { where(status: s.to_s) }
49
+
50
+ def self.current_draft
51
+ with_status(:draft).order(:id).first_or_create!
52
+ end
53
+
54
+ def draft? = status == "draft"
55
+ def submitted? = status == "submitted"
56
+ def processing? = status == "processing"
57
+ def ready? = status == "ready"
58
+ def applied? = status == "applied"
59
+ def failed? = status == "failed"
60
+
61
+ # ready + the requested window (if any) has arrived.
62
+ def due_for_apply?
63
+ ready? && (window_at.nil? || window_at <= Time.current)
64
+ end
65
+
66
+ # Freeze the batch: seal the digest, record who/when, optionally a window.
67
+ def submit!(requested_by: nil, window_at: nil)
68
+ raise ArgumentError, "only a draft batch can be submitted" unless draft?
69
+ raise ArgumentError, "cannot submit an empty batch" if intents.reload.empty?
70
+
71
+ with_lock do
72
+ self.status = "submitted"
73
+ self.submitted_at = Time.current
74
+ self.window_at = window_at
75
+ self.requested_by = requested_by if requested_by
76
+ self.content_digest = InlineFormsSchemaEdit::BatchExport.digest_for(self)
77
+ save!
78
+ end
79
+ self
80
+ end
81
+
82
+ # Status-flow update used by the pipeline (CI callback / apply task).
83
+ def transition!(to, git_sha: nil, error: nil)
84
+ to = to.to_s
85
+ unless TRANSITIONS.fetch(status, []).include?(to)
86
+ raise ArgumentError, "illegal transition #{status} -> #{to}"
87
+ end
88
+
89
+ self.status = to
90
+ self.git_sha = git_sha if git_sha
91
+ self.error = error if error
92
+ self.applied_at = Time.current if to == "applied"
93
+ save!
94
+ self
95
+ end
96
+
97
+ private
98
+
99
+ def allow_only_status_flow_after_draft
100
+ return if status_was == "draft"
101
+
102
+ illegal = changed - STATUS_FLOW_COLUMNS
103
+ return if illegal.empty?
104
+
105
+ errors.add(:base, "batch is frozen after submit (illegal change to #{illegal.join(', ')})")
106
+ throw :abort
107
+ end
108
+
109
+ def only_draft_batches_are_destroyable
110
+ return if draft?
111
+
112
+ errors.add(:base, "only draft batches can be deleted")
113
+ throw :abort
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,80 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ module InlineForms
4
+ # A persisted schema intent: one proposed "add attribute X of form-element
5
+ # Y to model Z at this position" row inside a SchemaBatch. This is the AR
6
+ # counterpart of the InlineForms::SchemaIntent value object (which stays a
7
+ # plain object in inline_forms); #to_schema_intent bridges the two.
8
+ #
9
+ # Rows are editable only while their batch is a draft. `migration_version`
10
+ # is the one exception: the pipeline backfills it after apply.
11
+ class SchemaIntentRecord < ActiveRecord::Base
12
+ self.table_name = "inline_forms_schema_intents"
13
+
14
+ # Pipeline backfill columns, writable after the batch froze.
15
+ BACKFILL_COLUMNS = %w[migration_version updated_at].freeze
16
+
17
+ belongs_to :batch,
18
+ class_name: "InlineForms::SchemaBatch",
19
+ foreign_key: :batch_id,
20
+ inverse_of: :intents
21
+
22
+ # Columns are `target_model` / `attr_name` (`model_name` collides with
23
+ # ActiveModel::Naming, `attribute` with the AR attribute API); the export
24
+ # JSON keys stay "model_name"/"attribute" to match the SchemaIntent
25
+ # value object.
26
+ validates :target_model, :attr_name, :form_element, presence: true
27
+
28
+ before_save :only_in_draft_batches
29
+ before_destroy :only_in_draft_batches_destroy
30
+
31
+ before_create :assign_position
32
+
33
+ def to_schema_intent
34
+ InlineForms::SchemaIntent.new(
35
+ model_name: target_model,
36
+ attribute: attr_name,
37
+ form_element: form_element,
38
+ after: after_attr.presence,
39
+ before: before_attr.presence
40
+ )
41
+ end
42
+
43
+ def header? = form_element == "header"
44
+
45
+ # Canonical export shape; also the digest input (see BatchExport), so
46
+ # key order is fixed and values are normalized strings/nil.
47
+ def as_export
48
+ {
49
+ "model_name" => target_model,
50
+ "attribute" => attr_name,
51
+ "form_element" => form_element,
52
+ "after" => after_attr.presence,
53
+ "before" => before_attr.presence,
54
+ "label" => label.presence,
55
+ "locale" => locale.presence
56
+ }
57
+ end
58
+
59
+ private
60
+
61
+ def assign_position
62
+ self.position ||= (batch&.intents&.maximum(:position) || 0) + 1
63
+ end
64
+
65
+ def only_in_draft_batches
66
+ return if batch.nil? || batch.draft?
67
+ return if !new_record? && (changed - BACKFILL_COLUMNS).empty?
68
+
69
+ errors.add(:base, "batch is frozen; intents can no longer change")
70
+ throw :abort
71
+ end
72
+
73
+ def only_in_draft_batches_destroy
74
+ return if batch.nil? || batch.draft?
75
+
76
+ errors.add(:base, "batch is frozen; intents can no longer be removed")
77
+ throw :abort
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,57 @@
1
+ <h2>Applied: <code><%= @intent.attribute %>:<%= @intent.form_element %></code> on <code><%= @intent.model_name %></code></h2>
2
+
3
+ <% if @label_basename %>
4
+ <p class="notice">
5
+ Wrote label <code><%= @intent_params[:label] %></code> (locale
6
+ <code><%= @intent_params[:locale] %></code>) to
7
+ <code><%= @label_basename %></code>. Restart to load the new translation.
8
+ </p>
9
+ <% end %>
10
+
11
+ <% if @is_header %>
12
+ <p class="notice">
13
+ Added a <strong>header</strong> row to
14
+ <code>app/models/<%= @intent.model_name.underscore %>.rb</code>. No column,
15
+ <strong>no migration</strong> &mdash; it appears on the next reload.
16
+ </p>
17
+
18
+ <h2>Next steps (you run these)</h2>
19
+ <ol>
20
+ <li>Review the change: <code>git status</code> / <code>git diff</code></li>
21
+ <li>Commit it so the change flows through git to every environment.</li>
22
+ <% if @label_basename %><li>Restart the server to load the label.</li><% end %>
23
+ </ol>
24
+ <% else %>
25
+ <% if @migration_basename %>
26
+ <p class="notice">
27
+ Generated migration <code><%= @migration_basename %></code> and edited
28
+ <code>app/models/<%= @intent.model_name.underscore %>.rb</code>.
29
+ </p>
30
+ <% else %>
31
+ <p class="notice">Ran the generator for <code><%= @intent.generator_args.join(" ") %></code>.</p>
32
+ <% end %>
33
+
34
+ <% if InlineForms::VALUE_BEARING_FORM_ELEMENTS.include?(@intent.form_element) %>
35
+ <p class="notice">
36
+ Choice element: a placeholder values hash was inserted. Edit the
37
+ <code>[ :<%= @intent.attribute %>, :<%= @intent.form_element %>, { ... } ]</code>
38
+ row in the model to your real values.
39
+ </p>
40
+ <% end %>
41
+
42
+ <p class="notice">
43
+ <strong>Heads up (development):</strong> Rails now blocks every page with a
44
+ <code>PendingMigrationError</code> until this migration is applied. That is
45
+ Rails&rsquo; own check, not inline_forms &mdash; your cue to migrate.
46
+ </p>
47
+
48
+ <h2>Next steps (you run these)</h2>
49
+ <ol>
50
+ <li>Review the changes: <code>git status</code> / <code>git diff</code></li>
51
+ <li>Apply the migration: <code>bin/rails db:migrate</code></li>
52
+ <li>Restart the server so the new column is picked up.</li>
53
+ <li>Commit the generated files so the change flows through git to every environment.</li>
54
+ </ol>
55
+ <% end %>
56
+
57
+ <p class="muted">After the above, add another field at <code>/schema/new</code>.</p>
@@ -0,0 +1,63 @@
1
+ <% if flash[:alert].present? %>
2
+ <p class="error"><%= flash[:alert] %></p>
3
+ <% end %>
4
+
5
+ <h2>Draft batch (the cart)</h2>
6
+ <% if @draft_batch && @draft_batch.intents.any? %>
7
+ <table class="schema_list">
8
+ <thead><tr><th>#</th><th>Model</th><th>Attribute</th><th>Element</th><th>Label</th><th></th></tr></thead>
9
+ <tbody>
10
+ <% @draft_batch.intents.each do |intent| %>
11
+ <tr>
12
+ <td><%= intent.position %></td>
13
+ <td><%= intent.target_model %></td>
14
+ <td><code><%= intent.attr_name %></code><%= " (after #{intent.after_attr})" if intent.after_attr.present? %></td>
15
+ <td><%= intent.form_element %></td>
16
+ <td><%= intent.label %></td>
17
+ <td>
18
+ <%= form_tag inline_forms_schema_remove_draft_path(intent), method: :delete, data: { turbo: false } do %>
19
+ <%= submit_tag "remove" %>
20
+ <% end %>
21
+ </td>
22
+ </tr>
23
+ <% end %>
24
+ </tbody>
25
+ </table>
26
+
27
+ <%= form_tag inline_forms_schema_submit_batch_path, method: :post, data: { turbo: false } do %>
28
+ <label for="window_at">Apply window (optional; blank = next default window)</label>
29
+ <input type="datetime-local" name="window_at" id="window_at" />
30
+ <div class="actions">
31
+ <%= submit_tag "Submit batch (freezes it)" %>
32
+ </div>
33
+ <% end %>
34
+ <p class="muted">Submitting freezes the batch: no more edits. The pipeline picks
35
+ it up, generates the code on a checkout, runs the test gate, and applies it in
36
+ the chosen window. New drafts go into a fresh batch.</p>
37
+ <% else %>
38
+ <p class="muted">No drafted changes. <%= link_to "Add a field", inline_forms_schema_new_path %>.</p>
39
+ <% end %>
40
+
41
+ <h2>Batch history</h2>
42
+ <% if @batches.any? %>
43
+ <table class="schema_list">
44
+ <thead><tr><th>Batch</th><th>Status</th><th>Submitted</th><th>Window</th><th>Fields</th><th>Git</th><th>Error</th></tr></thead>
45
+ <tbody>
46
+ <% @batches.each do |batch| %>
47
+ <tr>
48
+ <td>#<%= batch.id %></td>
49
+ <td><strong><%= batch.status %></strong></td>
50
+ <td><%= batch.submitted_at&.strftime("%F %R") %></td>
51
+ <td><%= batch.window_at ? batch.window_at.strftime("%F %R") : "next window" %></td>
52
+ <td><%= batch.intents.map { |i| "#{i.target_model}##{i.attr_name}" }.join(", ") %></td>
53
+ <td><code><%= batch.git_sha.to_s.first(10) %></code></td>
54
+ <td class="muted"><%= batch.error.to_s.truncate(120) %></td>
55
+ </tr>
56
+ <% end %>
57
+ </tbody>
58
+ </table>
59
+ <% else %>
60
+ <p class="muted">No submitted batches yet.</p>
61
+ <% end %>
62
+
63
+ <p><%= link_to "Add a field", inline_forms_schema_new_path %></p>
@@ -0,0 +1,49 @@
1
+ <% if @error.present? %>
2
+ <p class="error"><%= @error %></p>
3
+ <% end %>
4
+
5
+ <%= form_tag inline_forms_schema_preview_path, method: :post, data: { turbo: false } do %>
6
+ <label for="model_name">Model</label>
7
+ <input type="text" name="model_name" id="model_name" list="schema_models"
8
+ value="<%= @intent_params[:model_name] %>" placeholder="e.g. Apartment" />
9
+ <datalist id="schema_models">
10
+ <% @models.each do |m| %><option value="<%= m %>"></option><% end %>
11
+ </datalist>
12
+
13
+ <label for="attribute">Attribute name</label>
14
+ <input type="text" name="attribute" id="attribute"
15
+ value="<%= @intent_params[:attribute] %>" placeholder="e.g. internal_note" />
16
+
17
+ <label for="form_element">Form element</label>
18
+ <select name="form_element" id="form_element">
19
+ <optgroup label="Fields (add a column)">
20
+ <% @form_elements.each do |fe| %>
21
+ <option value="<%= fe %>" <%= "selected" if @intent_params[:form_element] == fe.to_s %>><%= fe %></option>
22
+ <% end %>
23
+ </optgroup>
24
+ <optgroup label="No column">
25
+ <option value="header" <%= "selected" if @intent_params[:form_element] == "header" %>>header (section separator &mdash; no migration)</option>
26
+ </optgroup>
27
+ </select>
28
+
29
+ <label for="after">Insert after (optional attribute name)</label>
30
+ <input type="text" name="after" id="after"
31
+ value="<%= @intent_params[:after] %>" placeholder="leave blank to append" />
32
+
33
+ <label for="label">Label (optional) &mdash; the text shown for this field/header</label>
34
+ <input type="text" name="label" id="label"
35
+ value="<%= @intent_params[:label] %>" placeholder="e.g. Internal note" />
36
+
37
+ <label for="locale">Label locale</label>
38
+ <select name="locale" id="locale">
39
+ <% @locales.each do |loc| %>
40
+ <option value="<%= loc %>" <%= "selected" if @intent_params[:locale] == loc %>><%= loc %></option>
41
+ <% end %>
42
+ </select>
43
+
44
+ <div class="actions">
45
+ <%= submit_tag "Preview" %>
46
+ </div>
47
+ <% end %>
48
+
49
+ <p class="muted"><%= link_to "Batches (drafts + history)", inline_forms_schema_index_path %></p>