Transaction Chart added to BlockScoutWeb

pull/2404/head
slightlycyborg 5 years ago
parent 20317a5154
commit 449e4cfaf6
  1. 1
      apps/block_scout_web/assets/css/app.scss
  2. 7
      apps/block_scout_web/assets/css/components/_dashboard-banner.scss
  3. 2
      apps/block_scout_web/assets/js/app.js
  4. 94
      apps/block_scout_web/assets/js/lib/history_chart.js
  5. 114
      apps/block_scout_web/assets/js/lib/transaction_history_chart.js
  6. 24
      apps/block_scout_web/assets/js/pages/chain.js
  7. 8
      apps/block_scout_web/config/config.exs
  8. 4
      apps/block_scout_web/lib/block_scout_web/channels/transaction_channel.ex
  9. 38
      apps/block_scout_web/lib/block_scout_web/controllers/chain/transaction_history_chart_controller.ex
  10. 25
      apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex
  11. 12
      apps/block_scout_web/lib/block_scout_web/notifier.ex
  12. 1
      apps/block_scout_web/lib/block_scout_web/realtime_event_handler.ex
  13. 5
      apps/block_scout_web/lib/block_scout_web/router.ex
  14. 46
      apps/block_scout_web/lib/block_scout_web/templates/chain/show.html.eex
  15. 3
      apps/block_scout_web/lib/block_scout_web/templates/layout/app.html.eex
  16. 9
      apps/block_scout_web/priv/gettext/default.pot
  17. 48
      apps/block_scout_web/test/block_scout_web/controllers/chain/transaction_history_chart_controller_test.exs

@ -133,6 +133,7 @@ $fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
dashboardBannerChartAxisFontColor: $dashboard-banner-chart-axis-font-color;
dashboardLineColorMarket: $dashboard-line-color-market;
dashboardLineColorPrice: $dashboard-line-color-price;
dashboardLineColorTransactions: $dashboard-line-color-transactions;
primary: $primary;
secondary: $secondary;
}

