gitlab_internal_events_cli 0.0.1

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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.tool-versions +1 -0
  4. data/Gemfile +11 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE.txt +19 -0
  7. data/README.md +164 -0
  8. data/Rakefile +10 -0
  9. data/exe/gitlab-internal-events-cli +7 -0
  10. data/gitlab_internal_events_cli.gemspec +39 -0
  11. data/lib/gitlab_internal_events_cli/cli.rb +59 -0
  12. data/lib/gitlab_internal_events_cli/configuration.rb +115 -0
  13. data/lib/gitlab_internal_events_cli/event.rb +73 -0
  14. data/lib/gitlab_internal_events_cli/flows/event_definer.rb +306 -0
  15. data/lib/gitlab_internal_events_cli/flows/flow_advisor.rb +90 -0
  16. data/lib/gitlab_internal_events_cli/flows/metric_definer.rb +468 -0
  17. data/lib/gitlab_internal_events_cli/flows/usage_viewer.rb +474 -0
  18. data/lib/gitlab_internal_events_cli/gitlab_prompt.rb +9 -0
  19. data/lib/gitlab_internal_events_cli/global_state.rb +63 -0
  20. data/lib/gitlab_internal_events_cli/helpers/cli_inputs.rb +138 -0
  21. data/lib/gitlab_internal_events_cli/helpers/event_options.rb +63 -0
  22. data/lib/gitlab_internal_events_cli/helpers/files.rb +84 -0
  23. data/lib/gitlab_internal_events_cli/helpers/formatting.rb +166 -0
  24. data/lib/gitlab_internal_events_cli/helpers/group_ownership.rb +160 -0
  25. data/lib/gitlab_internal_events_cli/helpers/metric_options.rb +253 -0
  26. data/lib/gitlab_internal_events_cli/helpers/schema_loader.rb +25 -0
  27. data/lib/gitlab_internal_events_cli/helpers/service_ping_dashboards.rb +22 -0
  28. data/lib/gitlab_internal_events_cli/helpers.rb +47 -0
  29. data/lib/gitlab_internal_events_cli/http_cache.rb +52 -0
  30. data/lib/gitlab_internal_events_cli/metric.rb +406 -0
  31. data/lib/gitlab_internal_events_cli/schema_resolver.rb +25 -0
  32. data/lib/gitlab_internal_events_cli/subflows/database_metric_definer.rb +71 -0
  33. data/lib/gitlab_internal_events_cli/subflows/event_metric_definer.rb +258 -0
  34. data/lib/gitlab_internal_events_cli/text/event_definer.rb +166 -0
  35. data/lib/gitlab_internal_events_cli/text/flow_advisor.rb +64 -0
  36. data/lib/gitlab_internal_events_cli/text/metric_definer.rb +138 -0
  37. data/lib/gitlab_internal_events_cli/time_framed_key_path.rb +18 -0
  38. data/lib/gitlab_internal_events_cli/version.rb +5 -0
  39. data/lib/gitlab_internal_events_cli.rb +36 -0
  40. metadata +170 -0
