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,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
|