@ -6,6 +6,7 @@ $dashboard-banner-gradient-end: lighten(
$dashboard-banner-network-plain-container-background-color: lighten($dashboard-banner-gradient-end, 5%)!default;
$dashboard-line-color-price: lighten($dashboard-banner-gradient-end, 5%) !default;
$dashboard-line-color-market: $secondary !default;
$dashboard-line-color-transactions: $warning !default;
$dashboard-stats-item-label-color: #fff !default;
$dashboard-stats-item-value-color: rgba(#fff, 0.8) !default;
$dashboard-banner-chart-legend-label-color: #fff !default;
@ -66,7 +67,7 @@ $dashboard-banner-chart-axis-font-color: $dashboard-stats-item-value-color !defa
.dashboard-banner-chart-legend {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(var(--numChartData), 0.9fr);
padding-bottom: 12px;
.dashboard-banner-chart-legend-item {
@ -103,6 +104,10 @@ $dashboard-banner-chart-axis-font-color: $dashboard-stats-item-value-color !defa
&:nth-child(2)::before {
background-color: $dashboard-line-color-market;
}
&:nth-child(3)::before {
background-color: $dashboard-line-color-transactions;
}
}
.dashboard-banner-chart-legend-label {

@ -42,7 +42,7 @@ import './lib/currency'
import './lib/from_now'
import './lib/indexing'
import './lib/loading_element'
import './lib/market_history_chart'
import './lib/history_chart'
import './lib/pending_transactions_toggle'
import './lib/pretty_json'
import './lib/reload_button'

@ -44,7 +44,7 @@ const config = {
}
}, {
id: 'marketCap',
position: 'right',
display: false,
gridLines: {
display: false,
drawBorder: false
@ -54,6 +54,20 @@ const config = {
maxTicksLimit: 6,
drawOnChartArea: false
}
}, {
id: 'numTransactions',
display: false,
position: 'right',
gridLines: {
display: false,
drawBorder: false
},
ticks: {
beginAtZero: true,
callback: (value, index, values) => `${numeral(value).format('0,0')}`,
maxTicksLimit: 4,
fontColor: sassVariables.dashboardBannerChartAxisFontColor
}
}]
},
tooltips: {
@ -66,6 +80,8 @@ const config = {
return `${label}: ${formatUsdValue(yLabel)}`
} else if (datasets[datasetIndex].yAxisID === 'marketCap') {
return `${label}: ${formatUsdValue(yLabel)}`
} else if (datasets[datasetIndex].yAxisID === 'numTransactions') {
return `${label}: ${yLabel}`
} else {
return yLabel
}
@ -88,32 +104,65 @@ function getMarketCapData (marketHistoryData, availableSupply) {
}
class MarketHistoryChart {
constructor (el, availableSupply, marketHistoryData) {
constructor (el, availableSupply, marketHistoryData, dataConfig) {
var axes = config.options.scales.yAxes.reduce(function(solution, elem){
solution[elem.id] = elem
return solution
},
{})
this.price = {
label: window.localized['Price'],
yAxisID: 'price',
data: getPriceData(marketHistoryData),
data: [],
fill: false,
pointRadius: 0,
backgroundColor: sassVariables.dashboardLineColorPrice,
borderColor: sassVariables.dashboardLineColorPrice,
lineTension: 0
}
if (dataConfig.market == undefined || dataConfig.market.indexOf("price") == -1){
this.price.hidden = true
axes["price"].display = false
}
this.marketCap = {
label: window.localized['Market Cap'],
yAxisID: 'marketCap',
data: getMarketCapData(marketHistoryData, availableSupply),
data: [],
fill: false,
pointRadius: 0,
backgroundColor: sassVariables.dashboardLineColorMarket,
borderColor: sassVariables.dashboardLineColorMarket,
lineTension: 0
}
if (dataConfig.market == undefined || dataConfig.market.indexOf("market_cap") == -1){
this.marketCap.hidden = true
axes["marketCap"].display = false
}
this.numTransactions = {
label: window.localized['Tx/day'],
yAxisID: 'numTransactions',
data: [],
fill: false,
pointRadius: 0,
backgroundColor: sassVariables.dashboardLineColorMarket,
borderColor: sassVariables.dashboardLineColorTransactions,
lineTension: 0,
}
if (dataConfig.transactions == undefined || dataConfig.transactions.indexOf("transactions_per_day") == -1){
this.numTransactions.hidden = true
axes["numTransactions"].display = false
}
this.availableSupply = availableSupply
config.data.datasets = [this.price, this.marketCap]
//TODO: This is where we dynamically append datasets
config.data.datasets = [this.price, this.marketCap, this.numTransactions]
this.chart = new Chart(el, config)
}
update (availableSupply, marketHistoryData) {
updateMarketHistory (availableSupply, marketHistoryData) {
this.price.data = getPriceData(marketHistoryData)
if (this.availableSupply !== null && typeof this.availableSupply === 'object') {
const today = new Date().toJSON().slice(0, 10)
@ -124,31 +173,50 @@ class MarketHistoryChart {
}
this.chart.update()
}
updateTransactionHistory (transaction_history) {
this.numTransactions.data = transaction_history.map(dataPoint => {
return {x:dataPoint.date, y:dataPoint.number_of_transactions}
})
this.chart.update()
}
}
export function createMarketHistoryChart (el) {
const dataPath = el.dataset.market_history_chart_path
const dataPaths = $(el).data('history_chart_paths')
const dataConfig = $(el).data('history_chart_config')
const $chartLoading = $('[data-chart-loading-message]')
const $chartError = $('[data-chart-error-message]')
const chart = new MarketHistoryChart(el, 0, [])
$.getJSON(dataPath, {type: 'JSON'})
const chart = new MarketHistoryChart(el, 0, [], dataConfig)
Object.keys(dataPaths).forEach(function(history_source){
$.getJSON(dataPaths[history_source], {type: 'JSON'})
.done(data => {
switch(history_source){
case "market":
const availableSupply = JSON.parse(data.supply_data)
const marketHistoryData = humps.camelizeKeys(JSON.parse(data.history_data))
$(el).show()
chart.update(availableSupply, marketHistoryData)
chart.updateMarketHistory(availableSupply, marketHistoryData)
break;
case "transaction":
const transaction_history = JSON.parse(data.history_data)
$(el).show()
chart.updateTransactionHistory(transaction_history)
break;
}
})
.fail(() => {
$chartError.show()
})
.always(() => {
$chartLoading.hide()
})
return chart
})})
return chart;
}
$('[data-chart-error-message]').on('click', _event => {
$('[data-chart-loading-message]').show()
$('[data-chart-error-message]').hide()
createMarketHistoryChart($('[data-chart="marketHistoryChart"]')[0])
createMarketHistoryChart($('[data-chart="historyChart"]')[0])
})

@ -0,0 +1,114 @@
import $ from 'jquery'
import Chart from 'chart.js'
import humps from 'humps'
import numeral from 'numeral'
import { formatUsdValue } from '../lib/currency'
import sassVariables from '../../css/app.scss'
const config = {
type: 'line',
responsive: true,
data: {
datasets: []
},
options: {
legend: {
display: false
},
scales: {
xAxes: [{
gridLines: {
display: false,
drawBorder: false
},
type: 'time',
time: {
unit: 'day',
stepSize: 14
},
ticks: {
fontColor: sassVariables.dashboardBannerChartAxisFontColor
}
}],
yAxes: [{
id: 'num_transactions',
gridLines: {
display: false,
drawBorder: false
},
ticks: {
beginAtZero: true,
callback: (value, index, values) => `$${numeral(value).format('0,0.00')}`,
maxTicksLimit: 4,
fontColor: sassVariables.dashboardBannerChartAxisFontColor
}
}]
},
tooltips: {
mode: 'index',
intersect: false,
callbacks: {
label: ({datasetIndex, yLabel}, {datasets}) => {
const label = datasets[datasetIndex].label
if (datasets[datasetIndex].yAxisID === 'num_transactions') {
return `${label}: ${formatUsdValue(yLabel)}`
} else {
return yLabel
}
}
}
}
}
}
function transformData (marketHistoryData) {
return marketHistoryData.map(
({ date, num_transactions }) => ({x: date, y: num_transactions}))
}
class TransactionHistoryChart {
constructor (el, transactionHistoryData) {
this.num_transactions = {
label: window.localized['Price'],
yAxisID: 'num_transactions',
data: transformData(transactionHistoryData),
fill: false,
pointRadius: 0,
backgroundColor: sassVariables.dashboardLineColorPrice,
borderColor: sassVariables.dashboardLineColorPrice,
lineTension: 0
}
config.data.datasets = [this.num_transactions]
this.chart = new Chart(el, config)
}
update (transactionHistoryData) {
this.num_transactions.data = transformData(TransactionHistoryData)
this.chart.update()
}
}
export function createTransactionHistoryChart (el) {
const dataPath = el.dataset.transaction_history_chart_path
const $chartLoading = $('[data-chart-loading-message]')
const $chartError = $('[data-chart-error-message]')
const chart = new TransactionHistoryChart(el, 0, [])
$.getJSON(dataPath, {type: 'JSON'})
.done(data => {
const transactionStats = JSON.parse(data.history_data)
$(el).show()
chart.update(transactionStats)
})
.fail(() => {
$chartError.show()
})
.always(() => {
$chartLoading.hide()
})
return chart
}
$('[data-chart-error-message]').on('click', _event => {
$('[data-chart-loading-message]').show()
$('[data-chart-error-message]').hide()
createTransactionHistoryChart($('[data-chart="marketHistoryChart"]')[0])
})

@ -7,7 +7,7 @@ import { exchangeRateChannel, formatUsdValue } from '../lib/currency'
import { createStore, connectElements } from '../lib/redux_helpers.js'
import { batchChannel } from '../lib/utils'
import listMorph from '../lib/list_morph'
import { createMarketHistoryChart } from '../lib/market_history_chart'
import { createMarketHistoryChart } from '../lib/history_chart'
const BATCH_THRESHOLD = 6
@ -103,6 +103,11 @@ function baseReducer (state = initialState, action) {
})
}
}
case 'RECIEVED_UPDATED_TRANSACTION_STATS':{
return Object.assign({}, state, {
transactionStats: action.msg.stats,
})
}
case 'START_TRANSACTIONS_FETCH':
return Object.assign({}, state, { transactionsError: false, transactionsLoading: true })
case 'TRANSACTIONS_FETCHED':
@ -137,13 +142,17 @@ function withMissingBlocks (reducer) {
let chart
const elements = {
'[data-chart="marketHistoryChart"]': {
'[data-chart="historyChart"]': {
load ($el) {
chart = createMarketHistoryChart($el[0])
},
render ($el, state, oldState) {
if (!chart || (oldState.availableSupply === state.availableSupply && oldState.marketHistoryData === state.marketHistoryData) || !state.availableSupply) return
chart.update(state.availableSupply, state.marketHistoryData)
if (chart && !(oldState.availableSupply === state.availableSupply && oldState.marketHistoryData === state.marketHistoryData) && state.availableSupply) {
chart.updateMarketHistory(state.availableSupply, state.marketHistoryData)
}
if (chart && !(_.isEqual(oldState.transactionStats, state.transactionStats))){
chart.updateTransactionHistory(state.transactionStats)
}
}
},
'[data-selector="transaction-count"]': {
@ -284,6 +293,13 @@ if ($chainDetailsPage.length) {
type: 'RECEIVED_NEW_TRANSACTION_BATCH',
msgs: humps.camelizeKeys(msgs)
})))
const transactionStatsChannel = socket.channel('transactions:stats')
transactionStatsChannel.join()
transactionStatsChannel.on('update', msg => store.dispatch({
type: 'RECIEVED_UPDATED_TRANSACTION_STATS',
msg: msg
}))
}
function loadTransactions (store) {

@ -61,6 +61,14 @@ config :block_scout_web, BlockScoutWeb.SocialMedia,
facebook: "PoaNetwork",
instagram: "PoaNetwork"
# Configures History
config :block_scout_web,
chart_config: %{market: [:price,:market_cap], transactions: [:transactions_per_day]}
config :block_scout_web, BlockScoutWeb.Chain.TransactionHistoryChartController,
history_size: 30 #days
config :ex_cldr,
default_locale: "en",
locales: ["en"],

@ -18,6 +18,10 @@ defmodule BlockScoutWeb.TransactionChannel do
{:ok, %{}, socket}
end
def join("transactions:stats", _params, socket) do
{:ok, %{}, socket}
end
def join("transactions:" <> _transaction_hash, _params, socket) do
{:ok, %{}, socket}
end

@ -0,0 +1,38 @@
defmodule BlockScoutWeb.Chain.TransactionHistoryChartController do
use BlockScoutWeb, :controller
alias Explorer.Chain.Transaction.History.TransactionStats
def show(conn, _params) do
with true <- ajax?(conn) do
[{:history_size, history_size}] = Application.get_env(:block_scout_web, __MODULE__, 30)
latest = Date.utc_today()
earliest = Date.add(latest, -1 * history_size)
transaction_history_data = TransactionStats.by_date_range(earliest, latest)
|> extract_history
|> encode_transaction_history_data
json(conn, %{
history_data: transaction_history_data,
})
else
_ -> unprocessable_entity(conn)
end
end
defp extract_history(db_results) do
Enum.map(db_results, fn row ->
%{date: row.date, number_of_transactions: row.number_of_transactions} end)
end
defp encode_transaction_history_data(transaction_history_data) do
transaction_history_data
|> Jason.encode()
|> case do
{:ok, data} -> data
_ -> []
end
end
end

@ -4,6 +4,7 @@ defmodule BlockScoutWeb.ChainController do
alias BlockScoutWeb.ChainView
alias Explorer.{Chain, PagingOptions, Repo}
alias Explorer.Chain.{Address, Block, Transaction}
alias Explorer.Chain.Transaction.History.TransactionStats
alias Explorer.Chain.Supply.RSK
alias Explorer.Counters.AverageBlockTime
alias Explorer.ExchangeRates.Token
@ -14,6 +15,7 @@ defmodule BlockScoutWeb.ChainController do
transaction_estimated_count = Chain.transaction_estimated_count()
block_count = Chain.block_estimated_count()
market_cap_calculation =
case Application.get_env(:explorer, :supply) do
RSK ->
@ -25,20 +27,41 @@ defmodule BlockScoutWeb.ChainController do
exchange_rate = Market.get_exchange_rate(Explorer.coin()) || Token.null()
transaction_stats = get_transaction_stats()
chart_data_paths = %{market: market_history_chart_path(conn, :show),
transaction: transaction_history_chart_path(conn, :show)}
chart_config = Application.get_env(:block_scout_web, :chart_config, %{})
render(
conn,
"show.html",
address_count: Chain.count_addresses_with_balance_from_cache(),
average_block_time: AverageBlockTime.average_block_time(),
exchange_rate: exchange_rate,
chart_data_path: market_history_chart_path(conn, :show),
chart_config: chart_config,
chart_config_json: Jason.encode!(chart_config),
chart_data_paths: chart_data_paths,
market_cap_calculation: market_cap_calculation,
transaction_estimated_count: transaction_estimated_count,
transactions_path: recent_transactions_path(conn, :index),
transaction_stats: transaction_stats,
block_count: block_count
)
end
def get_transaction_stats() do
stats_scale = date_range(1)
TransactionStats.by_date_range(stats_scale.earliest, stats_scale.latest)
end
def date_range(num_days) do
today = Date.utc_today()
x_days_back = Date.add(today, -1*(num_days-1))
%{earliest: x_days_back, latest: today}
end
def search(conn, %{"q" => query}) do
query
|> String.trim()

@ -7,6 +7,7 @@ defmodule BlockScoutWeb.Notifier do
alias BlockScoutWeb.Endpoint
alias Explorer.{Chain, Market, Repo}
alias Explorer.Chain.{Address, InternalTransaction, Transaction}
alias Explorer.Chain.Transaction.History.TransactionStats
alias Explorer.Counters.AverageBlockTime
alias Explorer.ExchangeRates.Token
@ -85,6 +86,17 @@ defmodule BlockScoutWeb.Notifier do
|> Enum.each(&broadcast_transaction/1)
end
def handle_event({:chain_event, :transaction_stats}) do
today = Date.utc_today()
[{:history_size, history_size}] = Application.get_env(:block_scout_web, BlockScoutWeb.Chain.TransactionHistoryChartController, 30)
x_days_back = Date.add(today, -1 * history_size)
stats = TransactionStats.by_date_range(x_days_back, today)
|> Enum.map(fn item -> Map.drop(item, [:__meta__]) end)
Endpoint.broadcast("transactions:stats", "update", %{stats: stats})
end
def handle_event(_), do: nil
@doc """

@ -25,6 +25,7 @@ defmodule BlockScoutWeb.RealtimeEventHandler do
Subscriber.to(:address_coin_balances, :on_demand)
# Does not come from the indexer
Subscriber.to(:exchange_rate)
Subscriber.to(:transaction_stats)
{:ok, []}
end

@ -81,6 +81,11 @@ defmodule BlockScoutWeb.Router do
singleton: true
)
resources("/transaction_history_chart", Chain.TransactionHistoryChartController,
only: [:show],
singleton: true
)
resources "/blocks", BlockController, only: [:index, :show], param: "hash_or_number" do
resources("/transactions", BlockTransactionController, only: [:index], as: :transaction)
end

@ -15,10 +15,36 @@
<button data-chart-error-message class="alert alert-danger col-12 text-left mt-5" style="display: none;">
<span><%= gettext("There was a problem loading the chart.") %></span>
</button>
<canvas data-chart="marketHistoryChart" data-market_history_chart_path="<%= @chart_data_path %>" width="350" height="152" style="display: none;"></canvas>
<canvas data-chart="historyChart"
data-history_chart_paths=
'{<%= for {{key, value}, x} <- Enum.with_index(@chart_data_paths) do %>
"<%= key %>":"<%= value %>"
<%= if x<(map_size(@chart_data_paths)-1) do %>
,
<% end %>
<% end %>}'
data-history_chart_config = '<%= @chart_config_json %>'
width="350" height="152" style="display: none;"></canvas>
</div>
<!-- Legend -->
<script>
numChartData = <%= Enum.reduce(@chart_config, 0, fn (source, acc) ->
acc + length(elem(source, 1)) end) %>
document.documentElement.style.setProperty("--numChartData", numChartData);
</script>
<div class="dashboard-banner-chart-legend">
<%= if Map.has_key?(@chart_config, :market) do %>
<%# THE FOLLOWING LINE PREVENTS COPY/PASTE ERRORS %>
<%# Explicity put @chart_config.market in a variable %>
<%# This is done so that when people add a new chart source, x, %>
<%# They wont just access @chart_config.x w/o first checking if x exists%>
<% market_chart_config = @chart_config.market%>
<%= if Enum.member?(market_chart_config, :price) do %>
<div class="dashboard-banner-chart-legend-item">
<span class="dashboard-banner-chart-legend-label">
<%= gettext "Price" %>
@ -26,6 +52,8 @@
<span class="dashboard-banner-chart-legend-value" data-selector="exchange-rate" data-wei-value="<%= Explorer.Chain.Wei.from(Decimal.new(1), :ether).value %>" data-usd-exchange-rate="<%= @exchange_rate.usd_value %>">
</span>
</div>
<% end %>
<%= if Enum.member?(@chart_config.market, :market_cap) do %>
<div class="dashboard-banner-chart-legend-item">
<span class="dashboard-banner-chart-legend-label">
<%= gettext "Market Cap" %>
@ -33,6 +61,22 @@
<span class="dashboard-banner-chart-legend-value" data-selector="market-cap" data-usd-value="<%= market_cap(@market_cap_calculation, @exchange_rate) %>">
</span>
</div>
<% end %>
<% end %>
<%= if Map.has_key?(@chart_config, :transactions) do %>
<% transaction_chart_config = @chart_config.transactions%>
<%= if Enum.member?(transaction_chart_config, :transactions_per_day) do %>
<div class="dashboard-banner-chart-legend-item">
<span class="dashboard-banner-chart-legend-label">
<%= gettext "Tx/day" %>
</span>
<span class="dashboard-banner-chart-legend-value" data-selector="numTransactions">
<%= Enum.at(@transaction_stats, 0).number_of_transactions %>
</span>
</div>
<% end %>
<% end %>
</div>
</div>
<!-- Stats -->

@ -52,7 +52,8 @@
'Less than': '<%= gettext("Less than") %>',
'Market Cap': '<%= gettext("Market Cap") %>',
'Price': '<%= gettext("Price") %>',
'Ether': '<%= gettext("Ether") %>'
'Ether': '<%= gettext("Ether") %>',
'Tx/day': '<%= gettext("Tx/day") %>'
}
</script>
<script src="<%= static_path(@conn, "/js/app.js") %>"></script>

@ -695,6 +695,15 @@ msgstr ""
msgid "Success"
msgstr ""
#, elixir-format
#: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:21
#: lib/block_scout_web/templates/transaction/_tile.html.eex:34
#: lib/block_scout_web/templates/transaction/overview.html.eex:84
msgid "Tx/day"
msgstr ""
#, elixir-format
#: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:21
#: lib/block_scout_web/templates/transaction/_tile.html.eex:34

@ -0,0 +1,48 @@
defmodule BlockScoutWeb.Chain.TransactionHistoryChartControllerTest do
use BlockScoutWeb.ConnCase
alias BlockScoutWeb.Chain.TransactionHistoryChartController
alias Explorer.Chain.Transaction.History.TransactionStats
alias Explorer.Repo
describe "GET show/2" do
test "returns error when not an ajax request" do
path = transaction_history_chart_path(BlockScoutWeb.Endpoint, :show)
conn = get(build_conn(), path)
assert conn.status == 422
end
test "returns ok when request is ajax" do
path = transaction_history_chart_path(BlockScoutWeb.Endpoint, :show)
conn =
build_conn()
|> put_req_header("x-requested-with", "xmlhttprequest")
|> get(path)
assert json_response(conn, 200)
end
test "returns appropriate json data" do
td = Date.utc_today()
dts = [Date.utc_today(), Date.add(td, -1), Date.add(td, -2)]
some_transaction_stats = [%{date: Enum.at(dts, 0), number_of_transactions: 10},
%{date: Enum.at(dts, 1), number_of_transactions: 20},
%{date: Enum.at(dts, 2), number_of_transactions: 30}]
{num, _} = Repo.insert_all(TransactionStats, some_transaction_stats)
assert num == 3
conn =
build_conn()
|> put_req_header("x-requested-with", "xmlhttprequest")
|> TransactionHistoryChartController.show([])
expected = "{\"history_data\":\"[{\\\"date\\\":\\\"2019-07-12\\\",\\\"number_of_transactions\\\":10},{\\\"date\\\":\\\"2019-07-11\\\",\\\"number_of_transactions\\\":20},{\\\"date\\\":\\\"2019-07-10\\\",\\\"number_of_transactions\\\":30}]\"}"
assert conn.resp_body =~ expected
end
end
end
Loading…
Cancel
Save