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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +448 -31
- data/README.gem.md +53 -0
- data/lib/ecoportal/api/common/graphql/auth_service.rb +1 -1
- data/lib/ecoportal/api/common/graphql/client.rb +38 -0
- data/lib/ecoportal/api/common/graphql/http_client.rb +39 -6
- data/lib/ecoportal/api/common/graphql/model/diffable/leaf_diff_service.rb +1 -1
- data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +1 -1
- data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +159 -11
- data/lib/ecoportal/api/graphql/base/temp_image.rb +28 -0
- data/lib/ecoportal/api/graphql/base.rb +1 -0
- data/lib/ecoportal/api/graphql/builder/template.rb +9 -4
- data/lib/ecoportal/api/graphql/compat/filter_translator.rb +1 -1
- data/lib/ecoportal/api/graphql/file_upload/client.rb +140 -35
- data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +8 -3
- data/lib/ecoportal/api/graphql/fragment/permissions.rb +0 -2
- data/lib/ecoportal/api/graphql/input/page/update.rb +109 -6
- data/lib/ecoportal/api/graphql/input/search_conf.rb +1 -1
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_container_uid.rb +1 -1
- data/lib/ecoportal/api/graphql/input/workflow_command/manage_copy_page_configuration.rb +1 -1
- data/lib/ecoportal/api/graphql/input/workflow_command/move_section_to_stage.rb +33 -0
- data/lib/ecoportal/api/graphql/input/workflow_command.rb +2 -0
- data/lib/ecoportal/api/graphql/logic/base_query.rb +1 -1
- data/lib/ecoportal/api/graphql/model/permissions.rb +6 -0
- data/lib/ecoportal/api/graphql/model/temp_image.rb +10 -0
- data/lib/ecoportal/api/graphql/model/template/binding.rb +60 -0
- data/lib/ecoportal/api/graphql/model/template/command_grouper.rb +107 -0
- data/lib/ecoportal/api/graphql/model/template/command_normalizer.rb +116 -0
- data/lib/ecoportal/api/graphql/model/template/command_synthesis.rb +262 -0
- data/lib/ecoportal/api/graphql/model/template/field.rb +68 -0
- data/lib/ecoportal/api/graphql/model/template/force.rb +65 -0
- data/lib/ecoportal/api/graphql/model/template/helper.rb +32 -0
- data/lib/ecoportal/api/graphql/model/template/instance.rb +202 -0
- data/lib/ecoportal/api/graphql/model/template/node.rb +78 -0
- data/lib/ecoportal/api/graphql/model/template/option.rb +49 -0
- data/lib/ecoportal/api/graphql/model/template/read.rb +164 -0
- data/lib/ecoportal/api/graphql/model/template/section.rb +82 -0
- data/lib/ecoportal/api/graphql/model/template/stage.rb +49 -0
- data/lib/ecoportal/api/graphql/model/template/staged_executor.rb +236 -0
- data/lib/ecoportal/api/graphql/model/template.rb +33 -0
- data/lib/ecoportal/api/graphql/model.rb +1 -0
- data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +11 -4
- data/lib/ecoportal/api/graphql/mutation/image/upload.rb +88 -0
- data/lib/ecoportal/api/graphql/mutation/image.rb +14 -0
- data/lib/ecoportal/api/graphql/mutation/template/create.rb +35 -3
- data/lib/ecoportal/api/graphql/mutation/template/update.rb +4 -2
- data/lib/ecoportal/api/graphql/mutation.rb +1 -0
- data/lib/ecoportal/api/graphql/payload/images_upload.rb +14 -0
- data/lib/ecoportal/api/graphql/payload.rb +1 -0
- data/lib/ecoportal/api/graphql/query/pages.rb +1 -1
- data/lib/ecoportal/api/graphql/query/permissions.rb +1 -1
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- metadata +36 -2
- 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
|