-
Notifications
You must be signed in to change notification settings - Fork 1
feat(datasource-pylon): close and create-with-notification action plugins (EXT-12) #366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
christophebrun-forest
wants to merge
1
commit into
feat/datasource-pylon
Choose a base branch
from
ext-12-actions-plugins
base: feat/datasource-pylon
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
21 changes: 21 additions & 0 deletions
21
packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/issue_enums.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| module ForestAdminDatasourcePylon | ||
| # The closed sets `POST /issues` and `PATCH /issues/{id}` document, shared by | ||
| # the plugins building forms over them. | ||
| module IssueEnums | ||
| # Accepted on a create, and absent from every read: Pylon never returns the | ||
| # priority of an issue, which is why no column carries it. | ||
| PRIORITY = %w[urgent high medium low].freeze | ||
|
|
||
| # Where the first message of a created issue is delivered. `internal` is the | ||
| # absence of a delivery, and travels as no `destination_metadata` at all. | ||
| DESTINATION = %w[email slack in_app_chat customer_portal sms whatsapp internal].freeze | ||
|
|
||
| INTERNAL_DESTINATION = 'internal'.freeze | ||
|
|
||
| # The states Pylon ships. An organization defines its own on top of them, so | ||
| # this list is what a form offers, never what a write is checked against. | ||
| STANDARD_STATES = %w[new waiting_on_you waiting_on_customer on_hold closed].freeze | ||
|
|
||
| CLOSED_STATE = 'closed'.freeze | ||
| end | ||
| end |
101 changes: 101 additions & 0 deletions
101
...es/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/plugins/close_issue.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| module ForestAdminDatasourcePylon | ||
| module Plugins | ||
| # Moves the selected issues to a state, `closed` unless told otherwise. | ||
| # | ||
| # One variant per scope, where the Zendesk plugin builds four: Zendesk has | ||
| # two terminal statuses to tell apart, Pylon has `closed` and, past it, the | ||
| # custom status slugs an organization defines — which the `state` option | ||
| # takes, rather than a second dimension of action names nobody would read. | ||
| # | ||
| # The state is written through the client rather than through the | ||
| # collection: the action is registered on the host collection, which is | ||
| # rarely PylonIssue, and going through a collection would mean resolving it | ||
| # from a datasource the plugin was not given. | ||
| class CloseIssue < ForestAdminDatasourceCustomizer::Plugins::Plugin | ||
| BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction | ||
| ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope | ||
| ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException | ||
|
|
||
| SCOPE_KEYS = %i[single bulk].freeze | ||
| SCOPES = { single: ActionScope::SINGLE, bulk: ActionScope::BULK }.freeze | ||
| NAMES = { single: 'Close Pylon issue', bulk: 'Close selected Pylon issues' }.freeze | ||
| NAME_OPTIONS = { single: :action_name, bulk: :bulk_action_name }.freeze | ||
|
|
||
| def run(_datasource_customizer, collection_customizer = nil, options = {}) | ||
| opts = options.is_a?(Hash) ? options : {} | ||
| datasource = opts[:datasource] | ||
| raise ForestException, 'CloseIssue plugin requires :datasource' unless datasource | ||
| raise ForestException, 'CloseIssue plugin requires a collection' unless collection_customizer | ||
|
|
||
| state = normalize_state(opts[:state]) | ||
|
|
||
| normalize_scopes(opts[:scopes]).each do |scope_key| | ||
| collection_customizer.add_action(name_for(scope_key, opts), | ||
| build_action(datasource, SCOPES[scope_key], state, opts[:issue_id_field])) | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # Left unchecked against `STANDARD_STATES`: Pylon takes the slug of a | ||
| # custom status just as well, and refusing one would refuse the very | ||
| # workflow an organization built. | ||
| def normalize_state(value) | ||
| state = value.nil? ? IssueEnums::CLOSED_STATE : value.to_s | ||
| return state unless state.strip.empty? | ||
|
|
||
| raise ForestException, 'CloseIssue :state cannot be empty.' | ||
| end | ||
|
|
||
| def normalize_scopes(value) | ||
| scopes = Array(value).map(&:to_sym).uniq | ||
| scopes = SCOPE_KEYS if scopes.empty? | ||
| unknown = scopes - SCOPE_KEYS | ||
| return scopes if unknown.empty? | ||
|
|
||
| raise ForestException, | ||
| "Unknown CloseIssue scopes: #{unknown.join(", ")}. Allowed: #{SCOPE_KEYS.join(", ")}." | ||
| end | ||
|
|
||
| def name_for(scope_key, opts) | ||
| opts[NAME_OPTIONS[scope_key]] || NAMES[scope_key] | ||
| end | ||
|
|
||
| def build_action(datasource, scope, state, issue_id_field) | ||
| BaseAction.new(scope: scope, &executor(datasource, state, issue_id_field)) | ||
| end | ||
|
|
||
| def executor(datasource, state, issue_id_field) | ||
| lambda do |context, result_builder| | ||
| ids = IssueTargets.resolve_issue_ids(context, issue_id_field) | ||
| next result_builder.error(message: Messages.no_target(issue_id_field)) if ids.empty? | ||
|
|
||
| succeeded, failed = apply_state(datasource, ids, state) | ||
| next result_builder.error(message: Messages.error(failed, state)) if succeeded.empty? | ||
|
|
||
| result_builder.success(message: Messages.success(succeeded, failed, state)) | ||
| end | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| # One rescue per id: a single issue Pylon refuses — deleted, or outside | ||
| # the token's scope — must not cost the operator the rest of a selection, | ||
| # and what failed is named in the message rather than left to a log. | ||
| def apply_state(datasource, ids, state) | ||
| succeeded = [] | ||
| failed = [] | ||
|
|
||
| ids.each do |id| | ||
| datasource.client.update_issue(id, 'state' => state) | ||
| succeeded << id | ||
| rescue StandardError => e | ||
| ForestAdminDatasourcePylon.logger.warn( | ||
| "[forest_admin_datasource_pylon] failed to move issue #{id} to '#{state}': #{e.class}: #{e.message}" | ||
| ) | ||
| failed << [id, "#{e.class}: #{e.message}"] | ||
| end | ||
|
|
||
| [succeeded, failed] | ||
| end | ||
| end | ||
| end | ||
| end | ||
52 changes: 52 additions & 0 deletions
52
..._admin_datasource_pylon/lib/forest_admin_datasource_pylon/plugins/close_issue/messages.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| module ForestAdminDatasourcePylon | ||
| module Plugins | ||
| class CloseIssue | ||
| # What the operator reads once the batch ran. Every id that failed is | ||
| # named: an action reporting a plain success over a batch it only half | ||
| # applied is the one thing the panel cannot recover from. | ||
| module Messages | ||
| module_function | ||
|
|
||
| def success(succeeded, failed, state) | ||
| [succeeded_phrase(succeeded, state), failed_phrase(failed)].compact.join(' ') | ||
| end | ||
|
|
||
| def error(failed, state) | ||
| return "Failed to #{verb(state)} issue #{failed.first.first}: #{failed.first.last}" if failed.size == 1 | ||
|
|
||
| "Failed to #{verb(state)} all #{failed.size} issues. First error: #{failed.first.last}" | ||
| end | ||
|
|
||
| def no_target(field) | ||
| return 'No Pylon issue selected.' if field.nil? | ||
|
|
||
| "No Pylon issue id found in '#{field}'." | ||
| end | ||
|
|
||
| def succeeded_phrase(succeeded, state) | ||
| return nil if succeeded.empty? | ||
|
|
||
| return "Issue #{succeeded.first} #{past_verb(state)}." if succeeded.size == 1 | ||
|
|
||
| "#{succeeded.size} issues #{past_verb(state)}." | ||
| end | ||
|
|
||
| def failed_phrase(failed) | ||
| return nil if failed.empty? | ||
|
|
||
| "#{failed.size} failed: #{failed.map(&:first).join(", ")}." | ||
| end | ||
|
|
||
| # A custom status is named as it is, where the state every organization | ||
| # has reads as the verb an operator used to fire the action. | ||
| def verb(state) | ||
| state == IssueEnums::CLOSED_STATE ? 'close' : "move to #{state}" | ||
| end | ||
|
|
||
| def past_verb(state) | ||
| state == IssueEnums::CLOSED_STATE ? 'closed' : "moved to #{state}" | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
109 changes: 109 additions & 0 deletions
109
...asource_pylon/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| module ForestAdminDatasourcePylon | ||
| module Plugins | ||
| # Opens a Pylon issue and delivers its first message to the requester. | ||
| # | ||
| # Pylon creates the contact on the fly from the form's email, so the action | ||
| # can be registered on any host collection — no relation to Pylon needed. | ||
| # | ||
| # Where Zendesk notifies as a side effect of a public comment, Pylon says it | ||
| # outright: `destination_metadata.destination` names the channel the | ||
| # issue's `body_html` is delivered through, and no `destination_metadata` at | ||
| # all is what leaves the issue internal. The "Send as internal note" | ||
| # checkbox is that choice, worded the way the Zendesk plugin words it. | ||
| # | ||
| # The form is FormBuilder's, the wire payload is Payload's; what is left | ||
| # here is the registration, its options, and what the operator reads back. | ||
| class CreateIssueWithNotification < ForestAdminDatasourceCustomizer::Plugins::Plugin | ||
| BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction | ||
| ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope | ||
| ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException | ||
|
|
||
| NAME = 'Create Pylon issue and notify'.freeze | ||
|
|
||
| def run(_datasource_customizer, collection_customizer = nil, options = {}) | ||
| options = {} unless options.is_a?(Hash) | ||
| datasource = options[:datasource] | ||
| raise ForestException, 'CreateIssueWithNotification plugin requires :datasource' unless datasource | ||
| raise ForestException, 'CreateIssueWithNotification plugin requires a collection' unless collection_customizer | ||
|
|
||
| opts = options.except(:datasource) | ||
| opts[:email_templates] = Array(opts[:email_templates]).compact | ||
| opts[:destination] = normalize_destination(opts[:destination]) | ||
| opts[:priority_override] = normalize_priority(opts[:priority_override]) | ||
|
|
||
| collection_customizer.add_action(opts[:action_name] || NAME, build_action(datasource, opts)) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def normalize_destination(value) | ||
| return Payload::EMAIL_DESTINATION if value.nil? | ||
|
|
||
| normalize(value, IssueEnums::DESTINATION, 'destination') | ||
| end | ||
|
|
||
| def normalize_priority(value) | ||
| return nil unless Payload.present?(value) | ||
|
|
||
| normalize(value, IssueEnums::PRIORITY, 'priority') | ||
| end | ||
|
|
||
| def normalize(value, allowed, label) | ||
| normalized = value.to_s | ||
| return normalized if allowed.include?(normalized) | ||
|
|
||
| raise ForestException, | ||
| "Unknown CreateIssueWithNotification #{label}: #{normalized}. Allowed: #{allowed.join(", ")}." | ||
| end | ||
|
|
||
| def build_action(datasource, opts) | ||
| BaseAction.new(scope: ActionScope::SINGLE, form: FormBuilder.build(opts), &executor(datasource, opts)) | ||
| end | ||
|
|
||
| def executor(datasource, opts) | ||
| lambda do |context, result_builder| | ||
| values = context.form_values | ||
| email = values['Requester email'] | ||
| next result_builder.error(message: 'Requester email is required.') unless Payload.present?(email) | ||
|
|
||
| issue = datasource.client.create_issue(Payload.build(values, email, opts)) | ||
| writeback = write_back_issue_id(context, opts[:issue_id_field], issue['id']) | ||
| result_builder.success(message: success_message(issue, values, opts, writeback)) | ||
| end | ||
| end | ||
|
|
||
| # Best-effort: Pylon has no transaction to roll back, and the issue exists | ||
| # whether or not the host record could be stamped with its id. | ||
| def write_back_issue_id(context, field, issue_id) | ||
| return :skipped if field.nil? | ||
|
|
||
| context.collection.update(context.filter, { field => issue_id }) | ||
| :ok | ||
| rescue StandardError => e | ||
| ForestAdminDatasourcePylon.logger.warn( | ||
| "[forest_admin_datasource_pylon] failed to store the issue id in '#{field}': #{e.class}: #{e.message}" | ||
| ) | ||
| [:failed, "#{e.class}: #{e.message}"] | ||
| end | ||
|
|
||
| def success_message(issue, values, opts, writeback) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| base = base_success_message(issue, values, opts) | ||
| return base unless writeback.is_a?(Array) && writeback.first == :failed | ||
|
|
||
| "#{base} (warning: could not store the issue id on the record: #{writeback.last})" | ||
| end | ||
|
|
||
| # The number is what an operator recognises an issue by; the id stands in | ||
| # when Pylon answered without one. | ||
| def base_success_message(issue, values, opts) | ||
| reference = issue['number'] || issue['id'] | ||
| destination = Payload.destination_for(values, opts) | ||
| if Payload.internal?(destination) | ||
| return "Issue ##{reference} created (internal, the requester was not contacted)." | ||
| end | ||
|
|
||
| "Issue ##{reference} created and the requester notified by #{destination.tr("_", " ")}." | ||
| end | ||
| end | ||
| end | ||
| end | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Function with many parameters (count = 4): build_action [qlty:function-parameters]