eco-helpers 3.2.16 → 3.2.17
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 +117 -0
- data/eco-helpers.gemspec +2 -2
- data/lib/eco/api/usecases/graphql/compat/parity/comparison.rb +70 -0
- data/lib/eco/api/usecases/graphql/compat/parity/harness.rb +102 -0
- data/lib/eco/api/usecases/graphql/compat/parity/run_result.rb +96 -0
- data/lib/eco/api/usecases/graphql/compat.rb +5 -0
- data/lib/eco/api/usecases/graphql/helpers/location/command/end_points/optimizations.rb +4 -4
- data/lib/eco/api/usecases/graphql/helpers/pages/copying.rb +71 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/creatable.rb +78 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/filters.rb +114 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/ooze_handlers.rb +112 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/rescuable.rb +52 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/shortcuts.rb +186 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/typed_fields_pairing.rb +303 -0
- data/lib/eco/api/usecases/graphql/helpers/pages.rb +21 -0
- data/lib/eco/api/usecases/graphql/helpers.rb +1 -0
- data/lib/eco/api/usecases/graphql/samples/location/command/service/tree_update.rb +1 -1
- data/lib/eco/api/usecases/graphql/samples/pages/register/base.rb +181 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register/migration_case.rb +132 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register/target_oozes_update_case.rb +163 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register.rb +8 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/base.rb +70 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/command_emitter.rb +139 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/builder.rb +126 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/format_map.rb +108 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/parser.rb +98 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build.rb +17 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/applier.rb +141 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/drift_report.rb +104 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/loop.rb +155 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/recording_executor.rb +58 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/sync_readiness.rb +178 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/verifier.rb +141 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy.rb +21 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template.rb +11 -0
- data/lib/eco/api/usecases/graphql/samples/pages.rb +2 -0
- data/lib/eco/version.rb +1 -1
- metadata +34 -5
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages
|
|
2
|
+
# Native GraphQL re-expression of OozeSamples::TargetOozesUpdateCase.
|
|
3
|
+
#
|
|
4
|
+
# Where Register::Base scopes work to a register and iterates its entries via a
|
|
5
|
+
# cursor search, this case takes an explicit list of target page ids from a CSV
|
|
6
|
+
# (`options[:source][:file]`, first column, header dropped) and processes each of
|
|
7
|
+
# those pages. It reproduces the SHAPE of the v2 TargetOozesUpdateCase:
|
|
8
|
+
# * id-batching (`batched_target_ids`)
|
|
9
|
+
# * a preview with duplicate detection + interactive proceed prompt
|
|
10
|
+
# * fetch-per-id (`graphql.pages.get`), dedup-by-id, retrieved/non-retrieved KPIs
|
|
11
|
+
# * hand each fetched page to `process_page` (a.k.a. `process_ooze`)
|
|
12
|
+
#
|
|
13
|
+
# As with Register::Base, the v2 batch_queue / enqueue / queue_shift machinery is
|
|
14
|
+
# intentionally dropped: the GraphQL update path is per-page. `update_page(page)`
|
|
15
|
+
# (from Page::Base) persists changes as the subclass makes them.
|
|
16
|
+
#
|
|
17
|
+
# == Subclass interface
|
|
18
|
+
#
|
|
19
|
+
# class Custom::UseCase::TOOCSCoding < Eco::API::UseCases::GraphQL::Samples::Pages::Register::TargetOozesUpdateCase
|
|
20
|
+
# name 'toocs-coding'
|
|
21
|
+
#
|
|
22
|
+
# def process_page(page)
|
|
23
|
+
# fld = page.components.get_by_name('TOOCS Code')
|
|
24
|
+
# return skip('no field') unless fld
|
|
25
|
+
# fld.value = merge_field_values(fld, options.dig(:source, :value))
|
|
26
|
+
# update_page(page)
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# The CSV file is provided via `options[:source][:file]`.
|
|
31
|
+
class Register::TargetOozesUpdateCase < Register::Base
|
|
32
|
+
name 'graphql-target-oozes-update-case'
|
|
33
|
+
type :other
|
|
34
|
+
|
|
35
|
+
attr_reader :non_retrieved_pages
|
|
36
|
+
|
|
37
|
+
# Entry point — mirrors Register::Base#process but iterates target ids rather
|
|
38
|
+
# than a register search.
|
|
39
|
+
def process
|
|
40
|
+
init_kpis
|
|
41
|
+
target_ids_preview
|
|
42
|
+
each_target_page do |page|
|
|
43
|
+
process_page(page)
|
|
44
|
+
@processed_pages += 1
|
|
45
|
+
end
|
|
46
|
+
log_kpis
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
protected
|
|
50
|
+
|
|
51
|
+
# Fetch and iterate each target page by id, in batches. Counts retrieved /
|
|
52
|
+
# non-retrieved and dedups by id (v2 parity).
|
|
53
|
+
def each_target_page
|
|
54
|
+
batched_target_ids do |ids|
|
|
55
|
+
ids.each do |id|
|
|
56
|
+
if page_result_ids[id]
|
|
57
|
+
@dupped_search_pages += 1
|
|
58
|
+
else
|
|
59
|
+
page_result_ids[id] = true
|
|
60
|
+
@total_pages += 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if (page = fetch_page(id))
|
|
64
|
+
@retrieved_pages += 1
|
|
65
|
+
yield page
|
|
66
|
+
else
|
|
67
|
+
@non_retrieved_pages += 1
|
|
68
|
+
log(:warn) { "Could not get page #{id}" }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Fetch one page by id via the GraphQL compat client. Wrapped so a bad id does
|
|
75
|
+
# not abort the whole run.
|
|
76
|
+
# @return [Object, nil] the page model, or nil when it could not be fetched.
|
|
77
|
+
def fetch_page(id)
|
|
78
|
+
with_rescue("page #{id}") do
|
|
79
|
+
graphql.pages.get(id)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Yields the target ids in slices of `batch_size`.
|
|
86
|
+
def batched_target_ids
|
|
87
|
+
msg = "Missing block. It yields in slices of #{self.class.batch_size} ids"
|
|
88
|
+
raise ArgumentError, msg unless block_given?
|
|
89
|
+
|
|
90
|
+
pool = []
|
|
91
|
+
target_ids.each do |id|
|
|
92
|
+
pool << id
|
|
93
|
+
if pool.length >= self.class.batch_size
|
|
94
|
+
yield(pool)
|
|
95
|
+
pool = []
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
yield(pool) unless pool.empty?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Preview: total count + duplicate detection + interactive proceed prompt
|
|
102
|
+
# (v2-faithful — matches TargetOozesUpdateCase#target_ids_preview).
|
|
103
|
+
def target_ids_preview
|
|
104
|
+
dups = target_ids.select {|id| target_ids.count(id) > 1}
|
|
105
|
+
@total_search_pages = target_ids.count
|
|
106
|
+
@dupped_search_pages = 0
|
|
107
|
+
|
|
108
|
+
dups_str = dups.any? ? "There are #{dups.count} duplicated ids" : 'No duplicates detected'
|
|
109
|
+
msg = "Total target entries: #{target_ids.count} (#{dups_str})"
|
|
110
|
+
|
|
111
|
+
default_answer = session.config.run_mode_remote? ? 'Y' : 'N'
|
|
112
|
+
|
|
113
|
+
session.prompt_user(
|
|
114
|
+
'Do you want to proceed (y/N):',
|
|
115
|
+
explanation: msg,
|
|
116
|
+
default: default_answer,
|
|
117
|
+
timeout: 10
|
|
118
|
+
) do |res|
|
|
119
|
+
unless res.upcase.start_with?('Y')
|
|
120
|
+
puts '...'
|
|
121
|
+
log(:info) { 'Aborting script...' }
|
|
122
|
+
exit(0)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The list of target page ids (first CSV column, header dropped).
|
|
128
|
+
def target_ids
|
|
129
|
+
@target_ids ||= input_csv.columns.first[1..]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def input_csv
|
|
133
|
+
@input_csv ||= Eco::CSV.read(options.dig(:source, :file))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# #each_page is register-search based (inherited from Register::Base) and is NOT
|
|
137
|
+
# the iteration path here — target ids come from the CSV. Guard against accidental
|
|
138
|
+
# use so a subclass mixing the two gets a clear error rather than a silent org scan.
|
|
139
|
+
def each_page(*)
|
|
140
|
+
raise NotImplementedError,
|
|
141
|
+
"#{self.class} iterates target ids from a CSV — use #each_target_page, not #each_page"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def init_kpis
|
|
145
|
+
super
|
|
146
|
+
@non_retrieved_pages = 0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def kpis_message
|
|
150
|
+
[
|
|
151
|
+
'Run end:',
|
|
152
|
+
" * Target entries: #{total_search_pages}",
|
|
153
|
+
" * Duplicated: #{dupped_search_pages}",
|
|
154
|
+
" * Retrieved: #{retrieved_pages}",
|
|
155
|
+
" * Could not get: #{non_retrieved_pages}",
|
|
156
|
+
" * Processed: #{processed_pages}",
|
|
157
|
+
" * Updated: #{updated_pages} (of #{total_pages} unique)",
|
|
158
|
+
" * Skipped: #{skipped_pages}",
|
|
159
|
+
" * Failed: #{failed_pages}"
|
|
160
|
+
].join("\n")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages
|
|
2
|
+
# Build-from-scratch template (workflow) construction use case.
|
|
3
|
+
#
|
|
4
|
+
# A subclass declares the desired template structure in #declare(emitter) using the
|
|
5
|
+
# CommandEmitter DSL; the base turns it into an ordered command batch and applies it via
|
|
6
|
+
# graphql.template.create / update (executeWorkflowCommands). `simulate?` previews the batch
|
|
7
|
+
# without writing. The emitted batch (#desired_commands) is unit-testable with no live client.
|
|
8
|
+
#
|
|
9
|
+
# class Custom::UseCase::BuildIncidentTemplate < Samples::Pages::Template::Base
|
|
10
|
+
# name 'build-incident-template'
|
|
11
|
+
#
|
|
12
|
+
# def declare(t)
|
|
13
|
+
# t.stage(name: 'Report', ordering: 0) do |stage|
|
|
14
|
+
# stage.section(layout: 'content') do |section|
|
|
15
|
+
# section.field(label: 'Description', field_type: 'plain_text')
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
class Template::Base < Eco::API::UseCases::GraphQL::Base
|
|
21
|
+
name 'graphql-template-base'
|
|
22
|
+
type :other
|
|
23
|
+
|
|
24
|
+
require_relative 'command_emitter'
|
|
25
|
+
|
|
26
|
+
def process
|
|
27
|
+
commands = desired_commands
|
|
28
|
+
if simulate?
|
|
29
|
+
log(:info) { preview_message(commands) }
|
|
30
|
+
return nil
|
|
31
|
+
end
|
|
32
|
+
apply(commands)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# == Subclass override point ================================================
|
|
36
|
+
|
|
37
|
+
# Declare the desired template structure onto the given CommandEmitter.
|
|
38
|
+
def declare(_emitter)
|
|
39
|
+
raise NotImplementedError, "Implement #declare(emitter) in #{self.class}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The ordered command batch this template would apply. Pure — no client needed.
|
|
43
|
+
def desired_commands
|
|
44
|
+
emitter = CommandEmitter.new
|
|
45
|
+
declare(emitter)
|
|
46
|
+
emitter.commands
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
protected
|
|
50
|
+
|
|
51
|
+
# Persist a fresh template from the command batch. Override to #update an existing one.
|
|
52
|
+
def apply(commands)
|
|
53
|
+
graphql.template.create(commands: commands)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Update an existing template model with the command batch.
|
|
57
|
+
def apply_update(model, commands)
|
|
58
|
+
graphql.template.update(model, commands: commands)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def preview_message(commands)
|
|
64
|
+
[
|
|
65
|
+
"Simulate — would apply #{commands.size} workflow command(s):",
|
|
66
|
+
*commands.map { |c| " * #{c.keys.first}: #{c.values.first.inspect}" }
|
|
67
|
+
].join("\n")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages
|
|
2
|
+
module Template
|
|
3
|
+
# Declarative build-from-scratch emitter for template (workflow) structure.
|
|
4
|
+
#
|
|
5
|
+
# Mirrors the *shape* of the location diff->commands engine (samples/location/command), but the
|
|
6
|
+
# id-threading is **placeholder-based**, not post-hoc newId-remap: each new node is given a
|
|
7
|
+
# client-chosen `placeholderId`, and later commands in the SAME batch reference it. The gem's
|
|
8
|
+
# executeWorkflowCommands resolves placeholders intra-batch. See the gem's PHASE0-FINDINGS.md.
|
|
9
|
+
#
|
|
10
|
+
# Commands are emitted in dependency order via depth-first DSL evaluation:
|
|
11
|
+
# addStage -> (per section) addSection, addStageSection -> (per field) addField
|
|
12
|
+
# -> (per option) addSelectFieldOption ; then forces: addForce -> (per binding) addBinding.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# emitter = CommandEmitter.new
|
|
16
|
+
# emitter.stage(name: 'Report', ordering: 0) do |stage|
|
|
17
|
+
# stage.section(layout: 'content') do |section|
|
|
18
|
+
# section.field(label: 'Notes', field_type: 'plain_text')
|
|
19
|
+
# section.field(label: 'Risk', field_type: 'select') do |field|
|
|
20
|
+
# field.option(label: 'High', weight: 10)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
# emitter.commands # => ordered Array of FINAL built { commandKey => kwargs } commands
|
|
25
|
+
#
|
|
26
|
+
# The output is fed DIRECTLY to graphql.template.create(commands:) / update(model, commands:).
|
|
27
|
+
# It is already built via WorkflowCommand.build — do NOT re-run it through build_commands
|
|
28
|
+
# (that normaliser is for hand-authored raw specs and is non-idempotent for key-renaming
|
|
29
|
+
# commands such as addSelectFieldOption: data_field_id -> dataFieldId).
|
|
30
|
+
class CommandEmitter
|
|
31
|
+
def initialize
|
|
32
|
+
@commands = []
|
|
33
|
+
@counters = Hash.new(0)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Array<Hash>] ordered command specs, each { commandKey => kwargs }
|
|
37
|
+
attr_reader :commands
|
|
38
|
+
|
|
39
|
+
# Declare a stage. Yields a StageBuilder for nested sections.
|
|
40
|
+
def stage(name:, ordering: nil, &block)
|
|
41
|
+
placeholder = allocate(:stg)
|
|
42
|
+
emit(:addStage, placeholderId: placeholder, name: name, ordering: ordering)
|
|
43
|
+
StageBuilder.new(self, placeholder).tap { |b| block&.call(b) }
|
|
44
|
+
placeholder
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Declare a force at template level. Yields a ForceBuilder for bindings.
|
|
48
|
+
def force(name:, custom_script: nil, url: nil, content_b64: nil, &block)
|
|
49
|
+
placeholder = allocate(:frc)
|
|
50
|
+
emit(:addForce, placeholderId: placeholder, name: name, customScript: custom_script,
|
|
51
|
+
url: url, contentB64: content_b64)
|
|
52
|
+
ForceBuilder.new(self, placeholder).tap { |b| block&.call(b) }
|
|
53
|
+
placeholder
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# --- internal API used by the nested builders ---------------------------
|
|
57
|
+
|
|
58
|
+
# Allocate the next placeholder id for a node kind (:stg, :sec, :fld, :frc, :bnd).
|
|
59
|
+
def allocate(kind)
|
|
60
|
+
"#{kind}#{@counters[kind] += 1}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Build + append one command via the gem's WorkflowCommand contract.
|
|
64
|
+
# The gem constant is resolved lazily (at call time, not load time) — the gem's GraphQL
|
|
65
|
+
# namespace is not yet loaded while this file is being required.
|
|
66
|
+
def emit(command_key, **kwargs)
|
|
67
|
+
@commands << Ecoportal::API::GraphQL::Input::WorkflowCommand.build(command_key, **kwargs)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Nested builders — each holds its parent's placeholder and appends in-order.
|
|
71
|
+
|
|
72
|
+
class StageBuilder
|
|
73
|
+
def initialize(emitter, stage_id)
|
|
74
|
+
@emitter = emitter
|
|
75
|
+
@stage_id = stage_id
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A section is created independently, then linked into this stage.
|
|
79
|
+
def section(layout: 'content', &block)
|
|
80
|
+
section_id = @emitter.allocate(:sec)
|
|
81
|
+
@emitter.emit(:addSection, placeholderId: section_id, layout: layout)
|
|
82
|
+
@emitter.emit(:addStageSection, stageId: @stage_id, sectionId: section_id)
|
|
83
|
+
SectionBuilder.new(@emitter, @stage_id, section_id).tap { |b| block&.call(b) }
|
|
84
|
+
section_id
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class SectionBuilder
|
|
89
|
+
def initialize(emitter, stage_id, section_id)
|
|
90
|
+
@emitter = emitter
|
|
91
|
+
@stage_id = stage_id
|
|
92
|
+
@section_id = section_id
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `description` / `required` are accepted for forward-compatibility with the CSV-build
|
|
96
|
+
# identity convention (hidden-field + description). NOTE: the RELEASED gem's addField input
|
|
97
|
+
# (`Input::WorkflowCommand::AddField::VALID_KEYS`) currently slices to
|
|
98
|
+
# placeholderId/fieldType/label/stageId/sectionId/column and compacts everything else away —
|
|
99
|
+
# so today these pass-throughs are DROPPED by the gem contract (harmless no-op). They are wired
|
|
100
|
+
# here so that, once the gem's addField gains a description key, no emitter change is needed.
|
|
101
|
+
def field(label:, field_type:, column: 0, description: nil, required: nil, &block)
|
|
102
|
+
field_id = @emitter.allocate(:fld)
|
|
103
|
+
@emitter.emit(:addField, placeholderId: field_id, fieldType: field_type, label: label,
|
|
104
|
+
stageId: @stage_id, sectionId: @section_id, column: column,
|
|
105
|
+
description: description, required: required)
|
|
106
|
+
FieldBuilder.new(@emitter, field_id).tap { |b| block&.call(b) }
|
|
107
|
+
field_id
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
class FieldBuilder
|
|
112
|
+
def initialize(emitter, field_id)
|
|
113
|
+
@emitter = emitter
|
|
114
|
+
@field_id = field_id
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Select-field option. dataFieldId threads the (placeholder) field id.
|
|
118
|
+
def option(label:, weight: nil)
|
|
119
|
+
@emitter.emit(:addSelectFieldOption, data_field_id: @field_id, label: label, weight: weight)
|
|
120
|
+
@field_id
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
class ForceBuilder
|
|
125
|
+
def initialize(emitter, force_id)
|
|
126
|
+
@emitter = emitter
|
|
127
|
+
@force_id = force_id
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def binding(name:, reference_id: nil, type: nil)
|
|
131
|
+
binding_id = @emitter.allocate(:bnd)
|
|
132
|
+
@emitter.emit(:addBinding, placeholderId: binding_id, forceId: @force_id, name: name,
|
|
133
|
+
referenceId: reference_id, type: type)
|
|
134
|
+
binding_id
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages::Template
|
|
2
|
+
module CsvBuild
|
|
3
|
+
# CSV → template BUILD pipeline.
|
|
4
|
+
#
|
|
5
|
+
# Ties the pieces together: a `Parser` turns a columnar CSV into a stage/section/field tree, this
|
|
6
|
+
# Builder replays that tree onto the EXISTING `Template::CommandEmitter` (reused verbatim — same
|
|
7
|
+
# placeholder-id threading the create path already relies on), and then hands the resulting ordered
|
|
8
|
+
# `WorkflowCommand` batch to the released gem's `Builder::Template#create(commands:)`.
|
|
9
|
+
#
|
|
10
|
+
# Offline / dry-run by DEFAULT — real creation needs credentials, so `build` only assembles and
|
|
11
|
+
# returns the command batch; `create!` is the explicit live path (requires a gem `Builder::Template`
|
|
12
|
+
# or `GraphQL#template` facade).
|
|
13
|
+
#
|
|
14
|
+
# builder = CsvBuild::Builder.from_csv(csv_string)
|
|
15
|
+
# commands = builder.commands # ordered { commandKey => kwargs } batch, offline
|
|
16
|
+
# # live (needs creds):
|
|
17
|
+
# builder.create!(graphql.template) # => gem create mutation response
|
|
18
|
+
#
|
|
19
|
+
# SECTION / FIELD IDENTITY: each field's `description` (from FormatMap) is passed to the emitter's
|
|
20
|
+
# `field(description:)`, and a hidden anchor per section carries the section identity — so a rebuilt
|
|
21
|
+
# template stays pair-able against a prior version by the diff engine. When the field type is
|
|
22
|
+
# `select`, the parsed options are emitted as addSelectFieldOption commands.
|
|
23
|
+
#
|
|
24
|
+
# ⚠ HONEST LIMITATION: the RELEASED gem's `addField` input drops any key outside
|
|
25
|
+
# placeholderId/fieldType/label/stageId/sectionId/column (see CommandEmitter#field note). So today
|
|
26
|
+
# the `description` identity token is NOT persisted through addField — the hidden ANCHOR FIELD's
|
|
27
|
+
# presence (its label) is the only identity signal that currently survives to the batch. The
|
|
28
|
+
# description wiring is in place so identity flows end-to-end the moment the gem's addField input
|
|
29
|
+
# gains a description key. Until then, section/field identity relies on the anchor + heading/label.
|
|
30
|
+
class Builder
|
|
31
|
+
# Hidden marker field type used to anchor SECTION identity across rebuilds (identity convention:
|
|
32
|
+
# hidden-field + description). Kept as a constant so it is trivial to retune when the real format
|
|
33
|
+
# / platform convention for the anchor is confirmed.
|
|
34
|
+
SECTION_ANCHOR_TYPE = 'hidden'.freeze
|
|
35
|
+
SECTION_ANCHOR_LABEL = '__section_id'.freeze
|
|
36
|
+
|
|
37
|
+
def self.from_csv(csv_string, **opts)
|
|
38
|
+
new(Parser.new(csv_string), **opts)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.from_file(path, **opts)
|
|
42
|
+
new(Parser.from_file(path), **opts)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param parser [Parser]
|
|
46
|
+
# @param section_anchors [Boolean] emit a hidden anchor field per section for stable identity
|
|
47
|
+
# (default true — the CSV-pipeline identity convention). Set false for a plain build.
|
|
48
|
+
def initialize(parser, section_anchors: true)
|
|
49
|
+
@parser = parser
|
|
50
|
+
@section_anchors = section_anchors
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The ordered command batch — PURE, no client needed. This is what specs assert against.
|
|
54
|
+
def commands
|
|
55
|
+
@commands ||= begin
|
|
56
|
+
emitter = CommandEmitter.new
|
|
57
|
+
emit_tree(emitter)
|
|
58
|
+
emitter.commands
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# LIVE build (needs creds). `template_facade` is the gem `Builder::Template` (a.k.a.
|
|
63
|
+
# `graphql.template`) exposing `create(commands:)`.
|
|
64
|
+
def create!(template_facade, &block)
|
|
65
|
+
unless template_facade.respond_to?(:create)
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
'template facade must respond to #create(commands:)'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
template_facade.create(commands: commands, &block)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Human preview of the batch (for a dry-run / simulate log).
|
|
74
|
+
def preview
|
|
75
|
+
["CSV build — #{commands.size} workflow command(s):",
|
|
76
|
+
*commands.map { |c| " * #{c.keys.first}: #{c.values.first.inspect}" }].join("\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def emit_tree(emitter)
|
|
82
|
+
@parser.tree.each do |stage|
|
|
83
|
+
emitter.stage(name: stage[:name], ordering: stage[:ordering]) do |stage_builder|
|
|
84
|
+
stage[:sections].each { |section| emit_section(stage_builder, section) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def emit_section(stage_builder, section)
|
|
90
|
+
stage_builder.section(layout: section[:layout]) do |section_builder|
|
|
91
|
+
emit_section_anchor(section_builder, section) if @section_anchors
|
|
92
|
+
section[:fields].each { |field| emit_field(section_builder, field) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Hidden anchor field carrying the section identity in its description (identity convention).
|
|
97
|
+
def emit_section_anchor(section_builder, section)
|
|
98
|
+
section_builder.field(
|
|
99
|
+
label: SECTION_ANCHOR_LABEL,
|
|
100
|
+
field_type: SECTION_ANCHOR_TYPE,
|
|
101
|
+
description: section_identity(section)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def emit_field(section_builder, field)
|
|
106
|
+
section_builder.field(
|
|
107
|
+
label: field.label,
|
|
108
|
+
field_type: field.type,
|
|
109
|
+
column: field.column,
|
|
110
|
+
required: field.required,
|
|
111
|
+
description: field.description
|
|
112
|
+
) do |field_builder|
|
|
113
|
+
Array(field.options).each do |opt|
|
|
114
|
+
field_builder.option(label: opt[:label], weight: opt[:weight])
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Stable section identity token. Uses the section heading; a hidden anchor field carries it so
|
|
120
|
+
# the diff/pairing engine can re-home the section on a rebuild.
|
|
121
|
+
def section_identity(section)
|
|
122
|
+
"section:#{section[:heading]}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages::Template
|
|
2
|
+
module CsvBuild
|
|
3
|
+
# ISOLATED column-format mapping for the CSV → template build pipeline.
|
|
4
|
+
#
|
|
5
|
+
# ============================ IMPORTANT / TODO ============================
|
|
6
|
+
# The EXACT columnar CSV format for the ~300-template bulk build is due ~mid-July 2026 and is NOT
|
|
7
|
+
# yet confirmed. This class is the SINGLE place the assumed format is encoded, precisely so that
|
|
8
|
+
# when the real format lands, only THIS file (and its spec) needs to change — the Parser and
|
|
9
|
+
# Builder are written against the neutral intermediate `RowSpec`, not against column names.
|
|
10
|
+
#
|
|
11
|
+
# Assumed format (documented here, one row per FIELD, hierarchy carried on each row):
|
|
12
|
+
#
|
|
13
|
+
# stage,stage_ordering,section,section_ordering,section_layout,
|
|
14
|
+
# field_label,field_type,field_required,field_column,field_description,field_options
|
|
15
|
+
#
|
|
16
|
+
# * `field_type` — a template field type accepted by addField (e.g. plain_text, select,
|
|
17
|
+
# date, number, gauge, rich_text, people, ...). Passed through verbatim.
|
|
18
|
+
# * `field_options` — for select-type fields: pipe-separated `label:weight` pairs,
|
|
19
|
+
# e.g. "High:10|Medium:5|Low:0". Weight optional ("High|Medium|Low").
|
|
20
|
+
# * `field_description` — carries the SECTION/FIELD IDENTITY convention (see below).
|
|
21
|
+
#
|
|
22
|
+
# SECTION / FIELD IDENTITY CONVENTION (per the CSV-pipeline project notes): stable identity for a
|
|
23
|
+
# section/field across rebuilds is expressed via a HIDDEN marker field + a DESCRIPTION token, not
|
|
24
|
+
# via the volatile Mongo id. The parser threads `field_description` through so the builder can
|
|
25
|
+
# stamp a deterministic identity token; sections inherit identity from their heading + a hidden
|
|
26
|
+
# anchor field. This keeps a rebuilt template pair-able against a prior version by the diff engine.
|
|
27
|
+
# ==========================================================================
|
|
28
|
+
#
|
|
29
|
+
# To adapt to the real format: change COLUMNS + the `#value`/`#options` readers here. Everything
|
|
30
|
+
# downstream consumes `RowSpec`, which is format-agnostic.
|
|
31
|
+
module FormatMap
|
|
32
|
+
# Logical field => CSV header name (assumed). CHANGE HERE when the real format arrives.
|
|
33
|
+
COLUMNS = {
|
|
34
|
+
stage: 'stage',
|
|
35
|
+
stage_ordering: 'stage_ordering',
|
|
36
|
+
section: 'section',
|
|
37
|
+
section_ordering: 'section_ordering',
|
|
38
|
+
section_layout: 'section_layout',
|
|
39
|
+
field_label: 'field_label',
|
|
40
|
+
field_type: 'field_type',
|
|
41
|
+
field_required: 'field_required',
|
|
42
|
+
field_column: 'field_column',
|
|
43
|
+
field_description: 'field_description',
|
|
44
|
+
field_options: 'field_options'
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# Delimiters (assumed). CHANGE HERE if the real format differs.
|
|
48
|
+
OPTIONS_DELIMITER = '|'.freeze
|
|
49
|
+
OPTION_WEIGHT_SEPARATOR = ':'.freeze
|
|
50
|
+
|
|
51
|
+
# Neutral, format-agnostic row: what the Builder consumes. Only THIS is passed downstream.
|
|
52
|
+
RowSpec = Struct.new(:stage, :stage_ordering, :section, :section_ordering, :section_layout,
|
|
53
|
+
:field_label, :field_type, :field_required, :field_column,
|
|
54
|
+
:field_description, :field_options, keyword_init: true)
|
|
55
|
+
|
|
56
|
+
module_function
|
|
57
|
+
|
|
58
|
+
# Map one CSV row (a Hash keyed by header string) to a RowSpec.
|
|
59
|
+
def row_spec(row)
|
|
60
|
+
RowSpec.new(
|
|
61
|
+
stage: value(row, :stage),
|
|
62
|
+
stage_ordering: integer(row, :stage_ordering),
|
|
63
|
+
section: value(row, :section),
|
|
64
|
+
section_ordering: integer(row, :section_ordering),
|
|
65
|
+
section_layout: value(row, :section_layout) || 'content',
|
|
66
|
+
field_label: value(row, :field_label),
|
|
67
|
+
field_type: value(row, :field_type),
|
|
68
|
+
field_required: truthy?(row, :field_required),
|
|
69
|
+
field_column: integer(row, :field_column) || 0,
|
|
70
|
+
field_description: value(row, :field_description),
|
|
71
|
+
field_options: options(value(row, :field_options))
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Parse the options cell into [ { label:, weight: }, ... ]. Empty → [].
|
|
76
|
+
def options(cell)
|
|
77
|
+
return [] if cell.nil? || cell.to_s.strip.empty?
|
|
78
|
+
|
|
79
|
+
cell.to_s.split(OPTIONS_DELIMITER).map do |token|
|
|
80
|
+
label, weight = token.split(OPTION_WEIGHT_SEPARATOR, 2).map(&:strip)
|
|
81
|
+
{ label: label, weight: (weight && !weight.empty? ? Integer(weight, exception: false) : nil) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def value(row, logical)
|
|
86
|
+
header = COLUMNS.fetch(logical)
|
|
87
|
+
raw = row[header]
|
|
88
|
+
raw = row[header.to_sym] if raw.nil? && row.respond_to?(:key?)
|
|
89
|
+
return nil if raw.nil?
|
|
90
|
+
|
|
91
|
+
str = raw.to_s.strip
|
|
92
|
+
str.empty? ? nil : str
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def integer(row, logical)
|
|
96
|
+
raw = value(row, logical)
|
|
97
|
+
raw && Integer(raw, exception: false)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def truthy?(row, logical)
|
|
101
|
+
raw = value(row, logical)
|
|
102
|
+
return false if raw.nil?
|
|
103
|
+
|
|
104
|
+
%w[1 true yes y required].include?(raw.downcase)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|