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,107 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # Groups an ordered command batch into chunks no larger than `chunk_size`, never
7
+ # splitting a placeholder-minting command away from any OTHER command in the SAME
8
+ # batch that references that placeholder (a `placeholderId` is only valid within the
9
+ # ONE `executeWorkflowCommands`/`updatePageTemplate` call that mints it —
10
+ # `18_template_editor_save_path.md` section B.1). Connected components (union-find
11
+ # over "which commands share a placeholder token") are packed into chunks in
12
+ # first-appearance order, so a chunk boundary only ever falls BETWEEN independent
13
+ # groups, never inside one.
14
+ module CommandGrouper
15
+ PLACEHOLDER_RE = /\Aph_/
16
+
17
+ class << self
18
+ # @param commands [Array<Hash>] built command hashes (`{ commandKey => input }`).
19
+ # @param chunk_size [Integer]
20
+ # @return [Array<Array<Hash>>] ordered chunks.
21
+ def chunk(commands, chunk_size)
22
+ slice_groups(group(commands), chunk_size)
23
+ end
24
+
25
+ # Every `ph_...` token referenced ANYWHERE in `commands` (minted or referenced).
26
+ # Public: `StagedExecutor`'s stale-placeholder guard uses this directly.
27
+ def tokens_in(commands)
28
+ commands.flat_map { |c| placeholder_tokens(c) }.uniq
29
+ end
30
+
31
+ private
32
+
33
+ def group(commands)
34
+ parent = {}
35
+ find = find_proc(parent)
36
+ token_owner = {}
37
+
38
+ commands.each_index do |i|
39
+ find.call(i)
40
+ placeholder_tokens(commands[i]).each do |tok|
41
+ token_owner.key?(tok) ? union(parent, find, i, token_owner[tok]) : (token_owner[tok] = i)
42
+ end
43
+ end
44
+
45
+ buckets = Hash.new { |h, k| h[k] = [] }
46
+ commands.each_index { |i| buckets[find.call(i)] << commands[i] }
47
+ buckets.values
48
+ end
49
+
50
+ def find_proc(parent)
51
+ find = nil
52
+ find = lambda do |x|
53
+ parent[x] ||= x
54
+ parent[x] = find.call(parent[x]) unless parent[x] == x
55
+ parent[x]
56
+ end
57
+ end
58
+
59
+ def union(parent, find, idx_a, idx_b)
60
+ root_a = find.call(idx_a)
61
+ root_b = find.call(idx_b)
62
+ parent[root_a] = root_b unless root_a == root_b
63
+ end
64
+
65
+ def slice_groups(groups, chunk_size)
66
+ chunks = []
67
+ current = []
68
+ groups.each do |members|
69
+ guard_group_size!(members, chunk_size)
70
+ if current.size + members.size > chunk_size
71
+ chunks << current
72
+ current = []
73
+ end
74
+ current.concat(members)
75
+ end
76
+ chunks << current unless current.empty?
77
+ chunks
78
+ end
79
+
80
+ def guard_group_size!(members, chunk_size)
81
+ return if members.size <= chunk_size
82
+
83
+ tally = members.map { |c| c.keys.first }.tally
84
+ raise "CommandGrouper: a placeholder-connected group of #{members.size} commands " \
85
+ "exceeds chunk_size (#{chunk_size}) and cannot be split without breaking a " \
86
+ "same-batch placeholder reference. Key tally: #{tally}"
87
+ end
88
+
89
+ def placeholder_tokens(command)
90
+ command.values.flat_map { |v| scan(v) }
91
+ end
92
+
93
+ def scan(value)
94
+ case value
95
+ when String then PLACEHOLDER_RE.match?(value) ? [value] : []
96
+ when Hash then value.values.flat_map { |v| scan(v) }
97
+ when Array then value.flat_map { |v| scan(v) }
98
+ else []
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,116 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # Normalises an ordered batch of built `WorkflowCommand` hashes
7
+ # (`[{ commandKey => { ...input } }, ...]`) so that batches produced by DIFFERENT
8
+ # emitters -- each of which mints its own placeholder/id tokens (`Builder::
9
+ # TemplateBuilder`'s `ph_stg_1`, `Diff::CommandSynthesizer`'s `ph_stg_0`,
10
+ # `CommandSynthesis`'s `ph_stg_1`, a self-version replay's real server ids, the downstream
11
+ # writers' caller-chosen tokens) -- can be compared for STRUCTURAL equivalence.
12
+ #
13
+ # Two normalisations, both pure (no mutation of the input):
14
+ #
15
+ # 1. REFERENCE-TOKEN POSITIONING. Every value carried under a key this class
16
+ # recognises as an id/placeholder REFERENCE (see `REFERENCE_KEYS`) is replaced
17
+ # by a positional token `"ph_<n>"`, assigned in FIRST-APPEARANCE order across
18
+ # the whole ordered batch (scanning left to right, top to bottom, depth-first
19
+ # within each command's own Hash). The SAME source value always maps to the
20
+ # SAME token wherever it recurs (so a mint-then-reference pair, e.g. `addStage`'s
21
+ # `placeholderId` and a later `addStageSection`'s `stageId`, stays linked). Only
22
+ # the KEY decides whether a String is a reference -- literal content
23
+ # (`label`/`name`/`value`/`script`/`url`/...) is never touched, so a coincidental
24
+ # value match with a real id never causes a false substitution.
25
+ # 2. KEY ORDER + correlation stripping. Each command's own input Hash (and any
26
+ # nested Hash, e.g. `byType`) is recursively re-sorted by key so two batches that
27
+ # differ only in the Hash literal's construction order compare equal, and any
28
+ # `CORRELATION_KEYS` entry (never part of the compared shape) is dropped.
29
+ #
30
+ # Two normalised batches are STRUCTURALLY EQUIVALENT iff `Normalizer.normalize(a) ==
31
+ # Normalizer.normalize(b)` -- byte-identical once serialised (`to_json`), per the
32
+ # DSL-08 acceptance test.
33
+ class CommandNormalizer
34
+ # Keys whose String value addresses a node (mint or reference), across every
35
+ # `WorkflowCommand` input class this repo ships plus the downstream writers' own commands
36
+ # (`placeholderId`/`stageId`/`sectionId`/`dataFieldId`/`data_field_id`/`optionId`/
37
+ # `forceId`/`referenceId`/`anchorId`/`id`) -- verified against every `VALID_KEYS`
38
+ # list under `lib/ecoportal/api/graphql/input/workflow_command/`. Deliberately a
39
+ # CLOSED, named set (not "every String") so a literal content String (a label, a
40
+ # script, a URL) is never mistaken for a reference.
41
+ REFERENCE_KEYS = %i[
42
+ placeholderId stageId sectionId dataFieldId data_field_id optionId
43
+ forceId referenceId anchorId id
44
+ ].freeze
45
+
46
+ # Fields that exist only for request bookkeeping, never part of the compared shape.
47
+ CORRELATION_KEYS = %i[clientMutationId correlationId].freeze
48
+
49
+ # @param commands [Array<Hash>] an ordered batch, each a single-key Hash
50
+ # (`{ commandKey => { ...input } }`).
51
+ # @return [Array<Hash>] the same batch, reference tokens repositioned, keys sorted,
52
+ # correlation-only keys dropped. Deterministic and comparable with `==`.
53
+ def self.normalize(commands)
54
+ new.normalize(commands)
55
+ end
56
+
57
+ def normalize(commands)
58
+ @tokens = {}
59
+ @next_index = 0
60
+ Array(commands).map { |command| normalize_command(command) }
61
+ end
62
+
63
+ private
64
+
65
+ def normalize_command(command)
66
+ command_key = command.keys.first
67
+ body = command[command_key]
68
+ { command_key => deep_sort(normalize_hash(body)) }
69
+ end
70
+
71
+ def normalize_hash(hash)
72
+ hash.each_with_object({}) do |(key, value), out|
73
+ next if CORRELATION_KEYS.include?(key.to_sym)
74
+
75
+ out[key] = normalize_value(key, value)
76
+ end
77
+ end
78
+
79
+ def normalize_value(key, value)
80
+ case value
81
+ when Hash then normalize_hash(value)
82
+ when Array then value.map { |element| normalize_value(key, element) }
83
+ when String then reference_key?(key) ? token_for(value) : value
84
+ else value
85
+ end
86
+ end
87
+
88
+ def reference_key?(key)
89
+ REFERENCE_KEYS.include?(key.to_sym)
90
+ end
91
+
92
+ # The SAME source value (whatever token/id format the emitter minted) always maps
93
+ # to the SAME positional token -- first appearance decides the number.
94
+ def token_for(raw_value)
95
+ @tokens[raw_value] ||= begin
96
+ @next_index += 1
97
+ "ph_#{@next_index}"
98
+ end
99
+ end
100
+
101
+ # Recursively re-sorts every Hash (top-level input and any nested sub-hash, e.g.
102
+ # `byType`) by key, so construction-order differences never cause a false mismatch.
103
+ # Arrays keep their own order (option/command ORDER is part of what is compared).
104
+ def deep_sort(value)
105
+ case value
106
+ when Hash then value.map { |k, v| [k, deep_sort(v)] }.sort_by { |(k, _)| k.to_s }.to_h
107
+ when Array then value.map { |element| deep_sort(element) }
108
+ else value
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,262 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # Diff engine: walks the in-memory tree and emits the ordered `[CommandInput]` batch
7
+ # `#as_commands` returns, plus the phase-scoped subsets `StagedExecutor` sends one at
8
+ # a time (each subset re-synthesised against the CURRENT tree, so a node resolved by
9
+ # an earlier phase — `Node#resolve!` — is addressed by its real id, not its stale
10
+ # placeholder, the next time a phase is built; see `Node#ref`).
11
+ #
12
+ # ORDER — stage -> section -> field -> option/config -> force -> helper -> binding,
13
+ # matching `Diff::CommandSynthesizer::KIND_ORDER`'s own dependency-safe order plus the
14
+ # two groups it does not cover (force, helper/binding — no existing gem emitter
15
+ # before this class). Within a stage: structural section commands first
16
+ # (add/remove/header), THEN a `reorderSection` TRAILER for every section with a
17
+ # pending move (see `Section#move`'s own doc for why last) — this mirrors the
18
+ # EPT-2078 "preserve reorderSection trailer protection" rule.
19
+ #
20
+ # SAFETY — never guesses. A field's `field_type` has no setter (see `Field`'s own
21
+ # doc), so a type "change" cannot even be expressed here; every other write goes
22
+ # through the same `WorkflowCommand.build` layer `Builder::TemplateBuilder` and
23
+ # `Diff::CommandSynthesizer` already use, so a missing required key (`value` on a
24
+ # new option, etc.) raises there — never invented here.
25
+ class CommandSynthesis
26
+ def initialize(instance)
27
+ @instance = instance
28
+ end
29
+
30
+ # The full ordered batch — the public `#as_commands` contract.
31
+ def commands
32
+ stage_phase + section_phase + field_phase + force_phase + helper_binding_phase
33
+ end
34
+
35
+ # --- phase-scoped subsets, used by StagedExecutor -------------------
36
+
37
+ def stage_phase
38
+ instance.stages.select(&:new?).flat_map { |stage| stage_commands(stage) }
39
+ end
40
+
41
+ def section_phase
42
+ instance.stages.flat_map { |stage| section_commands_for_stage(stage) }
43
+ end
44
+
45
+ def field_phase
46
+ instance.stages.flat_map { |stage| stage.sections.flat_map { |section| field_commands_for_section(section) } }
47
+ end
48
+
49
+ def force_phase
50
+ instance.stages.flat_map { |stage|
51
+ stage.forces.reject(&:new?).flat_map { |f| force_edit_commands(f) } +
52
+ stage.forces.select(&:new?).flat_map { |f| force_add_commands(f) }
53
+ }
54
+ end
55
+
56
+ def helper_binding_phase
57
+ instance.stages.flat_map { |stage| stage.forces.flat_map { |force| helper_and_binding_commands(force) } }
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :instance
63
+
64
+ def build(key, **kwargs)
65
+ Ecoportal::API::GraphQL::Input::WorkflowCommand.build(key, **kwargs)
66
+ end
67
+
68
+ # --- stage -----------------------------------------------------------
69
+
70
+ def stage_commands(stage)
71
+ [build(:addStage, name: stage.name, ordering: stage.ordering, placeholderId: stage.placeholder)]
72
+ end
73
+
74
+ # --- section -----------------------------------------------------------
75
+
76
+ def section_commands_for_stage(stage)
77
+ structural = stage.sections.flat_map { |section| section_structural_commands(stage, section) }
78
+ trailer = stage.sections.reject(&:removed?).filter_map { |section| reorder_command(stage, section) }
79
+ structural + trailer
80
+ end
81
+
82
+ def section_structural_commands(stage, section)
83
+ return removed_section_commands(section) if section.removed?
84
+ return new_section_commands(stage, section) if section.new?
85
+
86
+ edited_section_commands(section)
87
+ end
88
+
89
+ def removed_section_commands(section)
90
+ [build(:removeSection, sectionId: section.id)]
91
+ end
92
+
93
+ def new_section_commands(stage, section)
94
+ cmds = [build(:addSection, placeholderId: section.placeholder, layout: section.layout)]
95
+ cmds << build(:addStageSection, stageId: stage.ref, sectionId: section.ref)
96
+ cmds << header_command(section)
97
+ cmds.compact
98
+ end
99
+
100
+ def edited_section_commands(section)
101
+ return [] unless section.dirty?
102
+
103
+ [header_command(section)].compact
104
+ end
105
+
106
+ def header_command(section)
107
+ changed = section.new? ? section_header_kwargs(section) : section.changed_attributes
108
+ return nil if changed.empty? || changed.values.all?(&:nil?)
109
+
110
+ build(:editSectionHeader, sectionId: section.ref,
111
+ header: changed[:heading], leftHeader: changed[:left_heading],
112
+ rightHeader: changed[:right_heading])
113
+ end
114
+
115
+ def section_header_kwargs(section)
116
+ { heading: section.heading, left_heading: section.left_heading, right_heading: section.right_heading }
117
+ end
118
+
119
+ def reorder_command(stage, section)
120
+ move = section.pending_move
121
+ return nil if move.nil?
122
+
123
+ build(:reorderSection, sectionId: section.ref, stageId: stage.ref,
124
+ anchorId: move[:anchor].ref, anchorPosition: move[:position])
125
+ end
126
+
127
+ # --- field / option ----------------------------------------------------
128
+
129
+ def field_commands_for_section(section)
130
+ section.fields.flat_map { |field| field_commands(section, field) }
131
+ end
132
+
133
+ def field_commands(section, field)
134
+ return [build(:removeField, id: field.id)] if field.removed?
135
+
136
+ cmds = field.new? ? new_field_commands(section, field) : edited_field_commands(field)
137
+ cmds + option_commands(field)
138
+ end
139
+
140
+ def new_field_commands(section, field)
141
+ cmds = [build(:addField, placeholderId: field.placeholder, fieldType: field.field_type,
142
+ label: field.label, stageId: field.stage.ref, sectionId: section.ref,
143
+ column: field.column)]
144
+ cfg = field_config_kwargs(field)
145
+ cmds << build(:editFieldConfiguration, data_field_id: field.ref, **stage_id_kwarg(field), **cfg) unless cfg.empty?
146
+ cmds
147
+ end
148
+
149
+ def edited_field_commands(field)
150
+ cfg = wire_field_config_keys(field.changed_attributes)
151
+ return [] if cfg.empty?
152
+
153
+ [build(:editFieldConfiguration, data_field_id: field.ref, **stage_id_kwarg(field), **cfg)]
154
+ end
155
+
156
+ # `editFieldConfiguration.stageId` disambiguates WHICH stage's copy of the field is
157
+ # being edited -- REQUIRED on a phased page (`edit_field_configuration.rb`'s own
158
+ # schema-authority comment), a key the schema does not need (and this class never
159
+ # sends, not even as an explicit `null`) on a non-phased page. `Field#stage` is
160
+ # ALWAYS present either way (`Read`'s workflow-first read populates it for BOTH
161
+ # page kinds) -- `#phased` on the owning `Instance`, not "does the field know its
162
+ # stage", is the real gate the schema cares about (mirrors the DSL dialect's own
163
+ # `Bindings.write_field_property`/the downstream `FieldWriter.configure`, both of which only
164
+ # thread a stage id through when one is actually known/needed).
165
+ def stage_id_kwarg(field)
166
+ field.template.phased? ? { stageId: field.stage.ref } : {}
167
+ end
168
+
169
+ # Only the non-nil subset of a NEW field's attributes — `editFieldConfiguration`
170
+ # after a fresh `addField` should not send a literal `nil` for something the
171
+ # caller never set. `label` is dropped: it already went through `addField`.
172
+ def field_config_kwargs(field)
173
+ wire_field_config_keys({ tooltip: field.tooltip, description: field.description,
174
+ required: field.required, hidden: field.hidden }.compact)
175
+ end
176
+
177
+ # `Field#hidden` (this model's read/write attribute name, matching `Read`'s own
178
+ # `tf` fragment key) maps onto `EditFieldConfiguration`'s real top-level argument
179
+ # `hideView` (`input/workflow_command/edit_field_configuration.rb`'s own
180
+ # `BASE_KEYS` — there is no plain `hidden` key on that input) — the one place this
181
+ # read/write naming split is bridged, so every other layer can stay consistent.
182
+ def wire_field_config_keys(cfg)
183
+ return cfg unless cfg.key?(:hidden)
184
+
185
+ cfg.merge(hideView: cfg[:hidden]).except(:hidden)
186
+ end
187
+
188
+ def option_commands(field)
189
+ field.options.flat_map { |opt| option_command(field, opt) }
190
+ end
191
+
192
+ def option_command(field, opt)
193
+ return [build(:removeSelectFieldOption, data_field_id: field.ref, option_id: opt.id)] if opt.removed?
194
+
195
+ if opt.new?
196
+ # addSelectFieldOption REQUIRES `value` (backend: AddSelectFieldOptionInput,
197
+ # required: true — see `Diff::CommandSynthesizer#add_option`'s own identical
198
+ # rule). `WorkflowCommand::AddSelectFieldOption.build` accepts a literal `nil`
199
+ # without complaint (it is a required KEYWORD, not a required VALUE — Ruby
200
+ # only enforces the keyword's presence), so this class enforces the missing-
201
+ # value rule itself rather than letting a `nil` reach the server.
202
+ if opt.value.nil?
203
+ raise ArgumentError,
204
+ "CommandSynthesis: cannot build addSelectFieldOption for option " \
205
+ "'#{opt.label}' (field #{field.ref}) — value is nil. Set " \
206
+ '`option.value = ...` before calling #as_commands/#save!.'
207
+ end
208
+
209
+ return [build(:addSelectFieldOption, data_field_id: field.ref, placeholderId: opt.placeholder,
210
+ label: opt.label, value: opt.value, weight: opt.weight)]
211
+ end
212
+
213
+ changed = opt.changed_attributes
214
+ return [] if changed.empty?
215
+
216
+ [build(:editSelectFieldOption, data_field_id: field.ref, option_id: opt.id, **changed)]
217
+ end
218
+
219
+ # --- force / helper / binding ------------------------------------------
220
+
221
+ def force_add_commands(force)
222
+ [build(:addForce, placeholderId: force.placeholder, name: force.name,
223
+ customScript: force.custom_script, url: force.url, contentB64: force.content_b64)]
224
+ end
225
+
226
+ def force_edit_commands(force)
227
+ changed = force.changed_attributes
228
+ return [] if changed.empty?
229
+
230
+ [build(:editForce, id: force.id, name: changed[:name], customScript: changed[:custom_script],
231
+ url: changed[:url], contentB64: changed[:content_b64])]
232
+ end
233
+
234
+ def helper_and_binding_commands(force)
235
+ force.helpers.select(&:new?).map { |h| helper_command(force, h) } +
236
+ force.bindings.flat_map { |b| binding_command(force, b) }
237
+ end
238
+
239
+ def helper_command(force, helper)
240
+ build(:addLinkedHelper, placeholderId: helper.placeholder, forceId: force.ref, name: helper.name,
241
+ filePath: helper.file_path, script: helper.script, contentB64: helper.content_b64)
242
+ end
243
+
244
+ def binding_command(force, binding)
245
+ return [] if binding.removed?
246
+
247
+ if binding.new?
248
+ [build(:addBinding, placeholderId: binding.placeholder, forceId: force.ref, name: binding.name,
249
+ referenceId: binding.reference_id, type: binding.type)]
250
+ else
251
+ changed = binding.changed_attributes
252
+ return [] if changed.empty?
253
+
254
+ [build(:editBinding, id: binding.id, name: changed[:name], referenceId: changed[:reference_id])]
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,68 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # One data field. `field_type` is immutable after creation on purpose: the platform
7
+ # has no `editFieldType` command (confirmed,
8
+ # the repo's internal docs section
9
+ # C.1) — giving this class no setter for it makes a destructive remove+add
10
+ # structurally unreachable rather than a rule `#as_commands` has to remember to
11
+ # enforce.
12
+ class Field
13
+ include Node
14
+
15
+ PLACEHOLDER_PREFIX = 'fld'.freeze
16
+
17
+ attr_reader :template, :section, :stage, :field_type, :column, :options
18
+ attr_accessor :label, :tooltip, :description, :required, :hidden
19
+
20
+ def initialize(template:, section:, stage:, field_type:, id: nil, label: nil, column: nil,
21
+ tooltip: nil, description: nil, required: nil, hidden: nil, options: [])
22
+ @template = template
23
+ @section = section
24
+ @stage = stage
25
+ @id = id
26
+ @field_type = field_type
27
+ @column = column
28
+ @label = label
29
+ @tooltip = tooltip
30
+ @description = description
31
+ @required = required
32
+ @hidden = hidden
33
+ @options = options
34
+ @snapshot = snapshot_attrs.freeze
35
+ end
36
+
37
+ # Appends a new (in-memory) option. Only meaningful for a Select-shaped field, but
38
+ # this class does not enforce `field_type` — the server does, on `addSelectFieldOption`.
39
+ def add_option(value:, label: nil, weight: nil)
40
+ opt = Option.new(template: template, field: self, label: label, value: value, weight: weight)
41
+ options << opt
42
+ opt
43
+ end
44
+
45
+ def dirty?
46
+ !removed? && !new? && snapshot_attrs != @snapshot
47
+ end
48
+
49
+ # The subset of `changed_attributes` that maps onto `editFieldConfiguration`'s
50
+ # top-level keys (label/tooltip/description/required/hidden) — empty when nothing
51
+ # changed, so callers can `commands << ... unless field_config_changes.empty?`.
52
+ def changed_attributes
53
+ return {} if new? || removed?
54
+
55
+ snapshot_attrs.each_with_object({}) { |(k, v), h| h[k] = v if v != @snapshot[k] }
56
+ end
57
+
58
+ private
59
+
60
+ def snapshot_attrs
61
+ { label: label, tooltip: tooltip, description: description, required: required, hidden: hidden }
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,65 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # One force, owned by a Stage (`18_template_editor_save_path.md` section A.1:
7
+ # `TemplateStageFields.forces`). Editing an existing force is limited to
8
+ # name/custom_script/url/content_b64 (`editForce`'s own argument list,
9
+ # `input/workflow_command/edit_force.rb`) — `weight`/`lastSyncedAt` are
10
+ # server-managed and not exposed as writable attributes here.
11
+ class Force
12
+ include Node
13
+
14
+ PLACEHOLDER_PREFIX = 'frc'.freeze
15
+
16
+ attr_reader :template, :stage, :bindings, :helpers
17
+ attr_accessor :name, :custom_script, :url, :content_b64
18
+
19
+ def initialize(template:, stage:, id: nil, name: nil, custom_script: nil, url: nil, content_b64: nil)
20
+ @template = template
21
+ @stage = stage
22
+ @id = id
23
+ @name = name
24
+ @custom_script = custom_script
25
+ @url = url
26
+ @content_b64 = content_b64 # rubocop:disable Naming/VariableNumber
27
+ @bindings = []
28
+ @helpers = []
29
+ @snapshot = snapshot_attrs.freeze
30
+ end
31
+
32
+ def add_binding(reference_id:, name: nil, type: Binding::BINDING_TYPE)
33
+ binding = Binding.new(template: template, force: self, name: name, reference_id: reference_id, type: type)
34
+ bindings << binding
35
+ binding
36
+ end
37
+
38
+ def add_helper(name: nil, file_path: nil, script: nil, content_b64: nil)
39
+ helper = Helper.new(template: template, force: self, name: name, file_path: file_path,
40
+ script: script, content_b64: content_b64)
41
+ helpers << helper
42
+ helper
43
+ end
44
+
45
+ def dirty?
46
+ !removed? && !new? && snapshot_attrs != @snapshot
47
+ end
48
+
49
+ def changed_attributes
50
+ return {} if new? || removed?
51
+
52
+ snapshot_attrs.each_with_object({}) { |(k, v), h| h[k] = v if v != @snapshot[k] }
53
+ end
54
+
55
+ private
56
+
57
+ def snapshot_attrs
58
+ { name: name, custom_script: custom_script, url: url, content_b64: content_b64 }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,32 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # A force's linked helper (`addLinkedHelper`). ADD-only in this design — no live
7
+ # call site or schema citation for `editLinkedHelper`'s read-side shape was available
8
+ # to this session (see `Read.call`'s header for the read-side field-name caveat), so
9
+ # editing an existing helper is left unimplemented rather than guessed.
10
+ class Helper
11
+ include Node
12
+
13
+ PLACEHOLDER_PREFIX = 'hlp'.freeze
14
+
15
+ attr_reader :template, :force
16
+ attr_accessor :name, :file_path, :script, :content_b64
17
+
18
+ def initialize(template:, force:, id: nil, name: nil, file_path: nil, script: nil, content_b64: nil)
19
+ @template = template
20
+ @force = force
21
+ @id = id
22
+ @name = name
23
+ @file_path = file_path
24
+ @script = script
25
+ @content_b64 = content_b64 # rubocop:disable Naming/VariableNumber
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end