ecoportal-api-graphql 2.2.0 → 3.0.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +448 -31
  3. data/README.gem.md +53 -0
  4. data/lib/ecoportal/api/common/graphql/auth_service.rb +1 -1
  5. data/lib/ecoportal/api/common/graphql/client.rb +38 -0
  6. data/lib/ecoportal/api/common/graphql/http_client.rb +39 -6
  7. data/lib/ecoportal/api/common/graphql/model/diffable/leaf_diff_service.rb +1 -1
  8. data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +1 -1
  9. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +159 -11
  10. data/lib/ecoportal/api/graphql/base/temp_image.rb +28 -0
  11. data/lib/ecoportal/api/graphql/base.rb +1 -0
  12. data/lib/ecoportal/api/graphql/builder/template.rb +9 -4
  13. data/lib/ecoportal/api/graphql/compat/filter_translator.rb +1 -1
  14. data/lib/ecoportal/api/graphql/file_upload/client.rb +140 -35
  15. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +8 -3
  16. data/lib/ecoportal/api/graphql/fragment/permissions.rb +0 -2
  17. data/lib/ecoportal/api/graphql/input/page/update.rb +109 -6
  18. data/lib/ecoportal/api/graphql/input/search_conf.rb +1 -1
  19. data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_container_uid.rb +1 -1
  20. data/lib/ecoportal/api/graphql/input/workflow_command/manage_copy_page_configuration.rb +1 -1
  21. data/lib/ecoportal/api/graphql/input/workflow_command/move_section_to_stage.rb +33 -0
  22. data/lib/ecoportal/api/graphql/input/workflow_command.rb +2 -0
  23. data/lib/ecoportal/api/graphql/logic/base_query.rb +1 -1
  24. data/lib/ecoportal/api/graphql/model/permissions.rb +6 -0
  25. data/lib/ecoportal/api/graphql/model/temp_image.rb +10 -0
  26. data/lib/ecoportal/api/graphql/model/template/binding.rb +60 -0
  27. data/lib/ecoportal/api/graphql/model/template/command_grouper.rb +107 -0
  28. data/lib/ecoportal/api/graphql/model/template/command_normalizer.rb +116 -0
  29. data/lib/ecoportal/api/graphql/model/template/command_synthesis.rb +262 -0
  30. data/lib/ecoportal/api/graphql/model/template/field.rb +68 -0
  31. data/lib/ecoportal/api/graphql/model/template/force.rb +65 -0
  32. data/lib/ecoportal/api/graphql/model/template/helper.rb +32 -0
  33. data/lib/ecoportal/api/graphql/model/template/instance.rb +202 -0
  34. data/lib/ecoportal/api/graphql/model/template/node.rb +78 -0
  35. data/lib/ecoportal/api/graphql/model/template/option.rb +49 -0
  36. data/lib/ecoportal/api/graphql/model/template/read.rb +164 -0
  37. data/lib/ecoportal/api/graphql/model/template/section.rb +82 -0
  38. data/lib/ecoportal/api/graphql/model/template/stage.rb +49 -0
  39. data/lib/ecoportal/api/graphql/model/template/staged_executor.rb +236 -0
  40. data/lib/ecoportal/api/graphql/model/template.rb +33 -0
  41. data/lib/ecoportal/api/graphql/model.rb +1 -0
  42. data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +11 -4
  43. data/lib/ecoportal/api/graphql/mutation/image/upload.rb +88 -0
  44. data/lib/ecoportal/api/graphql/mutation/image.rb +14 -0
  45. data/lib/ecoportal/api/graphql/mutation/template/create.rb +35 -3
  46. data/lib/ecoportal/api/graphql/mutation/template/update.rb +4 -2
  47. data/lib/ecoportal/api/graphql/mutation.rb +1 -0
  48. data/lib/ecoportal/api/graphql/payload/images_upload.rb +14 -0
  49. data/lib/ecoportal/api/graphql/payload.rb +1 -0
  50. data/lib/ecoportal/api/graphql/query/pages.rb +1 -1
  51. data/lib/ecoportal/api/graphql/query/permissions.rb +1 -1
  52. data/lib/ecoportal/api/graphql_version.rb +1 -1
  53. metadata +36 -2
  54. data/README.md +0 -24
