forest_admin_datasource_pylon 1.41.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/.rspec +3 -0
- data/README.md +179 -0
- data/Rakefile +6 -0
- data/forest_admin_datasource_pylon.gemspec +36 -0
- data/lib/forest_admin_datasource_pylon/client/writes.rb +88 -0
- data/lib/forest_admin_datasource_pylon/client.rb +436 -0
- data/lib/forest_admin_datasource_pylon/collections/account/api_filters.rb +43 -0
- data/lib/forest_admin_datasource_pylon/collections/account/schema_definition.rb +91 -0
- data/lib/forest_admin_datasource_pylon/collections/account/serializer.rb +21 -0
- data/lib/forest_admin_datasource_pylon/collections/account.rb +47 -0
- data/lib/forest_admin_datasource_pylon/collections/base_collection.rb +563 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/api_filters.rb +38 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/schema_definition.rb +93 -0
- data/lib/forest_admin_datasource_pylon/collections/contact/serializer.rb +20 -0
- data/lib/forest_admin_datasource_pylon/collections/contact.rb +45 -0
- data/lib/forest_admin_datasource_pylon/collections/cursor_collection.rb +131 -0
- data/lib/forest_admin_datasource_pylon/collections/fetch_all_collection.rb +192 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/api_filters.rb +41 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/id_lookup_reader.rb +88 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb +89 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/schema_definition.rb +122 -0
- data/lib/forest_admin_datasource_pylon/collections/issue/serializer.rb +26 -0
- data/lib/forest_admin_datasource_pylon/collections/issue.rb +128 -0
- data/lib/forest_admin_datasource_pylon/collections/record_serialization.rb +84 -0
- data/lib/forest_admin_datasource_pylon/collections/relation_embedder.rb +101 -0
- data/lib/forest_admin_datasource_pylon/collections/team.rb +49 -0
- data/lib/forest_admin_datasource_pylon/collections/user.rb +69 -0
- data/lib/forest_admin_datasource_pylon/collections/writes.rb +417 -0
- data/lib/forest_admin_datasource_pylon/configuration.rb +84 -0
- data/lib/forest_admin_datasource_pylon/datasource.rb +53 -0
- data/lib/forest_admin_datasource_pylon/issue_enums.rb +20 -0
- data/lib/forest_admin_datasource_pylon/pagination/cursor_walker.rb +103 -0
- data/lib/forest_admin_datasource_pylon/plugins/close_issue/messages.rb +62 -0
- data/lib/forest_admin_datasource_pylon/plugins/close_issue.rb +141 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb +173 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/payload.rb +72 -0
- data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification.rb +175 -0
- data/lib/forest_admin_datasource_pylon/plugins/issue_targets.rb +51 -0
- data/lib/forest_admin_datasource_pylon/query/condition_tree_translator.rb +151 -0
- data/lib/forest_admin_datasource_pylon/query/filter_value.rb +135 -0
- data/lib/forest_admin_datasource_pylon/query/operator_maps.rb +108 -0
- data/lib/forest_admin_datasource_pylon/rate_limiter.rb +139 -0
- data/lib/forest_admin_datasource_pylon/rate_limits.rb +96 -0
- data/lib/forest_admin_datasource_pylon/retry_policy.rb +86 -0
- data/lib/forest_admin_datasource_pylon/schema/custom_fields_introspector.rb +203 -0
- data/lib/forest_admin_datasource_pylon/throttle.rb +21 -0
- data/lib/forest_admin_datasource_pylon/version.rb +3 -0
- data/lib/forest_admin_datasource_pylon.rb +70 -0
- metadata +152 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Plugins
|
|
3
|
+
class CloseIssue
|
|
4
|
+
# What the operator reads once the batch ran. Every id that failed is
|
|
5
|
+
# named: an action reporting a plain success over a batch it only half
|
|
6
|
+
# applied is the one thing the panel cannot recover from.
|
|
7
|
+
module Messages
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def success(succeeded, failed, state)
|
|
11
|
+
[succeeded_phrase(succeeded, state), failed_phrase(failed)].compact.join(' ')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def error(failed, state)
|
|
15
|
+
return "Failed to #{verb(state)} issue #{failed.first.first}: #{failed.first.last}" if failed.size == 1
|
|
16
|
+
|
|
17
|
+
"Failed to #{verb(state)} all #{failed.size} issues. First error: #{failed.first.last}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Worded around the selection rather than around the run: what the
|
|
21
|
+
# operator can act on is how many issues they picked, and the cap is
|
|
22
|
+
# named so the next attempt is a size they can aim for.
|
|
23
|
+
def too_many(count, state)
|
|
24
|
+
"This selection names #{count} Pylon issues, more than the #{CloseIssue::MAX_TARGETS} one run of " \
|
|
25
|
+
'this action covers: Pylon takes one request per issue, and a run stopping halfway would leave ' \
|
|
26
|
+
"part of the selection #{past_verb(state)} without naming which part. Select fewer issues, and " \
|
|
27
|
+
'run the action again on the rest.'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def no_target(field)
|
|
31
|
+
return 'No Pylon issue selected.' if field.nil?
|
|
32
|
+
|
|
33
|
+
"No Pylon issue id found in '#{field}'."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def succeeded_phrase(succeeded, state)
|
|
37
|
+
return nil if succeeded.empty?
|
|
38
|
+
|
|
39
|
+
return "Issue #{succeeded.first} #{past_verb(state)}." if succeeded.size == 1
|
|
40
|
+
|
|
41
|
+
"#{succeeded.size} issues #{past_verb(state)}."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def failed_phrase(failed)
|
|
45
|
+
return nil if failed.empty?
|
|
46
|
+
|
|
47
|
+
"#{failed.size} failed: #{failed.map(&:first).join(", ")}."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A custom status is named as it is, where the state every organization
|
|
51
|
+
# has reads as the verb an operator used to fire the action.
|
|
52
|
+
def verb(state)
|
|
53
|
+
state == IssueEnums::CLOSED_STATE ? 'close' : "move to #{state}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def past_verb(state)
|
|
57
|
+
state == IssueEnums::CLOSED_STATE ? 'closed' : "moved to #{state}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Plugins
|
|
3
|
+
# Moves the selected issues to a state, `closed` unless told otherwise.
|
|
4
|
+
#
|
|
5
|
+
# One variant per scope, where the Zendesk plugin builds four: Zendesk has
|
|
6
|
+
# two terminal statuses to tell apart, Pylon has `closed` and, past it, the
|
|
7
|
+
# custom status slugs an organization defines — which the `state` option
|
|
8
|
+
# takes, rather than a second dimension of action names nobody would read.
|
|
9
|
+
#
|
|
10
|
+
# The state is written through the client rather than through the
|
|
11
|
+
# collection: the action is registered on the host collection, which is
|
|
12
|
+
# rarely PylonIssue, and going through a collection would mean resolving it
|
|
13
|
+
# from a datasource the plugin was not given.
|
|
14
|
+
class CloseIssue < ForestAdminDatasourceCustomizer::Plugins::Plugin
|
|
15
|
+
BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
|
|
16
|
+
ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
|
|
17
|
+
ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException
|
|
18
|
+
|
|
19
|
+
# What one run may write, the budget a filter-driven write already gets:
|
|
20
|
+
# this writes one PATCH per issue too, sequentially, and the action runs
|
|
21
|
+
# inside a single HTTP request. Past this the batch is refused before the
|
|
22
|
+
# first write rather than discovered after the twentieth, where a run cut
|
|
23
|
+
# short by a timeout leaves part of the selection moved and reports which
|
|
24
|
+
# part to nobody -- `apply_state` only names it once the loop returns.
|
|
25
|
+
#
|
|
26
|
+
# The collection cannot bound this: the action writes through the client,
|
|
27
|
+
# being registered on the host collection rather than on PylonIssue.
|
|
28
|
+
MAX_TARGETS = Collections::Writes::MAX_WRITE_REQUESTS
|
|
29
|
+
|
|
30
|
+
SCOPE_KEYS = %i[single bulk].freeze
|
|
31
|
+
SCOPES = { single: ActionScope::SINGLE, bulk: ActionScope::BULK }.freeze
|
|
32
|
+
NAMES = { single: 'Close Pylon issue', bulk: 'Close selected Pylon issues' }.freeze
|
|
33
|
+
NAME_OPTIONS = { single: :action_name, bulk: :bulk_action_name }.freeze
|
|
34
|
+
|
|
35
|
+
def run(_datasource_customizer, collection_customizer = nil, options = {})
|
|
36
|
+
opts = options.is_a?(Hash) ? options : {}
|
|
37
|
+
datasource = opts[:datasource]
|
|
38
|
+
raise ForestException, 'CloseIssue plugin requires :datasource' unless datasource
|
|
39
|
+
raise ForestException, 'CloseIssue plugin requires a collection' unless collection_customizer
|
|
40
|
+
|
|
41
|
+
state = normalize_state(opts[:state])
|
|
42
|
+
|
|
43
|
+
names = normalize_scopes(opts[:scopes]).to_h { |scope_key| [scope_key, name_for(scope_key, opts)] }
|
|
44
|
+
refuse_colliding_names(names)
|
|
45
|
+
|
|
46
|
+
names.each do |scope_key, name|
|
|
47
|
+
collection_customizer.add_action(name,
|
|
48
|
+
build_action(datasource, SCOPES[scope_key], state, opts[:issue_id_field]))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Left unchecked against the states Pylon ships: it takes the slug of a
|
|
55
|
+
# custom status just as well, and refusing one would refuse the very
|
|
56
|
+
# workflow an organization built.
|
|
57
|
+
def normalize_state(value)
|
|
58
|
+
state = value.nil? ? IssueEnums::CLOSED_STATE : value.to_s.strip
|
|
59
|
+
return state unless state.empty?
|
|
60
|
+
|
|
61
|
+
raise ForestException, 'CloseIssue :state cannot be empty.'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Through `to_s`: a value that is neither a string nor a symbol has no
|
|
65
|
+
# `to_sym`, and raising NoMethodError here would hide the unknown-scope
|
|
66
|
+
# error that names what was actually passed.
|
|
67
|
+
def normalize_scopes(value)
|
|
68
|
+
scopes = Array(value).map { |scope| scope.to_s.to_sym }.uniq
|
|
69
|
+
scopes = SCOPE_KEYS if scopes.empty?
|
|
70
|
+
unknown = scopes - SCOPE_KEYS
|
|
71
|
+
return scopes if unknown.empty?
|
|
72
|
+
|
|
73
|
+
raise ForestException,
|
|
74
|
+
"Unknown CloseIssue scopes: #{unknown.join(", ")}. Allowed: #{SCOPE_KEYS.join(", ")}."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Through `to_s`, like the scopes: an action registers under the name it is
|
|
78
|
+
# given, and a Symbol reaching the schema breaks the agent rather than the
|
|
79
|
+
# action -- `GeneratorAction.get_action_slug` calls `strip` on it, and
|
|
80
|
+
# `GeneratorCollection` sorts the names of a collection's actions, which
|
|
81
|
+
# raises as soon as a Symbol sits beside a String. It also makes
|
|
82
|
+
# `:Resolve` and `'Resolve'` the one name they read as.
|
|
83
|
+
def name_for(scope_key, opts)
|
|
84
|
+
(opts[NAME_OPTIONS[scope_key]] || NAMES[scope_key]).to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# `add_action` keys a collection's actions by name, so two variants sharing
|
|
88
|
+
# one leave the second overwriting the first: the single-record action
|
|
89
|
+
# disappears from the panel and what stays answers with the other scope --
|
|
90
|
+
# a bulk action offered on one record, or the reverse. Refused at
|
|
91
|
+
# registration, like the other configurations this plugin will not perform
|
|
92
|
+
# quietly.
|
|
93
|
+
def refuse_colliding_names(names)
|
|
94
|
+
collision = names.values.tally.find { |_name, count| count > 1 }
|
|
95
|
+
return if collision.nil?
|
|
96
|
+
|
|
97
|
+
raise ForestException,
|
|
98
|
+
"CloseIssue registers one action per scope, and #{names.keys.join(" and ")} both resolve to " \
|
|
99
|
+
"'#{collision.first}'. Give :action_name and :bulk_action_name different values, or register " \
|
|
100
|
+
'a single scope.'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_action(datasource, scope, state, issue_id_field)
|
|
104
|
+
BaseAction.new(scope: scope, &executor(datasource, state, issue_id_field))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def executor(datasource, state, issue_id_field)
|
|
108
|
+
lambda do |context, result_builder|
|
|
109
|
+
ids = IssueTargets.resolve_issue_ids(context, issue_id_field)
|
|
110
|
+
next result_builder.error(message: Messages.no_target(issue_id_field)) if ids.empty?
|
|
111
|
+
next result_builder.error(message: Messages.too_many(ids.size, state)) if ids.size > MAX_TARGETS
|
|
112
|
+
|
|
113
|
+
succeeded, failed = apply_state(datasource, ids, state)
|
|
114
|
+
next result_builder.error(message: Messages.error(failed, state)) if succeeded.empty?
|
|
115
|
+
|
|
116
|
+
result_builder.success(message: Messages.success(succeeded, failed, state))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# One rescue per id: a single issue Pylon refuses — deleted, or outside
|
|
121
|
+
# the token's scope — must not cost the operator the rest of a selection,
|
|
122
|
+
# and what failed is named in the message rather than left to a log.
|
|
123
|
+
def apply_state(datasource, ids, state)
|
|
124
|
+
succeeded = []
|
|
125
|
+
failed = []
|
|
126
|
+
|
|
127
|
+
ids.each do |id|
|
|
128
|
+
datasource.client.update_issue(id, 'state' => state)
|
|
129
|
+
succeeded << id
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
132
|
+
"[forest_admin_datasource_pylon] failed to move issue #{id} to '#{state}': #{e.class}: #{e.message}"
|
|
133
|
+
)
|
|
134
|
+
failed << [id, "#{e.class}: #{e.message}"]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
[succeeded, failed]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourcePylon
|
|
4
|
+
module Plugins
|
|
5
|
+
class CreateIssueWithNotification
|
|
6
|
+
module FormBuilder
|
|
7
|
+
FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType
|
|
8
|
+
|
|
9
|
+
NO_TEMPLATE = 'No template'.freeze
|
|
10
|
+
TOKEN_RE = /\{\{\s*record\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/
|
|
11
|
+
|
|
12
|
+
# Shared with `Payload`, which reads the submitted value back, and with
|
|
13
|
+
# the executor, which has to neutralise it when the form does not carry
|
|
14
|
+
# the field at all.
|
|
15
|
+
INTERNAL_NOTE_LABEL = 'Send as internal note'.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# ActionCollectionDecorator rejects forms that mix Page elements with
|
|
20
|
+
# non-Page elements, so each mode (flat / wizard) stays homogeneous.
|
|
21
|
+
def build(opts)
|
|
22
|
+
body = body_fields(opts)
|
|
23
|
+
return body if opts[:email_templates].empty?
|
|
24
|
+
|
|
25
|
+
[
|
|
26
|
+
{ type: 'Layout', component: 'Page', next_button_label: 'Continue',
|
|
27
|
+
elements: [template_field(opts[:email_templates])] },
|
|
28
|
+
{ type: 'Layout', component: 'Page', previous_button_label: 'Back',
|
|
29
|
+
elements: body }
|
|
30
|
+
]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# No Type field, unlike the Zendesk form: `POST /issues` does not take
|
|
34
|
+
# one -- Pylon accepts `type` on an update only, which is what
|
|
35
|
+
# `Issue::UPDATE_ONLY` already says.
|
|
36
|
+
def body_fields(opts)
|
|
37
|
+
fields = [requester_field(opts[:requester_email_default]),
|
|
38
|
+
subject_field(opts[:default_subject]),
|
|
39
|
+
message_field(opts[:default_message], opts[:email_templates])]
|
|
40
|
+
fields << priority_field unless present?(opts[:priority_override])
|
|
41
|
+
fields << internal_note_field if opts[:show_internal_note]
|
|
42
|
+
fields
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def requester_field(default)
|
|
46
|
+
{ type: FieldType::STRING, label: 'Requester email', is_required: true,
|
|
47
|
+
description: 'Email of the Pylon requester; the contact is created on the fly when it is unknown. ' \
|
|
48
|
+
'Pre-filled from the selected record when available.',
|
|
49
|
+
default_value: requester_default(default) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def template_field(templates)
|
|
53
|
+
{ type: FieldType::ENUM, label: 'Template', is_required: true,
|
|
54
|
+
enum_values: [NO_TEMPLATE] + templates.map { |t| t[:title] },
|
|
55
|
+
default_value: NO_TEMPLATE,
|
|
56
|
+
description: 'Pick a template to pre-fill the Message on the next page.' }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def subject_field(default_subject)
|
|
60
|
+
{ type: FieldType::STRING, label: 'Subject', is_required: true,
|
|
61
|
+
default_value: template_default(default_subject, escape_html: false) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def message_field(default_message, templates)
|
|
65
|
+
field = { type: FieldType::STRING, label: 'Message', widget: 'RichText', is_required: true,
|
|
66
|
+
description: 'The body of the issue (HTML). Unless it is sent as an internal note, this is ' \
|
|
67
|
+
'the message Pylon delivers to the requester.' }
|
|
68
|
+
default = template_default(default_message, escape_html: true)
|
|
69
|
+
return field.merge(default_value: default) if templates.empty?
|
|
70
|
+
|
|
71
|
+
# Both keys: `default_value:` fills the first render — drop_default
|
|
72
|
+
# runs once, the data key sticking after it — where `value:` is
|
|
73
|
+
# re-evaluated by drop_deferred on every fetch, which is what a
|
|
74
|
+
# Template change re-fires the message proc through.
|
|
75
|
+
field.merge(default_value: default, value: message_value(templates, default))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# No default: Pylon applies its own when the key is absent, and no
|
|
79
|
+
# priority is ever read back — the issue payload does not carry one, so
|
|
80
|
+
# nothing in Forest will show the operator what they picked.
|
|
81
|
+
def priority_field
|
|
82
|
+
{ type: FieldType::ENUM, label: 'Priority', enum_values: IssueEnums::PRIORITY,
|
|
83
|
+
description: 'Set on creation only; Pylon does not return the priority of an issue, so it is not ' \
|
|
84
|
+
'shown anywhere in Forest afterwards.' }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def internal_note_field
|
|
88
|
+
{ type: FieldType::BOOLEAN, label: INTERNAL_NOTE_LABEL,
|
|
89
|
+
description: 'When checked, the issue is created without contacting the requester.',
|
|
90
|
+
default_value: false }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def requester_default(value)
|
|
94
|
+
return nil if value.nil?
|
|
95
|
+
return template_default(value, escape_html: false) if value.is_a?(String)
|
|
96
|
+
|
|
97
|
+
lambda do |context|
|
|
98
|
+
record = fetch_record(context)
|
|
99
|
+
record.empty? ? nil : value.call(record)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
102
|
+
"[forest_admin_datasource_pylon] requester_email_default resolver raised: #{e.class}: #{e.message}"
|
|
103
|
+
)
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def template_default(template, escape_html:)
|
|
109
|
+
return nil unless present?(template)
|
|
110
|
+
return template unless template.match?(TOKEN_RE)
|
|
111
|
+
|
|
112
|
+
->(context) { interpolate(template, fetch_record(context), escape_html: escape_html) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns nil unless Template was just changed, so set_watch_changes
|
|
116
|
+
# carries over the user's current Message edits between renders.
|
|
117
|
+
#
|
|
118
|
+
# Taking a template back restores the configured default rather than
|
|
119
|
+
# emptying a required field: it is what the operator was handed before
|
|
120
|
+
# they picked one.
|
|
121
|
+
def message_value(templates, default)
|
|
122
|
+
by_title = templates.to_h { |t| [t[:title], t[:content].to_s] }
|
|
123
|
+
lambda do |context|
|
|
124
|
+
return nil unless context.field_changed?('Template')
|
|
125
|
+
|
|
126
|
+
title = context.get_form_value('Template')
|
|
127
|
+
return evaluate_default(default, context) if title == NO_TEMPLATE
|
|
128
|
+
|
|
129
|
+
interpolated(by_title[title].to_s, context)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A default carrying tokens is a proc, and one without is the string
|
|
134
|
+
# itself; no default at all empties the field, as it always did.
|
|
135
|
+
def evaluate_default(default, context)
|
|
136
|
+
default.is_a?(Proc) ? default.call(context) : default.to_s
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def interpolated(content, context)
|
|
140
|
+
return content unless content.match?(TOKEN_RE)
|
|
141
|
+
|
|
142
|
+
interpolate(content, fetch_record(context), escape_html: true)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def fetch_record(context)
|
|
146
|
+
context.get_record([]) || {}
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
149
|
+
"[forest_admin_datasource_pylon] failed to fetch record for token interpolation: #{e.class}: #{e.message}"
|
|
150
|
+
)
|
|
151
|
+
{}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The message ships as `body_html` and is delivered as such — an
|
|
155
|
+
# unescaped `<` or `&` coming from a record value would break the
|
|
156
|
+
# outbound message or smuggle markup into it.
|
|
157
|
+
def interpolate(template, record, escape_html:)
|
|
158
|
+
template.gsub(TOKEN_RE) do
|
|
159
|
+
key = ::Regexp.last_match(1)
|
|
160
|
+
value = record[key]
|
|
161
|
+
next '' if value.nil?
|
|
162
|
+
|
|
163
|
+
escape_html ? CGI.escapeHTML(value.to_s) : value.to_s
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def present?(value)
|
|
168
|
+
!value.nil? && value.to_s != ''
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Plugins
|
|
3
|
+
class CreateIssueWithNotification
|
|
4
|
+
# What the filled form becomes on the wire, where FormBuilder owns what
|
|
5
|
+
# the operator fills.
|
|
6
|
+
module Payload
|
|
7
|
+
# Only meaningful on an email delivery: Pylon reads the sending address
|
|
8
|
+
# and the copies off the email app they belong to. The address is
|
|
9
|
+
# mandatory there — `CreateIssueWithNotification` refuses to register an
|
|
10
|
+
# email delivery without one.
|
|
11
|
+
EMAIL_DESTINATION = 'email'.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def build(values, email, opts)
|
|
16
|
+
payload = {
|
|
17
|
+
'title' => values['Subject'],
|
|
18
|
+
'body_html' => values['Message'],
|
|
19
|
+
# Pylon wants a name alongside the address when it creates the
|
|
20
|
+
# contact; derive it from the local part. It is ignored when the
|
|
21
|
+
# contact already exists.
|
|
22
|
+
'requester_email' => email,
|
|
23
|
+
'requester_name' => derive_requester_name(email)
|
|
24
|
+
}
|
|
25
|
+
priority = opts[:priority_override] || values['Priority']
|
|
26
|
+
payload['priority'] = priority if present?(priority)
|
|
27
|
+
|
|
28
|
+
destination = destination_for(values, opts)
|
|
29
|
+
payload['destination_metadata'] = metadata(destination, opts) unless internal?(destination)
|
|
30
|
+
payload
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The checkbox wins over the configured destination: it is the
|
|
34
|
+
# operator's call, made on the record they are looking at.
|
|
35
|
+
def destination_for(values, opts)
|
|
36
|
+
truthy?(values[FormBuilder::INTERNAL_NOTE_LABEL]) ? IssueEnums::INTERNAL_DESTINATION : opts[:destination]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# An internal issue travels as no metadata at all rather than as
|
|
40
|
+
# `{destination: 'internal'}`: that is the form the API reference names
|
|
41
|
+
# for "do not contact the requester", and the one that stays right if
|
|
42
|
+
# Pylon ever adds a required companion field to a real destination.
|
|
43
|
+
def internal?(destination)
|
|
44
|
+
destination == IssueEnums::INTERNAL_DESTINATION
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def metadata(destination, opts)
|
|
48
|
+
metadata = { 'destination' => destination }
|
|
49
|
+
return metadata unless destination == EMAIL_DESTINATION
|
|
50
|
+
|
|
51
|
+
metadata['email'] = opts[:sender_email]
|
|
52
|
+
metadata['email_ccs'] = Array(opts[:email_ccs]) if Array(opts[:email_ccs]).any?
|
|
53
|
+
metadata['email_bccs'] = Array(opts[:email_bccs]) if Array(opts[:email_bccs]).any?
|
|
54
|
+
metadata
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def derive_requester_name(email)
|
|
58
|
+
local = email.to_s.split('@').first.to_s
|
|
59
|
+
local.empty? ? email.to_s : local
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def truthy?(value)
|
|
63
|
+
value == true || value.to_s.casecmp('true').zero?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def present?(value)
|
|
67
|
+
!value.nil? && value.to_s != ''
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Plugins
|
|
3
|
+
# Opens a Pylon issue and delivers its first message to the requester.
|
|
4
|
+
#
|
|
5
|
+
# Pylon creates the contact on the fly from the form's email, so the action
|
|
6
|
+
# can be registered on any host collection — no relation to Pylon needed.
|
|
7
|
+
#
|
|
8
|
+
# Where Zendesk notifies as a side effect of a public comment, Pylon says it
|
|
9
|
+
# outright: `destination_metadata.destination` names the channel the
|
|
10
|
+
# issue's `body_html` is delivered through, and no `destination_metadata` at
|
|
11
|
+
# all is what leaves the issue internal. The "Send as internal note"
|
|
12
|
+
# checkbox is that choice, worded the way the Zendesk plugin words it.
|
|
13
|
+
#
|
|
14
|
+
# The form is FormBuilder's, the wire payload is Payload's; what is left
|
|
15
|
+
# here is the registration, its options, and what the operator reads back.
|
|
16
|
+
class CreateIssueWithNotification < ForestAdminDatasourceCustomizer::Plugins::Plugin
|
|
17
|
+
BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
|
|
18
|
+
ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
|
|
19
|
+
ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException
|
|
20
|
+
|
|
21
|
+
NAME = 'Create Pylon issue and notify'.freeze
|
|
22
|
+
|
|
23
|
+
def run(_datasource_customizer, collection_customizer = nil, options = {})
|
|
24
|
+
options = {} unless options.is_a?(Hash)
|
|
25
|
+
datasource = options[:datasource]
|
|
26
|
+
raise ForestException, 'CreateIssueWithNotification plugin requires :datasource' unless datasource
|
|
27
|
+
raise ForestException, 'CreateIssueWithNotification plugin requires a collection' unless collection_customizer
|
|
28
|
+
|
|
29
|
+
opts = options.except(:datasource)
|
|
30
|
+
opts[:email_templates] = normalize_templates(opts[:email_templates])
|
|
31
|
+
opts[:destination] = normalize_destination(opts[:destination])
|
|
32
|
+
opts[:priority_override] = normalize_priority(opts[:priority_override])
|
|
33
|
+
require_sender_email!(opts)
|
|
34
|
+
|
|
35
|
+
collection_customizer.add_action(opts[:action_name] || NAME, build_action(datasource, opts))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# The title is what the enum carries and what the content is looked up
|
|
41
|
+
# by, so it has to name one template: a duplicate makes the first
|
|
42
|
+
# unreachable and would send the other one's content under its name, and
|
|
43
|
+
# the sentinel of the "pick nothing" option makes the template it names
|
|
44
|
+
# unpickable. Both are configuration, so both are refused at registration
|
|
45
|
+
# rather than discovered by whoever sends the wrong message.
|
|
46
|
+
def normalize_templates(value)
|
|
47
|
+
templates = Array(value).compact
|
|
48
|
+
titles = templates.map { |template| template[:title].to_s }
|
|
49
|
+
|
|
50
|
+
reserved = titles.include?(FormBuilder::NO_TEMPLATE)
|
|
51
|
+
raise ForestException, "An email template cannot be titled #{FormBuilder::NO_TEMPLATE.inspect}." if reserved
|
|
52
|
+
|
|
53
|
+
duplicated = titles.tally.select { |_title, count| count > 1 }.keys
|
|
54
|
+
raise ForestException, "Duplicate email template titles: #{duplicated.join(", ")}." if duplicated.any?
|
|
55
|
+
|
|
56
|
+
templates
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize_destination(value)
|
|
60
|
+
return Payload::EMAIL_DESTINATION if value.nil?
|
|
61
|
+
|
|
62
|
+
normalize(value, IssueEnums::DESTINATION, 'destination')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `POST /issues` refuses an email delivery that does not name the address it
|
|
66
|
+
# is sent from, so the option is mandatory there rather than optional. The
|
|
67
|
+
# refusal belongs at registration, where it names the option and the agent
|
|
68
|
+
# will not boot without it, rather than at the first execution, where it
|
|
69
|
+
# reaches the operator as a Pylon 400 on a form they filled correctly.
|
|
70
|
+
def require_sender_email!(opts)
|
|
71
|
+
return unless opts[:destination] == Payload::EMAIL_DESTINATION
|
|
72
|
+
return if Payload.present?(opts[:sender_email])
|
|
73
|
+
|
|
74
|
+
raise ForestException,
|
|
75
|
+
'CreateIssueWithNotification requires :sender_email when the destination is email. ' \
|
|
76
|
+
'It must be one of the addresses configured in the Pylon email app.'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def normalize_priority(value)
|
|
80
|
+
return nil unless Payload.present?(value)
|
|
81
|
+
|
|
82
|
+
normalize(value, IssueEnums::PRIORITY, 'priority')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normalize(value, allowed, label)
|
|
86
|
+
normalized = value.to_s
|
|
87
|
+
return normalized if allowed.include?(normalized)
|
|
88
|
+
|
|
89
|
+
raise ForestException,
|
|
90
|
+
"Unknown CreateIssueWithNotification #{label}: #{normalized}. Allowed: #{allowed.join(", ")}."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_action(datasource, opts)
|
|
94
|
+
BaseAction.new(scope: ActionScope::SINGLE, form: FormBuilder.build(opts), &executor(datasource, opts))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def executor(datasource, opts)
|
|
98
|
+
lambda do |context, result_builder|
|
|
99
|
+
values = submitted_values(context, opts)
|
|
100
|
+
email = values['Requester email']
|
|
101
|
+
next result_builder.error(message: 'Requester email is required.') unless Payload.present?(email)
|
|
102
|
+
|
|
103
|
+
issue = create_issue(datasource, Payload.build(values, email, opts))
|
|
104
|
+
next result_builder.error(message: issue.last) if issue.is_a?(Array)
|
|
105
|
+
|
|
106
|
+
writeback = write_back_issue_id(context, opts[:issue_id_field], issue['id'])
|
|
107
|
+
result_builder.success(message: success_message(issue, values, opts, writeback))
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The form only carries the internal-note checkbox when the option asked
|
|
112
|
+
# for it, but a form value is not what the form offered: the agent copies
|
|
113
|
+
# every key the request sent, matched against a field or not. Left as
|
|
114
|
+
# submitted, the flag would create an internal issue on an action
|
|
115
|
+
# registered without it — and the requester the plugin exists to notify
|
|
116
|
+
# would not be. What the form does not show cannot be sent.
|
|
117
|
+
def submitted_values(context, opts)
|
|
118
|
+
values = context.form_values
|
|
119
|
+
return values if opts[:show_internal_note]
|
|
120
|
+
|
|
121
|
+
values.merge(FormBuilder::INTERNAL_NOTE_LABEL => false)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A 4xx is Pylon naming what the operator filled in, and reaches them as
|
|
125
|
+
# the action's own error, message intact: raised, it would leave the agent
|
|
126
|
+
# to answer 'Unexpected error' — APIError is none of the classes whose
|
|
127
|
+
# message the translator passes through — and to log nothing either, its
|
|
128
|
+
# status being under 500. Anything else is Pylon or the network failing,
|
|
129
|
+
# which no edit of the form would change, and stays the 500 it is.
|
|
130
|
+
def create_issue(datasource, payload)
|
|
131
|
+
datasource.client.create_issue(payload)
|
|
132
|
+
rescue APIError => e
|
|
133
|
+
raise unless (400..499).cover?(e.status.to_i)
|
|
134
|
+
|
|
135
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
136
|
+
"[forest_admin_datasource_pylon] Pylon refused the issue creation: #{e.message}"
|
|
137
|
+
)
|
|
138
|
+
[:rejected, e.message]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Best-effort: Pylon has no transaction to roll back, and the issue exists
|
|
142
|
+
# whether or not the host record could be stamped with its id.
|
|
143
|
+
def write_back_issue_id(context, field, issue_id)
|
|
144
|
+
return :skipped if field.nil?
|
|
145
|
+
|
|
146
|
+
context.collection.update(context.filter, { field => issue_id })
|
|
147
|
+
:ok
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
150
|
+
"[forest_admin_datasource_pylon] failed to store the issue id in '#{field}': #{e.class}: #{e.message}"
|
|
151
|
+
)
|
|
152
|
+
[:failed, "#{e.class}: #{e.message}"]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def success_message(issue, values, opts, writeback)
|
|
156
|
+
base = base_success_message(issue, values, opts)
|
|
157
|
+
return base unless writeback.is_a?(Array) && writeback.first == :failed
|
|
158
|
+
|
|
159
|
+
"#{base} (warning: could not store the issue id on the record: #{writeback.last})"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The number is what an operator recognises an issue by; the id stands in
|
|
163
|
+
# when Pylon answered without one.
|
|
164
|
+
def base_success_message(issue, values, opts)
|
|
165
|
+
reference = issue['number'] || issue['id']
|
|
166
|
+
destination = Payload.destination_for(values, opts)
|
|
167
|
+
if Payload.internal?(destination)
|
|
168
|
+
return "Issue ##{reference} created (internal, the requester was not contacted)."
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
"Issue ##{reference} created and the requester notified by #{destination.tr("_", " ")}."
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module ForestAdminDatasourcePylon
|
|
2
|
+
module Plugins
|
|
3
|
+
# Which Pylon issues an action was fired on.
|
|
4
|
+
#
|
|
5
|
+
# Two shapes, one option: `issue_id_field` names a column of the host
|
|
6
|
+
# collection holding a Pylon issue id — a business collection keeping the
|
|
7
|
+
# issue it opened — and, left out, the ids are the primary keys of the
|
|
8
|
+
# selected records, which is what an action registered on PylonIssue itself
|
|
9
|
+
# acts on.
|
|
10
|
+
module IssueTargets
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# A collection whose column was renamed, or a record the scope hides,
|
|
14
|
+
# answers "no issue selected" through the action's own message rather than
|
|
15
|
+
# through a stack trace in the panel.
|
|
16
|
+
#
|
|
17
|
+
# A ValidationError is the exception: it is the datasource refusing the
|
|
18
|
+
# selection in words written for the operator — PylonIssue naming more
|
|
19
|
+
# issues by id than one page of lookups covers, for one — and the agent
|
|
20
|
+
# surfaces its message as it is. Swallowed, it would reach them as "no
|
|
21
|
+
# issue selected" about a selection they can see they made, which is the
|
|
22
|
+
# one thing the message must not say.
|
|
23
|
+
def resolve_issue_ids(context, field = nil)
|
|
24
|
+
ids = field.nil? ? primary_key_ids(context) : column_ids(context, field)
|
|
25
|
+
# Deduplicated: a column of issue ids is not a key, so two selected
|
|
26
|
+
# records may name the same issue, which would then be written twice and
|
|
27
|
+
# counted twice in what the action reports back.
|
|
28
|
+
ids.filter_map do |id|
|
|
29
|
+
id.to_s unless id.nil? || id.to_s.empty?
|
|
30
|
+
end.uniq
|
|
31
|
+
rescue ForestAdminDatasourceToolkit::Exceptions::ValidationError
|
|
32
|
+
raise
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
source = field ? "from '#{field}'" : 'from the selected records'
|
|
35
|
+
ForestAdminDatasourcePylon.logger.warn(
|
|
36
|
+
"[forest_admin_datasource_pylon] failed to resolve the issues to act on #{source}: " \
|
|
37
|
+
"#{e.class}: #{e.message}"
|
|
38
|
+
)
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def primary_key_ids(context)
|
|
43
|
+
Array(context.get_record_ids)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def column_ids(context, field)
|
|
47
|
+
context.get_records([field.to_s]).map { |record| record[field.to_s] }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|