Implement search and pagination in Google Keywords modal (#4378)

* Extend the GSC API with search functionality

* Fix typo in error tuple atom

* log GSC errors

* rename confusing variable name

* Fix the API response format and error handling

* Read results under the `results` key to be consistent with other
  endpoints

* Return errors with a non-200 status code, and with an error payload
  that will be well constructed into ApiError

* rebuild Google Keywords modal with useAPIClient

* Add pagination support in Search Terms API

* delete unused fixture file

* rename fixture files

* fix tests
This commit is contained in:
RobertJoonas 2024-07-23 11:53:52 +03:00 committed by GitHub
parent afc2fbaeb2
commit 2f87832532
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 286 additions and 224 deletions

View File

@ -59,8 +59,8 @@ export function get(url, query = {}, ...extraQuery) {
return fetch(url, { signal: abortController.signal, headers: headers }) return fetch(url, { signal: abortController.signal, headers: headers })
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
return response.json().then((msg) => { return response.json().then((payload) => {
throw new ApiError(msg.error, msg) throw new ApiError(payload.error, payload)
}) })
} }
return response.json() return response.json()

View File

@ -71,7 +71,8 @@ export function useAPIClient(props) {
const getNextPageParam = (lastPageResults, _, lastPageIndex) => { const getNextPageParam = (lastPageResults, _, lastPageIndex) => {
return lastPageResults.length === LIMIT ? lastPageIndex + 1 : null return lastPageResults.length === LIMIT ? lastPageIndex + 1 : null
} }
const initialPageParam = 1 const defaultInitialPageParam = 1
const initialPageParam = props.initialPageParam === undefined ? defaultInitialPageParam : props.initialPageParam
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: key, queryKey: key,

View File

@ -6,7 +6,8 @@ import { useQueryContext } from "../../query-context";
import { useSiteContext } from "../../site-context"; import { useSiteContext } from "../../site-context";
import { useDebounce } from "../../custom-hooks"; import { useDebounce } from "../../custom-hooks";
import { useAPIClient } from "../../hooks/api-client"; import { useAPIClient } from "../../hooks/api-client";
const MIN_HEIGHT_PX = 500
export const MIN_HEIGHT_PX = 500
// The main function component for rendering the "Details" reports on the dashboard, // The main function component for rendering the "Details" reports on the dashboard,
// i.e. a breakdown by a single (non-time) dimension, with a given set of metrics. // i.e. a breakdown by a single (non-time) dimension, with a given set of metrics.

View File

@ -1,118 +1,193 @@
import React from "react"; import React, { useState, useEffect, useRef } from "react";
import { Link, withRouter } from 'react-router-dom'
import Modal from './modal' import Modal from './modal'
import * as api from '../../api'
import numberFormatter, { percentageFormatter } from '../../util/number-formatter'
import { parseQuery } from '../../query'
import RocketIcon from './rocket-icon' import RocketIcon from './rocket-icon'
import { useQueryContext } from "../../query-context";
import { useSiteContext } from "../../site-context";
import { useAPIClient } from "../../hooks/api-client";
import { useDebounce } from "../../custom-hooks";
import { createVisitors, Metric, renderNumberWithTooltip } from "../reports/metrics";
import numberFormatter, { percentageFormatter } from "../../util/number-formatter";
import classNames from "classnames";
import { MIN_HEIGHT_PX } from "./breakdown-modal";
class GoogleKeywordsModal extends React.Component { function GoogleKeywordsModal() {
constructor(props) { const searchBoxRef = useRef(null)
super(props) const { query } = useQueryContext()
this.state = { const site = useSiteContext()
loading: true, const endpoint = `/api/stats/${encodeURIComponent(site.domain)}/referrers/Google`
query: parseQuery(props.location.search, props.site)
}
}
componentDidMount() { const [search, setSearch] = useState('')
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/Google`, this.state.query, { limit: 100 })
.then((res) => this.setState({
loading: false,
searchTerms: res.search_terms,
notConfigured: res.not_configured,
isOwner: res.is_owner
}))
}
renderTerm(term) { const metrics = [
return ( createVisitors({renderLabel: (_query) => 'Visitors'}),
<React.Fragment key={term.name}> new Metric({key: 'impressions', renderLabel: (_query) => 'Impressions', renderValue: renderNumberWithTooltip}),
<tr className="text-sm dark:text-gray-200" key={term.name}> new Metric({key: 'ctr', renderLabel: (_query) => 'CTR', renderValue: percentageFormatter}),
<td className="p-2">{term.name}</td> new Metric({key: 'position', renderLabel: (_query) => 'Position', renderValue: numberFormatter})
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(term.visitors)}</td> ]
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(term.impressions)}</td>
<td className="p-2 w-32 font-medium" align="right">{percentageFormatter(term.ctr)}</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(term.position)}</td>
</tr>
</React.Fragment>
)
}
renderKeywords() { const {
if (this.state.notConfigured) { data,
if (this.state.isOwner) { hasNextPage,
return ( fetchNextPage,
<div className="text-center text-gray-700 dark:text-gray-300 mt-6"> isFetchingNextPage,
<RocketIcon /> isFetching,
<div className="text-lg">The site is not connected to Google Search Keywords</div> isPending,
<div className="text-lg">Configure the integration to view search terms</div> error,
<a href={`/${encodeURIComponent(this.props.site.domain)}/settings/integrations`} className="button mt-4">Connect with Google</a> status
</div> } = useAPIClient({
) key: [endpoint, {query, search}],
} else { getRequestParams: (key) => {
return ( const [_endpoint, {query, search}] = key
<div className="text-center text-gray-700 dark:text-gray-300 mt-6"> const params = { detailed: true }
<RocketIcon />
<div className="text-lg">The site is not connected to Google Search Kewyords</div> return [query, search === '' ? params : {...params, search}]
<div className="text-lg">Cannot show search terms</div> },
</div> initialPageParam: 0
) })
useEffect(() => {
const searchBox = searchBoxRef.current
const handleKeyUp = (event) => {
if (event.key === 'Escape') {
event.target.blur()
event.stopPropagation()
} }
} else if (this.state.searchTerms.length > 0) {
return (
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Search Term</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Visitors</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Impressions</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CTR</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Position</th>
</tr>
</thead>
<tbody>
{this.state.searchTerms.map(this.renderTerm.bind(this))}
</tbody>
</table>
)
} else {
return (
<div className="text-center text-gray-700 dark:text-gray-300 mt-6">
<RocketIcon />
<div className="text-lg">Could not find any search terms for this period</div>
</div>
)
} }
}
renderBody() { searchBox.addEventListener('keyup', handleKeyUp);
if (this.state.loading) {
return (
<div className="loading mt-32 mx-auto"><div></div></div>
)
} else {
return (
<React.Fragment>
<Link to={`/${encodeURIComponent(this.props.site.domain)}/referrers${window.location.search}`} className="font-bold text-gray-700 dark:text-gray-200 hover:underline"> All referrers</Link>
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div> return () => {
<main className="modal__content"> searchBox.removeEventListener('keyup', handleKeyUp);
{this.renderKeywords()}
</main>
</React.Fragment>
)
} }
} }, [])
render() { function renderRow(item) {
return ( return (
<Modal show={!this.state.loading}> <tr className="text-sm dark:text-gray-200" key={item.name}>
{this.renderBody()} <td className="p-2">{item.name}</td>
</Modal> {metrics.map((metric) => {
return (
<td key={metric.key} className="p-2 w-32 font-medium" align="right">
{metric.renderValue(item[metric.key])}
</td>
)
})}
</tr>
) )
} }
function renderInitialLoadingSpinner() {
return (
<div className="w-full h-full flex flex-col justify-center" style={{ minHeight: `${MIN_HEIGHT_PX}px` }}>
<div className="mx-auto loading"><div></div></div>
</div>
)
}
function renderSmallLoadingSpinner() {
return (
<div className="loading sm"><div></div></div>
)
}
function renderLoadMoreButton() {
if (isPending) return null
if (!isFetching && !hasNextPage) return null
return (
<div className="flex flex-col w-full my-4 items-center justify-center h-10">
{!isFetching && <button onClick={fetchNextPage} type="button" className="button">Load more</button>}
{isFetchingNextPage && renderSmallLoadingSpinner()}
</div>
)
}
function handleInputChange(e) {
setSearch(e.target.value)
}
const debouncedHandleInputChange = useDebounce(handleInputChange)
function renderSearchInput() {
const searchBoxClass = classNames('shadow-sm dark:bg-gray-900 dark:text-gray-100 focus:ring-indigo-500 focus:border-indigo-500 block sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800 w-48', {
'pointer-events-none' : status === 'error'
})
return (
<input
ref={searchBoxRef}
type="text"
placeholder={"Search"}
className={searchBoxClass}
onChange={debouncedHandleInputChange}
/>
)
}
function renderModalBody() {
if (data?.pages?.length) {
return (
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th
className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
align="left"
>
Search term
</th>
{metrics.map((metric) => {
return (
<th key={metric.key} className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">
{metric.renderLabel(query)}
</th>
)
})}
</tr>
</thead>
<tbody>
{data.pages.map((p) => p.map(renderRow))}
</tbody>
</table>
</main>
)
}
}
function renderError() {
return (
<div
className="grid grid-rows-2 text-gray-700 dark:text-gray-300"
style={{ height: `${MIN_HEIGHT_PX}px` }}
>
<div className="text-center self-end"><RocketIcon /></div>
<div className="text-lg text-center">{error.message}</div>
</div>
)
}
return (
<Modal >
<div className="w-full h-full">
<div className="flex justify-between items-center">
<div className="flex items-center gap-x-2">
<h1 className="text-xl font-bold dark:text-gray-100">Google Search Terms</h1>
{!isPending && isFetching && renderSmallLoadingSpinner()}
</div>
{renderSearchInput()}
</div>
<div className="my-4 border-b border-gray-300"></div>
<div style={{ minHeight: `${MIN_HEIGHT_PX}px` }}>
{status === 'error' && renderError()}
{isPending && renderInitialLoadingSpinner()}
{!isPending && renderModalBody()}
{renderLoadMoreButton()}
</div>
</div>
</Modal>
)
} }
export default withRouter(GoogleKeywordsModal) export default GoogleKeywordsModal

View File

@ -159,6 +159,6 @@ export const createExitRate = (props) => {
return new Metric({...props, key: "exit_rate", renderValue, renderLabel}) return new Metric({...props, key: "exit_rate", renderValue, renderLabel})
} }
function renderNumberWithTooltip(value) { export function renderNumberWithTooltip(value) {
return <span tooltip={value}>{numberFormatter(value)}</span> return <span tooltip={value}>{numberFormatter(value)}</span>
} }

View File

@ -7,10 +7,19 @@ import RocketIcon from '../modals/rocket-icon'
import * as api from '../../api' import * as api from '../../api'
import LazyLoader from '../../components/lazy-loader' import LazyLoader from '../../components/lazy-loader'
export function ConfigureSearchTermsCTA({site}) {
return (
<>
<div>Configure the integration to view search terms</div>
<a href={`/${encodeURIComponent(site.domain)}/settings/integrations`} className="button mt-4">Connect with Google</a>
</>
)
}
export default class SearchTerms extends React.Component { export default class SearchTerms extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = { loading: true } this.state = { loading: true, errorPayload: null }
this.onVisible = this.onVisible.bind(this) this.onVisible = this.onVisible.bind(this)
this.fetchSearchTerms = this.fetchSearchTerms.bind(this) this.fetchSearchTerms = this.fetchSearchTerms.bind(this)
} }
@ -37,14 +46,11 @@ export default class SearchTerms extends React.Component {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/Google`, this.props.query) api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/Google`, this.props.query)
.then((res) => this.setState({ .then((res) => this.setState({
loading: false, loading: false,
searchTerms: res.search_terms || [], searchTerms: res.results,
notConfigured: res.not_configured, errorPayload: null
isAdmin: res.is_admin,
unsupportedFilters: res.unsupported_filters
})).catch((error) => { })).catch((error) => {
this.setState({ loading: false, searchTerms: [], notConfigured: true, error: true, isAdmin: error.payload.is_admin }) this.setState({ loading: false, searchTerms: [], errorPayload: error.payload })
} })
)
} }
renderSearchTerm(term) { renderSearchTerm(term) {
@ -68,22 +74,14 @@ export default class SearchTerms extends React.Component {
} }
renderList() { renderList() {
if (this.state.unsupportedFilters) { if (this.state.errorPayload) {
const {reason, is_admin, error} = this.state.errorPayload
return ( return (
<div className="text-center text-gray-700 dark:text-gray-300 text-sm mt-20"> <div className="text-center text-gray-700 dark:text-gray-300 text-sm mt-20">
<RocketIcon /> <RocketIcon />
<div>Unable to fetch keyword data from Search Console because it does not support the current set of filters</div> <div>{error}</div>
</div> {reason === 'not_configured' && is_admin && <ConfigureSearchTermsCTA site={this.props.site}/> }
)
} else if (this.state.notConfigured) {
return (
<div className="text-center text-gray-700 dark:text-gray-300 text-sm mt-20">
<RocketIcon />
<div>
This site is not connected to Search Console so we cannot show the search terms
{this.state.isAdmin && this.state.error && <><br /><br /><p>Please click below to connect your Search Console account.</p></>}
</div>
{this.state.isAdmin && <a href={`/${encodeURIComponent(this.props.site.domain)}/settings/integrations`} className="button mt-4">Connect with Google</a>}
</div> </div>
) )
} else if (this.state.searchTerms.length > 0) { } else if (this.state.searchTerms.length > 0) {

View File

@ -1,50 +0,0 @@
[
{
"status": 200,
"url": "https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Adummy.test/searchAnalytics/query",
"method": "post",
"request_body": {
"dimensionFilterGroups": [
{
"filters": [
{
"dimension": "page",
"expression": "https://sc-domain%3Adummy.test5"
}
]
}
],
"dimensions": [
"query"
],
"endDate": "2022-01-05",
"rowLimit": 5,
"startDate": "2022-01-01"
},
"response_body": {
"responseAggregationType": "auto",
"rows": [
{
"clicks": 25.0,
"ctr": 0.3,
"impressions": 50.0,
"keys": [
"keyword1",
"keyword2"
],
"position": 2.0
},
{
"clicks": 15.0,
"ctr": 0.5,
"impressions": 25.0,
"keys": [
"keyword3",
"keyword4"
],
"position": 4.0
}
]
}
}
]

View File

@ -10,6 +10,7 @@
], ],
"endDate": "2022-01-05", "endDate": "2022-01-05",
"rowLimit": 5, "rowLimit": 5,
"startRow": 0,
"startDate": "2022-01-01" "startDate": "2022-01-01"
}, },
"response_body": { "response_body": {

View File

@ -59,18 +59,18 @@ defmodule Plausible.Google.API do
end end
end end
def fetch_stats(site, query, limit) do def fetch_stats(site, query, pagination, search) do
with {:ok, site} <- ensure_search_console_property(site), with {:ok, site} <- ensure_search_console_property(site),
{:ok, access_token} <- maybe_refresh_token(site.google_auth), {:ok, access_token} <- maybe_refresh_token(site.google_auth),
{:ok, search_console_filters} <- {:ok, gsc_filters} <-
SearchConsole.Filters.transform(site.google_auth.property, query.filters), SearchConsole.Filters.transform(site.google_auth.property, query.filters, search),
{:ok, stats} <- {:ok, stats} <-
HTTP.list_stats( HTTP.list_stats(
access_token, access_token,
site.google_auth.property, site.google_auth.property,
query.date_range, query.date_range,
limit, pagination,
search_console_filters gsc_filters
) do ) do
stats stats
|> Map.get("rows", []) |> Map.get("rows", [])

View File

@ -39,12 +39,15 @@ defmodule Plausible.Google.HTTP do
response.body response.body
end end
def list_stats(access_token, property, date_range, limit, search_console_filters) do def list_stats(access_token, property, date_range, pagination, search_console_filters) do
{limit, page} = pagination
params = %{ params = %{
startDate: Date.to_iso8601(date_range.first), startDate: Date.to_iso8601(date_range.first),
endDate: Date.to_iso8601(date_range.last), endDate: Date.to_iso8601(date_range.last),
dimensions: ["query"], dimensions: ["query"],
rowLimit: limit, rowLimit: limit,
startRow: page * limit,
dimensionFilterGroups: search_console_filters dimensionFilterGroups: search_console_filters
} }
@ -65,7 +68,7 @@ defmodule Plausible.Google.HTTP do
{:error, error} {:error, error}
{:error, reason} -> {:error, reason} ->
Logger.error("Google Analytics: failed to list stats: #{inspect(reason)}") Logger.error("Google Search Console: failed to list stats: #{inspect(reason)}")
{:error, "failed_to_list_stats"} {:error, "failed_to_list_stats"}
end end
end end

View File

@ -2,17 +2,18 @@ defmodule Plausible.Google.SearchConsole.Filters do
@moduledoc false @moduledoc false
import Plausible.Stats.Filters.Utils, only: [page_regex: 1] import Plausible.Stats.Filters.Utils, only: [page_regex: 1]
def transform(property, plausible_filters) do def transform(property, plausible_filters, search) do
search_console_filters = gsc_filters =
Enum.reduce_while(plausible_filters, [], fn plausible_filter, search_console_filters -> Enum.reduce_while(plausible_filters, [], fn plausible_filter, gsc_filters ->
case transform_filter(property, plausible_filter) do case transform_filter(property, plausible_filter) do
:unsupported -> {:halt, :unsupported_filters} :unsupported -> {:halt, :unsupported_filters}
:ignore -> {:cont, search_console_filters} :ignore -> {:cont, gsc_filters}
search_console_filter -> {:cont, [search_console_filter | search_console_filters]} gsc_filter -> {:cont, [gsc_filter | gsc_filters]}
end end
end) end)
|> maybe_add_search_filter(search)
case search_console_filters do case gsc_filters do
:unsupported_filters -> :unsupported_filters :unsupported_filters -> :unsupported_filters
[] -> {:ok, []} [] -> {:ok, []}
filters when is_list(filters) -> {:ok, [%{filters: filters}]} filters when is_list(filters) -> {:ok, [%{filters: filters}]}
@ -64,4 +65,10 @@ defmodule Plausible.Google.SearchConsole.Filters do
country = Location.Country.get_country(alpha_2) country = Location.Country.get_country(alpha_2)
country.alpha_3 country.alpha_3
end end
defp maybe_add_search_filter(gsc_filters, search) when byte_size(search) > 0 do
[%{operator: "includingRegex", expression: search, dimension: "query"} | gsc_filters]
end
defp maybe_add_search_filter(gsc_filters, _search), do: gsc_filters
end end

View File

@ -752,23 +752,46 @@ defmodule PlausibleWeb.Api.StatsController do
user_id = get_session(conn, :current_user_id) user_id = get_session(conn, :current_user_id)
is_admin = user_id && Plausible.Sites.has_admin_access?(user_id, site) is_admin = user_id && Plausible.Sites.has_admin_access?(user_id, site)
case google_api().fetch_stats(site, query, params["limit"] || 9) do pagination = {
{:error, :google_propery_not_configured} -> to_int(params["limit"], 9),
json(conn, %{not_configured: true, is_admin: is_admin}) to_int(params["page"], 0)
}
search = params["search"] || ""
not_configured_error_payload =
%{
error: "The site is not connected to Google Search Keywords",
reason: :not_configured,
is_admin: is_admin
}
unsupported_filters_error_payload = %{
error:
"Unable to fetch keyword data from Search Console because it does not support the current set of filters",
reason: :unsupported_filters
}
case google_api().fetch_stats(site, query, pagination, search) do
{:error, :google_property_not_configured} ->
conn
|> put_status(422)
|> json(not_configured_error_payload)
{:error, :unsupported_filters} -> {:error, :unsupported_filters} ->
json(conn, %{unsupported_filters: true}) conn
|> put_status(422)
|> json(unsupported_filters_error_payload)
{:ok, terms} -> {:ok, terms} ->
json(conn, %{search_terms: terms}) json(conn, %{results: terms})
{:error, error} ->
Logger.error("Plausible.Google.API.fetch_stats failed with error: `#{inspect(error)}`")
{:error, _} ->
conn conn
|> put_status(502) |> put_status(502)
|> json(%{ |> json(not_configured_error_payload)
not_configured: true,
is_admin: is_admin
})
end end
end end

View File

@ -34,6 +34,7 @@ defmodule Plausible.Google.APITest do
dimensions: ["query"], dimensions: ["query"],
endDate: "2022-01-05", endDate: "2022-01-05",
rowLimit: 5, rowLimit: 5,
startRow: 0,
startDate: "2022-01-01" startDate: "2022-01-01"
} -> } ->
{:error, %{reason: %Finch.Response{status: Enum.random([401, 403])}}} {:error, %{reason: %Finch.Response{status: Enum.random([401, 403])}}}
@ -42,7 +43,7 @@ defmodule Plausible.Google.APITest do
query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])} query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])}
assert {:error, "google_auth_error"} = Google.API.fetch_stats(site, query, 5) assert {:error, "google_auth_error"} = Google.API.fetch_stats(site, query, {5, 0}, "")
end end
test "returns whatever error code google returns on API client error", %{site: site} do test "returns whatever error code google returns on API client error", %{site: site} do
@ -59,7 +60,7 @@ defmodule Plausible.Google.APITest do
query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])} query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])}
assert {:error, "some_error"} = Google.API.fetch_stats(site, query, 5) assert {:error, "some_error"} = Google.API.fetch_stats(site, query, {5, 0}, "")
end end
test "returns generic HTTP error and logs it", %{site: site} do test "returns generic HTTP error and logs it", %{site: site} do
@ -79,15 +80,16 @@ defmodule Plausible.Google.APITest do
log = log =
capture_log(fn -> capture_log(fn ->
assert {:error, "failed_to_list_stats"} = assert {:error, "failed_to_list_stats"} =
Google.API.fetch_stats(site, query, 5) Google.API.fetch_stats(site, query, {5, 0}, "")
end) end)
assert log =~ "Google Analytics: failed to list stats: %Finch.Error{reason: :some_reason}" assert log =~
"Google Search Console: failed to list stats: %Finch.Error{reason: :some_reason}"
end end
end end
test "returns error when token refresh fails", %{user: user, site: site} do test "returns error when token refresh fails", %{user: user, site: site} do
mock_http_with("google_analytics_auth#invalid_grant.json") mock_http_with("google_auth#invalid_grant.json")
insert(:google_auth, insert(:google_auth,
user: user, user: user,
@ -100,13 +102,13 @@ defmodule Plausible.Google.APITest do
query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])} query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])}
assert {:error, "invalid_grant"} = Google.API.fetch_stats(site, query, 5) assert {:error, "invalid_grant"} = Google.API.fetch_stats(site, query, 5, "")
end end
test "returns error when google auth not configured", %{site: site} do test "returns error when google auth not configured", %{site: site} do
query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])} query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])}
assert {:error, :google_property_not_configured} = Google.API.fetch_stats(site, query, 5) assert {:error, :google_property_not_configured} = Google.API.fetch_stats(site, query, 5, "")
end end
describe "fetch_stats/3 with valid auth" do describe "fetch_stats/3 with valid auth" do
@ -122,7 +124,7 @@ defmodule Plausible.Google.APITest do
end end
test "returns name and visitor count", %{site: site} do test "returns name and visitor count", %{site: site} do
mock_http_with("google_analytics_stats.json") mock_http_with("google_search_console.json")
query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])} query = %Plausible.Stats.Query{date_range: Date.range(~D[2022-01-01], ~D[2022-01-05])}
@ -130,7 +132,7 @@ defmodule Plausible.Google.APITest do
[ [
%{name: "keyword1", visitors: 25, ctr: 36.8, impressions: 50, position: 2.2}, %{name: "keyword1", visitors: 25, ctr: 36.8, impressions: 50, position: 2.2},
%{name: "keyword3", visitors: 15} %{name: "keyword3", visitors: 15}
]} = Google.API.fetch_stats(site, query, 5) ]} = Google.API.fetch_stats(site, query, {5, 0}, "")
end end
test "transforms page filters to search console format", %{site: site} do test "transforms page filters to search console format", %{site: site} do
@ -147,6 +149,7 @@ defmodule Plausible.Google.APITest do
dimensions: ["query"], dimensions: ["query"],
endDate: "2022-01-05", endDate: "2022-01-05",
rowLimit: 5, rowLimit: 5,
startRow: 0,
startDate: "2022-01-01" startDate: "2022-01-01"
} -> } ->
{:ok, %Finch.Response{status: 200, body: %{"rows" => []}}} {:ok, %Finch.Response{status: 200, body: %{"rows" => []}}}
@ -161,7 +164,7 @@ defmodule Plausible.Google.APITest do
"filters" => "event:page==/page" "filters" => "event:page==/page"
}) })
assert {:ok, []} = Google.API.fetch_stats(site, query, 5) assert {:ok, []} = Google.API.fetch_stats(site, query, {5, 0}, "")
end end
test "returns :invalid filters when using filters that cannot be used in Search Console", %{ test "returns :invalid filters when using filters that cannot be used in Search Console", %{
@ -175,7 +178,7 @@ defmodule Plausible.Google.APITest do
"filters" => "event:goal==Signup" "filters" => "event:goal==Signup"
}) })
assert {:error, :unsupported_filters} = Google.API.fetch_stats(site, query, 5) assert {:error, :unsupported_filters} = Google.API.fetch_stats(site, query, 5, "")
end end
end end
end end

