eco-helpers 3.2.14 → 3.2.16
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/.ai-assistance/conventions/code-working-tree-protocol.md +176 -0
- data/.ai-assistance/scripts/token-logger.js +220 -0
- data/.ai-assistance/scripts/token-report.ts +158 -0
- data/.ai-assistance/scripts/token-session-start.js +66 -0
- data/.ai-assistance/skills/ep-ai-manager/SKILL.md +417 -0
- data/.ai-assistance/skills/ruby-scripting/SKILL.md +215 -0
- data/.ai-assistance/standards-version.json +10 -0
- data/.ai-assistance/token-budget.json +39 -0
- data/.claude/settings.json +103 -0
- data/.gitignore +2 -0
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +83 -0
- data/eco-helpers.gemspec +1 -1
- data/lib/eco/api/usecases/CLAUDE.md +78 -0
- data/lib/eco/api/usecases/default/pages.rb +30 -0
- data/lib/eco/api/usecases/graphql/CLAUDE.md +120 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/dirty_array.rb +22 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/field_patches.rb +241 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/force_compat.rb +73 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect.rb +234 -0
- data/lib/eco/api/usecases/graphql/compat.rb +6 -0
- data/lib/eco/api/usecases/graphql/helpers/CLAUDE.md +79 -0
- data/lib/eco/api/usecases/graphql/samples/CLAUDE.md +76 -0
- data/lib/eco/api/usecases/graphql/samples/pages/CLAUDE.md +59 -0
- data/lib/eco/api/usecases/graphql/samples/pages/org_page/base.rb +41 -0
- data/lib/eco/api/usecases/graphql/samples/pages/org_page/dsl.rb +8 -0
- data/lib/eco/api/usecases/graphql/samples/pages/org_page.rb +7 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page/base.rb +148 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page/dsl.rb +38 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page.rb +7 -0
- data/lib/eco/api/usecases/graphql/samples/pages.rb +7 -0
- data/lib/eco/api/usecases/graphql/samples.rb +1 -0
- data/lib/eco/api/usecases/graphql.rb +1 -0
- data/lib/eco/api/usecases/ooze_samples/ooze_base_case.rb +4 -0
- data/lib/eco/api/usecases/ooze_samples/register_update_case.rb +7 -1
- data/lib/eco/version.rb +1 -1
- metadata +31 -3
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Compat::OozeRedirect
|
|
2
|
+
# Applies one-time compatibility patches to v2 and GraphQL field classes.
|
|
3
|
+
# Idempotent — safe to call multiple times (first call does the work, rest no-op).
|
|
4
|
+
#
|
|
5
|
+
# After apply!:
|
|
6
|
+
# 1. V2 type classes recognise GraphQL instances in case/when dispatch
|
|
7
|
+
# 2. GraphQL field classes respond to the v2 field interface
|
|
8
|
+
module FieldPatches
|
|
9
|
+
@applied = false
|
|
10
|
+
|
|
11
|
+
V2_TO_GQL = {
|
|
12
|
+
'PlainTextField' => 'PlainText',
|
|
13
|
+
'NumberField' => 'Number',
|
|
14
|
+
'SelectionField' => 'Select',
|
|
15
|
+
'PeopleField' => 'People',
|
|
16
|
+
'ReferenceField' => 'CrossReference'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def self.apply!
|
|
20
|
+
return if @applied
|
|
21
|
+
|
|
22
|
+
# eco-helpers lazy-requires the gem only when session.api(version: :graphql) is first
|
|
23
|
+
# used (runtime). apply! runs at OozeRedirect.included (boot) — BEFORE that — so the
|
|
24
|
+
# gem classes aren't loaded yet: every safe_const below would return nil, the patches
|
|
25
|
+
# would silently no-op, and @applied would latch with nothing patched. Force the gem
|
|
26
|
+
# load now so all the GraphQL consts resolve.
|
|
27
|
+
require 'ecoportal/api-graphql'
|
|
28
|
+
|
|
29
|
+
@applied = true
|
|
30
|
+
patch_v2_type_dispatch!
|
|
31
|
+
patch_graphql_base_field!
|
|
32
|
+
patch_graphql_people!
|
|
33
|
+
patch_graphql_cross_reference!
|
|
34
|
+
patch_graphql_select!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# --- 1. V2 type dispatch -------------------------------------------------
|
|
38
|
+
|
|
39
|
+
# Reopen each V2 field class so that `===` also returns true for the
|
|
40
|
+
# equivalent GraphQL field type. This makes existing `case/when` dispatch
|
|
41
|
+
# work transparently without touching the end scripts.
|
|
42
|
+
def self.patch_v2_type_dispatch!
|
|
43
|
+
V2_TO_GQL.each do |v2_type, gql_type|
|
|
44
|
+
v2_klass = safe_const("Ecoportal::API::V2::Page::Component::#{v2_type}")
|
|
45
|
+
gql_klass = safe_const("Ecoportal::API::GraphQL::Base::Page::DataField::#{gql_type}")
|
|
46
|
+
next unless v2_klass && gql_klass
|
|
47
|
+
|
|
48
|
+
gql_ref = gql_klass # capture for the closures
|
|
49
|
+
v2_ref = v2_klass
|
|
50
|
+
|
|
51
|
+
# case/when dispatch: `V2Class === graphql_instance`.
|
|
52
|
+
v2_klass.define_singleton_method(:===) do |obj|
|
|
53
|
+
super(obj) || obj.is_a?(gql_ref)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# is_a?/kind_of? guards: `graphql_instance.is_a?(V2Class)` — scripts use both the
|
|
57
|
+
# case/when form AND explicit is_a? guards (e.g. cans update_select). === alone
|
|
58
|
+
# doesn't cover is_a?, so make the GraphQL field recognise its v2 counterpart too.
|
|
59
|
+
gql_klass.prepend(Module.new do
|
|
60
|
+
define_method(:is_a?) { |klass| klass == v2_ref || super(klass) }
|
|
61
|
+
define_method(:kind_of?) { |klass| klass == v2_ref || super(klass) }
|
|
62
|
+
end)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# --- 2. GraphQL base field: submit! + ooze stub --------------------------
|
|
67
|
+
|
|
68
|
+
def self.patch_graphql_base_field!
|
|
69
|
+
base = safe_const('Ecoportal::API::GraphQL::Base::Page::DataField')
|
|
70
|
+
return unless base
|
|
71
|
+
|
|
72
|
+
# submit! — stores a pending submit flag consumed by the infrastructure
|
|
73
|
+
base.class_eval do
|
|
74
|
+
def _compat_submit_pending?
|
|
75
|
+
@_v2_compat_submit_pending
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# BasePage submit! — called as target.submit!(force: true) in scripts
|
|
80
|
+
page_base = safe_const('Ecoportal::API::GraphQL::Interface::BasePage')
|
|
81
|
+
return unless page_base
|
|
82
|
+
|
|
83
|
+
page_base.class_eval do
|
|
84
|
+
# v2-compat stage SUBMIT. Scripts call `target.submit!` (optionally pinning a
|
|
85
|
+
# stage via `submit!(stage_id:)`). Captures the intent; OozeRedirect#process_ooze
|
|
86
|
+
# consumes it → `graphql.pages.update(submit: true)`. Submitting completes the
|
|
87
|
+
# stage's fill-in task (the server requires all visible, non-hidden required
|
|
88
|
+
# fields to be filled). If the stage also has a review task configured, it then
|
|
89
|
+
# awaits a SEPARATE `sign_off!`. submit! does NOT sign off.
|
|
90
|
+
#
|
|
91
|
+
# `submit!(force: true)` maps to completePageTask.forcedComplete: true — the
|
|
92
|
+
# backend then SKIPS the empty visible-required-field check. This is admin/
|
|
93
|
+
# superuser-only server-side; the integration's service user must hold that
|
|
94
|
+
# permission (it did under APIv2, so the same account carries it on GraphQL).
|
|
95
|
+
def submit!(force: false, stage_id: nil)
|
|
96
|
+
@_v2_compat_submit_pending = true
|
|
97
|
+
@_v2_compat_submit_stage_id = stage_id
|
|
98
|
+
@_v2_compat_submit_force = force ? true : false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# v2-compat stage SIGN-OFF. Scripts call `target.sign_off!` (optionally
|
|
102
|
+
# `sign_off!(stage_id:)`). Per the platform model the stage tasks are sequential
|
|
103
|
+
# (fill-in → review): signing off a stage that has NOT been submitted yet also
|
|
104
|
+
# submits it — a permitted user may submit + sign off in one go (also the page-
|
|
105
|
+
# creation path). process_ooze realises this as
|
|
106
|
+
# `updatePage(submit: true, completePageTask { signOff: true })`, which the
|
|
107
|
+
# backend treats as submit + inline review approval, advancing the stage.
|
|
108
|
+
def sign_off!(stage_id: nil)
|
|
109
|
+
@_v2_compat_sign_off_pending = true
|
|
110
|
+
@_v2_compat_sign_off_stage_id = stage_id
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def _compat_submit?
|
|
114
|
+
@_v2_compat_submit_pending
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def _compat_submit_stage_id
|
|
118
|
+
@_v2_compat_submit_stage_id
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def _compat_submit_force?
|
|
122
|
+
@_v2_compat_submit_force ? true : false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def _compat_sign_off?
|
|
126
|
+
@_v2_compat_sign_off_pending
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def _compat_sign_off_stage_id
|
|
130
|
+
@_v2_compat_sign_off_stage_id
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# --- 3. People field -----------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def self.patch_graphql_people!
|
|
138
|
+
klass = safe_const('Ecoportal::API::GraphQL::Base::Page::DataField::People')
|
|
139
|
+
return unless klass
|
|
140
|
+
|
|
141
|
+
klass.prepend(Module.new do
|
|
142
|
+
# Return a DirtyArray so fld.people_ids << value triggers dirty tracking
|
|
143
|
+
def people_ids
|
|
144
|
+
DirtyArray.new(self, Array(super))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Stub: back-reference used in v2 warning messages
|
|
148
|
+
def ooze
|
|
149
|
+
Struct.new(:uid, :name, :id).new('(graphql)', label, id)
|
|
150
|
+
end
|
|
151
|
+
end)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# --- 4. CrossReference (v2 ReferenceField) field -------------------------
|
|
155
|
+
|
|
156
|
+
def self.patch_graphql_cross_reference!
|
|
157
|
+
klass = safe_const('Ecoportal::API::GraphQL::Base::Page::DataField::CrossReference')
|
|
158
|
+
return unless klass
|
|
159
|
+
|
|
160
|
+
klass.prepend(Module.new do
|
|
161
|
+
# v2 compat: fld.reference_ids → fld.page_ids
|
|
162
|
+
def reference_ids
|
|
163
|
+
page_ids || []
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# v2 compat: fld.add(ep_id)
|
|
167
|
+
def add(ep_id)
|
|
168
|
+
return if ep_id.nil?
|
|
169
|
+
|
|
170
|
+
self.page_ids = (page_ids || []) + [ep_id] unless (page_ids || []).include?(ep_id)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# v2 compat: fld.clear
|
|
174
|
+
def clear
|
|
175
|
+
self.page_ids = []
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def ooze
|
|
179
|
+
Struct.new(:uid, :name, :id).new('(graphql)', label, id)
|
|
180
|
+
end
|
|
181
|
+
end)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# --- 5. Select field -----------------------------------------------------
|
|
185
|
+
|
|
186
|
+
OptionStruct = Struct.new(:id, :name, :value, :selected)
|
|
187
|
+
|
|
188
|
+
def self.patch_graphql_select!
|
|
189
|
+
klass = safe_const('Ecoportal::API::GraphQL::Base::Page::DataField::Select')
|
|
190
|
+
return unless klass
|
|
191
|
+
|
|
192
|
+
klass.prepend(Module.new do
|
|
193
|
+
# v2 compat: fld.options → array of OptionStructs (respond to .name, .value)
|
|
194
|
+
def options
|
|
195
|
+
Array(doc['options'] || []).map do |opt|
|
|
196
|
+
FieldPatches::OptionStruct.new(opt['id'], opt['name'], opt['value'], opt['selected'])
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# v2 compat: fld.select(value, by_name: false) or fld.select(name, by_name: true)
|
|
201
|
+
def select(val, by_name: false)
|
|
202
|
+
select_option(val.to_s)
|
|
203
|
+
self
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# v2 compat: fld.deselect(value) — deselect one specific option
|
|
207
|
+
def deselect(val)
|
|
208
|
+
(doc['options'] || []).each do |opt|
|
|
209
|
+
target = val.to_s
|
|
210
|
+
if [opt['id'], opt['name'], opt['value']].map(&:to_s).include?(target)
|
|
211
|
+
opt['selected'] = false
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
# No explicit dirty call: mutating doc['options'] in place is picked up
|
|
215
|
+
# by DataField's LeafDiffService diff (same as native select_option /
|
|
216
|
+
# clear_selection). There is no mark_dirty! method on the field.
|
|
217
|
+
self
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# v2 compat: fld.values — array of selected option values
|
|
221
|
+
def values
|
|
222
|
+
(doc['options'] || []).select { |opt| opt['selected'] }.map { |opt| opt['value'] }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def ooze
|
|
226
|
+
Struct.new(:uid, :name, :id).new('(graphql)', label, id)
|
|
227
|
+
end
|
|
228
|
+
end)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.safe_const(path)
|
|
232
|
+
path.split('::').reduce(Object) { |mod, c| mod.const_get(c) }
|
|
233
|
+
rescue NameError
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
private_class_method :patch_v2_type_dispatch!, :patch_graphql_base_field!,
|
|
238
|
+
:patch_graphql_people!, :patch_graphql_cross_reference!,
|
|
239
|
+
:patch_graphql_select!, :safe_const
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Compat::OozeRedirect
|
|
2
|
+
# Extends OozeRedirect with Force / binding support via the GraphQL
|
|
3
|
+
# executeWorkflowCommands mutation.
|
|
4
|
+
#
|
|
5
|
+
# Automatically included by OozeRedirect when the ForceFields fragment is
|
|
6
|
+
# available in the loaded gem version. No additional include is needed in
|
|
7
|
+
# end scripts — the same single include covers forces too:
|
|
8
|
+
#
|
|
9
|
+
# include Eco::API::UseCases::GraphQL::Compat::OozeRedirect
|
|
10
|
+
#
|
|
11
|
+
# ## What ForceCompat adds
|
|
12
|
+
#
|
|
13
|
+
# 1. Override `with_each_entry` to fetch pages via Query::PageWithForces,
|
|
14
|
+
# which includes forces in the GraphQL response.
|
|
15
|
+
# 2. Override `process_ooze` to call save_force_changes! after the script
|
|
16
|
+
# runs, executing any accumulated force commands via executeWorkflowCommands.
|
|
17
|
+
#
|
|
18
|
+
# ## Accumulated commands
|
|
19
|
+
#
|
|
20
|
+
# Scripts interact with forces exactly as in v2:
|
|
21
|
+
# force.custom_script = new_script # queues editForce
|
|
22
|
+
# force.bindings.add(field, name: 'n') # queues addBinding
|
|
23
|
+
# force.bindings.delete!(binding) # queues removeBinding
|
|
24
|
+
#
|
|
25
|
+
# After process_ooze returns, ForceCompat collects all pending commands from
|
|
26
|
+
# all forces on the page and submits them in one executeWorkflowCommands call.
|
|
27
|
+
module ForceCompat
|
|
28
|
+
module Infrastructure
|
|
29
|
+
# Fetch pages with forces included in the GraphQL response.
|
|
30
|
+
def with_each_entry
|
|
31
|
+
warn_once(:force_fetch, 'ForceCompat: fetching pages with forces (Query::PageWithForces)')
|
|
32
|
+
target_ids.each do |page_id|
|
|
33
|
+
@target = Ecoportal::API::GraphQL::Query::PageWithForces.new(graphql.client).query(id: page_id)
|
|
34
|
+
if @target
|
|
35
|
+
yield
|
|
36
|
+
else
|
|
37
|
+
log(:warn) { "[ForceCompat] Could not fetch page #{page_id}" }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# After the script's process_ooze runs, submit all accumulated force commands.
|
|
43
|
+
def process_ooze
|
|
44
|
+
super
|
|
45
|
+
save_force_changes!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def save_force_changes!
|
|
51
|
+
return unless target.respond_to?(:forces)
|
|
52
|
+
|
|
53
|
+
force_col = target.forces
|
|
54
|
+
return unless force_col.dirty?
|
|
55
|
+
|
|
56
|
+
commands = force_col.pending_commands
|
|
57
|
+
return if commands.empty?
|
|
58
|
+
|
|
59
|
+
warn_once(:force_save, "ForceCompat: executing #{commands.size} force command(s) via executeWorkflowCommands")
|
|
60
|
+
return if simulate?
|
|
61
|
+
|
|
62
|
+
response = graphql.page.execute_force_commands(
|
|
63
|
+
id: target.id,
|
|
64
|
+
patch_ver: target.patchVer.to_i,
|
|
65
|
+
commands: commands
|
|
66
|
+
)
|
|
67
|
+
if response.respond_to?(:error?) && response.error?
|
|
68
|
+
log(:error) { "[ForceCompat] executeWorkflowCommands failed for page #{target.id}: #{response.body}" }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Compat
|
|
2
|
+
# Transparently redirects an OozeSamples-based use case from the APIv2 ooze
|
|
3
|
+
# path to the GraphQL compat layer. Include it in the case class and nothing
|
|
4
|
+
# else needs to change in the script.
|
|
5
|
+
#
|
|
6
|
+
# ## What it redirects
|
|
7
|
+
#
|
|
8
|
+
# - Page iteration: `with_each_entry` fetches via `graphql.pages.get`
|
|
9
|
+
# - `api_v2` / `apiv2`: returns the graphql compat client
|
|
10
|
+
# - `stage(name)`: resolves via `target.stages[name]` (GraphQL stage model)
|
|
11
|
+
# - `with_fields(ooz, label:, type:)`: reads from `ooz.components` (GraphQL)
|
|
12
|
+
# - `update_ooze(ooz)`: saves via `graphql.pages.update`
|
|
13
|
+
# - `target.submit!` / `target.sign_off!`: captured and forwarded to `graphql.pages.update`
|
|
14
|
+
#
|
|
15
|
+
# ## What it patches (one-time, guarded)
|
|
16
|
+
#
|
|
17
|
+
# - V2 type classes (`PlainTextField`, `SelectionField`, etc.) recognise
|
|
18
|
+
# GraphQL instances in `case/when` dispatch via `===`
|
|
19
|
+
# - GraphQL `People` field: `people_ids <<` triggers dirty tracking
|
|
20
|
+
# - GraphQL `CrossReference` field: `add`, `clear`, `reference_ids`
|
|
21
|
+
# - GraphQL `Select` field: `select`, `deselect`, `values`, `options`
|
|
22
|
+
# (options returns Struct objects with `.name` / `.value`)
|
|
23
|
+
# - All GraphQL fields: `ooze` stub returns a minimal object for warning messages
|
|
24
|
+
# - `Interface::BasePage`: `submit!(stage_id:)` queues a submit-only flag (completes
|
|
25
|
+
# the fill-in task); `sign_off!(stage_id:)` queues sign-off. Tasks are sequential
|
|
26
|
+
# (fill-in → review): sign-off on a not-yet-submitted stage submits + signs off in
|
|
27
|
+
# one go (completePageTask{ signOff: true }). `submit!(force: true)` maps to
|
|
28
|
+
# completePageTask{ forcedComplete: true } (admin/superuser; skips the required-field check).
|
|
29
|
+
#
|
|
30
|
+
# ## Per-run debug logging
|
|
31
|
+
#
|
|
32
|
+
# Each type of redirection logs once per run (not per page) at `:debug` level.
|
|
33
|
+
# Filter with `ECOPORTAL_LOG_LEVEL=debug` or the session logger config.
|
|
34
|
+
#
|
|
35
|
+
# ## Usage
|
|
36
|
+
#
|
|
37
|
+
# class Custom::UseCase::TOOCSCoding < Eco::API::UseCases::OozeSamples::TargetOozesUpdateCase
|
|
38
|
+
# include Eco::API::UseCases::GraphQL::Compat::OozeRedirect
|
|
39
|
+
# # ... rest of class unchanged ...
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# ## Limitations
|
|
43
|
+
#
|
|
44
|
+
# - Force / binding operations (`target.forces`, `force.bindings`) are not
|
|
45
|
+
# available in GraphQL — those calls will raise NoMethodError.
|
|
46
|
+
# - Field property setters (`fld.label=`, `fld.required=`) are template
|
|
47
|
+
# mutations; they are not supported and will raise NoMethodError.
|
|
48
|
+
# - `page_result.membranes` (on PreviewPage search results) is not supported
|
|
49
|
+
# directly; use register search via `graphql.registers.search` instead.
|
|
50
|
+
#
|
|
51
|
+
# ## TODO: Force / binding support (blocked on GraphQL endpoint)
|
|
52
|
+
#
|
|
53
|
+
# Engineering is working on exposing "legacy forces" via the GraphQL API.
|
|
54
|
+
# Once that endpoint is available, extend this module with a `ForceCompat`
|
|
55
|
+
# sub-module (same include pattern — nothing changes in end scripts) covering:
|
|
56
|
+
#
|
|
57
|
+
# target.forces.get_by_name(name) → query forces by name on a page
|
|
58
|
+
# force.bindings.get_by_name(name) → field bindings on a force
|
|
59
|
+
# force.bindings.add(field, name:) → add a binding
|
|
60
|
+
# force.bindings.delete!(binding) → remove a binding
|
|
61
|
+
# force.custom_script → read the LISP script
|
|
62
|
+
# force.custom_script = new_script → write the LISP script
|
|
63
|
+
# force.script → raw script content (alias)
|
|
64
|
+
#
|
|
65
|
+
# Affected cases currently blocked (from multi_org_api survey, ~50% of all
|
|
66
|
+
# ooze cases):
|
|
67
|
+
# act-gov: 5 x 20240130_act_*_case, rearrage_page_sites_case
|
|
68
|
+
# briscoes: remove_induction_sections, 310524_Briscoes_Remove_Tasks
|
|
69
|
+
# chorus: 4 x audit_update cases
|
|
70
|
+
# hcc: update_enterprise_risk_case
|
|
71
|
+
# lic: update_life_cycle_force_case
|
|
72
|
+
# mitre10: rich_text_update, update_location_force, updating_template
|
|
73
|
+
# npdc: contractor_title_force, risk_titile_force, fix_title_syncing,
|
|
74
|
+
# reminder_date_fields, 10092024_NPDC_CP_Add_Force
|
|
75
|
+
# profile-group: int_training_review, 20231026_profile_wellness
|
|
76
|
+
# turners-growers: event_changes, inj_cost_calc, remove_line_force
|
|
77
|
+
# twg: hide_attached_risks, add_new_force
|
|
78
|
+
#
|
|
79
|
+
# Implementation sketch (to be built when the endpoint lands):
|
|
80
|
+
#
|
|
81
|
+
# module ForceCompat
|
|
82
|
+
# # Patch GraphQL BasePage to respond to .forces
|
|
83
|
+
# # Returns a proxy whose #get_by_name queries the GraphQL force endpoint
|
|
84
|
+
# # and returns ForceProxy objects wrapping the response.
|
|
85
|
+
# # ForceProxy exposes #bindings (BindingsProxy), #custom_script, #script.
|
|
86
|
+
# # BindingsProxy exposes #get_by_name, #add, #delete!.
|
|
87
|
+
# end
|
|
88
|
+
#
|
|
89
|
+
# OozeRedirect.include(ForceCompat) # or extend OozeRedirect::FieldPatches
|
|
90
|
+
module OozeRedirect
|
|
91
|
+
require_relative 'ooze_redirect/dirty_array'
|
|
92
|
+
require_relative 'ooze_redirect/field_patches'
|
|
93
|
+
require_relative 'ooze_redirect/force_compat'
|
|
94
|
+
|
|
95
|
+
def self.included(base)
|
|
96
|
+
FieldPatches.apply!
|
|
97
|
+
# Provide `graphql` (lazy, memoized session.api(version: :graphql)) regardless of
|
|
98
|
+
# the host's base class. OozeSamples cases (cans/toocs) don't include the GraphQL
|
|
99
|
+
# helpers that Custom::UseCase paths pick up, yet Infrastructure#api_v2 /
|
|
100
|
+
# #with_each_entry / #update_ooze all call `graphql` — without this they raise
|
|
101
|
+
# NameError: undefined `graphql`.
|
|
102
|
+
base.include(Eco::API::UseCases::GraphQL::Helpers::Base::GraphQLEnv)
|
|
103
|
+
# Prepend Infrastructure first so it sits below ForceCompat in the MRO.
|
|
104
|
+
# With Ruby's prepend, last-prepended wins — so ForceCompat::Infrastructure
|
|
105
|
+
# must be prepended AFTER Infrastructure to be first in the lookup chain:
|
|
106
|
+
# MRO: ForceCompat::Infrastructure → Infrastructure → base class
|
|
107
|
+
base.prepend(Infrastructure)
|
|
108
|
+
base.prepend(ForceCompat::Infrastructure) if force_support?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns true when the installed ecoportal-api-graphql gem exposes
|
|
112
|
+
# Query::PageWithForces (the forces-aware page query).
|
|
113
|
+
# Forces are NOT usable on GraphQL yet: Query::PageWithForces is a WIP whose query
|
|
114
|
+
# currently fails schema validation (selections on PageUnion; `id` on DataFieldBinding/
|
|
115
|
+
# SectionBinding; unused ForceFields) and the backend forces endpoint is still in
|
|
116
|
+
# progress. The class merely being *defined* is not a readiness signal — activating
|
|
117
|
+
# ForceCompat on that basis makes EVERY OozeRedirect case fetch via the broken force
|
|
118
|
+
# query (e.g. toocs, which doesn't even use forces). Keep OFF until forces actually
|
|
119
|
+
# work; then restore a real readiness check.
|
|
120
|
+
def self.force_support?
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Method overrides that replace the v2 infrastructure with GraphQL calls.
|
|
125
|
+
# Prepended onto the including class so they take priority over inherited methods.
|
|
126
|
+
module Infrastructure
|
|
127
|
+
# NOTE: with_each_entry / update_ooze / process_ooze are intentionally NOT overridden.
|
|
128
|
+
# The base OozeSamples loop (RegisterUpdateCase / TargetOozesUpdateCase) already does the
|
|
129
|
+
# right thing once api_v2 is redirected to GraphQL: it counts KPIs (search/retrieved/
|
|
130
|
+
# updated/created), dedups, queues, and — crucially — runs dry_run_feedback in simulate
|
|
131
|
+
# to PRINT the pending diff. Fetches go through ooze(id) → apiv2.pages.get (redirected
|
|
132
|
+
# below), and saves go through update_oozes → update_ooze → apiv2.pages.update. A captured
|
|
133
|
+
# target.submit!/sign_off! rides along on that single update via Input::Page::Update
|
|
134
|
+
# .from_model (it reads the page's _compat_* flags). Overriding the loop here is what
|
|
135
|
+
# silently dropped all of that — don't reintroduce it.
|
|
136
|
+
|
|
137
|
+
# --- API client redirect ------------------------------------------------
|
|
138
|
+
|
|
139
|
+
# Return the GraphQL compat client in place of the v2 client.
|
|
140
|
+
# Handles direct api_v2.pages.* calls in scripts that don't use OozeSamples
|
|
141
|
+
# iteration (e.g. get_new, create, update called directly).
|
|
142
|
+
def api_v2
|
|
143
|
+
warn_once(:api_v2, 'api_v2 → graphql compat layer')
|
|
144
|
+
graphql
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
alias apiv2 api_v2
|
|
148
|
+
|
|
149
|
+
# --- Stage access -------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
# Resolve a stage by name from the current GraphQL page.
|
|
152
|
+
# Replaces the v2 stage fetch from OozeBaseCase.
|
|
153
|
+
def stage(id_name, ooze: target)
|
|
154
|
+
warn_once(:stage, "stage('#{id_name}') → StageView over target.stages[name]")
|
|
155
|
+
stg = ooze.stages[id_name.to_s]
|
|
156
|
+
return nil unless stg
|
|
157
|
+
|
|
158
|
+
# Return a StageView (v2 "page-at-a-stage" semantics): #id / #name / #state /
|
|
159
|
+
# #submit! / #as_update / #dirty? delegate to the PAGE, while #components and
|
|
160
|
+
# #sections are scoped to this stage. Scripts rely on stage.id == page.id
|
|
161
|
+
# (e.g. with_row(stage.id), keyed by page id); the raw Base::Page::Phased::Stage#id
|
|
162
|
+
# is the STAGE's own id and breaks that. Field objects stay the page's canonical
|
|
163
|
+
# ones, so mutations via stage.components still reach pages.update.
|
|
164
|
+
Ecoportal::API::GraphQL::Compat::StageView.new(ooze, stg.id)
|
|
165
|
+
rescue NoMethodError
|
|
166
|
+
log(:warn) { "[OozeRedirect] stages not available on #{ooze.class}" }
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# --- Field access -------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
# Redirect field lookups from v2 component access to GraphQL components.
|
|
173
|
+
# Supports the same type: and label: keyword arguments.
|
|
174
|
+
def with_fields(ooz = target, type: nil, label: nil) # rubocop:disable Metrics/MethodLength
|
|
175
|
+
warn_once(:with_fields, 'with_fields → ooz.components (GraphQL)')
|
|
176
|
+
collection = ooz.respond_to?(:components) ? ooz.components : nil
|
|
177
|
+
|
|
178
|
+
unless collection
|
|
179
|
+
log(:warn) { "[OozeRedirect] No components on #{ooz.class}" }
|
|
180
|
+
return []
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
if label && type
|
|
184
|
+
field = collection.get_by_name(label)
|
|
185
|
+
field && type_match?(field, type) ? [field] : []
|
|
186
|
+
elsif label
|
|
187
|
+
[collection.get_by_name(label)].compact
|
|
188
|
+
elsif type
|
|
189
|
+
collection.get_by_type(type)
|
|
190
|
+
else
|
|
191
|
+
collection.to_a
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# --- Dirty check --------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
# v2 dirty?(ooze) reads patch_doc(ooze)['page']; the GraphQL compat get_body wraps the
|
|
198
|
+
# model's as_update under 'page' (nil when unchanged), so the base dirty? works too —
|
|
199
|
+
# but delegate to the model's real #dirty? for clarity AND treat a captured submit!/
|
|
200
|
+
# sign_off! as dirty, so the base update_ooze still fires for a page that only needs
|
|
201
|
+
# submitting (no field changes). The submit/sign-off itself is carried on that single
|
|
202
|
+
# update by Input::Page::Update.from_model (reads the _compat_* flags).
|
|
203
|
+
def dirty?(object)
|
|
204
|
+
return false unless object
|
|
205
|
+
|
|
206
|
+
pending = (object.respond_to?(:_compat_submit?) && object._compat_submit?) ||
|
|
207
|
+
(object.respond_to?(:_compat_sign_off?) && object._compat_sign_off?)
|
|
208
|
+
return true if pending
|
|
209
|
+
return object.dirty? if object.respond_to?(:dirty?)
|
|
210
|
+
|
|
211
|
+
super
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# Log a debug message exactly once per use-case run per operation type.
|
|
217
|
+
def warn_once(op_key, message)
|
|
218
|
+
@_warned_ops ||= Set.new
|
|
219
|
+
return if @_warned_ops.include?(op_key)
|
|
220
|
+
|
|
221
|
+
@_warned_ops << op_key
|
|
222
|
+
log(:debug) { "[OozeRedirect] #{message}" }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Check whether a GraphQL field matches a v2-style type string.
|
|
226
|
+
# v2 uses e.g. "select", "people", "cross_reference"; normalise both sides.
|
|
227
|
+
def type_match?(field, type_key)
|
|
228
|
+
target_norm = type_key.to_s.downcase.delete('_')
|
|
229
|
+
field_norm = field.type.to_s.downcase.delete('_')
|
|
230
|
+
target_norm == field_norm
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# usecases/graphql/helpers
|
|
2
|
+
|
|
3
|
+
Mixin modules providing domain-specific helper methods for GraphQL use cases.
|
|
4
|
+
All modules are ultimately included via `Helpers::Base` into `GraphQL::Base`.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Include chain
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
GraphQL::Base
|
|
12
|
+
includes Helpers::Base
|
|
13
|
+
includes CaseEnv → session, options, config, simulate?, log, ErrorHandling
|
|
14
|
+
includes GraphQLEnv → graphql (lazy, memoized)
|
|
15
|
+
includes Helpers (loader)
|
|
16
|
+
includes Helpers::Location → location tree helpers
|
|
17
|
+
includes Helpers::Contractors → contractor entity helpers
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Helpers::Base (`helpers/base.rb`)
|
|
23
|
+
|
|
24
|
+
Core environment — included in every GraphQL use case.
|
|
25
|
+
|
|
26
|
+
| Method | Source | Description |
|
|
27
|
+
|--------|--------|-------------|
|
|
28
|
+
| `session` | `CaseEnv` | Current `Eco::API::Session` |
|
|
29
|
+
| `options` | `CaseEnv` | Options hash from CLI/runner |
|
|
30
|
+
| `config` | `CaseEnv` | `session.config` shortcut |
|
|
31
|
+
| `simulate?` | `CaseEnv` | `options[:simulate] \|\| options[:dry_run]` |
|
|
32
|
+
| `log(level)` | `CaseEnv` | Logger proxy |
|
|
33
|
+
| `graphql` | `GraphQLEnv` | Lazy-loaded `Ecoportal::API::GraphQL` instance |
|
|
34
|
+
| `backup(data, type:)` | `Helpers::Base` | Save JSON to requests folder |
|
|
35
|
+
| `exit_error(msg)` | `Helpers::Base` | Log error and `exit(1)` |
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Helpers::Location (`helpers/location/`)
|
|
40
|
+
|
|
41
|
+
Location tree access, tag remapping, classification parsing.
|
|
42
|
+
|
|
43
|
+
- `helpers/location/base.rb` — `Location::Base` mixin
|
|
44
|
+
- `helpers/location/base/tree_tracking.rb` — track tree mutations
|
|
45
|
+
- `helpers/location/base/classifications_parser.rb` — parse location classifications
|
|
46
|
+
- `helpers/location/tags_remap/` — remapping tags across location changes
|
|
47
|
+
- `helpers/location/command/` — apply/diff location structure commands
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Helpers::Contractors (`helpers/contractors/`)
|
|
52
|
+
|
|
53
|
+
Contractor entity loading helpers.
|
|
54
|
+
|
|
55
|
+
- `helpers/contractors/base.rb` — base contractor helpers
|
|
56
|
+
- `helpers/contractors/base/load.rb` — batch load contractor entities
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Adding a new helper
|
|
61
|
+
|
|
62
|
+
1. Create `helpers/my_domain/base.rb`:
|
|
63
|
+
```ruby
|
|
64
|
+
module Eco::API::UseCases::GraphQL::Helpers
|
|
65
|
+
module MyDomain
|
|
66
|
+
module Base
|
|
67
|
+
private
|
|
68
|
+
def my_helper_method
|
|
69
|
+
graphql.myDomainQuery(...)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
2. Create `helpers/my_domain.rb` as a loader that includes `Base`
|
|
76
|
+
3. Add `require_relative 'my_domain'` to `helpers.rb`
|
|
77
|
+
|
|
78
|
+
The helper is then available in all cases that include `Helpers::Base` (i.e., all
|
|
79
|
+
subclasses of `GraphQL::Base` including `PageCase` and `OrgPageCase`).
|