support_desk 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/.rubocop.yml +32 -0
- data/.simplecov +53 -0
- data/Appraisals +18 -0
- data/CHANGELOG.md +135 -0
- data/LICENSE.txt +21 -0
- data/README.md +469 -0
- data/Rakefile +55 -0
- data/app/assets/stylesheets/support_desk.css +242 -0
- data/app/controllers/support_desk/application_controller.rb +92 -0
- data/app/controllers/support_desk/console/application_controller.rb +18 -0
- data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
- data/app/controllers/support_desk/tickets_controller.rb +119 -0
- data/app/helpers/support_desk/engine_helper.rb +196 -0
- data/app/views/chats/slots/_inbox_top.html.erb +31 -0
- data/app/views/chats/slots/_locked_composer.html.erb +18 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
- data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
- data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
- data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
- data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
- data/app/views/support_desk/console/tickets/index.html.erb +37 -0
- data/app/views/support_desk/console/tickets/show.html.erb +45 -0
- data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
- data/app/views/support_desk/tickets/_door.html.erb +12 -0
- data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
- data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
- data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
- data/app/views/support_desk/tickets/_write.html.erb +56 -0
- data/app/views/support_desk/tickets/index.html.erb +53 -0
- data/app/views/support_desk/tickets/new.html.erb +14 -0
- data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
- data/config/console_routes.rb +16 -0
- data/config/locales/support_desk.console.en.yml +93 -0
- data/config/locales/support_desk.console.es.yml +93 -0
- data/config/locales/support_desk.en.yml +79 -0
- data/config/locales/support_desk.es.yml +84 -0
- data/config/routes.rb +24 -0
- data/context7.json +4 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/support_desk/console_generator.rb +94 -0
- data/lib/generators/support_desk/install_generator.rb +86 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
- data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
- data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
- data/lib/generators/support_desk/templates/initializer.rb +186 -0
- data/lib/generators/support_desk/views_generator.rb +50 -0
- data/lib/support_desk/configuration.rb +675 -0
- data/lib/support_desk/console.rb +487 -0
- data/lib/support_desk/console_engine.rb +63 -0
- data/lib/support_desk/console_routes.rb +107 -0
- data/lib/support_desk/context_card.rb +90 -0
- data/lib/support_desk/current.rb +26 -0
- data/lib/support_desk/doctor.rb +220 -0
- data/lib/support_desk/engine.rb +141 -0
- data/lib/support_desk/errors.rb +49 -0
- data/lib/support_desk/events.rb +122 -0
- data/lib/support_desk/macros.rb +73 -0
- data/lib/support_desk/models/application_record.rb +11 -0
- data/lib/support_desk/models/assignment.rb +83 -0
- data/lib/support_desk/models/concerns/agent.rb +79 -0
- data/lib/support_desk/models/concerns/requester.rb +71 -0
- data/lib/support_desk/models/concerns/supportable.rb +88 -0
- data/lib/support_desk/models/desk.rb +101 -0
- data/lib/support_desk/models/event.rb +72 -0
- data/lib/support_desk/models/ticket.rb +1124 -0
- data/lib/support_desk/queue.rb +171 -0
- data/lib/support_desk/summary.rb +70 -0
- data/lib/support_desk/test_helpers.rb +137 -0
- data/lib/support_desk/timeline.rb +104 -0
- data/lib/support_desk/topic.rb +290 -0
- data/lib/support_desk/topic_tree.rb +214 -0
- data/lib/support_desk/version.rb +5 -0
- data/lib/support_desk/wizard.rb +392 -0
- data/lib/support_desk.rb +288 -0
- metadata +229 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model/type"
|
|
4
|
+
|
|
5
|
+
module SupportDesk
|
|
6
|
+
# What a ticket is about, as a value object.
|
|
7
|
+
#
|
|
8
|
+
# Topics are a tree defined in code (see SupportDesk::TopicTree), frozen at
|
|
9
|
+
# boot, and stored on the ticket as a stable path — `"payments/withdrawal"`
|
|
10
|
+
# — so seeds, exports and metrics stay readable and re-ordering the tree
|
|
11
|
+
# never rewrites a single row.
|
|
12
|
+
#
|
|
13
|
+
# ticket.topic # => #<SupportDesk::Topic payments/withdrawal>
|
|
14
|
+
# ticket.topic == :ride # compares against the path
|
|
15
|
+
# ticket.topic.under?(:payments) # self or any descendant
|
|
16
|
+
# ticket.topic.label # "Una retirada" (i18n)
|
|
17
|
+
# ticket.topic.to_s # "payments/withdrawal"
|
|
18
|
+
#
|
|
19
|
+
# Immutable: every node is frozen once the tree is built.
|
|
20
|
+
class Topic
|
|
21
|
+
# Options a branch passes down to its descendants unless they say
|
|
22
|
+
# otherwise. Presentation (label, ask, prefill) is never inherited — only
|
|
23
|
+
# the options that carry behaviour.
|
|
24
|
+
INHERITED_OPTIONS = %i[about candidates desk route_to priority only retired].freeze
|
|
25
|
+
|
|
26
|
+
SUBJECT_MODES = %i[required optional none].freeze
|
|
27
|
+
PRIORITIES = { normal: 0, high: 1, urgent: 2 }.freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :key, :path, :parent, :children, :options
|
|
30
|
+
|
|
31
|
+
# Nodes are built by the topics DSL, never by hand.
|
|
32
|
+
def initialize(key:, parent: nil, **options)
|
|
33
|
+
@key = key.to_sym
|
|
34
|
+
@parent = parent
|
|
35
|
+
@path = [ parent&.path, @key ].compact.join("/")
|
|
36
|
+
@options = options.freeze
|
|
37
|
+
@children = []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# --- Tree shape -----------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
# Top level, nothing below it, or something below it.
|
|
43
|
+
def root? = parent.nil?
|
|
44
|
+
# Nothing below it.
|
|
45
|
+
def leaf? = children.empty?
|
|
46
|
+
# Something below it.
|
|
47
|
+
def branch? = !leaf?
|
|
48
|
+
|
|
49
|
+
# Every ancestor, closest first.
|
|
50
|
+
def ancestors
|
|
51
|
+
parent ? [ parent, *parent.ancestors ] : []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# This node and every node below it, depth first.
|
|
55
|
+
def self_and_descendants
|
|
56
|
+
[ self, *children.flat_map(&:self_and_descendants) ]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# --- Identity -------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def to_s = path
|
|
62
|
+
# The path, for URLs.
|
|
63
|
+
def to_param = path
|
|
64
|
+
|
|
65
|
+
# True for the null object returned for paths that aren't in the tree.
|
|
66
|
+
def unknown? = false
|
|
67
|
+
|
|
68
|
+
# Compares against a Topic, a path String, or a Symbol key/path, so
|
|
69
|
+
# `ticket.topic == :ride` reads the way you'd say it.
|
|
70
|
+
def ==(other)
|
|
71
|
+
case other
|
|
72
|
+
when Topic then path == other.path
|
|
73
|
+
when String, Symbol then path == other.to_s
|
|
74
|
+
else false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
alias eql? ==
|
|
78
|
+
|
|
79
|
+
# Hashes by path, so topics work as Hash keys.
|
|
80
|
+
def hash = path.hash
|
|
81
|
+
|
|
82
|
+
# True when this topic is +other+ or lives under it:
|
|
83
|
+
# `topic("payments/withdrawal").under?(:payments)`.
|
|
84
|
+
def under?(other)
|
|
85
|
+
other = other.to_s
|
|
86
|
+
path == other || path.start_with?("#{other}/")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# --- Presentation ---------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
# The human name of this node, from `label:` or i18n
|
|
92
|
+
# (`support_desk.topics.<dotted path>.label`), falling back to a
|
|
93
|
+
# humanized key so a missing translation is never a missing screen.
|
|
94
|
+
def label
|
|
95
|
+
case options[:label]
|
|
96
|
+
when String then options[:label]
|
|
97
|
+
when Symbol then translate(i18n_key_for(options[:label], :label)) || key.to_s.humanize
|
|
98
|
+
else translate(i18n_key(:label)) || key.to_s.humanize
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The whole branch spelled out: "Pagos › Una retirada".
|
|
103
|
+
def full_label
|
|
104
|
+
[ *ancestors.reverse, self ].map(&:label).join(" › ")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The picker prompt ("¿Con qué viaje necesitas ayuda?").
|
|
108
|
+
def ask
|
|
109
|
+
options[:ask].is_a?(String) ? options[:ask] : translate(i18n_key(:ask))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The composer placeholder for tickets opened under this topic.
|
|
113
|
+
def placeholder
|
|
114
|
+
options[:placeholder].is_a?(String) ? options[:placeholder] : translate(i18n_key(:placeholder))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Deterministic text dropped into the composer. `prefill:` may be a String
|
|
118
|
+
# or a callable taking the chosen subject.
|
|
119
|
+
def prefill(subject = nil)
|
|
120
|
+
value = options[:prefill]
|
|
121
|
+
value.respond_to?(:call) ? value.call(subject) : value
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# An optional icon key for hosts that render one; the gem never does.
|
|
125
|
+
def icon = options[:icon]
|
|
126
|
+
|
|
127
|
+
# --- Behaviour ------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
# The `supportable` classes a ticket under this topic may attach, as
|
|
130
|
+
# Classes. Resolved lazily from stored names so the tree survives
|
|
131
|
+
# Zeitwerk reloads.
|
|
132
|
+
def about
|
|
133
|
+
Array(inherited_or_own(:about)).map { |klass| klass.is_a?(String) ? klass.constantize : klass }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Whether this topic attaches +klass+ (or an instance of it).
|
|
137
|
+
def about?(klass)
|
|
138
|
+
about.any? { |candidate| klass.is_a?(Class) ? klass <= candidate : klass.class <= candidate }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Stored (unconstantized) `about:` class names — what boot validation and
|
|
142
|
+
# `doctor` check, without forcing the classes to load.
|
|
143
|
+
def about_class_names
|
|
144
|
+
Array(inherited_or_own(:about)).map { |klass| klass.is_a?(String) ? klass : klass.name }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# :required — the wizard insists on a subject; :optional — it offers
|
|
148
|
+
# "none of these"; :none — free form. Defaults to :optional when the
|
|
149
|
+
# topic attaches anything at all, :none when it doesn't.
|
|
150
|
+
def subject_mode
|
|
151
|
+
options.fetch(:subject) { about.any? ? :optional : :none }.to_sym
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Whether the wizard insists on a subject, or asks for none at all.
|
|
155
|
+
def subject_required? = subject_mode == :required
|
|
156
|
+
def free_form? = subject_mode == :none
|
|
157
|
+
|
|
158
|
+
# Whether this node is the tree's declared way out — what `other`
|
|
159
|
+
# builds, or any leaf a host declared `free_form: true`.
|
|
160
|
+
#
|
|
161
|
+
# Deliberately NOT the same question as #free_form?: plenty of topics
|
|
162
|
+
# take no subject ("app feedback", "safety") without being the place a
|
|
163
|
+
# ticket should land when nobody picked anything.
|
|
164
|
+
def catch_all? = !!options[:free_form]
|
|
165
|
+
|
|
166
|
+
# The picker's candidate records for +requester+: the topic's
|
|
167
|
+
# `candidates:` proc, else each `about:` class's own
|
|
168
|
+
# `.support_candidates_for`.
|
|
169
|
+
def candidates_for(requester)
|
|
170
|
+
proc = inherited_or_own(:candidates)
|
|
171
|
+
return proc.call(requester) if proc.respond_to?(:call)
|
|
172
|
+
|
|
173
|
+
about.first&.support_candidates_for(requester)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Whether this node shows in the wizard for +requester+ (`only:` proc,
|
|
177
|
+
# and retired nodes never do).
|
|
178
|
+
def visible_for?(requester)
|
|
179
|
+
return false if retired?
|
|
180
|
+
|
|
181
|
+
only = inherited_or_own(:only)
|
|
182
|
+
only.respond_to?(:call) ? !!only.call(requester) : true
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# 0 normal, 1 high, 2 urgent — the integer a ticket is opened with.
|
|
186
|
+
def priority
|
|
187
|
+
PRIORITIES.fetch((inherited_or_own(:priority) || :normal).to_sym, 0)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# An agent scope or proc that wins over desk routing for this subtree.
|
|
191
|
+
def route_to = inherited_or_own(:route_to)
|
|
192
|
+
|
|
193
|
+
# The desk key tickets under this topic belong to.
|
|
194
|
+
def desk_key
|
|
195
|
+
(inherited_or_own(:desk) || :default).to_sym
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Hidden from the wizard; the label still resolves for historic tickets.
|
|
199
|
+
def retired? = !!inherited_or_own(:retired)
|
|
200
|
+
|
|
201
|
+
# The topic, in one line.
|
|
202
|
+
def inspect = "#<SupportDesk::Topic #{path}>"
|
|
203
|
+
|
|
204
|
+
# Freeze this node and everything under it — the tree is built once, at
|
|
205
|
+
# boot, and then it is a value.
|
|
206
|
+
def deep_freeze # :nodoc:
|
|
207
|
+
children.each(&:deep_freeze)
|
|
208
|
+
children.freeze
|
|
209
|
+
freeze
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Used by the DSL while the tree is being built.
|
|
213
|
+
def add_child(child) # :nodoc:
|
|
214
|
+
children << child
|
|
215
|
+
child
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def inherited_or_own(option)
|
|
221
|
+
return options[option] if options.key?(option)
|
|
222
|
+
return nil unless INHERITED_OPTIONS.include?(option)
|
|
223
|
+
|
|
224
|
+
parent&.send(:inherited_or_own, option)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def i18n_key(suffix)
|
|
228
|
+
"support_desk.topics.#{path.tr("/", ".")}.#{suffix}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def i18n_key_for(symbol, suffix)
|
|
232
|
+
symbol.to_s.include?(".") ? symbol.to_s : "support_desk.topics.#{symbol}.#{suffix}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def translate(key)
|
|
236
|
+
return nil unless defined?(I18n)
|
|
237
|
+
|
|
238
|
+
I18n.t(key, default: nil)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The null object for a path no tree knows: a retired node whose label
|
|
242
|
+
# still reads well, so a historic ticket never blows up a view.
|
|
243
|
+
#
|
|
244
|
+
# ticket.topic.unknown? # => true
|
|
245
|
+
# ticket.topic.label # => "Payments/withdrawal"
|
|
246
|
+
class Unknown < Topic
|
|
247
|
+
def initialize(path)
|
|
248
|
+
super(key: path.to_s.split("/").last || path.to_s)
|
|
249
|
+
@path = path.to_s
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def unknown? = true
|
|
253
|
+
def retired? = true
|
|
254
|
+
def label = translate(i18n_key(:label)) || @path.tr("/", " ").humanize
|
|
255
|
+
def about = []
|
|
256
|
+
def subject_mode = :none
|
|
257
|
+
def inspect = "#<SupportDesk::Topic::Unknown #{path}>"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The ActiveModel attribute type behind `attribute :topic`: casts
|
|
261
|
+
# Symbol | String | Topic on the way in, hands back a Topic on the way
|
|
262
|
+
# out, and serializes to the path — so `where(topic: :ride)` works and a
|
|
263
|
+
# view never sees a bare string.
|
|
264
|
+
class Type < ActiveModel::Type::Value
|
|
265
|
+
def type = :string
|
|
266
|
+
|
|
267
|
+
def cast(value)
|
|
268
|
+
case value
|
|
269
|
+
when nil then nil
|
|
270
|
+
when Topic then value
|
|
271
|
+
else SupportDesk.find_topic(value.to_s)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def serialize(value)
|
|
276
|
+
case value
|
|
277
|
+
when nil then nil
|
|
278
|
+
when Topic then value.path
|
|
279
|
+
else value.to_s
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def deserialize(value) = cast(value)
|
|
284
|
+
|
|
285
|
+
def changed_in_place?(raw_old_value, new_value)
|
|
286
|
+
raw_old_value != serialize(new_value)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "topic"
|
|
4
|
+
|
|
5
|
+
module SupportDesk
|
|
6
|
+
# The tree of topics a desk offers, built once at boot from the `topics`
|
|
7
|
+
# block and frozen:
|
|
8
|
+
#
|
|
9
|
+
# config.topics do
|
|
10
|
+
# topic :ride, about: Ride
|
|
11
|
+
# topic :payments do
|
|
12
|
+
# topic :withdrawal, about: Payouts::Withdrawal
|
|
13
|
+
# end
|
|
14
|
+
# other
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# It is the one object the wizard, the console picker, routing and metrics
|
|
18
|
+
# all read: `tree.find("payments/withdrawal")`, `tree.visible_for(user)`,
|
|
19
|
+
# `tree.leaves`.
|
|
20
|
+
class TopicTree
|
|
21
|
+
include Enumerable
|
|
22
|
+
|
|
23
|
+
# The free-form leaf every tree should have — a taxonomy without an exit
|
|
24
|
+
# is how people pick the wrong topic.
|
|
25
|
+
OTHER_KEY = :other
|
|
26
|
+
|
|
27
|
+
attr_reader :roots
|
|
28
|
+
|
|
29
|
+
# Build a tree from a `topics do … end` block, and freeze it.
|
|
30
|
+
def self.build(&block)
|
|
31
|
+
new.tap { |tree| Builder.new(tree).instance_eval(&block) if block }.freeze!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# An empty tree. Prefer `TopicTree.build`.
|
|
35
|
+
def initialize
|
|
36
|
+
@roots = []
|
|
37
|
+
@index = {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Depth-first over every node.
|
|
41
|
+
def each(&block)
|
|
42
|
+
return to_enum(:each) unless block
|
|
43
|
+
|
|
44
|
+
@roots.each { |root| root.self_and_descendants.each(&block) }
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The node at +path+ ("payments/withdrawal"), or nil.
|
|
49
|
+
def find(path)
|
|
50
|
+
return nil if path.nil?
|
|
51
|
+
|
|
52
|
+
@index[path.to_s]
|
|
53
|
+
end
|
|
54
|
+
alias [] find
|
|
55
|
+
|
|
56
|
+
# The node at +path+, or raise SupportDesk::UnknownTopic.
|
|
57
|
+
def find!(path)
|
|
58
|
+
find(path) || raise(UnknownTopic, "no topic #{path.inspect} in this desk's tree " \
|
|
59
|
+
"(known: #{@index.keys.sort.join(", ")})")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Whether the tree has a node at +path+.
|
|
63
|
+
def include?(path) = !find(path).nil?
|
|
64
|
+
|
|
65
|
+
# Every node a ticket can actually be filed under.
|
|
66
|
+
def leaves = select(&:leaf?)
|
|
67
|
+
|
|
68
|
+
# A desk with no topics at all: every ticket lands free-form.
|
|
69
|
+
def empty? = @roots.empty?
|
|
70
|
+
|
|
71
|
+
# How many nodes, branches included.
|
|
72
|
+
def size = count
|
|
73
|
+
|
|
74
|
+
# The nodes a requester may see at a given level: pass nothing for the
|
|
75
|
+
# top level, a node (or its path) to walk into a branch.
|
|
76
|
+
def visible_for(requester, under: nil)
|
|
77
|
+
nodes = under ? Array(find(under.to_s)&.children) : @roots
|
|
78
|
+
nodes.select { |node| node.visible_for?(requester) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# True when the tree has somewhere to put "something else": a leaf
|
|
82
|
+
# DECLARED as the way out, with `other` or `free_form: true`.
|
|
83
|
+
#
|
|
84
|
+
# Not "any leaf that takes no subject" — a tree can be full of
|
|
85
|
+
# subject-less topics ("safety", "feedback") and still have no honest
|
|
86
|
+
# answer to "none of the above", which is the failure this guards.
|
|
87
|
+
def free_form?
|
|
88
|
+
!free_form_leaf.nil?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The leaf a ticket lands on when nobody picked a topic.
|
|
92
|
+
def free_form_leaf
|
|
93
|
+
candidates = leaves.select { |leaf| leaf.catch_all? && !leaf.retired? }
|
|
94
|
+
candidates.find { |leaf| leaf.key == OTHER_KEY } || candidates.first
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Every `about:` class name mentioned anywhere in the tree — what boot
|
|
98
|
+
# validation and `doctor` check against the supportable registry.
|
|
99
|
+
def about_class_names
|
|
100
|
+
flat_map(&:about_class_names).uniq
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Used by the DSL while the tree is being built.
|
|
104
|
+
def add(node, parent: nil) # :nodoc:
|
|
105
|
+
parent ? parent.add_child(node) : @roots << node
|
|
106
|
+
@index[node.path] = node
|
|
107
|
+
node
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Used by `other false` to take the way out back out.
|
|
111
|
+
def remove(path) # :nodoc:
|
|
112
|
+
node = @index.delete(path.to_s)
|
|
113
|
+
return nil unless node
|
|
114
|
+
|
|
115
|
+
(node.parent ? node.parent.children : @roots).delete(node)
|
|
116
|
+
node
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Freeze the tree and every node in it: built once, at boot.
|
|
120
|
+
def freeze! # :nodoc:
|
|
121
|
+
@roots.each(&:deep_freeze)
|
|
122
|
+
@roots.freeze
|
|
123
|
+
@index.freeze
|
|
124
|
+
freeze
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Every path in the tree, in one line.
|
|
128
|
+
def inspect = "#<SupportDesk::TopicTree #{map(&:path).join(" ")}>"
|
|
129
|
+
|
|
130
|
+
# The `topic` / `other` DSL. It runs once, at boot, inside
|
|
131
|
+
# `config.topics do … end` — no metaprogramming, nothing to grep for
|
|
132
|
+
# later: every node you can see in the initializer is every node there is.
|
|
133
|
+
class Builder
|
|
134
|
+
def initialize(tree, parent: nil)
|
|
135
|
+
@tree = tree
|
|
136
|
+
@parent = parent
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Declare a node. With a block it's a branch and the block declares its
|
|
140
|
+
# children; without one it's a leaf. See SupportDesk::Topic for every
|
|
141
|
+
# option (`about:`, `ask:`, `candidates:`, `subject:`, `prefill:`,
|
|
142
|
+
# `placeholder:`, `only:`, `priority:`, `route_to:`, `desk:`,
|
|
143
|
+
# `retired:`, `icon:`, `label:`).
|
|
144
|
+
def topic(key, **options, &block)
|
|
145
|
+
validate!(key, options)
|
|
146
|
+
|
|
147
|
+
node = Topic.new(key: key, parent: @parent, **normalize(options))
|
|
148
|
+
@tree.add(node, parent: @parent)
|
|
149
|
+
Builder.new(@tree, parent: node).instance_eval(&block) if block
|
|
150
|
+
node
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The free-form leaf ("Otra cosa"). `other false` removes it — the gem
|
|
154
|
+
# warns at boot when a tree ends up without one. A host that wants a
|
|
155
|
+
# differently named way out declares that leaf `free_form: true`.
|
|
156
|
+
def other(enabled = true, **options)
|
|
157
|
+
return @tree.remove(TopicTree::OTHER_KEY.to_s) unless enabled
|
|
158
|
+
|
|
159
|
+
topic(TopicTree::OTHER_KEY, subject: :none, free_form: true, **options)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
KNOWN_OPTIONS = %i[
|
|
165
|
+
label about ask candidates subject prefill placeholder only priority route_to desk retired icon
|
|
166
|
+
free_form
|
|
167
|
+
].freeze
|
|
168
|
+
|
|
169
|
+
def validate!(key, options)
|
|
170
|
+
unless key.is_a?(Symbol) || key.is_a?(String)
|
|
171
|
+
raise ConfigurationError, "topic keys must be symbols, got #{key.inspect}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
unknown = options.keys - KNOWN_OPTIONS
|
|
175
|
+
if unknown.any?
|
|
176
|
+
raise ConfigurationError,
|
|
177
|
+
"unknown topic option#{"s" if unknown.size > 1} #{unknown.map(&:inspect).join(", ")} " \
|
|
178
|
+
"on topic #{key.inspect} — known options are #{KNOWN_OPTIONS.map(&:inspect).join(", ")}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if options.key?(:subject) && !Topic::SUBJECT_MODES.include?(options[:subject].to_sym)
|
|
182
|
+
raise ConfigurationError,
|
|
183
|
+
"topic #{key.inspect}: subject must be one of #{Topic::SUBJECT_MODES.inspect}, " \
|
|
184
|
+
"got #{options[:subject].inspect}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if options.key?(:priority) && !Topic::PRIORITIES.key?(options[:priority].to_sym)
|
|
188
|
+
raise ConfigurationError,
|
|
189
|
+
"topic #{key.inspect}: priority must be one of #{Topic::PRIORITIES.keys.inspect}, " \
|
|
190
|
+
"got #{options[:priority].inspect}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
%i[candidates only prefill].each do |option|
|
|
194
|
+
next unless options.key?(option)
|
|
195
|
+
next if option == :prefill && options[option].is_a?(String)
|
|
196
|
+
next if options[option].respond_to?(:call)
|
|
197
|
+
|
|
198
|
+
raise ConfigurationError,
|
|
199
|
+
"topic #{key.inspect}: #{option} must respond to #call, got #{options[option].inspect}"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Store `about:` as class NAMES so the tree survives Zeitwerk reloads
|
|
204
|
+
# and the initializer can name classes that haven't loaded yet.
|
|
205
|
+
def normalize(options)
|
|
206
|
+
options = options.dup
|
|
207
|
+
if options.key?(:about)
|
|
208
|
+
options[:about] = Array(options[:about]).map { |klass| klass.is_a?(Class) ? klass.name : klass.to_s }
|
|
209
|
+
end
|
|
210
|
+
options
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|