qube_rails 0.2.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/CHANGELOG.md +161 -0
- data/LICENSE.txt +21 -0
- data/README.md +967 -0
- data/app/assets/javascripts/qube_rails/question.js +457 -0
- data/app/assets/stylesheets/qube_rails.css +1115 -0
- data/app/controllers/concerns/qube_rails/run_authorization.rb +37 -0
- data/app/controllers/qube_rails/connections_controller.rb +141 -0
- data/app/controllers/qube_rails/questions_controller.rb +132 -0
- data/app/controllers/qube_rails/runs_controller.rb +54 -0
- data/app/controllers/qube_rails/storyboard_controller.rb +50 -0
- data/app/controllers/qube_rails/webhooks_controller.rb +99 -0
- data/app/jobs/qube_rails/prune_runs_job.rb +18 -0
- data/app/jobs/qube_rails/refresh_job.rb +18 -0
- data/app/jobs/qube_rails/webhook_job.rb +43 -0
- data/app/models/qube_rails/application_record.rb +17 -0
- data/app/models/qube_rails/connection.rb +303 -0
- data/app/models/qube_rails/notification.rb +12 -0
- data/app/models/qube_rails/queued_request.rb +66 -0
- data/app/models/qube_rails/quickbooks_record.rb +36 -0
- data/app/models/qube_rails/request_delivery.rb +12 -0
- data/app/models/qube_rails/run.rb +187 -0
- data/app/models/qube_rails/sync_mark.rb +21 -0
- data/app/views/qube_rails/_error.html.erb +13 -0
- data/app/views/qube_rails/_question.html.erb +21 -0
- data/app/views/qube_rails/_run_status.html.erb +22 -0
- data/app/views/qube_rails/question/_choice.html.erb +20 -0
- data/app/views/qube_rails/question/_details.html.erb +6 -0
- data/app/views/qube_rails/question/_field.html.erb +15 -0
- data/app/views/qube_rails/question/_footer.html.erb +7 -0
- data/app/views/qube_rails/question/_message.html.erb +2 -0
- data/app/views/qube_rails/question/_pick.html.erb +19 -0
- data/app/views/qube_rails/question/_picks.html.erb +6 -0
- data/app/views/qube_rails/question/_question.html.erb +24 -0
- data/app/views/qube_rails/question/fields/_boolean.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_number.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_rows.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_search.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_select.html.erb +8 -0
- data/app/views/qube_rails/question/fields/_text.html.erb +7 -0
- data/app/views/qube_rails/question/rows/_cell.html.erb +24 -0
- data/app/views/qube_rails/question/rows/_row.html.erb +22 -0
- data/app/views/qube_rails/question/rows/_segmented.html.erb +14 -0
- data/app/views/qube_rails/run_status/_detail.html.erb +7 -0
- data/app/views/qube_rails/run_status/_redeliver.html.erb +5 -0
- data/app/views/qube_rails/run_status/_retry.html.erb +4 -0
- data/app/views/qube_rails/run_status/_stall.html.erb +3 -0
- data/app/views/qube_rails/run_status/_status.html.erb +21 -0
- data/app/views/qube_rails/run_status/_summary.html.erb +2 -0
- data/app/views/qube_rails/storyboard/show.html.erb +86 -0
- data/config/locales/en.yml +46 -0
- data/config/routes.rb +35 -0
- data/db/migrate/20261025000000_create_qube_tables.rb +83 -0
- data/db/migrate/20261026230000_add_qube_sync_status_indexes.rb +13 -0
- data/db/migrate/20261027000000_create_qube_sync_marks.rb +17 -0
- data/db/migrate/20261028000000_create_qube_requests.rb +38 -0
- data/lib/generators/qube_rails/install_generator.rb +47 -0
- data/lib/generators/qube_rails/templates/connection_events.rb.tt +38 -0
- data/lib/generators/qube_rails/templates/initializer.rb.tt +82 -0
- data/lib/generators/qube_rails/workflows_generator.rb +77 -0
- data/lib/qube_rails/backend.rb +70 -0
- data/lib/qube_rails/cache_dedupe.rb +34 -0
- data/lib/qube_rails/configuration.rb +319 -0
- data/lib/qube_rails/connection_events.rb +167 -0
- data/lib/qube_rails/database_dedupe.rb +28 -0
- data/lib/qube_rails/engine.rb +33 -0
- data/lib/qube_rails/json_coder.rb +43 -0
- data/lib/qube_rails/operation.rb +464 -0
- data/lib/qube_rails/question_view.rb +219 -0
- data/lib/qube_rails/request.rb +289 -0
- data/lib/qube_rails/rows_view.rb +146 -0
- data/lib/qube_rails/run_status_view.rb +71 -0
- data/lib/qube_rails/storyboard/ask_about_add.json +60 -0
- data/lib/qube_rails/storyboard/choose.json +51 -0
- data/lib/qube_rails/storyboard/enum_select.json +56 -0
- data/lib/qube_rails/storyboard/missing_record.json +141 -0
- data/lib/qube_rails/storyboard/name_clash.json +59 -0
- data/lib/qube_rails/storyboard/near_match.json +77 -0
- data/lib/qube_rails/storyboard/number_boolean.json +54 -0
- data/lib/qube_rails/storyboard/record_search.json +90 -0
- data/lib/qube_rails/storyboard/refusal.json +53 -0
- data/lib/qube_rails/storyboard/rows.json +359 -0
- data/lib/qube_rails/storyboard.rb +165 -0
- data/lib/qube_rails/sync_status.rb +124 -0
- data/lib/qube_rails/version.rb +5 -0
- data/lib/qube_rails/view_helpers.rb +29 -0
- data/lib/qube_rails.rb +201 -0
- metadata +180 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%# Where record stands with QuickBooks for an operation, in a sentence, and what went wrong when
|
|
2
|
+
its run failed, or nothing before it's sent. Render it on the record's page, with
|
|
3
|
+
connection: when the page is about one company file (else it's the connection the latest run
|
|
4
|
+
went out on):
|
|
5
|
+
render "qube_rails/run_status", record: @invoice, operation: "create_invoice"
|
|
6
|
+
It builds a QubeRails::RunStatusView from QubeRails::SyncStatus and renders the
|
|
7
|
+
qube_rails/run_status/* pieces; override a piece by putting a file at its path in your app
|
|
8
|
+
(README, "Styling"). %>
|
|
9
|
+
<% unless QubeRails.config.operation_class(operation) %>
|
|
10
|
+
<% raise ArgumentError, "QuBe Sync: no operation has the slug #{operation.inspect} (config.operations)" %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% sync = if local_assigns[:connection]
|
|
13
|
+
local_assigns[:connection].sync_status(record, operation: operation)
|
|
14
|
+
else
|
|
15
|
+
QubeRails::SyncStatus.latest(record, operation: operation)
|
|
16
|
+
end %>
|
|
17
|
+
<%# Refreshed when the run's next event comes, in an app with turbo-rails (QubeRails.broadcast_refresh). %>
|
|
18
|
+
<%= turbo_stream_from(record, :qube_rails) if respond_to?(:turbo_stream_from) %>
|
|
19
|
+
<% error = flash[:qube_rails_error] %>
|
|
20
|
+
<% flash.delete(:qube_rails_error) # shown once, by whichever partial comes first %>
|
|
21
|
+
<%= render "qube_rails/run_status/status", run_status: QubeRails::RunStatusView.from_sync(sync, qube_rails, error: error,
|
|
22
|
+
connection: local_assigns[:connection]) %>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%# One choice (a QubeRails::ChoiceView): a form posting choice=<id> and data[...] for its fields
|
|
2
|
+
to choice.action_url, with your CSRF token. Its button is choice.style's (:primary, :secondary
|
|
3
|
+
or :ghost), with that ui_classes piece (choice.button_piece). %>
|
|
4
|
+
<form class="<%= qube_classes(:choice, "qube-choice", choice.kind == :form ? "qube-choice-form" : nil) %>" method="post"
|
|
5
|
+
action="<%= choice.action_url %>" data-qube-choice="<%= choice.id %>" data-qube-choice-kind="<%= choice.kind %>">
|
|
6
|
+
<%= hidden_field_tag request_forgery_protection_token, form_authenticity_token, id: nil %>
|
|
7
|
+
<%= hidden_field_tag :choice, choice.id, id: nil %>
|
|
8
|
+
<% choice.fields.each do |field| %>
|
|
9
|
+
<%= render field.partial, field: field %>
|
|
10
|
+
<% end %>
|
|
11
|
+
<button type="submit" class="<%= qube_classes(choice.button_piece, "qube-choice-button") %>" data-qube-button="<%= choice.style %>">
|
|
12
|
+
<%= choice.label %>
|
|
13
|
+
<% if choice.detail %>
|
|
14
|
+
<span class="<%= qube_classes(:choice_detail, "qube-choice-detail") %>"><%= choice.detail %></span>
|
|
15
|
+
<% end %>
|
|
16
|
+
</button>
|
|
17
|
+
<% if choice.description %>
|
|
18
|
+
<p class="<%= qube_classes(:choice_description, "qube-choice-description") %>"><%= choice.description %></p>
|
|
19
|
+
<% end %>
|
|
20
|
+
</form>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# What the question is about: label and value rows (question.details). %>
|
|
2
|
+
<table class="<%= qube_classes(:details, "qube-question-details") %>">
|
|
3
|
+
<% question.details.each do |row| %>
|
|
4
|
+
<tr><th scope="row"><%= row.label %></th><td><%= row.value %></td></tr>
|
|
5
|
+
<% end %>
|
|
6
|
+
</table>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# The wrapper every field piece renders its control (control, HTML) in: a choice's field gets
|
|
2
|
+
its label; inside a rows table, the control stands alone. %>
|
|
3
|
+
<% if field.labelled? %>
|
|
4
|
+
<div class="<%= qube_classes(:field, "qube-field", "qube-field-#{field.type}") %>">
|
|
5
|
+
<% if field.type == "boolean" %>
|
|
6
|
+
<%= control %>
|
|
7
|
+
<%= label_tag field.dom_id, field.label, class: qube_classes(:label, "qube-label") %>
|
|
8
|
+
<% else %>
|
|
9
|
+
<%= label_tag field.dom_id, field.label, class: qube_classes(:label, "qube-label") %>
|
|
10
|
+
<%= control %>
|
|
11
|
+
<% end %>
|
|
12
|
+
</div>
|
|
13
|
+
<% else %>
|
|
14
|
+
<%= control %>
|
|
15
|
+
<% end %>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%# The exits (question.exits: none of these, cancel and the like) in a row; in a plain question
|
|
2
|
+
(no picks, no forms) every choice, the first primary. %>
|
|
3
|
+
<div class="<%= qube_classes(:choice_footer, "qube-choice-footer") %>"<%= " data-qube-plain" if question.plain? %>>
|
|
4
|
+
<% question.exits.each do |choice| %>
|
|
5
|
+
<%= render "qube_rails/question/choice", question: question, choice: choice %>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%# One pick (a QubeRails::ChoiceView of kind :pick): an option row, a form posting choice=<id>
|
|
2
|
+
to choice.action_url with your CSRF token. %>
|
|
3
|
+
<form class="qube-choice qube-pick-form" method="post" action="<%= choice.action_url %>" role="listitem"
|
|
4
|
+
data-qube-choice="<%= choice.id %>" data-qube-choice-kind="pick">
|
|
5
|
+
<%= hidden_field_tag request_forgery_protection_token, form_authenticity_token, id: nil %>
|
|
6
|
+
<%= hidden_field_tag :choice, choice.id, id: nil %>
|
|
7
|
+
<button type="submit" class="<%= qube_classes(:pick, "qube-pick") %>">
|
|
8
|
+
<span class="qube-pick-text">
|
|
9
|
+
<span class="qube-pick-label"><%= choice.label %></span>
|
|
10
|
+
<% if choice.detail %>
|
|
11
|
+
<span class="<%= qube_classes(:pick_detail, "qube-pick-detail") %>"><%= choice.detail %></span>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% if choice.description %>
|
|
14
|
+
<span class="qube-pick-description"><%= choice.description %></span>
|
|
15
|
+
<% end %>
|
|
16
|
+
</span>
|
|
17
|
+
<span class="<%= qube_classes(:pick_action, "qube-pick-action") %>"><%= QubeRails.t("question.use_pick") %></span>
|
|
18
|
+
</button>
|
|
19
|
+
</form>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# The question's picks (question.picks): option rows, one form each. %>
|
|
2
|
+
<div class="<%= qube_classes(:picks, "qube-picks") %>" role="list">
|
|
3
|
+
<% question.picks.each do |choice| %>
|
|
4
|
+
<%= render "qube_rails/question/pick", question: question, choice: choice %>
|
|
5
|
+
<% end %>
|
|
6
|
+
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%# A question (a QubeRails::QuestionView): a refusal, its message, what it's about, then its
|
|
2
|
+
choices by kind: the picks (option rows), each form choice ("or" between), and the exits in a
|
|
3
|
+
footer. A question with neither picks nor forms shows its choices in one row. %>
|
|
4
|
+
<section class="<%= qube_classes(:question, "qube-question") %>" data-qube-question="<%= question.token %>">
|
|
5
|
+
<%= render "qube_rails/error", message: question.error %>
|
|
6
|
+
<%= render "qube_rails/question/message", question: question %>
|
|
7
|
+
<% if question.details.any? %>
|
|
8
|
+
<%= render "qube_rails/question/details", question: question %>
|
|
9
|
+
<% end %>
|
|
10
|
+
<div class="qube-choices">
|
|
11
|
+
<% if question.picks.any? %>
|
|
12
|
+
<%= render "qube_rails/question/picks", question: question %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% question.forms.each_with_index do |choice, n| %>
|
|
15
|
+
<% if n.positive? || question.picks.any? %>
|
|
16
|
+
<div class="<%= qube_classes(:choice_divider, "qube-choice-divider") %>" role="separator"><span><%= QubeRails.t("question.or") %></span></div>
|
|
17
|
+
<% end %>
|
|
18
|
+
<%= render "qube_rails/question/choice", question: question, choice: choice %>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% if question.exits.any? %>
|
|
21
|
+
<%= render "qube_rails/question/footer", question: question %>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
24
|
+
</section>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%# A checkbox: ticked posts "true", sent as true; unticked, false. %>
|
|
2
|
+
<% control = capture do %>
|
|
3
|
+
<%= check_box_tag field.input_name, "true", false, id: field.dom_id, disabled: field.disabled,
|
|
4
|
+
class: qube_classes(:checkbox, "qube-checkbox", field.cell? ? "qube-rows-input" : nil),
|
|
5
|
+
"aria-label": (field.labelled? ? nil : field.label), data: field.data %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<%= render "qube_rails/question/field", field: field, control: control %>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%# A number field: posted as typed, sent as a number. %>
|
|
2
|
+
<% control = capture do %>
|
|
3
|
+
<%= number_field_tag field.input_name, nil, id: field.dom_id, step: "any", required: field.required,
|
|
4
|
+
disabled: field.disabled, class: qube_classes(:input, "qube-input", field.cell? ? "qube-rows-input" : nil),
|
|
5
|
+
"aria-label": (field.labelled? ? nil : field.label), data: field.data %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<%= render "qube_rails/question/field", field: field, control: control %>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<%# A rows field (field.rows, a QubeRails::RowsView): a block per record with the fields it asks,
|
|
2
|
+
posted as data[<field>][<row key>][<column>]. A field shows only while its "when" holds. "Set
|
|
3
|
+
all to:" sets the segmented column on every row, and "Use for all" copies one row's value to
|
|
4
|
+
the others (the behaviour script). %>
|
|
5
|
+
<% rows = field.rows %>
|
|
6
|
+
<% set_all = rows.set_all %>
|
|
7
|
+
<div class="<%= qube_classes(:field, "qube-field", "qube-rows-field") %>" data-qube-rows
|
|
8
|
+
data-qube-text-use-for-all="<%= QubeRails.t("question.use_for_all", count: "%{count}") %>">
|
|
9
|
+
<p class="<%= qube_classes(:label, "qube-rows-label") %>"><%= rows.label %></p>
|
|
10
|
+
<% if set_all %>
|
|
11
|
+
<div class="qube-rows-set-all" data-qube-rows-set-all="<%= set_all.name %>">
|
|
12
|
+
<span class="qube-rows-set-all-label"><%= QubeRails.t("question.set_all") %></span>
|
|
13
|
+
<% set_all.options.each do |option| %>
|
|
14
|
+
<button type="button" class="<%= qube_classes(:button_secondary, "qube-set-all-button") %>" data-qube-set-all="<%= option.value %>"><%= option.label %></button>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
<div class="<%= qube_classes(:rows, "qube-rows") %>" role="list">
|
|
19
|
+
<% rows.rows.each do |row| %>
|
|
20
|
+
<%= render "qube_rails/question/rows/row", rows: rows, row: row %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<%# A record field: what the user types is looked up at field.search_url (GET ?field=&q=&row=,
|
|
2
|
+
answering [{value, label, detail}]), and the match they pick fills the hidden input posted as
|
|
3
|
+
field.input_name. The behaviour script keys on the data-qube-* attributes; the box's
|
|
4
|
+
data-qube-text-* attributes are its words (I18n's qube_rails.question.*). %>
|
|
5
|
+
<% inputs = capture do %>
|
|
6
|
+
<%= tag.input(type: "search", id: field.dom_id, class: qube_classes(:input, "qube-search-input"), maxlength: 100,
|
|
7
|
+
autocomplete: "off", required: field.required, disabled: field.disabled,
|
|
8
|
+
role: "combobox", "aria-autocomplete": "list", "aria-expanded": "false",
|
|
9
|
+
"aria-label": (field.labelled? ? nil : field.label), data: { "qube-search-input" => "" }) %>
|
|
10
|
+
<%= tag.input(type: "hidden", name: field.input_name, disabled: field.disabled,
|
|
11
|
+
class: ["qube-search-value", field.cell? ? "qube-rows-input" : nil].compact.join(" "),
|
|
12
|
+
data: field.data.merge("qube-search-value" => "")) %>
|
|
13
|
+
<div class="<%= qube_classes(:search_results, "qube-search-results") %>" data-qube-search-results aria-live="polite"></div>
|
|
14
|
+
<% end %>
|
|
15
|
+
<% control = content_tag(:div, inputs, class: qube_classes(:search, "qube-search"),
|
|
16
|
+
data: { "qube-search" => field.search_url, "qube-field" => field.search_field,
|
|
17
|
+
"qube-row" => field.row_key,
|
|
18
|
+
"qube-result-class" => qube_classes(:search_result, "qube-search-result"),
|
|
19
|
+
"qube-text-no-matches" => QubeRails.t("question.no_matches"),
|
|
20
|
+
"qube-text-searching" => QubeRails.t("question.searching"),
|
|
21
|
+
"qube-text-search-failed" => QubeRails.t("question.search_failed"),
|
|
22
|
+
"qube-text-pick-a-match" => QubeRails.t("question.pick_a_match") }.compact) %>
|
|
23
|
+
<%= render "qube_rails/question/field", field: field, control: control %>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%# A fixed list (field.options, each value and label), with a blank first. %>
|
|
2
|
+
<% control = capture do %>
|
|
3
|
+
<%= select_tag field.input_name, options_for_select(field.options.map { |o| [o.label, o.value] }),
|
|
4
|
+
id: field.dom_id, include_blank: true, required: field.required, disabled: field.disabled,
|
|
5
|
+
class: qube_classes(:select, "qube-select", field.cell? ? "qube-rows-input" : nil),
|
|
6
|
+
"aria-label": (field.labelled? ? nil : field.label), data: field.data %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<%= render "qube_rails/question/field", field: field, control: control %>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%# A text field. %>
|
|
2
|
+
<% control = capture do %>
|
|
3
|
+
<%= text_field_tag field.input_name, nil, id: field.dom_id, required: field.required, disabled: field.disabled,
|
|
4
|
+
class: qube_classes(:input, "qube-input", field.cell? ? "qube-rows-input" : nil),
|
|
5
|
+
"aria-label": (field.labelled? ? nil : field.label), data: field.data %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<%= render "qube_rails/question/field", field: field, control: control %>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%# One field of a row (a QubeRails::CellView): a segmented choice, a field with its label (and,
|
|
2
|
+
for a search or a select, "Use for all"), or what the row already has, read-only.
|
|
3
|
+
hidden / data-qube-hidden mark a field whose "when" doesn't hold yet (its input disabled and
|
|
4
|
+
not required, so it isn't sent); data-qube-required says to require it once shown. %>
|
|
5
|
+
<% content = capture do %>
|
|
6
|
+
<% if cell.segmented? %>
|
|
7
|
+
<%= render "qube_rails/question/rows/segmented", field: cell.field %>
|
|
8
|
+
<% elsif cell.field %>
|
|
9
|
+
<%= render cell.field.partial, field: cell.field %>
|
|
10
|
+
<% if %w[search select].include?(cell.field.type) %>
|
|
11
|
+
<button type="button" class="<%= qube_classes(:use_for_all, "qube-use-for-all") %>" data-qube-use-for-all hidden></button>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% else %>
|
|
14
|
+
<div class="qube-field qube-row-preset">
|
|
15
|
+
<span class="<%= qube_classes(:label, "qube-label") %>"><%= cell.label %></span>
|
|
16
|
+
<span class="qube-row-preset-value"><%= cell.preset %></span>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% end %>
|
|
20
|
+
<%= content_tag :div, content, class: ["qube-row-cell", cell.segmented? ? "qube-row-cell-segmented" : nil].compact.join(" "),
|
|
21
|
+
hidden: cell.hidden?,
|
|
22
|
+
data: { "qube-col" => cell.column, "qube-when" => cell.when_json,
|
|
23
|
+
"qube-hidden" => (cell.hidden? ? "" : nil),
|
|
24
|
+
"qube-required" => (cell.required ? "" : nil) }.compact %>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%# One record of a rows field (row, a QubeRails::RowView): its name, its segmented choice, then
|
|
2
|
+
the fields its answers so far call for. %>
|
|
3
|
+
<% header_id = "qube-row-#{rows.input_name.gsub(/[^A-Za-z0-9]+/, "-")}#{row.index}".squeeze("-") %>
|
|
4
|
+
<div class="<%= qube_classes(:row, "qube-row") %>" role="listitem" aria-labelledby="<%= header_id %>"
|
|
5
|
+
data-qube-row="<%= row.key %>" data-qube-values="<%= row.values_json %>">
|
|
6
|
+
<div class="<%= qube_classes(:row_header, "qube-row-header") %>" id="<%= header_id %>">
|
|
7
|
+
<span class="qube-row-label"><%= row.label %></span>
|
|
8
|
+
<% if row.detail %>
|
|
9
|
+
<span class="qube-row-detail"><%= row.detail %></span>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="qube-row-body">
|
|
13
|
+
<% row.segmented_cells.each do |cell| %>
|
|
14
|
+
<%= render "qube_rails/question/rows/cell", cell: cell %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<div class="<%= qube_classes(:row_fields, "qube-row-fields") %>">
|
|
17
|
+
<% row.field_cells.each do |cell| %>
|
|
18
|
+
<%= render "qube_rails/question/rows/cell", cell: cell %>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# A short list (field.options, at most three) in a row as a segmented control: radios posted
|
|
2
|
+
as field.input_name, the column's label their group's name for screen readers. %>
|
|
3
|
+
<fieldset class="qube-segmented-field">
|
|
4
|
+
<legend class="qube-visually-hidden"><%= field.label %></legend>
|
|
5
|
+
<div class="<%= qube_classes(:segmented, "qube-segmented") %>">
|
|
6
|
+
<% field.options.each do |option| %>
|
|
7
|
+
<label class="<%= qube_classes(:segment, "qube-segment") %>">
|
|
8
|
+
<%= tag.input(type: "radio", name: field.input_name, value: option.value, required: field.required,
|
|
9
|
+
disabled: field.disabled, class: "qube-segment-input qube-rows-input", data: field.data) %>
|
|
10
|
+
<span><%= option.label %></span>
|
|
11
|
+
</label>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
</fieldset>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%# Why a run failed or was cancelled (run_status.reason), then what to do about it (detail). %>
|
|
2
|
+
<% if run_status.reason %>
|
|
3
|
+
<p class="<%= qube_classes(:reason, "qube-run-reason") %>"><%= run_status.reason %></p>
|
|
4
|
+
<% end %>
|
|
5
|
+
<% if run_status.detail %>
|
|
6
|
+
<p class="<%= qube_classes(:detail, "qube-run-error-detail") %>"><%= run_status.detail %></p>
|
|
7
|
+
<% end %>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<%# A run that looks stuck: posts to redeliver_url, which asks QuBe Sync to send its latest event
|
|
2
|
+
again. The words are I18n's: qube_rails.status.quiet and .check_for_updates. %>
|
|
3
|
+
<p class="qube-run-quiet"><%= run_status.quiet_text %></p>
|
|
4
|
+
<%= button_to QubeRails.t("status.check_for_updates"), run_status.redeliver_url,
|
|
5
|
+
class: qube_classes(:button_secondary, "qube-redeliver"), form: { class: "qube-redeliver-form" } %>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<%# A start QuBe Sync hasn't confirmed for a minute: posts to retry_url, which sends it again with
|
|
2
|
+
the same reference. The words are I18n's: qube_rails.status.try_again. %>
|
|
3
|
+
<%= button_to QubeRails.t("status.try_again"), run_status.retry_url, class: qube_classes(:button, "qube-retry-start"),
|
|
4
|
+
form: { class: "qube-retry-start-form" } %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%# A record's status (run_status, a QubeRails::RunStatusView): the error, then the summary, the
|
|
2
|
+
failure's detail, why it waits on a stalled connection, and the buttons, or only the error
|
|
3
|
+
before it's sent. %>
|
|
4
|
+
<%= render "qube_rails/error", message: run_status.error %>
|
|
5
|
+
<% if run_status.visible? %>
|
|
6
|
+
<div class="<%= qube_classes(:status, run_status.css_class) %>" data-qube-sync-status="<%= run_status.status %>"<% if run_status.stall %> data-qube-stall="<%= run_status.stall %>"<% end %>>
|
|
7
|
+
<%= render "qube_rails/run_status/summary", run_status: run_status %>
|
|
8
|
+
<% if run_status.reason || run_status.detail %>
|
|
9
|
+
<%= render "qube_rails/run_status/detail", run_status: run_status %>
|
|
10
|
+
<% end %>
|
|
11
|
+
<% if run_status.stall %>
|
|
12
|
+
<%= render "qube_rails/run_status/stall", run_status: run_status %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% if run_status.redeliver_url %>
|
|
15
|
+
<%= render "qube_rails/run_status/redeliver", run_status: run_status %>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% if run_status.retry_url %>
|
|
18
|
+
<%= render "qube_rails/run_status/retry", run_status: run_status %>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
21
|
+
<% end %>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<%# The storyboard: each screen (@screens, QubeRails::Storyboard) in a card with its name, a note,
|
|
2
|
+
the screen itself on a canvas, rendered by the engine's partials (or yours, where you've
|
|
3
|
+
overridden them), and the pieces to override for it. Its styles are the opt-in stylesheet's,
|
|
4
|
+
all under .qube-storyboard. %>
|
|
5
|
+
<% single = @screens.size == 1 %>
|
|
6
|
+
<% content_for :title, "QuickBooks screens" %>
|
|
7
|
+
<div class="qube-storyboard" id="qube-storyboard">
|
|
8
|
+
<header class="qube-storyboard-header">
|
|
9
|
+
<div>
|
|
10
|
+
<h1>QuickBooks screens</h1>
|
|
11
|
+
<p class="qube-storyboard-note">
|
|
12
|
+
Every screen your users can meet, from sample data, in your layout. Override a piece by putting a
|
|
13
|
+
file at its path in your app; add classes with config.ui_classes. Nothing here reaches QuBe Sync.
|
|
14
|
+
<% if single %>
|
|
15
|
+
<%= link_to "All screens", qube_rails.storyboard_path %>
|
|
16
|
+
<% end %>
|
|
17
|
+
</p>
|
|
18
|
+
</div>
|
|
19
|
+
<fieldset class="qube-storyboard-theme">
|
|
20
|
+
<legend>Canvas</legend>
|
|
21
|
+
<% [["app", "As your page"], ["light", "Light"], ["dark", "Dark"]].each do |value, label| %>
|
|
22
|
+
<label><input type="radio" name="qube-storyboard-canvas" value="<%= value %>" data-qube-canvas-theme="<%= value %>"<%= " checked" if value == "app" %>><%= label %></label>
|
|
23
|
+
<% end %>
|
|
24
|
+
</fieldset>
|
|
25
|
+
</header>
|
|
26
|
+
<% if flash[:qube_rails_storyboard] %>
|
|
27
|
+
<p class="qube-storyboard-notice" role="status"><%= flash[:qube_rails_storyboard] %></p>
|
|
28
|
+
<% end %>
|
|
29
|
+
<div class="qube-storyboard-body">
|
|
30
|
+
<nav class="qube-storyboard-nav" aria-label="Screens">
|
|
31
|
+
<% QubeRails::Storyboard.screens.group_by(&:group).each do |group, screens| %>
|
|
32
|
+
<div class="qube-storyboard-nav-group">
|
|
33
|
+
<p class="qube-storyboard-nav-title"><%= group %></p>
|
|
34
|
+
<ul>
|
|
35
|
+
<% screens.each do |screen| %>
|
|
36
|
+
<% href = single ? qube_rails.storyboard_screen_path(screen.key) : "#qube-storyboard-#{screen.key}" %>
|
|
37
|
+
<li><%= link_to screen.title, href, "aria-current": (single && @screens.first.key == screen.key ? "page" : nil) %></li>
|
|
38
|
+
<% end %>
|
|
39
|
+
</ul>
|
|
40
|
+
</div>
|
|
41
|
+
<% end %>
|
|
42
|
+
</nav>
|
|
43
|
+
<div class="qube-storyboard-screens">
|
|
44
|
+
<% @screens.each do |screen| %>
|
|
45
|
+
<% answer = qube_rails.storyboard_answer_path(screen.key) %>
|
|
46
|
+
<section class="qube-storyboard-panel" id="qube-storyboard-<%= screen.key %>" data-qube-storyboard="<%= screen.key %>"
|
|
47
|
+
aria-labelledby="qube-storyboard-<%= screen.key %>-title">
|
|
48
|
+
<header class="qube-storyboard-panel-head">
|
|
49
|
+
<h2 id="qube-storyboard-<%= screen.key %>-title"><a href="#qube-storyboard-<%= screen.key %>"><%= screen.title %></a></h2>
|
|
50
|
+
<span class="qube-storyboard-group"><%= screen.group %></span>
|
|
51
|
+
<% unless single %>
|
|
52
|
+
<%= link_to "Open on its own", qube_rails.storyboard_screen_path(screen.key), class: "qube-storyboard-open" %>
|
|
53
|
+
<% end %>
|
|
54
|
+
</header>
|
|
55
|
+
<p class="qube-storyboard-note"><%= screen.note %></p>
|
|
56
|
+
<div class="qube-storyboard-canvas" data-qube-storyboard-canvas>
|
|
57
|
+
<% if screen.kind == :run_status %>
|
|
58
|
+
<% view = QubeRails::Storyboard.run_status_view(screen, QubeRails::Storyboard::Urls.new(answer)) %>
|
|
59
|
+
<% if view.visible? %>
|
|
60
|
+
<%= render "qube_rails/run_status/status", run_status: view %>
|
|
61
|
+
<% else %>
|
|
62
|
+
<p class="qube-storyboard-empty">Nothing is shown before a record is sent.</p>
|
|
63
|
+
<% end %>
|
|
64
|
+
<% elsif screen.kind == :error %>
|
|
65
|
+
<%= render "qube_rails/error", message: screen.sample[:message] %>
|
|
66
|
+
<% else %>
|
|
67
|
+
<%= render "qube_rails/question/question",
|
|
68
|
+
question: QubeRails::Storyboard.question_view(screen, answer, qube_rails.storyboard_search_path(screen.key)) %>
|
|
69
|
+
<% end %>
|
|
70
|
+
</div>
|
|
71
|
+
<% if screen.partials.any? %>
|
|
72
|
+
<details class="qube-storyboard-files">
|
|
73
|
+
<summary>Override files (<%= screen.partials.size %>)</summary>
|
|
74
|
+
<ul>
|
|
75
|
+
<% screen.partials.each do |partial| %>
|
|
76
|
+
<% path = QubeRails::Storyboard.override_path(partial) %>
|
|
77
|
+
<li><code><%= path %></code><button type="button" class="qube-storyboard-copy" data-qube-copy="<%= path %>">Copy</button></li>
|
|
78
|
+
<% end %>
|
|
79
|
+
</ul>
|
|
80
|
+
</details>
|
|
81
|
+
<% end %>
|
|
82
|
+
</section>
|
|
83
|
+
<% end %>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Everything the engine's screens say to your users. Override any key in your app's own
|
|
2
|
+
# config/locales/*.yml (it loads after this file), or add another locale's file to translate them.
|
|
3
|
+
# README, "Wording and translations", lists them.
|
|
4
|
+
en:
|
|
5
|
+
qube_rails:
|
|
6
|
+
status:
|
|
7
|
+
in_quickbooks: "In QuickBooks"
|
|
8
|
+
added: "Added to QuickBooks"
|
|
9
|
+
done: "Done"
|
|
10
|
+
done_with: "Done: %{outcome}"
|
|
11
|
+
failed: "Couldn't sync"
|
|
12
|
+
not_started: "it couldn't be sent"
|
|
13
|
+
cancelled: "Cancelled"
|
|
14
|
+
cancel_reason: "it was stopped before it finished"
|
|
15
|
+
needs_answer: "Needs your answer"
|
|
16
|
+
starting: "Sending to QuickBooks"
|
|
17
|
+
unconfirmed: "Still waiting for QuickBooks"
|
|
18
|
+
running: "Syncing with QuickBooks"
|
|
19
|
+
quiet: "No update from QuickBooks for a while."
|
|
20
|
+
check_for_updates: "Check for updates"
|
|
21
|
+
try_again: "Try sending again"
|
|
22
|
+
waiting_for_quickbooks: "Waiting for QuickBooks"
|
|
23
|
+
stalled:
|
|
24
|
+
not_checking_in: "QuickBooks hasn't checked in for a while, so this is waiting. Make sure the computer with QuickBooks is on and the Web Connector is running."
|
|
25
|
+
quickbooks_unavailable: "The Web Connector is checking in but can't open QuickBooks. Open the company file in QuickBooks on that computer."
|
|
26
|
+
other: "Syncing with QuickBooks has stopped for now. Check the computer with QuickBooks and its Web Connector."
|
|
27
|
+
errors:
|
|
28
|
+
not_starting: "This was already sent, so there's nothing to try again."
|
|
29
|
+
not_quiet: "This was updated recently. Check again in a few minutes."
|
|
30
|
+
no_update_yet: "There's no update yet. Check again in a few minutes."
|
|
31
|
+
checked_just_now: "That was checked a moment ago. Try again in a minute."
|
|
32
|
+
unreachable: "Couldn't reach QuickBooks' sync service just now. Try again in a minute."
|
|
33
|
+
disconnect_unreachable: "Couldn't reach QuickBooks' sync service just now. Try disconnecting again in a minute."
|
|
34
|
+
still_syncing: "Something is still syncing with this company file. Let it finish, or answer its question, then disconnect."
|
|
35
|
+
question_changed: "This question changed after the page loaded, so your answer wasn't sent. The page now shows the latest."
|
|
36
|
+
answer_refused: "That answer couldn't be used. Check it and try again."
|
|
37
|
+
answer_refused_with: "That answer couldn't be used: %{detail}"
|
|
38
|
+
question:
|
|
39
|
+
set_all: "Set all to:"
|
|
40
|
+
use_pick: "Use this one"
|
|
41
|
+
use_for_all: "Use for all %{count}"
|
|
42
|
+
or: "or"
|
|
43
|
+
pick_a_match: "Pick one of the matches."
|
|
44
|
+
no_matches: "No matches"
|
|
45
|
+
searching: "Searching…"
|
|
46
|
+
search_failed: "Search isn't available right now. Try again in a moment."
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
QubeRails::Engine.routes.draw do
|
|
4
|
+
# A "Connect QuickBooks Desktop" button posts here (with an optional name), for a new
|
|
5
|
+
# connection; a connection's "Set up again" posts to its onboarding. QuBe Sync's setup doesn't
|
|
6
|
+
# come back to your app: it tells the connection by webhook.
|
|
7
|
+
post "quickbooks/connections", to: "connections#create", as: :connections
|
|
8
|
+
post "quickbooks/connections/:id/onboarding", to: "connections#onboarding", as: :connection_onboarding
|
|
9
|
+
delete "quickbooks/connections/:id", to: "connections#destroy", as: :connection
|
|
10
|
+
|
|
11
|
+
# Each run's webhook URL is QUBE_WEBHOOK_URL/runs/<run id> (QubeRails::Run#webhook_url), and a
|
|
12
|
+
# connection's is QUBE_WEBHOOK_URL/connections/<connection id>.
|
|
13
|
+
post "webhooks/qube/runs/:id", to: "webhooks#run_event", as: :run_webhook
|
|
14
|
+
post "webhooks/qube/connections/:id", to: "webhooks#connection_event", as: :connection_webhook
|
|
15
|
+
# A plain request's: QUBE_WEBHOOK_URL/requests/<id> (QubeRails::QueuedRequest#webhook_url).
|
|
16
|
+
post "webhooks/qube/requests/:id", to: "webhooks#request_event", as: :request_webhook
|
|
17
|
+
|
|
18
|
+
# A start QuBe Sync never confirmed, sent again, from the run status partial.
|
|
19
|
+
post "qube/runs/:id/retry_start", to: "runs#retry_start", as: :retry_start
|
|
20
|
+
|
|
21
|
+
# A run that looks stuck, its latest event sent again, from the run status partial.
|
|
22
|
+
post "qube/runs/:id/redeliver", to: "runs#redeliver", as: :redeliver
|
|
23
|
+
|
|
24
|
+
# Your user's answer to a run's question, from the question partial, and a record field's
|
|
25
|
+
# search.
|
|
26
|
+
post "qube/questions/runs/:id", to: "questions#create", as: :question
|
|
27
|
+
get "qube/questions/runs/:id/search", to: "questions#search", as: :question_search
|
|
28
|
+
|
|
29
|
+
# Every screen with sample data, while config.storyboard is true: a search answered from the
|
|
30
|
+
# samples, and a no-op every form posts to.
|
|
31
|
+
get "qube/storyboard", to: "storyboard#index", as: :storyboard
|
|
32
|
+
get "qube/storyboard/:screen", to: "storyboard#show", as: :storyboard_screen
|
|
33
|
+
get "qube/storyboard/:screen/search", to: "storyboard#search", as: :storyboard_search
|
|
34
|
+
post "qube/storyboard/:screen/answer", to: "storyboard#answer", as: :storyboard_answer
|
|
35
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# QuBe Sync's tables (qube_rails): your customers' connections, the runs your app starts, the
|
|
4
|
+
# notifications each run's handler has taken, and the QuickBooks ids your app learns, each against
|
|
5
|
+
# its connection. Your own tables need nothing.
|
|
6
|
+
#
|
|
7
|
+
# The polymorphic ids (owner_id, record_id) are strings, so integer and UUID keys both fit.
|
|
8
|
+
# input (what the run was started with, so a start can be resent unchanged), error (a failed
|
|
9
|
+
# run's, as its event carried it), question and links are JSON kept as text (QubeRails::JSONCoder says why). The 100-character
|
|
10
|
+
# limits keep the composite indexes inside MySQL's 3072-byte key limit under utf8mb4.
|
|
11
|
+
class CreateQubeTables < ActiveRecord::Migration[5.2]
|
|
12
|
+
def change
|
|
13
|
+
create_table :qube_connections do |t|
|
|
14
|
+
t.string :owner_type, null: false, limit: 100
|
|
15
|
+
t.string :owner_id, null: false
|
|
16
|
+
t.string :qube_id
|
|
17
|
+
t.string :name
|
|
18
|
+
t.string :status, null: false, default: "pending"
|
|
19
|
+
t.string :stall_reason
|
|
20
|
+
t.integer :revision
|
|
21
|
+
t.timestamps null: false
|
|
22
|
+
end
|
|
23
|
+
add_index :qube_connections, :qube_id, unique: true, name: "index_qube_connections_on_qube_id"
|
|
24
|
+
add_index :qube_connections, [:owner_type, :owner_id], name: "index_qube_connections_on_owner"
|
|
25
|
+
|
|
26
|
+
create_table :qube_runs do |t|
|
|
27
|
+
t.bigint :connection_id, null: false
|
|
28
|
+
t.string :operation, null: false, limit: 100
|
|
29
|
+
t.string :record_type, null: false, limit: 100
|
|
30
|
+
t.string :record_id, null: false
|
|
31
|
+
t.string :reference, null: false
|
|
32
|
+
t.string :qube_run_id
|
|
33
|
+
t.text :input
|
|
34
|
+
t.integer :revision, null: false, default: 0
|
|
35
|
+
t.string :state, null: false, default: "starting"
|
|
36
|
+
t.string :outcome
|
|
37
|
+
t.text :error
|
|
38
|
+
t.text :cancel_reason
|
|
39
|
+
t.datetime :ended_at
|
|
40
|
+
t.text :question
|
|
41
|
+
t.text :links
|
|
42
|
+
t.string :run_url
|
|
43
|
+
t.string :cancel_url
|
|
44
|
+
t.timestamps null: false
|
|
45
|
+
end
|
|
46
|
+
add_index :qube_runs, :reference, unique: true, name: "index_qube_runs_on_reference"
|
|
47
|
+
add_index :qube_runs, :connection_id, name: "index_qube_runs_on_connection_id"
|
|
48
|
+
add_index :qube_runs, [:record_type, :record_id, :operation, :created_at],
|
|
49
|
+
name: "index_qube_runs_on_record_and_operation"
|
|
50
|
+
add_foreign_key :qube_runs, :qube_connections, column: :connection_id
|
|
51
|
+
|
|
52
|
+
# The notifications a run's handler has taken, by delivery_id, so a redelivery runs it once.
|
|
53
|
+
# Written in the same transaction as the handler, and deleted with the run.
|
|
54
|
+
create_table :qube_notifications do |t|
|
|
55
|
+
t.bigint :run_id, null: false
|
|
56
|
+
t.string :delivery_id, null: false, limit: 100
|
|
57
|
+
t.datetime :created_at, null: false
|
|
58
|
+
end
|
|
59
|
+
add_index :qube_notifications, [:run_id, :delivery_id], unique: true,
|
|
60
|
+
name: "index_qube_notifications_on_run_and_delivery"
|
|
61
|
+
add_foreign_key :qube_notifications, :qube_runs, column: :run_id, on_delete: :cascade
|
|
62
|
+
|
|
63
|
+
create_table :qube_quickbooks_records do |t|
|
|
64
|
+
t.bigint :connection_id, null: false
|
|
65
|
+
t.string :kind, null: false, limit: 100
|
|
66
|
+
t.string :list, null: false, limit: 100
|
|
67
|
+
t.string :qb_id, null: false, limit: 100
|
|
68
|
+
t.string :edit_sequence
|
|
69
|
+
t.string :name
|
|
70
|
+
t.string :record_type, limit: 100
|
|
71
|
+
t.string :record_id
|
|
72
|
+
t.timestamps null: false
|
|
73
|
+
end
|
|
74
|
+
# A ListID is unique within its list family (QubeRails::QuickbooksRecord.family_for), not
|
|
75
|
+
# within one kind: every item type shares a counter, and so do customers, vendors, employees
|
|
76
|
+
# and other names.
|
|
77
|
+
add_index :qube_quickbooks_records, [:connection_id, :list, :qb_id], unique: true,
|
|
78
|
+
name: "index_qube_quickbooks_records_on_qb_id"
|
|
79
|
+
add_index :qube_quickbooks_records, [:connection_id, :kind, :record_type, :record_id], unique: true,
|
|
80
|
+
name: "index_qube_quickbooks_records_on_record"
|
|
81
|
+
add_foreign_key :qube_quickbooks_records, :qube_connections, column: :connection_id
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Indexes for QubeRails::SyncStatus and the connection's scopes (in_quickbooks, not_in_quickbooks,
|
|
4
|
+
# needing_answer, sending): a record's link on a connection whatever its kind, and its runs of an
|
|
5
|
+
# operation on a connection by state. Within MySQL's 3072-byte key limit under utf8mb4.
|
|
6
|
+
class AddQubeSyncStatusIndexes < ActiveRecord::Migration[5.2]
|
|
7
|
+
def change
|
|
8
|
+
add_index :qube_quickbooks_records, [:connection_id, :record_type, :record_id],
|
|
9
|
+
name: "index_qube_quickbooks_records_on_connection_and_record"
|
|
10
|
+
add_index :qube_runs, [:connection_id, :record_type, :record_id, :operation, :state],
|
|
11
|
+
name: "index_qube_runs_on_connection_record_and_state"
|
|
12
|
+
end
|
|
13
|
+
end
|