@@ -0,0 +1,474 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entrypoint for flow to print examples of how to trigger an
4
+ # event in different languages & different methods of testing
5
+ module GitlabInternalEventsCli
6
+ module Flows
7
+ class UsageViewer
8
+ include Helpers
9
+
10
+ PROPERTY_EXAMPLES = {
11
+ 'label' => "'string'",
12
+ 'property' => "'string'",
13
+ 'value' => '72'
14
+ }.freeze
15
+ DEFAULT_PROPERTY_VALUE = "'custom_value'"
16
+
17
+ attr_reader :cli, :event
18
+
19
+ def initialize(cli, event_path = nil, event = nil)
20
+ @cli = cli
21
+ @event = event
22
+ @selected_event_path = event_path
23
+ end
24
+
25
+ def run
26
+ prompt_for_eligible_event
27
+ prompt_for_usage_location
28
+ end
29
+
30
+ def prompt_for_eligible_event
31
+ return if event
32
+
33
+ event_details = events_by_filepath
34
+
35
+ @selected_event_path = cli.select(
36
+ 'Show examples for which event?',
37
+ get_event_options(event_details),
38
+ **select_opts,
39
+ **filter_opts
40
+ )
41
+
42
+ @event = event_details[@selected_event_path]
43
+ end
44
+
45
+ def prompt_for_usage_location(default = '1. ruby/rails')
46
+ choices = [
47
+ { name: '1. ruby/rails', value: :rails },
48
+ { name: '2. rspec', value: :rspec },
49
+ { name: '3. javascript (vue)', value: :vue },
50
+ { name: '4. javascript (plain)', value: :js },
51
+ { name: '5. vue template', value: :vue_template },
52
+ { name: '6. haml', value: :haml },
53
+ { name: '7. Manual testing in GDK', value: :gdk },
54
+ { name: '8. Data verification in Tableau', value: :tableau },
55
+ { name: '9. View examples for a different event', value: :other_event },
56
+ { name: '10. Exit', value: :exit }
57
+ ]
58
+
59
+ usage_location = cli.select(
60
+ 'Select a use-case to view examples for:',
61
+ choices,
62
+ **select_opts,
63
+ **filter_opts,
64
+ per_page: 10
65
+ ) do |menu|
66
+ menu.default default
67
+ end
68
+
69
+ case usage_location
70
+ when :rails
71
+ rails_examples
72
+ prompt_for_usage_location('1. ruby/rails')
73
+ when :rspec
74
+ rspec_examples
75
+ prompt_for_usage_location('2. rspec')
76
+ when :haml
77
+ haml_examples
78
+ prompt_for_usage_location('6. haml')
79
+ when :js
80
+ js_examples
81
+ prompt_for_usage_location('4. javascript (plain)')
82
+ when :vue
83
+ vue_examples
84
+ prompt_for_usage_location('3. javascript (vue)')
85
+ when :vue_template
86
+ vue_template_examples
87
+ prompt_for_usage_location('5. vue template')
88
+ when :gdk
89
+ gdk_examples
90
+ prompt_for_usage_location('7. Manual testing in GDK')
91
+ when :tableau
92
+ service_ping_dashboard_examples
93
+ prompt_for_usage_location('8. Data verification in Tableau')
94
+ when :other_event
95
+ self.class.new(cli).run
96
+ when :exit
97
+ cli.say(feedback_notice)
98
+ end
99
+ end
100
+
101
+ def rails_examples
102
+ identifier_args = identifiers.map do |identifier|
103
+ " #{identifier}: #{identifier}"
104
+ end
105
+
106
+ property_args = format_additional_properties do |property, value, description|
107
+ " #{property}: #{value}, # #{description}"
108
+ end
109
+
110
+ if property_args.any?
111
+ property_args.last.sub!(',', '')
112
+ property_arg = " additional_properties: {\n#{property_args.join("\n")}\n }"
113
+ end
114
+
115
+ args = ["'#{action}'", *identifier_args, property_arg].compact.join(",\n")
116
+ args = "\n #{args}\n" if args.lines.count > 1
117
+
118
+ cli.say format_warning <<~TEXT
119
+ #{divider}
120
+ #{format_help('# RAILS')}
121
+
122
+ include Gitlab::InternalEventsTracking
123
+
124
+ track_internal_event(#{args})
125
+
126
+ #{divider}
127
+ TEXT
128
+ end
129
+
130
+ def rspec_examples
131
+ cli.say format_warning <<~TEXT
132
+ #{divider}
133
+ #{format_help('# RSPEC')}
134
+
135
+ #{format_warning(rspec_composable_matchers)}
136
+
137
+ #{divider}
138
+ TEXT
139
+ end
140
+
141
+ def haml_examples
142
+ property_args = format_additional_properties do |property, value, _|
143
+ "event_#{property}: #{value}"
144
+ end
145
+
146
+ args = ["event_tracking: '#{action}'", *property_args].join(', ')
147
+
148
+ cli.say <<~TEXT
149
+ #{divider}
150
+ #{format_help('# HAML -- ON-CLICK')}
151
+
152
+ .inline-block{ #{format_warning("data: { #{args} }")} }
153
+ = _('Important Text')
154
+
155
+ #{divider}
156
+ #{format_help('# HAML -- COMPONENT ON-CLICK')}
157
+
158
+ = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { #{args} }")} })
159
+
160
+ #{divider}
161
+ #{format_help('# HAML -- COMPONENT ON-LOAD')}
162
+
163
+ = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking_load: true, #{args} }")} })
164
+
165
+ #{divider}
166
+ TEXT
167
+
168
+ cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
169
+ end
170
+
171
+ def vue_template_examples
172
+ on_click_args = template_formatted_args('data-event-tracking', indent: 2)
173
+ on_load_args = template_formatted_args('data-event-tracking-load', indent: 2)
174
+
175
+ cli.say <<~TEXT
176
+ #{divider}
177
+ #{format_help('// VUE TEMPLATE -- ON-CLICK')}
178
+
179
+ <script>
180
+ import { GlButton } from '@gitlab/ui';
181
+
182
+ export default {
183
+ components: { GlButton }
184
+ };
185
+ </script>
186
+
187
+ <template>
188
+ <gl-button#{on_click_args}
189
+ Click Me
190
+ </gl-button>
191
+ </template>
192
+
193
+ #{divider}
194
+ #{format_help('// VUE TEMPLATE -- ON-LOAD')}
195
+
196
+ <script>
197
+ import { GlButton } from '@gitlab/ui';
198
+
199
+ export default {
200
+ components: { GlButton }
201
+ };
202
+ </script>
203
+
204
+ <template>
205
+ <gl-button#{on_load_args}
206
+ Click Me
207
+ </gl-button>
208
+ </template>
209
+
210
+ #{divider}
211
+ TEXT
212
+
213
+ cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
214
+ end
215
+
216
+ def js_examples
217
+ args = js_formatted_args(indent: 2)
218
+
219
+ cli.say <<~TEXT
220
+ #{divider}
221
+ #{format_help('// FRONTEND -- RAW JAVASCRIPT')}
222
+
223
+ #{format_warning("import { InternalEvents } from '~/tracking';")}
224
+
225
+ export const performAction = () => {
226
+ #{format_warning("InternalEvents.trackEvent#{args}")}
227
+
228
+ return true;
229
+ };
230
+
231
+ #{divider}
232
+ TEXT
233
+
234
+ # https://docs.snowplow.io/docs/understanding-your-pipeline/schemas/
235
+ cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
236
+ end
237
+
238
+ def vue_examples
239
+ args = js_formatted_args(indent: 6)
240
+
241
+ cli.say <<~TEXT
242
+ #{divider}
243
+ #{format_help('// VUE')}
244
+
245
+ <script>
246
+ #{format_warning("import { InternalEvents } from '~/tracking';")}
247
+ import { GlButton } from '@gitlab/ui';
248
+
249
+ #{format_warning('const trackingMixin = InternalEvents.mixin();')}
250
+
251
+ export default {
252
+ components: { GlButton },
253
+ #{format_warning('mixins: [trackingMixin]')},
254
+ methods: {
255
+ performAction() {
256
+ #{format_warning("this.trackEvent#{args}")}
257
+ },
258
+ },
259
+ };
260
+ </script>
261
+
262
+ <template>
263
+ <gl-button @click=performAction>Click Me</gl-button>
264
+ </template>
265
+
266
+ #{divider}
267
+ TEXT
268
+
269
+ cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n")
270
+ end
271
+
272
+ private
273
+
274
+ def action
275
+ event['action']
276
+ end
277
+
278
+ def identifiers
279
+ Array(event['identifiers']).tap do |ids|
280
+ # We always auto assign namespace if project is provided
281
+ ids.delete('namespace') if ids.include?('project')
282
+ end
283
+ end
284
+
285
+ def additional_properties
286
+ Array(event['additional_properties'])
287
+ end
288
+
289
+ def format_additional_properties
290
+ additional_properties.map do |property, details|
291
+ example_value = PROPERTY_EXAMPLES.fetch(property, DEFAULT_PROPERTY_VALUE)
292
+ description = details['description'] || 'TODO'
293
+
294
+ yield(property, example_value, description)
295
+ end
296
+ end
297
+
298
+ def rspec_composable_matchers
299
+ identifier_args = identifiers.map do |identifier|
300
+ " #{identifier}: #{identifier}"
301
+ end
302
+
303
+ property_args = format_additional_properties do |property, value|
304
+ " #{property}: #{value}"
305
+ end
306
+
307
+ if property_args.any?
308
+ property_args = format_prefix ' ', <<~TEXT.chomp
309
+ additional_properties: {
310
+ #{property_args.join(",\n")}
311
+ }
312
+ TEXT
313
+ end
314
+
315
+ args = []
316
+ args += [*identifier_args] unless identifier_args.empty?
317
+ args += [*property_args] unless property_args.empty?
318
+ args = args.join(",\n")
319
+
320
+ unless args.empty?
321
+ args = <<~TEXT.chomp
322
+ .with(
323
+ #{args}
324
+ )
325
+ TEXT
326
+ end
327
+
328
+ non_sum_metrics_list = []
329
+ sum_metrics_list = []
330
+ related_metrics.map do |metric|
331
+ target_array = metric.operator == 'sum(value)' ? sum_metrics_list : non_sum_metrics_list
332
+ target_array << " '#{metric.key_path}'"
333
+ end
334
+
335
+ metrics_list = ''
336
+ unless non_sum_metrics_list.empty?
337
+ metrics_list += <<~TEXT.chomp
338
+ .and increment_usage_metrics(
339
+ #{non_sum_metrics_list.join(",\n")}
340
+ )
341
+ TEXT
342
+ end
343
+
344
+ unless sum_metrics_list.empty?
345
+ metrics_list += <<~TEXT.chomp
346
+ .and increment_usage_metrics(
347
+ #{sum_metrics_list.join(",\n")}
348
+ ).by(#{PROPERTY_EXAMPLES.fetch('value')})
349
+ TEXT
350
+ end
351
+
352
+ <<~TEXT.chomp
353
+ it "triggers an internal event" do
354
+ expect { subject }.to trigger_internal_events('#{action}')#{args}#{metrics_list}
355
+ end
356
+ TEXT
357
+ end
358
+
359
+ def js_formatted_args(indent:)
360
+ return "('#{action}');" if additional_properties.none?
361
+
362
+ property_args = format_additional_properties do |property, value, description|
363
+ " #{property}: #{value}, // #{description}"
364
+ end
365
+
366
+ [
367
+ '(',
368
+ " '#{action}',",
369
+ ' {',
370
+ *property_args,
371
+ ' },',
372
+ ');'
373
+ ].join("\n#{' ' * indent}")
374
+ end
375
+
376
+ def service_ping_metrics_info
377
+ product_group = related_metrics.map(&:product_group).uniq
378
+
379
+ <<~TEXT
380
+ #{product_group.map { |group| "#{group}: #{format_info(metric_exploration_group_path(group, find_stage(group)))}" }.join("\n")}
381
+
382
+ #{divider}
383
+ #{format_help("# METRIC TRENDS -- view data for a service ping metric for #{event.action}")}
384
+
385
+ #{related_metrics.map { |metric| "#{metric.key_path}: #{format_info(metric_trend_path(metric.key_path))}" }.join("\n")}
386
+ TEXT
387
+ end
388
+
389
+ def service_ping_no_metric_info
390
+ <<~TEXT
391
+ #{format_help("# Warning: There are no metrics for #{event.action} yet.")}
392
+ #{event.product_group}: #{format_info(metric_exploration_group_path(event.product_group, find_stage(event.product_group)))}
393
+ TEXT
394
+ end
395
+
396
+ def template_formatted_args(data_attr, indent:)
397
+ return " #{data_attr}=\"#{action}\">" if additional_properties.none?
398
+
399
+ spacer = ' ' * indent
400
+ property_args = format_additional_properties do |property, value, _|
401
+ " data-event-#{property}=#{value.tr("'", '"')}"
402
+ end
403
+
404
+ args = [
405
+ '',
406
+ " #{data_attr}=\"#{action}\"",
407
+ *property_args
408
+ ].join("\n#{spacer}")
409
+
410
+ "#{format_warning(args)}\n#{spacer}>"
411
+ end
412
+
413
+ def related_metrics
414
+ cli.global.metrics.select { |metric| metric.actions&.include?(event.action) }
415
+ end
416
+
417
+ def service_ping_dashboard_examples
418
+ cli.say <<~TEXT
419
+ #{divider}
420
+ #{format_help('# GROUP DASHBOARDS -- view all service ping metrics for a specific group')}
421
+
422
+ #{related_metrics.any? ? service_ping_metrics_info : service_ping_no_metric_info}
423
+ #{divider}
424
+ Note: The metric dashboard links can also be accessed from #{format_info('https://metrics.gitlab.com/')}
425
+
426
+ Not what you're looking for? Check this doc:
427
+ - #{format_info('https://docs.gitlab.com/ee/development/internal_analytics/#data-discovery')}
428
+
429
+ TEXT
430
+ end
431
+
432
+ def gdk_examples
433
+ key_paths = related_metrics.map(&:key_path)
434
+
435
+ cli.say <<~TEXT
436
+ #{divider}
437
+ #{format_help('# TERMINAL -- monitor events & changes to service ping metrics as they occur')}
438
+
439
+ 1. From `gitlab/` directory, run the monitor script:
440
+
441
+ #{format_warning("bin/rails runner scripts/internal_events/monitor.rb #{event.action}")}
442
+
443
+ 2. View metric updates within the terminal
444
+
445
+ 3. [Optional] Configure gdk with snowplow micro to see individual events: https://gitlab-org.gitlab.io/gitlab-development-kit/howto/snowplow_micro/
446
+
447
+ #{divider}
448
+ #{format_help('# RAILS CONSOLE -- generate service ping payload, including most recent usage data')}
449
+
450
+ #{format_warning("require_relative 'spec/support/helpers/service_ping_helpers.rb'")}
451
+
452
+ #{format_help('# Get current value of a metric')}
453
+ #{
454
+ if key_paths.any?
455
+ key_paths.map { |key_path| format_warning("ServicePingHelpers.get_current_usage_metric_value('#{key_path}')") }.join("\n")
456
+ else
457
+ format_help("# Warning: There are no metrics for #{event.action} yet. When there are, replace <key_path> below.\n") +
458
+ format_warning('ServicePingHelpers.get_current_usage_metric_value(<key_path>)')
459
+ end
460
+ }
461
+
462
+ #{format_help('# View entire service ping payload')}
463
+ #{format_warning('ServicePingHelpers.get_current_service_ping_payload')}
464
+ #{divider}
465
+ Need to test something else? Check these docs:
466
+ - https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/local_setup_and_debugging.html
467
+ - https://docs.gitlab.com/ee/development/internal_analytics/service_ping/troubleshooting.html
468
+ - https://docs.gitlab.com/ee/development/internal_analytics/review_guidelines.html
469
+
470
+ TEXT
471
+ end
472
+ end
473
+ end
474
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabInternalEventsCli
4
+ class GitlabPrompt < SimpleDelegator
5
+ def global
6
+ @global ||= GlobalState.new
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers for shared state across all CLI flows
4
+ module GitlabInternalEventsCli
5
+ class GlobalState
6
+ def events
7
+ @events ||= load_definitions(
8
+ Event,
9
+ GitlabInternalEventsCli::NEW_EVENT_FIELDS,
10
+ all_event_paths
11
+ )
12
+ end
13
+
14
+ def metrics
15
+ @metrics ||= begin
16
+ loaded_files = load_definitions(
17
+ Metric,
18
+ GitlabInternalEventsCli::NEW_METRIC_FIELDS,
19
+ all_metric_paths
20
+ )
21
+
22
+ loaded_files.flat_map do |metric|
23
+ # copy logic of Gitlab::Usage::MetricDefinition
24
+ next metric unless metric.time_frame.is_a?(Array)
25
+
26
+ metric.time_frame.map do |time_frame|
27
+ current_metric = metric.dup
28
+ current_metric.time_frame = time_frame
29
+ current_metric.key_path = TimeFramedKeyPath.build(current_metric.key_path, time_frame)
30
+ current_metric
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ def reload_definitions
37
+ @events = nil
38
+ @metrics = nil
39
+ end
40
+
41
+ private
42
+
43
+ def all_event_paths
44
+ GitlabInternalEventsCli.configuration.resolve_event_paths
45
+ end
46
+
47
+ def all_metric_paths
48
+ GitlabInternalEventsCli.configuration.resolve_metric_paths
49
+ end
50
+
51
+ def load_definitions(klass, fields, paths)
52
+ paths.filter_map do |path|
53
+ details = YAML.safe_load_file(path)
54
+ relevant_fields = fields.map(&:to_s)
55
+
56
+ relative_path = path.sub("#{GitlabInternalEventsCli.configuration.project_root}/", '')
57
+ klass.parse(**details.slice(*relevant_fields), file_path: relative_path)
58
+ rescue StandardError => e
59
+ puts "\n\n\e[31mEncountered an error while loading #{path}: #{e.message}\e[0m\n\n\n"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers related to configuration of TTY::Prompt prompts
4
+ module GitlabInternalEventsCli
5
+ module Helpers
6
+ module CliInputs
7
+ def prompt_for_array_selection(message, choices, default = nil, **opts, &formatter)
8
+ formatter ||= ->(choice) { choice.sort.join(', ') }
9
+
10
+ choices = choices.map do |choice|
11
+ { name: formatter.call(choice), value: choice }
12
+ end
13
+
14
+ cli.select(message, choices, **select_opts, **opts) do |menu|
15
+ menu.enum '.'
16
+ menu.default formatter.call(default) if default
17
+ end
18
+ end
19
+
20
+ # Prompts the user to input text. Prefer this over calling cli#ask directly (so styling is consistent).
21
+ #
22
+ #
23
+ # @return [String, nil] user-provided text
24
+ # @param message [String] a single line prompt/question or last line of a prompt
25
+ # @param value [String, nil] prepopulated as the answer which user can accept/modify
26
+ # @option multiline [Boolean] indicates that any help text or prompt prefix will be printed on another line
27
+ # before calling #prompt_for_text --> ex) see MetricDefiner#prompt_for_description
28
+ # @yield [TTY::Prompt::Question]
29
+ # @see https://github.com/piotrmurach/tty-prompt?tab=readme-ov-file#21-ask
30
+ def prompt_for_text(message, value = nil, multiline: false, **opts)
31
+ prompt = message.dup # mutable for concat in #ask callback
32
+
33
+ options = { **input_opts, **opts }
34
+ value ||= options.delete(:value)
35
+ options.delete(:prefix) if multiline
36
+
37
+ cli.ask(prompt, **options) do |q|
38
+ q.value(value) if value
39
+
40
+ yield q if block_given?
41
+
42
+ if multiline
43
+ # wrap error messages so they render nicely with prompt
44
+ q.messages.each do |key, error|
45
+ closing_text = "\n#{format_error('<<|')}" if error.lines.length > 1
46
+
47
+ q.messages[key] = [error, closing_text, "\n\n\n"].join
48
+ end
49
+ else
50
+ # append help text only if this line includes the formatted 'prompt' prefix,
51
+ # otherwise depend on the caller to print the help text if needed
52
+ prompt.concat(" #{q.required ? input_required_text : input_optional_text(value)}")
53
+ end
54
+ end
55
+ end
56
+
57
+ def input_opts
58
+ { prefix: format_prompt('Input text: ') }
59
+ end
60
+
61
+ def yes_no_opts
62
+ { prefix: format_prompt('Yes/No: ') }
63
+ end
64
+
65
+ # Provide to cli#select as kwargs for consistent style/ux
66
+ def select_opts
67
+ {
68
+ prefix: format_prompt('Select one: '),
69
+ cycle: true,
70
+ show_help: :always,
71
+ # Strip colors so #format_selection is applied uniformly
72
+ active_color: ->(choice) { format_selection(clear_format(choice)) }
73
+ }
74
+ end
75
+
76
+ # Provide to cli#multiselect as kwargs for consistent style/ux
77
+ def multiselect_opts
78
+ {
79
+ **select_opts,
80
+ prefix: format_prompt('Select multiple: '),
81
+ min: 1,
82
+ help: '(Space to select, Enter to submit, ↑/↓/←/→ to move, Ctrl+A|R to select all|none, letters to filter)'
83
+ }
84
+ end
85
+
86
+ # Accepts a number of lines occupied by text, so remaining
87
+ # screen real estate can be filled with select options
88
+ def filter_opts(header_size: nil)
89
+ {
90
+ filter: true,
91
+ per_page: header_size ? [(window_height - header_size), 10].max : 30
92
+ }
93
+ end
94
+
95
+ # Creates divider to be passed to a select or multiselect
96
+ # as a menu item. Use with #format_disabled_options_as_dividers
97
+ # for best formatting.
98
+ def select_option_divider(text)
99
+ { name: "-- #{text} --", value: nil, disabled: '' }
100
+ end
101
+
102
+ # Styling all disabled options in a menu without indication
103
+ # of being a selectable option
104
+ # @param select_menu [TTY::Prompt]
105
+ def format_disabled_options_as_dividers(select_menu)
106
+ select_menu.symbols(cross: '')
107
+ end
108
+
109
+ # For use when menu options are disabled by being grayed out
110
+ def disabled_format_callback
111
+ proc { |menu| menu.symbols(cross: format_help('✘')) }
112
+ end
113
+
114
+ # Help text to use with required, multiline cli#ask prompts.
115
+ # Otherwise, prefer #prompt_for_text.
116
+ def input_required_text
117
+ format_help('(leave blank for help)')
118
+ end
119
+
120
+ # Help text to use with optional, multiline cli#ask prompts.
121
+ # Otherwise, prefer #prompt_for_text.
122
+ def input_optional_text(value)
123
+ format_help("(enter to #{value ? 'submit' : 'skip'})")
124
+ end
125
+
126
+ def disableable_option(value:, disabled:, name: nil)
127
+ should_disable = yield
128
+ name ||= value
129
+
130
+ {
131
+ value: value,
132
+ name: (should_disable ? format_help(name) : name),
133
+ disabled: (disabled if should_disable)
134
+ }
135
+ end
136
+ end
137
+ end
138
+ end