View File

@ -7,7 +7,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
[:is, "visit:entry_page", ["/page"]] [:is, "visit:entry_page", ["/page"]]
] ]
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters) {:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
assert transformed == [ assert transformed == [
%{ %{
@ -27,7 +27,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
[:matches, "visit:entry_page", ["*page*"]] [:matches, "visit:entry_page", ["*page*"]]
] ]
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters) {:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
assert transformed == [ assert transformed == [
%{ %{
@ -47,7 +47,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
[:is, "visit:entry_page", ["/pageA", "/pageB"]] [:is, "visit:entry_page", ["/pageA", "/pageB"]]
] ]
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters) {:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
assert transformed == [ assert transformed == [
%{ %{
@ -67,7 +67,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
[:matches, "visit:entry_page", ["/pageA*", "/pageB*"]] [:matches, "visit:entry_page", ["/pageA*", "/pageB*"]]
] ]
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters) {:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
assert transformed == [ assert transformed == [
%{ %{
@ -87,7 +87,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
[:matches, "event:page", ["/pageA*", "/pageB*"]] [:matches, "event:page", ["/pageA*", "/pageB*"]]
] ]
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters) {:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
assert transformed == [ assert transformed == [
%{ %{
@ -107,7 +107,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
[:is, "visit:screen", ["Desktop"]] [:is, "visit:screen", ["Desktop"]]
] ]
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters) {:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
assert transformed == [ assert transformed == [
%{ %{
@ -123,7 +123,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
[:is, "visit:screen", ["Mobile", "Tablet"]] [:is, "visit:screen", ["Mobile", "Tablet"]]
] ]
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters) {:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
assert transformed == [ assert transformed == [
%{ %{
@ -139,7 +139,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
[:is, "visit:country", ["EE"]] [:is, "visit:country", ["EE"]]
] ]
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters) {:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
assert transformed == [ assert transformed == [
%{filters: [%{dimension: "country", operator: "includingRegex", expression: "EST"}]} %{filters: [%{dimension: "country", operator: "includingRegex", expression: "EST"}]}
@ -151,7 +151,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
[:is, "visit:country", ["EE", "PL"]] [:is, "visit:country", ["EE", "PL"]]
] ]
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters) {:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
assert transformed == [ assert transformed == [
%{ %{
@ -169,7 +169,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
[:is, "visit:screen", ["Desktop"]] [:is, "visit:screen", ["Desktop"]]
] ]
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters) {:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
assert transformed == [ assert transformed == [
%{ %{
@ -194,6 +194,6 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
[:is, "visit:utm_medium", "facebook"] [:is, "visit:utm_medium", "facebook"]
] ]
assert :unsupported_filters = Filters.transform("sc-domain:plausible.io", filters) assert :unsupported_filters = Filters.transform("sc-domain:plausible.io", filters, "")
end end
end end

View File

@ -1626,9 +1626,9 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
]) ])
conn = get(conn, "/api/stats/#{site.domain}/referrers/Google?period=day") conn = get(conn, "/api/stats/#{site.domain}/referrers/Google?period=day")
{:ok, terms} = Plausible.Google.API.Mock.fetch_stats(nil, nil, nil) {:ok, terms} = Plausible.Google.API.Mock.fetch_stats(nil, nil, nil, nil)
assert json_response(conn, 200) == %{"search_terms" => terms} assert json_response(conn, 200) == %{"results" => terms}
end end
test "works when filter expression is provided for source", %{ test "works when filter expression is provided for source", %{

View File

@ -3,7 +3,7 @@ defmodule Plausible.Google.API.Mock do
Mock of API to Google services. Mock of API to Google services.
""" """
def fetch_stats(_auth, _query, _limit) do def fetch_stats(_auth, _query, _pagination, _search) do
{:ok, {:ok,
[ [
%{"name" => "simple web analytics", "count" => 6}, %{"name" => "simple web analytics", "count" => 6},