@@ -0,0 +1,202 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # The mutable, in-memory template: `Model::Template.load(client, id:)` returns one of
7
+ # these. Owns the placeholder counters every `Node` mints from (see `Node#placeholder`
8
+ # — keeping them on the Instance rather than globally means two `Instance`s loaded in
9
+ # the same process never share a counter, so specs get deterministic `ph_sec_1`-style
10
+ # ids per fixture without any manual reset).
11
+ #
12
+ # THE THREE LINES (see the internal design doc,
13
+ # section 2.4):
14
+ #
15
+ # template = Ecoportal::API::GraphQL::Model::Template.load(client, id: page_id)
16
+ # template.fields.each { |f| f.label = "#{f.label} (reviewed)" }
17
+ # template.save!(client)
18
+ #
19
+ # `#save!` defaults to `simulate: true` (see its own doc) — nothing is sent to the
20
+ # server until the caller explicitly asks for `simulate: false`.
21
+ class Instance
22
+ attr_reader :id, :patch_ver, :stages, :phased
23
+
24
+ # @param phased [Boolean] true for a `PhasedPage` (the server's own type name),
25
+ # false for a `BasicPage`/build-from-nothing. Drives whether field-level EDIT
26
+ # commands need a `stageId` disambiguator (see `CommandSynthesis#stage_id_kwarg`)
27
+ # -- a `Field` always carries a `#stage` reference either way (workflow-first
28
+ # reads populate it for BOTH page kinds, per `Read`'s own header), so `#phased`,
29
+ # not "does the field know its stage", is the real gate the schema cares about.
30
+ def initialize(id:, patch_ver:, stages: [], phased: false)
31
+ @id = id
32
+ @patch_ver = patch_ver
33
+ @stages = stages
34
+ @phased = phased
35
+ @counters = Hash.new(0)
36
+ end
37
+
38
+ alias_method :phased?, :phased
39
+
40
+ def next_placeholder(prefix)
41
+ @counters[prefix] += 1
42
+ "ph_#{prefix}_#{@counters[prefix]}"
43
+ end
44
+
45
+ class << self
46
+ # @param client [Ecoportal::API::GraphQL]
47
+ # @param id [String]
48
+ def load(client, id:)
49
+ page = Read.call(client, id)
50
+ from_doc(page)
51
+ end
52
+
53
+ # Builds an Instance from an already-fetched `page` doc (same shape `Read.call`
54
+ # returns) — used by both `.load` and `StagedExecutor`'s between-phase re-reads,
55
+ # so both paths build the SAME tree shape from the SAME doc reader.
56
+ def from_doc(page)
57
+ instance = new(id: page['id'], patch_ver: page['patchVer'], phased: page['__typename'] == 'PhasedPage')
58
+ Read.stages(page).each { |stage_doc| instance.stages << build_stage(instance, stage_doc) }
59
+ instance
60
+ end
61
+
62
+ private
63
+
64
+ def build_stage(instance, stage_doc)
65
+ stage = Stage.new(template: instance, id: stage_doc['id'], name: stage_doc['name'],
66
+ ordering: stage_doc['ordering'])
67
+ Array(stage_doc['sections']).each { |sec_doc| stage.sections << build_section(instance, stage, sec_doc) }
68
+ Array(stage_doc['forces']).each { |force_doc| stage.forces << build_force(instance, stage, force_doc) }
69
+ stage
70
+ end
71
+
72
+ def build_section(instance, stage, doc)
73
+ split = doc['__typename'] == 'SplitSection'
74
+ section = Section.new(template: instance, stage: stage, id: doc['id'],
75
+ kind: (split ? :split : :content), heading: doc['heading'],
76
+ left_heading: doc['leftHeading'], right_heading: doc['rightHeading'])
77
+ if split
78
+ Array(doc['leftDataFields']).each { |f| section.left_fields << build_field(instance, section, stage, f) }
79
+ Array(doc['rightDataFields']).each { |f| section.right_fields << build_field(instance, section, stage, f) }
80
+ else
81
+ Array(doc['dataFields']).each { |f| section.left_fields << build_field(instance, section, stage, f) }
82
+ end
83
+ section
84
+ end
85
+
86
+ def build_field(instance, section, stage, doc)
87
+ field = Field.new(template: instance, section: section, stage: stage, id: doc['id'],
88
+ field_type: doc['__typename'], label: doc['label'], tooltip: doc['tooltip'],
89
+ description: doc['description'], required: doc['required'], hidden: doc['hidden'])
90
+ Array(doc['options']).each do |opt_doc|
91
+ field.options << Option.new(template: instance, field: field, id: opt_doc['id'],
92
+ label: opt_doc['name'], value: opt_doc['value'])
93
+ end
94
+ field
95
+ end
96
+
97
+ def build_force(instance, stage, doc)
98
+ force = Force.new(template: instance, stage: stage, id: doc['id'], name: doc['name'],
99
+ custom_script: doc['customScript'], url: doc['url'], content_b64: doc['contentB64'])
100
+ Array(doc['bindings']).each do |b|
101
+ force.bindings << Binding.new(template: instance, force: force, name: b['name'], reference_id: b['referenceId'])
102
+ end
103
+ Array(doc['helpers']).each do |h|
104
+ force.helpers << Helper.new(template: instance, force: force, id: h['id'], name: h['name'],
105
+ file_path: h['filePath'], script: h['script'], content_b64: h['contentB64'])
106
+ end
107
+ force
108
+ end
109
+ end
110
+
111
+ # Every (non-removed) field across every stage, document order.
112
+ def fields
113
+ stages.flat_map(&:fields)
114
+ end
115
+
116
+ def sections
117
+ stages.flat_map(&:sections)
118
+ end
119
+
120
+ def forces
121
+ stages.flat_map(&:forces)
122
+ end
123
+
124
+ # The public diff engine — see `CommandSynthesis`'s own doc for ordering/safety.
125
+ def as_commands
126
+ CommandSynthesis.new(self).commands
127
+ end
128
+
129
+ def dirty?
130
+ !as_commands.empty?
131
+ end
132
+
133
+ # @param client [Ecoportal::API::GraphQL, nil] required unless `simulate: true`.
134
+ # @param simulate [Boolean] default true — returns the staged PLAN (stages, chunks,
135
+ # command counts, placeholders) WITHOUT calling the server at all. Pass
136
+ # `simulate: false` to actually execute it.
137
+ # @param chunk_size [Integer]
138
+ # @return [StagedExecutor::Plan, StagedExecutor::Result]
139
+ def save!(client = nil, simulate: true, chunk_size: StagedExecutor::DEFAULT_CHUNK_SIZE)
140
+ executor = StagedExecutor.new(self, client: client, chunk_size: chunk_size)
141
+ simulate ? executor.plan : executor.execute!
142
+ end
143
+
144
+ # Re-reads the template fresh and reports, per node this Instance currently holds
145
+ # (and is not flagged removed), whether the live server agrees with THIS model's
146
+ # own current in-memory state. Call after a `save!(simulate: false)` — compares
147
+ # against the model's post-save state, not the pre-save snapshot, so a call with
148
+ # zero pending changes never reports a false "verified" for something it never
149
+ # actually checked (every tracked node is still compared, `changed or not`).
150
+ #
151
+ # @param client [Ecoportal::API::GraphQL]
152
+ # @return [Hash] { verified: [...], mismatch: [...], missing: [...] } — each entry
153
+ # `{ kind:, id:, attribute:, expected:, actual: }`. `missing` covers both "no
154
+ # node with this id exists live" and "this node never resolved a real id at all"
155
+ # (a save that silently no-oped).
156
+ def verify(client)
157
+ live = Instance.load(client, id: id)
158
+ report = { verified: [], mismatch: [], missing: [] }
159
+ verify_fields(live, report)
160
+ verify_options(live, report)
161
+ verify_sections(live, report)
162
+ report
163
+ end
164
+
165
+ private
166
+
167
+ def verify_fields(live, report)
168
+ live_by_id = live.fields.to_h { |f| [f.id, f] }
169
+ fields.reject(&:removed?).each { |field| compare_node(field, live_by_id[field.id], :field, %i[label tooltip description required hidden], report) }
170
+ end
171
+
172
+ def verify_options(live, report)
173
+ live_by_id = live.fields.flat_map(&:options).to_h { |o| [o.id, o] }
174
+ fields.reject(&:removed?).flat_map(&:options).reject(&:removed?).each do |opt|
175
+ compare_node(opt, live_by_id[opt.id], :option, %i[label value weight], report)
176
+ end
177
+ end
178
+
179
+ def verify_sections(live, report)
180
+ live_by_id = live.sections.to_h { |s| [s.id, s] }
181
+ sections.reject(&:removed?).each { |section| compare_node(section, live_by_id[section.id], :section, %i[heading left_heading right_heading], report) }
182
+ end
183
+
184
+ def compare_node(node, live_node, kind, attrs, report)
185
+ if node.id.nil? || live_node.nil?
186
+ report[:missing] << { kind: kind, id: node.id, attribute: nil, expected: nil, actual: nil }
187
+ return
188
+ end
189
+
190
+ attrs.each do |attr|
191
+ expected = node.public_send(attr)
192
+ actual = live_node.public_send(attr)
193
+ entry = { kind: kind, id: node.id, attribute: attr, expected: expected, actual: actual }
194
+ (expected == actual ? report[:verified] : report[:mismatch]) << entry
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,78 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # Shared behaviour for every mutable node in the editable template tree (Stage,
7
+ # Section, Field, Option, Force, Binding, Helper). A node is either:
8
+ #
9
+ # * LOADED — `id` is a real server id, carried in from `Read.call`. `snapshot`
10
+ # freezes the mutable attributes as they were at load time, so `#dirty?` /
11
+ # `#changed_attributes` can diff "now" against "as loaded" later.
12
+ # * NEW — `id` is nil; the node was created in memory (`Stage#add_section`, etc.)
13
+ # and has no server counterpart yet. `#ref` mints a client-chosen `placeholderId`
14
+ # the first time it is asked (deterministic, `"ph_<prefix>_<n>"`, matching the
15
+ # scheme `Builder::TemplateBuilder`/`Diff::CommandSynthesizer` already use,
16
+ # counter owned by the root `Instance` so two loaded templates never share
17
+ # counters) and memoises it — every command in the same `#as_commands` batch
18
+ # that addresses this node reuses the SAME token.
19
+ #
20
+ # `#ref` is the ONE thing every command-emission method should call to address a
21
+ # node — never `id` directly — because it transparently upgrades from placeholder to
22
+ # real id the moment `StagedExecutor` resolves this node against a live re-read
23
+ # (`#resolve!`), without the caller needing to know which phase it is in.
24
+ #
25
+ # Including class MUST define `PLACEHOLDER_PREFIX` (a short String, e.g. `'sec'`)
26
+ # and `#template` (the root `Instance`, which owns the placeholder counters).
27
+ module Node
28
+ attr_reader :id
29
+
30
+ def new?
31
+ id.nil?
32
+ end
33
+
34
+ def removed?
35
+ !!@removed
36
+ end
37
+
38
+ # Flags this node for a `remove*` command. Only meaningful for a LOADED node — a
39
+ # NEW node that is removed before ever being saved should simply be dropped from
40
+ # its parent collection instead (there is nothing server-side to remove yet).
41
+ def remove!
42
+ if new?
43
+ raise ArgumentError, "#{self.class}: cannot remove a node that was never saved " \
44
+ '(drop it from its parent collection instead)'
45
+ end
46
+
47
+ @removed = true
48
+ end
49
+
50
+ # The token every command that addresses this node must use: the real id once
51
+ # known, otherwise a memoised placeholder. `StagedExecutor#resolve!` is the only
52
+ # code that ever calls `#resolve!` — everything downstream just calls `#ref` again
53
+ # and transparently gets the real id from then on.
54
+ def ref
55
+ id || placeholder
56
+ end
57
+
58
+ def placeholder
59
+ @placeholder ||= template.next_placeholder(self.class::PLACEHOLDER_PREFIX)
60
+ end
61
+
62
+ # Called once a live re-read has matched this NEW node to its real server id.
63
+ # Raises if called on an already-resolved node with a DIFFERENT id (a resolver
64
+ # bug, not a transient condition) — silently overwriting a resolved id would hide
65
+ # a mismatched cross-check upstream.
66
+ def resolve!(real_id)
67
+ raise ArgumentError, "#{self.class}: resolve! requires a real id" if real_id.nil?
68
+ return if id == real_id
69
+ raise "#{self.class}: already resolved to #{id.inspect}, cannot re-resolve to #{real_id.inspect}" if id
70
+
71
+ @id = real_id
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,49 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # One option of a Select field. Read-side field name is `name` (see the gem's own
7
+ # `selectField` fragment, `fragment/pages/common_page_union.rb`); write-side commands
8
+ # (`addSelectFieldOption`/`editSelectFieldOption`) name the same thing `label` — this
9
+ # class always uses `label`, mapping the read-side `name` -> `label` at load time
10
+ # (`Read.call`), so callers never see the read/write naming split.
11
+ class Option
12
+ include Node
13
+
14
+ PLACEHOLDER_PREFIX = 'opt'.freeze
15
+
16
+ attr_reader :template, :field
17
+ attr_accessor :label, :value, :weight
18
+
19
+ def initialize(template:, field:, id: nil, label: nil, value: nil, weight: nil)
20
+ @template = template
21
+ @field = field
22
+ @id = id
23
+ @label = label
24
+ @value = value
25
+ @weight = weight
26
+ @snapshot = snapshot_attrs.freeze
27
+ end
28
+
29
+ def dirty?
30
+ !removed? && !new? && snapshot_attrs != @snapshot
31
+ end
32
+
33
+ def changed_attributes
34
+ return {} if new? || removed?
35
+
36
+ snapshot_attrs.each_with_object({}) { |(k, v), h| h[k] = v if v != @snapshot[k] }
37
+ end
38
+
39
+ private
40
+
41
+ def snapshot_attrs
42
+ { label: label, value: value, weight: weight }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,164 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # Live read for `Instance.load`/`StagedExecutor`'s between-phase re-reads.
7
+ #
8
+ # *** PLATFORM FACT — workflow-first, DO NOT collapse onto `page.stages` alone ***
9
+ #
10
+ # Ported verbatim (query shape) from a downstream script repo's
11
+ # template-fields read service, itself verified live 2026-08-01 and
12
+ # re-confirmed 2026-08-09: a command-bus-built template's PAGE projection can be
13
+ # EMPTY while its WORKFLOW carries the full structure (Workflow Builder / this gem's
14
+ # own `updatePageTemplate` writes workflow stage/section/field docs; the page
15
+ # projection does not always materialise from them), whereas a web-editor-built
16
+ # template DOES project page stages. Read `workflow { stages { sections } }` FIRST;
17
+ # fall back to the page projection only when the workflow yields nothing — never the
18
+ # other way round.
19
+ #
20
+ # SplitSection has NO `dataFields` — `leftDataFields`/`rightDataFields` only. Forces
21
+ # are read from `workflow.stages.forces` only (`18_template_editor_save_path.md`
22
+ # section A.1, `TemplateStageFields.forces`); the page projection carries no forces
23
+ # at all, so there is no fallback for that part of the read.
24
+ #
25
+ # `Helper`'s own five fields -- `id name filePath script contentB64` -- are LIVE-
26
+ # VERIFIED (not merely mirrored from the write side): a downstream script repo's
27
+ # template force-read case selects
28
+ # `helpers { id name filePath script contentB64 }` on exactly this same
29
+ # `forces` field, and that read has run live against pre_prod across 47 templates
30
+ # (that file's own header, lines 29-31/90). Use these five with confidence.
31
+ #
32
+ # UNVERIFIED against a live schema this session: any `Force` field beyond the
33
+ # `Helper` sub-fields cited above (e.g. a top-level `script` alongside
34
+ # `customScript`, or a plain `url`/`contentB64` on the force itself) -- cite before
35
+ # relying on anything not confirmed above.
36
+ module Read
37
+ STRUCTURE_QUERY = <<~GRAPHQL.freeze
38
+ query TemplateModelRead($id: ID!) {
39
+ currentOrganization {
40
+ page(id: $id, showHiddenData: true) {
41
+ __typename
42
+ ... on BasicPage {
43
+ id
44
+ name
45
+ patchVer
46
+ workflow { stages { ...wfStage } }
47
+ sections { ...sectionFields }
48
+ }
49
+ ... on PhasedPage {
50
+ id
51
+ name
52
+ patchVer
53
+ workflow { stages { ...wfStage } }
54
+ stages { id name ordering sections { ...sectionFields } }
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ fragment wfStage on Stage {
61
+ id
62
+ name
63
+ ordering
64
+ sections { ...sectionFields }
65
+ forces {
66
+ id
67
+ name
68
+ customScript
69
+ url
70
+ contentB64
71
+ bindings { name referenceId }
72
+ helpers { id name filePath script contentB64 }
73
+ }
74
+ }
75
+
76
+ fragment sectionFields on SectionUnion {
77
+ __typename
78
+ ... on ContentSection {
79
+ id
80
+ heading
81
+ dataFields { ...tf }
82
+ }
83
+ ... on SplitSection {
84
+ id
85
+ heading
86
+ leftHeading
87
+ rightHeading
88
+ leftDataFields { ...tf }
89
+ rightDataFields { ...tf }
90
+ }
91
+ }
92
+
93
+ fragment tf on DataFieldUnion {
94
+ __typename
95
+ ... on DataFieldsInterface {
96
+ id
97
+ label
98
+ description
99
+ tooltip
100
+ hidden
101
+ required
102
+ }
103
+ ... on Select {
104
+ options { id value name }
105
+ }
106
+ }
107
+ GRAPHQL
108
+
109
+ class << self
110
+ # @param client [Ecoportal::API::GraphQL] the top-level gem client (has
111
+ # `#client` / `.http_client`, matching every other read in this gem).
112
+ # @param id [String] the template's page id.
113
+ # @return [Hash] the raw `page` doc (string keys, as the server sent it).
114
+ def call(client, id)
115
+ raw = http(client).execute(STRUCTURE_QUERY, variables: { id: id })
116
+ page = raw.dig('data', 'currentOrganization', 'page')
117
+ raise "Template '#{id}' not readable as page" unless page
118
+
119
+ page
120
+ end
121
+
122
+ # Fields in document order, workflow-first, exactly `TemplateFieldsRead.fields`'s
123
+ # own semantics (kept as a module method so `StagedExecutor`'s re-read
124
+ # cross-checks can call it against a freshly re-fetched `page` doc without going
125
+ # through the whole `Instance` construction).
126
+ #
127
+ # @return [Array<Hash>] each entry carries stage_id/section_id/section_side
128
+ # alongside the field's own doc, in document order.
129
+ def stages(page)
130
+ from_workflow = Array(page.dig('workflow', 'stages'))
131
+ return from_workflow unless from_workflow.empty?
132
+
133
+ fallback_stages(page)
134
+ end
135
+
136
+ private
137
+
138
+ # Accepts, in order of preference: the top-level `Ecoportal::API::GraphQL`
139
+ # object (`client.client.http_client`), the inner
140
+ # `Common::GraphQL::Client` directly (`client.http_client`), or a bare
141
+ # `#execute(query, variables:) -> Hash` duck (a spec double) as-is.
142
+ def http(client)
143
+ return client.client.http_client if client.respond_to?(:client) && client.client.respond_to?(:http_client)
144
+ return client.http_client if client.respond_to?(:http_client)
145
+
146
+ client
147
+ end
148
+
149
+ def fallback_stages(page)
150
+ case page['__typename']
151
+ when 'PhasedPage' then Array(page['stages'])
152
+ when 'BasicPage'
153
+ [{ 'id' => nil, 'name' => nil, 'ordering' => 0, 'sections' => Array(page['sections']), 'forces' => [] }]
154
+ else
155
+ []
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,82 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # One section. `kind` is `:content` (single `fields` list) or `:split` (`left_fields`
7
+ # / `right_fields`, each independently addressable — a `SplitSection` has NO
8
+ # `dataFields` of its own, see `Read.call`'s header). `#fields` always returns the
9
+ # flattened, document-ordered list (left then right, for a split section) — the ONE
10
+ # place callers should iterate unless they specifically need a side.
11
+ class Section
12
+ include Node
13
+
14
+ PLACEHOLDER_PREFIX = 'sec'.freeze
15
+
16
+ attr_reader :template, :stage, :kind, :layout, :left_fields, :right_fields, :pending_move
17
+ attr_accessor :heading, :left_heading, :right_heading
18
+
19
+ def initialize(template:, stage:, id: nil, kind: :content, layout: nil,
20
+ heading: nil, left_heading: nil, right_heading: nil)
21
+ @template = template
22
+ @stage = stage
23
+ @id = id
24
+ @kind = kind
25
+ @layout = layout
26
+ @heading = heading
27
+ @left_heading = left_heading
28
+ @right_heading = right_heading
29
+ @left_fields = []
30
+ @right_fields = []
31
+ @pending_move = nil
32
+ @snapshot = snapshot_attrs.freeze
33
+ end
34
+
35
+ # Document-ordered fields. For a :content section this IS `left_fields`; for a
36
+ # :split section it is left-then-right (matches `Read.call`'s own document-order
37
+ # flattening, so `#fields` here and the loaded order agree).
38
+ def fields
39
+ kind == :split ? left_fields + right_fields : left_fields
40
+ end
41
+
42
+ def add_field(field_type:, label: nil, column: nil, side: nil, **kargs)
43
+ field = Field.new(template: template, section: self, stage: stage,
44
+ field_type: field_type, label: label, column: column, **kargs)
45
+ (side == :right ? right_fields : left_fields) << field
46
+ field
47
+ end
48
+
49
+ # Records the intent to reposition this (existing) section relative to `anchor`
50
+ # (another Section) — emitted as ONE `reorderSection` command as a per-stage
51
+ # TRAILER (after every structural add/remove in the same stage), because the
52
+ # server rewrites EVERY sibling's weight on a reorder
53
+ # (`18_template_editor_save_path.md` section C.1) — running it before a same-batch
54
+ # `addStageSection` would have its effect immediately overwritten by the next
55
+ # section's own weight assignment.
56
+ def move(anchor:, position:)
57
+ raise ArgumentError, "position must be 'BEFORE' or 'AFTER'" unless %w[BEFORE AFTER].include?(position)
58
+
59
+ @pending_move = { anchor: anchor, position: position }
60
+ end
61
+
62
+ def dirty?
63
+ !removed? && !new? && snapshot_attrs != @snapshot
64
+ end
65
+
66
+ def changed_attributes
67
+ return {} if new? || removed?
68
+
69
+ snapshot_attrs.each_with_object({}) { |(k, v), h| h[k] = v if v != @snapshot[k] }
70
+ end
71
+
72
+ private
73
+
74
+ def snapshot_attrs
75
+ { heading: heading, left_heading: left_heading, right_heading: right_heading }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,49 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # One workflow stage. `editStage`/`moveStage` exist server-side but are not wired to
7
+ # a mutable accessor here yet — out of scope for this pass (see the model's own
8
+ # design doc, "not yet implemented"); `name`/`ordering` are captured read-only for
9
+ # display and cross-checks.
10
+ class Stage
11
+ include Node
12
+
13
+ PLACEHOLDER_PREFIX = 'stg'.freeze
14
+
15
+ attr_reader :template, :sections, :forces, :name, :ordering
16
+
17
+ def initialize(template:, id: nil, name: nil, ordering: nil)
18
+ @template = template
19
+ @id = id
20
+ @name = name
21
+ @ordering = ordering
22
+ @sections = []
23
+ @forces = []
24
+ end
25
+
26
+ def add_section(kind: :content, layout: nil, heading: nil, left_heading: nil, right_heading: nil)
27
+ section = Section.new(template: template, stage: self, kind: kind, layout: layout,
28
+ heading: heading, left_heading: left_heading, right_heading: right_heading)
29
+ sections << section
30
+ section
31
+ end
32
+
33
+ def add_force(name: nil, custom_script: nil, url: nil, content_b64: nil)
34
+ force = Force.new(template: template, stage: self, name: name, custom_script: custom_script,
35
+ url: url, content_b64: content_b64)
36
+ forces << force
37
+ force
38
+ end
39
+
40
+ # Every field across every (non-removed) section of this stage, document order.
41
+ def fields
42
+ sections.reject(&:removed?).flat_map(&:fields)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end