rate-card 0.1.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/LICENSE.txt +21 -0
- data/README.md +226 -0
- data/exe/rate-card +101 -0
- data/lib/rate_card/client.rb +124 -0
- data/lib/rate_card/constants/addresses.rb +101 -0
- data/lib/rate_card/constants/carriers.rb +49 -0
- data/lib/rate_card/constants/cubic_tiers.rb +67 -0
- data/lib/rate_card/csv_writer.rb +58 -0
- data/lib/rate_card/failure.rb +8 -0
- data/lib/rate_card/grid.rb +184 -0
- data/lib/rate_card/input.rb +32 -0
- data/lib/rate_card/run_spec.rb +164 -0
- data/lib/rate_card/runner.rb +43 -0
- data/lib/rate_card/service.rb +38 -0
- data/lib/rate_card/service_catalog.rb +62 -0
- data/lib/rate_card/shipment.rb +78 -0
- data/lib/rate_card/table_renderer.rb +78 -0
- data/lib/rate_card/token.rb +48 -0
- data/lib/rate_card/token_prompt.rb +58 -0
- data/lib/rate_card/tui/app.rb +521 -0
- data/lib/rate_card/tui/fields/multi_select.rb +120 -0
- data/lib/rate_card/tui/fields/select.rb +80 -0
- data/lib/rate_card/tui/fields/text.rb +73 -0
- data/lib/rate_card/tui/messages.rb +59 -0
- data/lib/rate_card/tui/theme.rb +51 -0
- data/lib/rate_card/ui.rb +126 -0
- data/lib/rate_card/version.rb +5 -0
- data/lib/rate_card/warning.rb +31 -0
- data/lib/rate_card.rb +51 -0
- metadata +189 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bubbletea'
|
|
4
|
+
require 'bubbles'
|
|
5
|
+
require 'ntcharts'
|
|
6
|
+
|
|
7
|
+
module RateCard
|
|
8
|
+
module TUI
|
|
9
|
+
# The interactive session as one Elm-architecture model: catalogue lookup,
|
|
10
|
+
# the wizard answers, the confirm, and the fetch. The token is read before
|
|
11
|
+
# this starts — see TokenPrompt.
|
|
12
|
+
#
|
|
13
|
+
# This replaces Wizard. The prompts no longer block — there is one event
|
|
14
|
+
# loop and one #update, and each answered field advances @stage — so the
|
|
15
|
+
# flow that used to read top-to-bottom now reads as the STAGES list plus
|
|
16
|
+
# #field_for. That is the cost of the architecture; what it buys is a run
|
|
17
|
+
# that can show progress and failures live, and a recap you can go back and
|
|
18
|
+
# amend without losing the token.
|
|
19
|
+
#
|
|
20
|
+
# It produces #spec and #grid for the caller to report on after the loop
|
|
21
|
+
# exits. Nothing here writes files or prints tables.
|
|
22
|
+
class App
|
|
23
|
+
include Bubbletea::Model
|
|
24
|
+
|
|
25
|
+
# No :token stage — see TokenPrompt for why it cannot live in the loop.
|
|
26
|
+
STAGES = %i[
|
|
27
|
+
loading rate_mode carrier services zones unit weights package_type
|
|
28
|
+
rate_keys confirm fetching
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :spec, :grid, :error, :notifier
|
|
32
|
+
|
|
33
|
+
# notifier is set by the caller to the Bubbletea::Runner, whose #send is
|
|
34
|
+
# the only way a worker thread can get a message onto the event loop.
|
|
35
|
+
attr_writer :notifier
|
|
36
|
+
|
|
37
|
+
def initialize(token:, output_base:,
|
|
38
|
+
client_factory: ->(tok) { Client.new(token: tok) })
|
|
39
|
+
@token = token
|
|
40
|
+
@output_base = Pathname.new(output_base)
|
|
41
|
+
@client_factory = client_factory
|
|
42
|
+
|
|
43
|
+
@stage = :loading
|
|
44
|
+
@answers = {}
|
|
45
|
+
@field = nil
|
|
46
|
+
@cancelled = false
|
|
47
|
+
@error = nil
|
|
48
|
+
@spinner = Bubbles::Spinner.new
|
|
49
|
+
@progress = Bubbles::Progress.new(width: 32)
|
|
50
|
+
@completed = 0
|
|
51
|
+
@failed = 0
|
|
52
|
+
@failure_history = []
|
|
53
|
+
@failure_sparkline = Ntcharts::Sparkline.new(32, 1)
|
|
54
|
+
@failure_sparkline.style = Lipgloss::Style.new.foreground(Theme::WARNING)
|
|
55
|
+
@log = []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cancelled? = @cancelled
|
|
59
|
+
|
|
60
|
+
def init
|
|
61
|
+
[self, start_loading]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def update(message)
|
|
65
|
+
return [self, Bubbletea.quit] if quit_key?(message)
|
|
66
|
+
|
|
67
|
+
case message
|
|
68
|
+
when ServicesLoaded then services_loaded(message.services)
|
|
69
|
+
when LoadFailed then return fail_with(message.error)
|
|
70
|
+
when ProgressAdvanced then progress_advanced(message)
|
|
71
|
+
when FetchFinished then return finish(message.grid)
|
|
72
|
+
when FetchFailed then return fail_with(message.error)
|
|
73
|
+
else
|
|
74
|
+
return [self, spinner_or_field(message)]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
[self, nil]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def view
|
|
81
|
+
transcript = @log.filter_map { |entry| entry[:line] }
|
|
82
|
+
sections = []
|
|
83
|
+
sections << transcript.join("\n") unless transcript.empty?
|
|
84
|
+
sections << stage_view
|
|
85
|
+
"#{sections.compact.join("\n\n")}\n"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------- input
|
|
91
|
+
|
|
92
|
+
# Ctrl-C during the fetch still quits: the run is abandoned, and the
|
|
93
|
+
# caller sees a nil grid rather than a partial one presented as complete.
|
|
94
|
+
def quit_key?(message)
|
|
95
|
+
return false unless message.is_a?(Bubbletea::KeyMessage)
|
|
96
|
+
return false unless message.to_s == 'ctrl+c'
|
|
97
|
+
|
|
98
|
+
@cancelled = true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def spinner_or_field(message)
|
|
102
|
+
return advance_spinner(message) if @stage == :loading || @stage == :fetching
|
|
103
|
+
return nil if @field.nil?
|
|
104
|
+
return retreat if back_key?(message)
|
|
105
|
+
|
|
106
|
+
command = @field.update(message)
|
|
107
|
+
return command unless @field.done?
|
|
108
|
+
|
|
109
|
+
value = @field.value
|
|
110
|
+
# The recap's own Back row, which is the esc key by another name.
|
|
111
|
+
return retreat if @stage == :confirm && value == :back
|
|
112
|
+
|
|
113
|
+
record(@stage, value)
|
|
114
|
+
advance
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Esc, at any field including the confirm. The fields never see it, so
|
|
118
|
+
# none of them has to know about the wizard it sits in.
|
|
119
|
+
def back_key?(message)
|
|
120
|
+
message.is_a?(Bubbletea::KeyMessage) && message.esc?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def advance_spinner(message)
|
|
124
|
+
@spinner, command = @spinner.update(message)
|
|
125
|
+
command
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# -------------------------------------------------------------- staging
|
|
129
|
+
|
|
130
|
+
def record(stage, value)
|
|
131
|
+
@answers[stage] = value
|
|
132
|
+
@log << { stage: stage, line: answered_line(stage, value) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def advance
|
|
136
|
+
loop do
|
|
137
|
+
@stage = STAGES[STAGES.index(@stage) + 1]
|
|
138
|
+
return start_loading if @stage == :loading
|
|
139
|
+
return start_fetch if @stage == :fetching
|
|
140
|
+
|
|
141
|
+
@field = field_for(@stage)
|
|
142
|
+
# A stage with exactly one possible answer is decided, not asked.
|
|
143
|
+
break unless @field.nil?
|
|
144
|
+
end
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Steps back to the nearest earlier stage that actually asked something,
|
|
149
|
+
# skipping the ones that were decided rather than asked. The answer being
|
|
150
|
+
# revisited seeds the field, and every answer after it is forgotten —
|
|
151
|
+
# they were given against a choice that may be about to change.
|
|
152
|
+
def retreat
|
|
153
|
+
index = STAGES.index(@stage)
|
|
154
|
+
loop do
|
|
155
|
+
index -= 1
|
|
156
|
+
stage = STAGES[index]
|
|
157
|
+
return nil if index.negative? || stage.nil? || stage == :loading
|
|
158
|
+
|
|
159
|
+
field = field_for(stage)
|
|
160
|
+
next if field.nil?
|
|
161
|
+
|
|
162
|
+
forget_from(stage)
|
|
163
|
+
@stage = stage
|
|
164
|
+
@field = field
|
|
165
|
+
return nil
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def forget_from(stage)
|
|
170
|
+
dropped = STAGES[STAGES.index(stage)..]
|
|
171
|
+
dropped.each { |name| @answers.delete(name) }
|
|
172
|
+
@log.reject! { |entry| dropped.include?(entry[:stage]) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def field_for(stage)
|
|
176
|
+
case stage
|
|
177
|
+
when :rate_mode then rate_mode_field
|
|
178
|
+
when :carrier then carrier_field
|
|
179
|
+
when :services then services_field
|
|
180
|
+
when :zones then zones_field
|
|
181
|
+
when :unit then unit_field
|
|
182
|
+
when :weights then weights_field
|
|
183
|
+
when :package_type then package_type_field
|
|
184
|
+
when :rate_keys then rate_keys_field
|
|
185
|
+
when :confirm then confirm_field
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# --------------------------------------------------------------- fields
|
|
190
|
+
|
|
191
|
+
# The last gate before production is touched. It opens on Back, not on
|
|
192
|
+
# Run: the field before it is also confirmed with enter, so a held-down
|
|
193
|
+
# return key must not be able to start 128 production calls by itself.
|
|
194
|
+
def confirm_field
|
|
195
|
+
Fields::Select.new(label: 'Run this rate card?',
|
|
196
|
+
choices: [['Run', :run], ['Back', :back]], selected: 1)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Only asked when the token has at least one USPS service — cubic
|
|
200
|
+
# pricing is USPS-only, so a token with none has nothing to offer here
|
|
201
|
+
# and the question is decided as :weight and skipped, same as every
|
|
202
|
+
# other single-answer stage.
|
|
203
|
+
def rate_mode_field
|
|
204
|
+
return nil unless @services.any? { |service| service.carrier == 'USPS' }
|
|
205
|
+
|
|
206
|
+
choices = [['Weight', :weight], ['Cubic dimensions', :cubic]]
|
|
207
|
+
Fields::Select.new(label: 'Rate by', choices: choices,
|
|
208
|
+
selected: choices.index { |_, mode| mode == @answers[:rate_mode] } || 0)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def carrier_field
|
|
212
|
+
carriers = selectable_carriers
|
|
213
|
+
return nil if carriers.length <= 1
|
|
214
|
+
|
|
215
|
+
Fields::Select.new(label: 'Carrier', choices: carriers.map { |c| [c, c] },
|
|
216
|
+
selected: carriers.index(@answers[:carrier]) || 0)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Only carriers we hold a zone chart for. A service whose carrier has no
|
|
220
|
+
# chart is dropped from the menu rather than offered and then refused
|
|
221
|
+
# mid-run by Addresses.for_carrier.
|
|
222
|
+
def selectable_carriers
|
|
223
|
+
carriers = ServiceCatalog.group_by_carrier(@services)
|
|
224
|
+
.keys
|
|
225
|
+
.select { |carrier| Constants::Addresses.supported?(carrier) }
|
|
226
|
+
return carriers & ['USPS'] if @answers[:rate_mode] == :cubic
|
|
227
|
+
|
|
228
|
+
carriers
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def services_field
|
|
232
|
+
choices = @services.select { |service| service.carrier == carrier }
|
|
233
|
+
Fields::MultiSelect.new(
|
|
234
|
+
label: 'Services',
|
|
235
|
+
choices: choices.map { |service| [service.label, service] },
|
|
236
|
+
checked: checked_indexes(choices, @answers[:services])
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def zones_field
|
|
241
|
+
available = Constants::Addresses.available_zones(carrier)
|
|
242
|
+
full = "#{available.first}-#{available.last}"
|
|
243
|
+
answered = @answers[:zones]
|
|
244
|
+
|
|
245
|
+
Fields::Text.new(
|
|
246
|
+
label: 'Zones', default: answered ? RunSpec.compact_range(answered) : full,
|
|
247
|
+
hint: "available: #{full}",
|
|
248
|
+
parse: lambda { |raw|
|
|
249
|
+
zones = Input.parse_range(raw) & available
|
|
250
|
+
raise ArgumentError, "no valid zones in that input (available: #{full})" if zones.empty?
|
|
251
|
+
|
|
252
|
+
zones
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Weight is fixed per cubic tier, so asking for a display unit is
|
|
258
|
+
# meaningless in cubic mode — decided as :oz (unused) and skipped.
|
|
259
|
+
def unit_field
|
|
260
|
+
return nil if @answers[:rate_mode] == :cubic
|
|
261
|
+
|
|
262
|
+
choices = [['oz', :oz], ['lbs', :lbs]]
|
|
263
|
+
Fields::Select.new(label: 'Weight unit', choices: choices,
|
|
264
|
+
selected: choices.index { |_, unit| unit == @answers[:unit] } || 0)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def weights_field
|
|
268
|
+
return cubic_tiers_field if @answers[:rate_mode] == :cubic
|
|
269
|
+
|
|
270
|
+
answered = @answers[:weights]
|
|
271
|
+
|
|
272
|
+
Fields::Text.new(
|
|
273
|
+
label: 'Weight range',
|
|
274
|
+
default: answered ? RunSpec.compact_range(answered) : Input::DEFAULT_WEIGHT_RANGE,
|
|
275
|
+
hint: 'a range like 1-16, or a list like 1,4,8',
|
|
276
|
+
parse: lambda { |raw|
|
|
277
|
+
weights = Input.parse_range(raw).reject(&:zero?)
|
|
278
|
+
raise ArgumentError, 'enter a range like 1-16 or a list like 1,4,8' if weights.empty?
|
|
279
|
+
|
|
280
|
+
weights
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Stored under the same @answers[:weights] key the weight-range text
|
|
286
|
+
# field uses (an array of ids rather than an array of weights) so
|
|
287
|
+
# #advance, #retreat and #build_spec do not need a third answer slot.
|
|
288
|
+
def cubic_tiers_field
|
|
289
|
+
choices = Constants::CubicTiers.choices
|
|
290
|
+
answered = @answers[:weights]
|
|
291
|
+
Fields::MultiSelect.new(
|
|
292
|
+
label: 'Cubic tiers',
|
|
293
|
+
choices: choices,
|
|
294
|
+
checked: answered ? checked_indexes(choices.map(&:last), answered) : (0...choices.length)
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# The union of what the selected services accept, so a contract type like
|
|
299
|
+
# fedex_pak is offered and a type no selected service accepts is not.
|
|
300
|
+
def package_type_field
|
|
301
|
+
choices = @answers[:services].flat_map(&:package_types).uniq.sort
|
|
302
|
+
choices = Input::PACKAGE_TYPES if choices.empty?
|
|
303
|
+
if choices.length == 1
|
|
304
|
+
@answers[:package_type] = choices.first
|
|
305
|
+
return nil
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
Fields::Select.new(label: 'Package type', choices: choices.map { |t| [t, t] },
|
|
309
|
+
selected: choices.index(@answers[:package_type]) || 0)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def rate_keys_field
|
|
313
|
+
keys = RunSpec::RATE_KEY_FIELDS.keys
|
|
314
|
+
Fields::MultiSelect.new(
|
|
315
|
+
label: 'Rate columns',
|
|
316
|
+
choices: keys.map { |key| [RunSpec::RATE_KEY_LABELS.fetch(key), key] },
|
|
317
|
+
checked: @answers[:rate_keys] ? checked_indexes(keys, @answers[:rate_keys]) : (0...keys.length)
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Which rows a re-entered multi-select opens with ticked. Values that are
|
|
322
|
+
# no longer on offer — a service dropped by a carrier change — are simply
|
|
323
|
+
# not found, and so are not carried forward.
|
|
324
|
+
def checked_indexes(choices, answered)
|
|
325
|
+
return [] if answered.nil?
|
|
326
|
+
|
|
327
|
+
answered.filter_map { |value| choices.index(value) }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def carrier
|
|
331
|
+
@answers[:carrier] || selectable_carriers.first
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# ------------------------------------------------------------ catalogue
|
|
335
|
+
|
|
336
|
+
# A Proc command: Bubbletea runs it in a Thread and feeds the returned
|
|
337
|
+
# message back through #update, which is how the lookup happens without
|
|
338
|
+
# blocking the loop that draws the spinner.
|
|
339
|
+
def start_loading
|
|
340
|
+
client = @client_factory.call(@token)
|
|
341
|
+
lambda do
|
|
342
|
+
services = ServiceCatalog.from_response(client.fetch_services)
|
|
343
|
+
if services.empty?
|
|
344
|
+
LoadFailed.new(NoServices.new('the API returned no services for this token'))
|
|
345
|
+
elsif (rateable = with_supported_carrier(services)).empty?
|
|
346
|
+
LoadFailed.new(UnsupportedCarrier.new(unsupported_message(services)))
|
|
347
|
+
else
|
|
348
|
+
ServicesLoaded.new(rateable)
|
|
349
|
+
end
|
|
350
|
+
rescue StandardError => e
|
|
351
|
+
LoadFailed.new(e)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def with_supported_carrier(services)
|
|
356
|
+
services.select { |service| Constants::Addresses.supported?(service.carrier) }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def unsupported_message(services)
|
|
360
|
+
offered = services.map(&:carrier).uniq.sort.join(', ')
|
|
361
|
+
"this token's services are all on carriers with no curated zone chart " \
|
|
362
|
+
"(#{offered}) — rate cards are only available for " \
|
|
363
|
+
"#{Constants::Addresses.supported_carriers.join(', ')}"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def services_loaded(services)
|
|
367
|
+
@services = services
|
|
368
|
+
@log << { stage: :loading, line: " #{Theme.ok(Theme::TICK)} #{Theme.bold(identity[:name])} " \
|
|
369
|
+
"(#{identity[:customer_id]}) · #{services.length} services found" }
|
|
370
|
+
advance
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------- fetch
|
|
374
|
+
|
|
375
|
+
def start_fetch
|
|
376
|
+
@spec = build_spec
|
|
377
|
+
# Before the first call, so a bad path is not discovered after 128 of them.
|
|
378
|
+
begin
|
|
379
|
+
CsvWriter.ensure_writable!(@spec.output_base)
|
|
380
|
+
rescue OutputNotWritable => e
|
|
381
|
+
return fail_with(e).last
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
client = @client_factory.call(@token)
|
|
385
|
+
notifier = @notifier
|
|
386
|
+
spec = @spec
|
|
387
|
+
|
|
388
|
+
lambda do
|
|
389
|
+
completed = 0
|
|
390
|
+
failed = 0
|
|
391
|
+
mutex = Mutex.new
|
|
392
|
+
grid = Grid.build(spec: spec, client: client, on_progress: lambda {
|
|
393
|
+
mutex.synchronize { completed += 1 }
|
|
394
|
+
notifier&.send(ProgressAdvanced.new(completed: completed, failed: failed))
|
|
395
|
+
})
|
|
396
|
+
FetchFinished.new(grid)
|
|
397
|
+
rescue StandardError => e
|
|
398
|
+
FetchFailed.new(e)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def progress_advanced(message)
|
|
403
|
+
@completed = message.completed
|
|
404
|
+
@failed = message.failed
|
|
405
|
+
@failure_history << message.failed
|
|
406
|
+
@failure_sparkline.push(message.failed)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def finish(grid)
|
|
410
|
+
@grid = grid
|
|
411
|
+
[self, Bubbletea.quit]
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def fail_with(error)
|
|
415
|
+
@error = error
|
|
416
|
+
[self, Bubbletea.quit]
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def build_spec
|
|
420
|
+
cubic = @answers[:rate_mode] == :cubic
|
|
421
|
+
RunSpec.new(
|
|
422
|
+
token: @token,
|
|
423
|
+
customer_name: identity[:name],
|
|
424
|
+
customer_id: identity[:customer_id],
|
|
425
|
+
carrier: carrier,
|
|
426
|
+
services: @answers[:services],
|
|
427
|
+
zones: @answers[:zones],
|
|
428
|
+
weight_unit: @answers[:unit] || :oz,
|
|
429
|
+
weights: cubic ? [] : @answers[:weights],
|
|
430
|
+
cubic_tiers: cubic ? @answers[:weights] : [],
|
|
431
|
+
rate_mode: @answers[:rate_mode] || :weight,
|
|
432
|
+
package_type: @answers[:package_type],
|
|
433
|
+
rate_keys: @answers[:rate_keys],
|
|
434
|
+
output_base: @output_base,
|
|
435
|
+
show_table: true,
|
|
436
|
+
started_at: Time.now
|
|
437
|
+
).validate!
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def identity
|
|
441
|
+
@identity ||= Token.decode(@token)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# ----------------------------------------------------------------- view
|
|
445
|
+
|
|
446
|
+
def stage_view
|
|
447
|
+
case @stage
|
|
448
|
+
when :loading then " #{@spinner.view} Loading available services"
|
|
449
|
+
when :fetching then fetch_view
|
|
450
|
+
when :confirm then "#{recap_view}\n\n#{@field.view}"
|
|
451
|
+
else @field&.view
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Everything the run will do, gathered in one block. The answers are also
|
|
456
|
+
# in the transcript above, but they arrived one at a time over eight
|
|
457
|
+
# screens; this is the only place they can be read against each other.
|
|
458
|
+
def recap_view
|
|
459
|
+
[
|
|
460
|
+
" #{Theme.bold(carrier)} · #{@answers[:services].map(&:name).join(', ')}",
|
|
461
|
+
" zones #{RunSpec.compact_range(@answers[:zones])} · #{rows_recap} · #{@answers[:package_type]}",
|
|
462
|
+
" columns: #{@answers[:rate_keys].map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')}",
|
|
463
|
+
" #{Theme.bold(call_count.to_s)} rate calls against #{Theme.danger('production')}"
|
|
464
|
+
].join("\n")
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def rows_recap
|
|
468
|
+
if @answers[:rate_mode] == :cubic
|
|
469
|
+
"cubic tiers #{RunSpec.compact_range(@answers[:weights])}"
|
|
470
|
+
else
|
|
471
|
+
"weights #{RunSpec.compact_range(@answers[:weights])} #{@answers[:unit]}"
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def call_count
|
|
476
|
+
@answers[:weights].length * @answers[:zones].length
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def fetch_view
|
|
480
|
+
total = @spec.call_count
|
|
481
|
+
percent = total.zero? ? 0.0 : @completed.to_f / total
|
|
482
|
+
line = " #{@progress.view_as(percent)} #{@completed}/#{total}"
|
|
483
|
+
line += " #{Theme.warning("#{Theme::ALERT} #{@failed} failed")}" if @failed.positive?
|
|
484
|
+
view = "#{Theme.bold(' fetching rates')}\n#{line}"
|
|
485
|
+
view += "\n#{failure_sparkline_view}" if @failed.positive?
|
|
486
|
+
view
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Only drawn once a failure has happened: a spark line of zeroes for a
|
|
490
|
+
# clean run would be noise, not signal. It stays once shown even if
|
|
491
|
+
# later ticks are all clean — the failure already happened.
|
|
492
|
+
def failure_sparkline_view
|
|
493
|
+
@failure_sparkline.draw_braille
|
|
494
|
+
" #{@failure_sparkline.view}"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# One line per answered stage, kept above the current field so the
|
|
498
|
+
# transcript of the run stays on screen — the recap is then a summary of
|
|
499
|
+
# what is already visible rather than the first chance to check it.
|
|
500
|
+
def answered_line(stage, value)
|
|
501
|
+
label = { rate_mode: 'rate by', carrier: 'carrier', services: 'services', zones: 'zones',
|
|
502
|
+
unit: 'unit', weights: 'weights', package_type: 'package',
|
|
503
|
+
rate_keys: 'columns' }[stage]
|
|
504
|
+
label = 'cubic tiers' if stage == :weights && @answers[:rate_mode] == :cubic
|
|
505
|
+
return nil if label.nil?
|
|
506
|
+
|
|
507
|
+
" #{Theme.ok(Theme::TICK)} #{label}: #{Theme.bold(describe(stage, value))}"
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def describe(stage, value)
|
|
511
|
+
case stage
|
|
512
|
+
when :services then value.map(&:name).join(', ')
|
|
513
|
+
when :zones then RunSpec.compact_range(value)
|
|
514
|
+
when :weights then RunSpec.compact_range(value)
|
|
515
|
+
when :rate_keys then value.map { |k| RunSpec::RATE_KEY_LABELS.fetch(k) }.join(', ')
|
|
516
|
+
else value.to_s
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module RateCard
|
|
6
|
+
module TUI
|
|
7
|
+
module Fields
|
|
8
|
+
# Pick one or more. There is no multi-select in bubbles — List is
|
|
9
|
+
# single-select — so this is the one widget the migration had to build,
|
|
10
|
+
# and the wizard needs it twice (services, rate columns).
|
|
11
|
+
#
|
|
12
|
+
# Enter with nothing ticked is refused rather than accepted as "none":
|
|
13
|
+
# every caller here has a min of 1, and a silent empty answer would fail
|
|
14
|
+
# much later in RunSpec#validate!.
|
|
15
|
+
class MultiSelect
|
|
16
|
+
WINDOW = 8
|
|
17
|
+
|
|
18
|
+
attr_reader :label, :error
|
|
19
|
+
|
|
20
|
+
# choices: Array<[display String, value]>
|
|
21
|
+
# checked: indexes ticked on entry.
|
|
22
|
+
def initialize(label:, choices:, checked: [])
|
|
23
|
+
@label = label
|
|
24
|
+
@choices = choices
|
|
25
|
+
@checked = checked.to_a.to_set
|
|
26
|
+
@cursor = 0
|
|
27
|
+
@error = nil
|
|
28
|
+
@done = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def done? = @done
|
|
32
|
+
|
|
33
|
+
def value
|
|
34
|
+
@choices.each_with_index
|
|
35
|
+
.select { |_choice, index| @checked.include?(index) }
|
|
36
|
+
.map { |choice, _index| choice.last }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def update(message)
|
|
40
|
+
return nil unless message.is_a?(Bubbletea::KeyMessage)
|
|
41
|
+
|
|
42
|
+
if message.up? || message.to_s == 'k'
|
|
43
|
+
move(-1)
|
|
44
|
+
elsif message.down? || message.to_s == 'j'
|
|
45
|
+
move(1)
|
|
46
|
+
elsif space?(message)
|
|
47
|
+
toggle
|
|
48
|
+
elsif message.to_s == 'a'
|
|
49
|
+
toggle_all
|
|
50
|
+
elsif message.enter?
|
|
51
|
+
submit
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def view
|
|
57
|
+
lines = ["#{Theme.accent(Theme::CURSOR)} #{Theme.bold(@label)}"]
|
|
58
|
+
window.each { |index| lines << choice_line(index) }
|
|
59
|
+
lines << " #{Theme.muted(hint)}"
|
|
60
|
+
lines << " #{Theme.danger(Theme::CROSS)} #{Theme.danger(@error)}" if @error
|
|
61
|
+
lines.join("\n")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def choice_line(index)
|
|
67
|
+
display = @choices[index].first
|
|
68
|
+
box = @checked.include?(index) ? Theme.ok(Theme::CHECKED) : Theme.muted(Theme::UNCHECKED)
|
|
69
|
+
pointer = index == @cursor ? Theme.accent(Theme::CURSOR) : ' '
|
|
70
|
+
text = index == @cursor ? Theme.accent(display) : display
|
|
71
|
+
" #{pointer} #{box} #{text}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def hint
|
|
75
|
+
counter = "#{@checked.length}/#{@choices.length} selected"
|
|
76
|
+
"space toggle · a all · enter confirm · esc back · #{counter}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The spacebar arrives as KEY_SPACE from some terminals and as a plain
|
|
80
|
+
# ' ' rune from others; KeyMessage#space? only recognises the first, so
|
|
81
|
+
# relying on it alone makes the toggle key dead on half of them.
|
|
82
|
+
def space?(message)
|
|
83
|
+
message.space? || message.to_s == ' '
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def move(delta)
|
|
87
|
+
@cursor = (@cursor + delta) % @choices.length
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def toggle
|
|
91
|
+
@checked.include?(@cursor) ? @checked.delete(@cursor) : @checked.add(@cursor)
|
|
92
|
+
@error = nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# All-or-nothing on one key: with eight services enabled, ticking each
|
|
96
|
+
# in turn is the most common thing the old checkbox prompt made tedious.
|
|
97
|
+
def toggle_all
|
|
98
|
+
@checked = @checked.length == @choices.length ? Set.new : (0...@choices.length).to_set
|
|
99
|
+
@error = nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def submit
|
|
103
|
+
if @checked.empty?
|
|
104
|
+
@error = 'select at least one — space to tick, a for all'
|
|
105
|
+
return
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@done = true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def window
|
|
112
|
+
return (0...@choices.length) if @choices.length <= WINDOW
|
|
113
|
+
|
|
114
|
+
start = [[@cursor - (WINDOW / 2), 0].max, @choices.length - WINDOW].min
|
|
115
|
+
(start...(start + WINDOW))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|