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 +7 -0
- data/CHANGELOG.md +53 -0
- data/app/controllers/inline_forms/schema_controller.rb +228 -0
- data/app/models/inline_forms/schema_batch.rb +116 -0
- data/app/models/inline_forms/schema_intent_record.rb +80 -0
- data/app/views/inline_forms/schema/create.html.erb +57 -0
- data/app/views/inline_forms/schema/index.html.erb +63 -0
- data/app/views/inline_forms/schema/new.html.erb +49 -0
- data/app/views/inline_forms/schema/preview.html.erb +73 -0
- data/app/views/layouts/inline_forms_schema.html.erb +33 -0
- data/doc/schema-apply-workflow.yml.example +89 -0
- data/lib/generators/inline_forms_schema_edit/install_generator.rb +40 -0
- data/lib/generators/inline_forms_schema_edit/templates/create_schema_edit_tables.rb.erb +30 -0
- data/lib/inline_forms_schema_edit/batch_export.rb +41 -0
- data/lib/inline_forms_schema_edit/batch_import.rb +105 -0
- data/lib/inline_forms_schema_edit/engine.rb +13 -0
- data/lib/inline_forms_schema_edit/intent_validator.rb +71 -0
- data/lib/inline_forms_schema_edit/version.rb +7 -0
- data/lib/inline_forms_schema_edit.rb +69 -0
- data/lib/tasks/inline_forms_schema_edit.rake +83 -0
- metadata +75 -0
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> — 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’ own check, not inline_forms — 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 — 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) — 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>
|