qube_rails 0.2.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 +7 -0
- data/CHANGELOG.md +161 -0
- data/LICENSE.txt +21 -0
- data/README.md +967 -0
- data/app/assets/javascripts/qube_rails/question.js +457 -0
- data/app/assets/stylesheets/qube_rails.css +1115 -0
- data/app/controllers/concerns/qube_rails/run_authorization.rb +37 -0
- data/app/controllers/qube_rails/connections_controller.rb +141 -0
- data/app/controllers/qube_rails/questions_controller.rb +132 -0
- data/app/controllers/qube_rails/runs_controller.rb +54 -0
- data/app/controllers/qube_rails/storyboard_controller.rb +50 -0
- data/app/controllers/qube_rails/webhooks_controller.rb +99 -0
- data/app/jobs/qube_rails/prune_runs_job.rb +18 -0
- data/app/jobs/qube_rails/refresh_job.rb +18 -0
- data/app/jobs/qube_rails/webhook_job.rb +43 -0
- data/app/models/qube_rails/application_record.rb +17 -0
- data/app/models/qube_rails/connection.rb +303 -0
- data/app/models/qube_rails/notification.rb +12 -0
- data/app/models/qube_rails/queued_request.rb +66 -0
- data/app/models/qube_rails/quickbooks_record.rb +36 -0
- data/app/models/qube_rails/request_delivery.rb +12 -0
- data/app/models/qube_rails/run.rb +187 -0
- data/app/models/qube_rails/sync_mark.rb +21 -0
- data/app/views/qube_rails/_error.html.erb +13 -0
- data/app/views/qube_rails/_question.html.erb +21 -0
- data/app/views/qube_rails/_run_status.html.erb +22 -0
- data/app/views/qube_rails/question/_choice.html.erb +20 -0
- data/app/views/qube_rails/question/_details.html.erb +6 -0
- data/app/views/qube_rails/question/_field.html.erb +15 -0
- data/app/views/qube_rails/question/_footer.html.erb +7 -0
- data/app/views/qube_rails/question/_message.html.erb +2 -0
- data/app/views/qube_rails/question/_pick.html.erb +19 -0
- data/app/views/qube_rails/question/_picks.html.erb +6 -0
- data/app/views/qube_rails/question/_question.html.erb +24 -0
- data/app/views/qube_rails/question/fields/_boolean.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_number.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_rows.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_search.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_select.html.erb +8 -0
- data/app/views/qube_rails/question/fields/_text.html.erb +7 -0
- data/app/views/qube_rails/question/rows/_cell.html.erb +24 -0
- data/app/views/qube_rails/question/rows/_row.html.erb +22 -0
- data/app/views/qube_rails/question/rows/_segmented.html.erb +14 -0
- data/app/views/qube_rails/run_status/_detail.html.erb +7 -0
- data/app/views/qube_rails/run_status/_redeliver.html.erb +5 -0
- data/app/views/qube_rails/run_status/_retry.html.erb +4 -0
- data/app/views/qube_rails/run_status/_stall.html.erb +3 -0
- data/app/views/qube_rails/run_status/_status.html.erb +21 -0
- data/app/views/qube_rails/run_status/_summary.html.erb +2 -0
- data/app/views/qube_rails/storyboard/show.html.erb +86 -0
- data/config/locales/en.yml +46 -0
- data/config/routes.rb +35 -0
- data/db/migrate/20261025000000_create_qube_tables.rb +83 -0
- data/db/migrate/20261026230000_add_qube_sync_status_indexes.rb +13 -0
- data/db/migrate/20261027000000_create_qube_sync_marks.rb +17 -0
- data/db/migrate/20261028000000_create_qube_requests.rb +38 -0
- data/lib/generators/qube_rails/install_generator.rb +47 -0
- data/lib/generators/qube_rails/templates/connection_events.rb.tt +38 -0
- data/lib/generators/qube_rails/templates/initializer.rb.tt +82 -0
- data/lib/generators/qube_rails/workflows_generator.rb +77 -0
- data/lib/qube_rails/backend.rb +70 -0
- data/lib/qube_rails/cache_dedupe.rb +34 -0
- data/lib/qube_rails/configuration.rb +319 -0
- data/lib/qube_rails/connection_events.rb +167 -0
- data/lib/qube_rails/database_dedupe.rb +28 -0
- data/lib/qube_rails/engine.rb +33 -0
- data/lib/qube_rails/json_coder.rb +43 -0
- data/lib/qube_rails/operation.rb +464 -0
- data/lib/qube_rails/question_view.rb +219 -0
- data/lib/qube_rails/request.rb +289 -0
- data/lib/qube_rails/rows_view.rb +146 -0
- data/lib/qube_rails/run_status_view.rb +71 -0
- data/lib/qube_rails/storyboard/ask_about_add.json +60 -0
- data/lib/qube_rails/storyboard/choose.json +51 -0
- data/lib/qube_rails/storyboard/enum_select.json +56 -0
- data/lib/qube_rails/storyboard/missing_record.json +141 -0
- data/lib/qube_rails/storyboard/name_clash.json +59 -0
- data/lib/qube_rails/storyboard/near_match.json +77 -0
- data/lib/qube_rails/storyboard/number_boolean.json +54 -0
- data/lib/qube_rails/storyboard/record_search.json +90 -0
- data/lib/qube_rails/storyboard/refusal.json +53 -0
- data/lib/qube_rails/storyboard/rows.json +359 -0
- data/lib/qube_rails/storyboard.rb +165 -0
- data/lib/qube_rails/sync_status.rb +124 -0
- data/lib/qube_rails/version.rb +5 -0
- data/lib/qube_rails/view_helpers.rb +29 -0
- data/lib/qube_rails.rb +201 -0
- metadata +180 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module QubeRails
|
|
6
|
+
# One v2 operation your app queues itself, with no workflow, and what it does with that
|
|
7
|
+
# request's webhooks. Subclass it once per request, list it in config.operations by its SLUG,
|
|
8
|
+
# and see the README's "Plain requests".
|
|
9
|
+
#
|
|
10
|
+
# class FindCustomer < QubeRails::Request
|
|
11
|
+
# OPERATION = "customer.query"
|
|
12
|
+
# SLUG = "find_customer"
|
|
13
|
+
#
|
|
14
|
+
# def params
|
|
15
|
+
# { full_name: record.name }
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def answered(event) ... end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# row = QuickbooksDesktop::FindCustomer.queue(customer, connection)
|
|
22
|
+
class Request
|
|
23
|
+
# The QubeRails::QueuedRequest for record on connection. While the last one hasn't ended it's
|
|
24
|
+
# returned (reused?), sent again first if QuBe Sync never confirmed it.
|
|
25
|
+
def self.queue(record, connection)
|
|
26
|
+
unless made_by_qube_sync?(connection)
|
|
27
|
+
raise NotConnected, "QuBe Sync: #{name}.queue needs a connection QuBe Sync has made (one with a " \
|
|
28
|
+
"qube_id): the customer finishes onboarding first"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
row, params_error = claim(record, connection)
|
|
32
|
+
raise params_error if params_error
|
|
33
|
+
return retry_queue(row) if row.reused?
|
|
34
|
+
|
|
35
|
+
send_queue(row)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Sends a queue that got no answer again, with the same params and Idempotency-Key.
|
|
39
|
+
def self.retry_queue(row)
|
|
40
|
+
return row if row.qube_id
|
|
41
|
+
unless row.state == "queued"
|
|
42
|
+
raise ArgumentError, "QuBe Sync: request #{row.id} is #{row.state}: only a queue that got no answer is sent again"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
send_queue(row)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Under the connection row's lock, so a double click finds the same row.
|
|
49
|
+
def self.claim(record, connection)
|
|
50
|
+
connection.with_lock do
|
|
51
|
+
going = QubeRails::QueuedRequest.where(connection: connection).latest_for(record, self::SLUG)
|
|
52
|
+
next [going.tap { |row| row.reused = true }, nil] if going && !going.ended?
|
|
53
|
+
|
|
54
|
+
new_row(record, connection)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
private_class_method :claim
|
|
58
|
+
|
|
59
|
+
def self.new_row(record, connection)
|
|
60
|
+
row = QubeRails::QueuedRequest.new(
|
|
61
|
+
connection: connection, operation: self::SLUG, record: record,
|
|
62
|
+
reference: "#{self::SLUG}-#{record.id}-#{SecureRandom.uuid}", state: "queued"
|
|
63
|
+
)
|
|
64
|
+
begin
|
|
65
|
+
# As JSON reads it back, so a resend sends exactly what the first did.
|
|
66
|
+
row.params = JSONCoder.load(JSONCoder.dump(new(record, row).params))
|
|
67
|
+
rescue StandardError, NotImplementedError => e
|
|
68
|
+
row.state = "failed"
|
|
69
|
+
row.ended_at = Time.now
|
|
70
|
+
row.save!
|
|
71
|
+
return [row, e]
|
|
72
|
+
end
|
|
73
|
+
row.save!
|
|
74
|
+
[row, nil]
|
|
75
|
+
end
|
|
76
|
+
private_class_method :new_row
|
|
77
|
+
|
|
78
|
+
def self.send_queue(row)
|
|
79
|
+
answer =
|
|
80
|
+
begin
|
|
81
|
+
QubeRails.client.queue(self::OPERATION, row.connection.qube_id, row.params || {},
|
|
82
|
+
webhook_url: row.webhook_url, idempotency_key: row.reference)
|
|
83
|
+
rescue Backend::APIError => e
|
|
84
|
+
mark_failed(row) if refused?(e)
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
record_queue(row, answer.is_a?(Hash) ? answer : {})
|
|
88
|
+
row
|
|
89
|
+
end
|
|
90
|
+
private_class_method :send_queue
|
|
91
|
+
|
|
92
|
+
# A 4xx but 409 (the key in use by a call still in flight): nothing was queued.
|
|
93
|
+
def self.refused?(error)
|
|
94
|
+
error.status.between?(400, 499) && error.status != 409
|
|
95
|
+
end
|
|
96
|
+
private_class_method :refused?
|
|
97
|
+
|
|
98
|
+
def self.mark_failed(row)
|
|
99
|
+
now = Time.now
|
|
100
|
+
row.update_columns(state: "failed", ended_at: now, updated_at: now)
|
|
101
|
+
end
|
|
102
|
+
private_class_method :mark_failed
|
|
103
|
+
|
|
104
|
+
def self.record_queue(row, answer)
|
|
105
|
+
row.with_lock do
|
|
106
|
+
row.qube_id ||= answer["id"]
|
|
107
|
+
row.save!
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
private_class_method :record_queue
|
|
111
|
+
|
|
112
|
+
def self.made_by_qube_sync?(connection)
|
|
113
|
+
connection.is_a?(QubeRails::Connection) && !connection.qube_id.to_s.empty?
|
|
114
|
+
end
|
|
115
|
+
private_class_method :made_by_qube_sync?
|
|
116
|
+
|
|
117
|
+
# Write it to load the record your own way; left out, the row's record_type finds it.
|
|
118
|
+
def self.find_record(id)
|
|
119
|
+
raise NotImplementedError, "#{name}.find_record(#{id.inspect}) isn't written"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.record_for(row)
|
|
123
|
+
if method(:find_record).owner == QubeRails::Request.singleton_class
|
|
124
|
+
row.record
|
|
125
|
+
else
|
|
126
|
+
find_record(row.record_id)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
attr_reader :record, :queued_request
|
|
131
|
+
|
|
132
|
+
def initialize(record, queued_request)
|
|
133
|
+
@record = record
|
|
134
|
+
@queued_request = queued_request
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def connection
|
|
138
|
+
queued_request.connection
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The operation's params, from the record.
|
|
142
|
+
def params
|
|
143
|
+
raise NotImplementedError, "#{self.class.name}#params isn't written yet"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# One response_received page: event.response_json["data"], event.page.
|
|
147
|
+
def answered(event)
|
|
148
|
+
log_event(event)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Every page 1..N answered. Called once.
|
|
152
|
+
def done; end
|
|
153
|
+
|
|
154
|
+
# error or discarded: event.error["user_message"] is safe to show.
|
|
155
|
+
def failed(event)
|
|
156
|
+
log_event(event)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# QuBe Sync keeps trying: show event.error["user_message"], never resend.
|
|
160
|
+
def retrying(event)
|
|
161
|
+
log_event(event)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# QuickBooks may have applied it. A query can be queued again; check a write first.
|
|
165
|
+
def timed_out(event)
|
|
166
|
+
failed(event)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# One webhook, from the job, under the row's lock. Deliveries come at least once, in any order.
|
|
170
|
+
def handle(event)
|
|
171
|
+
changed = false
|
|
172
|
+
queued_request.with_lock { changed = apply(event) }
|
|
173
|
+
QubeRails.broadcast_refresh(record) if changed
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
FINAL_STATES = %w[error timed_out discarded].freeze
|
|
179
|
+
private_constant :FINAL_STATES
|
|
180
|
+
|
|
181
|
+
def apply(event)
|
|
182
|
+
row = queued_request
|
|
183
|
+
unless this_request?(event)
|
|
184
|
+
QubeRails.logger.warn("QuBe Sync: #{self.class.name} request #{row.id} skipped #{event.type} " \
|
|
185
|
+
"#{event["id"].inspect}: not this request")
|
|
186
|
+
return false
|
|
187
|
+
end
|
|
188
|
+
return false unless take_once(event)
|
|
189
|
+
|
|
190
|
+
row.qube_id ||= event.id
|
|
191
|
+
case event.state
|
|
192
|
+
when "response_received" then page_answered(event)
|
|
193
|
+
when "retryable" then retryable(event)
|
|
194
|
+
when *FINAL_STATES then final(event)
|
|
195
|
+
else
|
|
196
|
+
QubeRails.logger.warn("QuBe Sync: #{self.class.name} request #{row.id}: unknown state #{event.state.inspect}")
|
|
197
|
+
end
|
|
198
|
+
row.save!
|
|
199
|
+
true
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Request events carry no connection id: the id is checked once the row has one.
|
|
203
|
+
def this_request?(event)
|
|
204
|
+
return false unless event.type == "request"
|
|
205
|
+
|
|
206
|
+
qube_id = queued_request.qube_id
|
|
207
|
+
qube_id.nil? || event.id == qube_id
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Records the delivery in this transaction: false for one taken already.
|
|
211
|
+
def take_once(event)
|
|
212
|
+
deliveries = QubeRails::RequestDelivery.where(request_id: queued_request.id)
|
|
213
|
+
key = event.dedupe_key.to_s
|
|
214
|
+
return false if deliveries.where(dedupe_key: key).exists?
|
|
215
|
+
|
|
216
|
+
QubeRails::RequestDelivery.transaction(requires_new: true) do
|
|
217
|
+
deliveries.create!(dedupe_key: key, page: page_of(event), state: event.state.to_s)
|
|
218
|
+
end
|
|
219
|
+
true
|
|
220
|
+
rescue ActiveRecord::RecordNotUnique
|
|
221
|
+
false
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def page_answered(event)
|
|
225
|
+
row = queued_request
|
|
226
|
+
row.last_page = page_of(event) if %w[done not_applicable].include?(event.iteration_state)
|
|
227
|
+
row.state = "response_received" unless row.ended?
|
|
228
|
+
answered(event)
|
|
229
|
+
|
|
230
|
+
pages = answered_pages
|
|
231
|
+
row.pages_answered = pages.size
|
|
232
|
+
return if row.ended? || row.last_page.nil?
|
|
233
|
+
return unless pages.count { |page| page.between?(1, row.last_page) } == row.last_page
|
|
234
|
+
|
|
235
|
+
row.ended_at = Time.now
|
|
236
|
+
done
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def retryable(event)
|
|
240
|
+
row = queued_request
|
|
241
|
+
if page_settled?(event)
|
|
242
|
+
QubeRails.logger.info("QuBe Sync: #{self.class.name} request #{row.id} page #{page_of(event)}: " \
|
|
243
|
+
"skipped a retryable that came after its answer")
|
|
244
|
+
return
|
|
245
|
+
end
|
|
246
|
+
unless row.ended?
|
|
247
|
+
row.state = "retryable"
|
|
248
|
+
row.error = event.error
|
|
249
|
+
end
|
|
250
|
+
retrying(event)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def final(event)
|
|
254
|
+
row = queued_request
|
|
255
|
+
unless row.ended?
|
|
256
|
+
row.state = event.state
|
|
257
|
+
row.error = event.error
|
|
258
|
+
row.ended_at = Time.now
|
|
259
|
+
end
|
|
260
|
+
event.state == "timed_out" ? timed_out(event) : failed(event)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def answered_pages
|
|
264
|
+
QubeRails::RequestDelivery.where(request_id: queued_request.id, state: "response_received")
|
|
265
|
+
.distinct.pluck(:page).compact
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def page_settled?(event)
|
|
269
|
+
QubeRails::RequestDelivery.where(request_id: queued_request.id, page: page_of(event))
|
|
270
|
+
.where.not(state: "retryable").exists?
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# An answer that wasn't paged is page 1.
|
|
274
|
+
def page_of(event)
|
|
275
|
+
event.page.is_a?(Integer) ? event.page : 1
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def log_event(event)
|
|
279
|
+
code = event.error.is_a?(Hash) ? " #{event.error["error_code"] || event.error["error_type"]}" : ""
|
|
280
|
+
line = "QuBe Sync: #{self.class.name} #{record.id} (request #{queued_request.id}): " \
|
|
281
|
+
"#{event.state} page #{page_of(event)}#{code}"
|
|
282
|
+
if event.state == "response_received"
|
|
283
|
+
QubeRails.logger.info(line)
|
|
284
|
+
else
|
|
285
|
+
QubeRails.logger.warn(line)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module QubeRails
|
|
6
|
+
# A rows field (add_transaction's question about missing records): the same columns asked of
|
|
7
|
+
# each row, posted as data[<field>][<row key>][<column>]. Shown as one block per record, its
|
|
8
|
+
# fields stacked: columns are FieldViews (one per column), rows RowViews. set_all is the column
|
|
9
|
+
# the "Set all to:" line sets on every row (a segmented one asked by two rows or more), or nil.
|
|
10
|
+
class RowsView
|
|
11
|
+
attr_reader :name, :label, :input_name, :search_url, :columns, :rows
|
|
12
|
+
|
|
13
|
+
def initialize(field, input_name:, search_url:)
|
|
14
|
+
@name = field["name"].to_s
|
|
15
|
+
@label = (field["label"] || @name).to_s
|
|
16
|
+
@input_name = input_name
|
|
17
|
+
@search_url = search_url
|
|
18
|
+
declared = Array(field["columns"]).select { |c| c.is_a?(Hash) }
|
|
19
|
+
@columns = declared.map { |column| column_field(column) }
|
|
20
|
+
@rows = Array(field["rows"]).select { |r| r.is_a?(Hash) }.each_with_index.map do |row, n|
|
|
21
|
+
RowView.new(row, declared, self, n)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A column's control for one row (context :cell). Shown or not, it isn't required in the
|
|
26
|
+
# markup while hidden (the script sets required as it shows it).
|
|
27
|
+
def cell_field(column, row_key, row_index, shown)
|
|
28
|
+
col = column["name"].to_s
|
|
29
|
+
FieldView.new(name: col, type: FieldView.type_of(column, record_type: "search"),
|
|
30
|
+
label: column["label"] || col, input_name: "#{input_name}[#{row_key}][#{col}]",
|
|
31
|
+
dom_id: "qube-#{input_name.gsub(/[^A-Za-z0-9]+/, "-")}#{row_index}-#{col}".squeeze("-"),
|
|
32
|
+
required: column["required"] == true && shown, options: FieldView.options_of(column),
|
|
33
|
+
kind: column["kind"], search_url: search_url, search_field: "#{name}.#{col}",
|
|
34
|
+
row_key: row_key, when_values: when_of(column), context: :cell, disabled: !shown)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def when_of(column)
|
|
38
|
+
column["when"].is_a?(Hash) ? column["when"] : {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The column "Set all to:" sets, with its options: the first segmented one two rows or more ask.
|
|
42
|
+
def set_all
|
|
43
|
+
return nil if rows.size < 2
|
|
44
|
+
|
|
45
|
+
columns.find do |column|
|
|
46
|
+
column.type == "select" && column.options.size.between?(1, 3) &&
|
|
47
|
+
rows.count { |row| row.cells.any? { |cell| cell.column == column.name && cell.field } } >= 2
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def column_field(column)
|
|
54
|
+
col = column["name"].to_s
|
|
55
|
+
FieldView.new(name: col, type: FieldView.type_of(column, record_type: "search"), label: column["label"] || col,
|
|
56
|
+
required: column["required"] == true, options: FieldView.options_of(column),
|
|
57
|
+
kind: column["kind"], search_url: search_url, search_field: "#{name}.#{col}",
|
|
58
|
+
when_values: when_of(column), context: :cell)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One record: its cells (the columns it asks, and a value it already has), and the values it
|
|
63
|
+
# already has (values_json for data-qube-values, which a column's "when" reads with the row's
|
|
64
|
+
# answers). A column the row neither asks nor has a value for has no cell.
|
|
65
|
+
class RowView
|
|
66
|
+
attr_reader :key, :label, :detail, :values, :cells, :index
|
|
67
|
+
|
|
68
|
+
def initialize(row, declared, rows, index = 0)
|
|
69
|
+
@key = row["key"].to_s
|
|
70
|
+
@label = (row["label"] || @key).to_s
|
|
71
|
+
@detail = row["detail"].to_s.strip.empty? ? nil : row["detail"].to_s
|
|
72
|
+
@values = row["values"].is_a?(Hash) ? row["values"] : {}
|
|
73
|
+
@index = index
|
|
74
|
+
asks = Array(row["columns"]).map(&:to_s)
|
|
75
|
+
@cells = declared.filter_map do |column|
|
|
76
|
+
col = column["name"].to_s
|
|
77
|
+
when_values = rows.when_of(column)
|
|
78
|
+
shown = applies?(when_values)
|
|
79
|
+
required = column["required"] == true
|
|
80
|
+
label = (column["label"] || col).to_s
|
|
81
|
+
if asks.include?(col)
|
|
82
|
+
CellView.new(column: col, label: label, field: rows.cell_field(column, @key, index, shown),
|
|
83
|
+
when_values: when_values, shown: shown, required: required)
|
|
84
|
+
elsif !@values[col].to_s.empty?
|
|
85
|
+
CellView.new(column: col, label: label, preset: preset_text(column, @values[col]), when_values: when_values,
|
|
86
|
+
shown: shown)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def values_json
|
|
92
|
+
values.to_json
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The segmented cells (the row's choice, such as create or use), then the rest.
|
|
96
|
+
def segmented_cells
|
|
97
|
+
cells.select(&:segmented?)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def field_cells
|
|
101
|
+
cells.reject(&:segmented?)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Before the user picks anything, a column shows when its "when" holds for the row's values.
|
|
107
|
+
def applies?(when_values)
|
|
108
|
+
when_values.all? { |col, allowed| Array(allowed).include?(values[col]) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A value the row already has, shown as its enum label when it has one.
|
|
112
|
+
def preset_text(column, value)
|
|
113
|
+
entry = Array(column["enum"]).find { |e| e.is_a?(Hash) && e["value"] == value }
|
|
114
|
+
entry ? entry["label"].to_s : value.to_s
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# One field of a row: a field to fill (field), or what the row already has (preset, read-only).
|
|
119
|
+
# A cell whose "when" doesn't hold yet is hidden (shown false: hidden, data-qube-hidden, its input
|
|
120
|
+
# disabled and not required). required is the column's, which the script restores as it shows it.
|
|
121
|
+
class CellView
|
|
122
|
+
attr_reader :column, :label, :field, :preset, :when, :shown, :required
|
|
123
|
+
|
|
124
|
+
def initialize(column:, label: nil, field: nil, preset: nil, when_values: {}, shown: true, required: false)
|
|
125
|
+
@column = column
|
|
126
|
+
@label = label || column
|
|
127
|
+
@field = field
|
|
128
|
+
@preset = preset
|
|
129
|
+
@when = when_values
|
|
130
|
+
@shown = shown
|
|
131
|
+
@required = required
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def when_json
|
|
135
|
+
self.when.to_json
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def hidden?
|
|
139
|
+
!shown
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def segmented?
|
|
143
|
+
!field.nil? && field.segmented?
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# Where a record stands, as the run status partials render it: the override contract, kept
|
|
5
|
+
# stable across versions (README, "Styling"). status is QubeRails::SyncStatus's (:not_sent,
|
|
6
|
+
# :sending, :needs_answer, :in_quickbooks, :done, :failed, :cancelled); state the latest run's
|
|
7
|
+
# ("starting", "running", "awaiting_input", "completed", "failed" or "cancelled"), or nil.
|
|
8
|
+
# summary is a few words for the pill; reason, why it failed or was cancelled, as a sentence
|
|
9
|
+
# below it; detail, what to do about a failure.
|
|
10
|
+
class RunStatusView
|
|
11
|
+
attr_reader :status, :state, :summary, :reason, :detail, :quiet_text, :retry_url, :redeliver_url, :error, :stall,
|
|
12
|
+
:stall_text
|
|
13
|
+
|
|
14
|
+
# The stall reasons with words of their own (status.stalled.<reason>); any other is "other".
|
|
15
|
+
STALL_REASONS = %w[not_checking_in quickbooks_unavailable].freeze
|
|
16
|
+
|
|
17
|
+
# From a QubeRails::SyncStatus. urls answers retry_start_path(run) and redeliver_path(run):
|
|
18
|
+
# the engine's route proxy (qube_rails) in a view. connection is the one the status is about
|
|
19
|
+
# (else the latest run's): while it's stalled, a status waiting on QuickBooks (:sending) says
|
|
20
|
+
# why (stall, stall_text) and offers no redelivery, since the cause is known.
|
|
21
|
+
def self.from_sync(sync, urls, error: nil, connection: nil)
|
|
22
|
+
run = sync.run
|
|
23
|
+
state = run && (run.cancelled? ? "cancelled" : run.state)
|
|
24
|
+
reason = run && (sync.failed? || run.cancelled?) ? run.reason_text : nil
|
|
25
|
+
detail = sync.failed? && run ? run.error_detail : nil
|
|
26
|
+
connection ||= run && run.connection
|
|
27
|
+
stall = sync.status == :sending ? stall_of(connection) : nil
|
|
28
|
+
stuck = run && !stall ? run.looks_stuck? : false
|
|
29
|
+
new(status: sync.status, state: state,
|
|
30
|
+
summary: stall ? QubeRails.t("status.waiting_for_quickbooks") : sync.summary_text, reason: reason, detail: detail,
|
|
31
|
+
quiet_text: stuck ? QubeRails.t("status.quiet") : nil,
|
|
32
|
+
retry_url: sync.unconfirmed? ? urls.retry_start_path(run) : nil,
|
|
33
|
+
redeliver_url: stuck ? urls.redeliver_path(run) : nil, error: error, stall: stall)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A stalled connection's reason as a Symbol (:other when it gives none), or nil.
|
|
37
|
+
def self.stall_of(connection)
|
|
38
|
+
return nil unless connection && connection.status == "stalled"
|
|
39
|
+
|
|
40
|
+
reason = connection.stall_reason.to_s
|
|
41
|
+
reason.empty? ? :other : reason.to_sym
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(status:, state: nil, summary: nil, reason: nil, detail: nil, quiet_text: nil, retry_url: nil,
|
|
45
|
+
redeliver_url: nil, error: nil, stall: nil)
|
|
46
|
+
@status = status.to_sym
|
|
47
|
+
@state = state
|
|
48
|
+
@summary = summary
|
|
49
|
+
@reason = reason
|
|
50
|
+
@detail = detail
|
|
51
|
+
@quiet_text = quiet_text
|
|
52
|
+
@retry_url = retry_url
|
|
53
|
+
@redeliver_url = redeliver_url
|
|
54
|
+
@error = error
|
|
55
|
+
@stall = stall
|
|
56
|
+
@stall_text = stall && QubeRails.t("status.stalled.#{STALL_REASONS.include?(stall.to_s) ? stall : "other"}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Nothing is shown before the record is sent (the error aside).
|
|
60
|
+
def visible?
|
|
61
|
+
status != :not_sent
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The qube- classes on the status element: qube-sync-<status>, qube-run-<state>, and
|
|
65
|
+
# qube-connection-stalled while it waits on a stalled connection.
|
|
66
|
+
def css_class
|
|
67
|
+
["qube-run-status", "qube-sync-#{status}", state && "qube-run-#{state}", stall && "qube-connection-stalled"]
|
|
68
|
+
.compact.join(" ")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "A lost answer",
|
|
3
|
+
"note": "add_transaction's ask_about_add: QuickBooks never answered the add, and the run couldn't settle whether it went in. Use a candidate, add it again, or cancel.",
|
|
4
|
+
"prompt": {
|
|
5
|
+
"id": "add_transaction / ask_about_add",
|
|
6
|
+
"kind": "decision",
|
|
7
|
+
"token": "Xh6rV8wBt4QjC6eE",
|
|
8
|
+
"message": "QuickBooks didn't confirm whether this invoice went in. If it's the one below, use it; if it isn't there, add it again.",
|
|
9
|
+
"data": {
|
|
10
|
+
"operation": "invoice",
|
|
11
|
+
"why": "lost_answer",
|
|
12
|
+
"candidates_complete": true,
|
|
13
|
+
"external_guid": "{6B0C3A52-6E1A-4F43-9B1F-0D3E3E1F7A10}",
|
|
14
|
+
"not_found": null
|
|
15
|
+
},
|
|
16
|
+
"details": [
|
|
17
|
+
{
|
|
18
|
+
"label": "Adding",
|
|
19
|
+
"value": "Invoice"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"label": "Number",
|
|
23
|
+
"value": "1042"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"label": "Date",
|
|
27
|
+
"value": "2026-10-20"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"label": "For",
|
|
31
|
+
"value": "Riverbend Electric"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"options": [],
|
|
35
|
+
"choices": [
|
|
36
|
+
{
|
|
37
|
+
"id": "use_existing:1",
|
|
38
|
+
"label": "1042",
|
|
39
|
+
"description": null,
|
|
40
|
+
"detail": "2026-10-20 · Riverbend Electric · 1250.00",
|
|
41
|
+
"fields": []
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "add_again",
|
|
45
|
+
"label": "None of them: add it again",
|
|
46
|
+
"description": null,
|
|
47
|
+
"detail": null,
|
|
48
|
+
"fields": []
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "cancel",
|
|
52
|
+
"label": "Cancel",
|
|
53
|
+
"description": null,
|
|
54
|
+
"detail": null,
|
|
55
|
+
"fields": []
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"search": {}
|
|
60
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "A pick from a list",
|
|
3
|
+
"note": "resolve_transaction's ask: an option whose field has choose becomes one choice per item (use:1, use:2, ...), each with its detail; the server fills the field.",
|
|
4
|
+
"prompt": {
|
|
5
|
+
"id": "resolve_transaction / ask",
|
|
6
|
+
"kind": "decision",
|
|
7
|
+
"token": "Tc7mR3sWp9KdX2yA",
|
|
8
|
+
"message": "More than one invoice could be the one you meant. Pick it, say none of these is it, or cancel.",
|
|
9
|
+
"data": {
|
|
10
|
+
"ref_number": "1042"
|
|
11
|
+
},
|
|
12
|
+
"details": [
|
|
13
|
+
{
|
|
14
|
+
"label": "Invoice",
|
|
15
|
+
"value": "1042"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"options": [],
|
|
19
|
+
"choices": [
|
|
20
|
+
{
|
|
21
|
+
"id": "use:1",
|
|
22
|
+
"label": "1042",
|
|
23
|
+
"description": null,
|
|
24
|
+
"detail": "2026-10-20 · Riverbend Electric · 1250.00",
|
|
25
|
+
"fields": []
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "use:2",
|
|
29
|
+
"label": "1042-B",
|
|
30
|
+
"description": null,
|
|
31
|
+
"detail": "2026-10-21 · Riverbend Electric · 310.50",
|
|
32
|
+
"fields": []
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "none",
|
|
36
|
+
"label": "None of these",
|
|
37
|
+
"description": null,
|
|
38
|
+
"detail": null,
|
|
39
|
+
"fields": []
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "cancel",
|
|
43
|
+
"label": "Cancel",
|
|
44
|
+
"description": null,
|
|
45
|
+
"detail": null,
|
|
46
|
+
"fields": []
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"search": {}
|
|
51
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "A fixed list",
|
|
3
|
+
"note": "A string field with an enum shows as a select, a blank first; the answer is the picked value. No shipped template asks one; a chart of your own can.",
|
|
4
|
+
"prompt": {
|
|
5
|
+
"id": "route_order / ask_route",
|
|
6
|
+
"kind": "decision",
|
|
7
|
+
"token": "Ue2nS5tYq1MfZ3bB",
|
|
8
|
+
"message": "Which list should this order go to?",
|
|
9
|
+
"data": {},
|
|
10
|
+
"details": [
|
|
11
|
+
{
|
|
12
|
+
"label": "Order",
|
|
13
|
+
"value": "SO-2207"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"options": [],
|
|
17
|
+
"choices": [
|
|
18
|
+
{
|
|
19
|
+
"id": "route",
|
|
20
|
+
"label": "Send it",
|
|
21
|
+
"description": null,
|
|
22
|
+
"detail": null,
|
|
23
|
+
"fields": [
|
|
24
|
+
{
|
|
25
|
+
"name": "destination",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"label": "Goes to",
|
|
28
|
+
"required": true,
|
|
29
|
+
"enum": [
|
|
30
|
+
{
|
|
31
|
+
"value": "sales_order",
|
|
32
|
+
"label": "Sales order"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"value": "estimate",
|
|
36
|
+
"label": "Estimate"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"value": "invoice",
|
|
40
|
+
"label": "Invoice"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"id": "cancel",
|
|
48
|
+
"label": "Cancel",
|
|
49
|
+
"description": null,
|
|
50
|
+
"detail": null,
|
|
51
|
+
"fields": []
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"search": {}
|
|
56
|
+
}
|