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:
parent
afc2fbaeb2
commit
2f87832532
|
|
@ -59,8 +59,8 @@ export function get(url, query = {}, ...extraQuery) {
|
|||
return fetch(url, { signal: abortController.signal, headers: headers })
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then((msg) => {
|
||||
throw new ApiError(msg.error, msg)
|
||||
return response.json().then((payload) => {
|
||||
throw new ApiError(payload.error, payload)
|
||||
})
|
||||
}
|
||||
return response.json()
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@ export function useAPIClient(props) {
|
|||
const getNextPageParam = (lastPageResults, _, lastPageIndex) => {
|
||||
return lastPageResults.length === LIMIT ? lastPageIndex + 1 : null
|
||||
}
|
||||
const initialPageParam = 1
|
||||
const defaultInitialPageParam = 1
|
||||
const initialPageParam = props.initialPageParam === undefined ? defaultInitialPageParam : props.initialPageParam
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: key,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { useQueryContext } from "../../query-context";
|
|||
import { useSiteContext } from "../../site-context";
|
||||
import { useDebounce } from "../../custom-hooks";
|
||||
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,
|
||||
// i.e. a breakdown by a single (non-time) dimension, with a given set of metrics.
|
||||
|
|
|
|||
|
|
@ -1,118 +1,193 @@
|
|||
import React from "react";
|
||||
import { Link, withRouter } from 'react-router-dom'
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
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 { 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 {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loading: true,
|
||||
query: parseQuery(props.location.search, props.site)
|
||||
}
|
||||
}
|
||||
function GoogleKeywordsModal() {
|
||||
const searchBoxRef = useRef(null)
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
const endpoint = `/api/stats/${encodeURIComponent(site.domain)}/referrers/Google`
|
||||
|
||||
componentDidMount() {
|
||||
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
|
||||
}))
|
||||
}
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
renderTerm(term) {
|
||||
return (
|
||||
<React.Fragment key={term.name}>
|
||||
<tr className="text-sm dark:text-gray-200" key={term.name}>
|
||||
<td className="p-2">{term.name}</td>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
const metrics = [
|
||||
createVisitors({renderLabel: (_query) => 'Visitors'}),
|
||||
new Metric({key: 'impressions', renderLabel: (_query) => 'Impressions', renderValue: renderNumberWithTooltip}),
|
||||
new Metric({key: 'ctr', renderLabel: (_query) => 'CTR', renderValue: percentageFormatter}),
|
||||
new Metric({key: 'position', renderLabel: (_query) => 'Position', renderValue: numberFormatter})
|
||||
]
|
||||
|
||||
renderKeywords() {
|
||||
if (this.state.notConfigured) {
|
||||
if (this.state.isOwner) {
|
||||
return (
|
||||
<div className="text-center text-gray-700 dark:text-gray-300 mt-6">
|
||||
<RocketIcon />
|
||||
<div className="text-lg">The site is not connected to Google Search Keywords</div>
|
||||
<div className="text-lg">Configure the integration to view search terms</div>
|
||||
<a href={`/${encodeURIComponent(this.props.site.domain)}/settings/integrations`} className="button mt-4">Connect with Google</a>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="text-center text-gray-700 dark:text-gray-300 mt-6">
|
||||
<RocketIcon />
|
||||
<div className="text-lg">The site is not connected to Google Search Kewyords</div>
|
||||
<div className="text-lg">Cannot show search terms</div>
|
||||
</div>
|
||||
)
|
||||
const {
|
||||
data,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetching,
|
||||
isPending,
|
||||
error,
|
||||
status
|
||||
} = useAPIClient({
|
||||
key: [endpoint, {query, search}],
|
||||
getRequestParams: (key) => {
|
||||
const [_endpoint, {query, search}] = key
|
||||
const params = { detailed: true }
|
||||
|
||||
return [query, search === '' ? params : {...params, search}]
|
||||
},
|
||||
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() {
|
||||
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>
|
||||
searchBox.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div>
|
||||
<main className="modal__content">
|
||||
{this.renderKeywords()}
|
||||
</main>
|
||||
</React.Fragment>
|
||||
)
|
||||
return () => {
|
||||
searchBox.removeEventListener('keyup', handleKeyUp);
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
render() {
|
||||
function renderRow(item) {
|
||||
return (
|
||||
<Modal show={!this.state.loading}>
|
||||
{this.renderBody()}
|
||||
</Modal>
|
||||
<tr className="text-sm dark:text-gray-200" key={item.name}>
|
||||
<td className="p-2">{item.name}</td>
|
||||
{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
|
||||
|
|
|
|||
|
|
@ -159,6 +159,6 @@ export const createExitRate = (props) => {
|
|||
return new Metric({...props, key: "exit_rate", renderValue, renderLabel})
|
||||
}
|
||||
|
||||
function renderNumberWithTooltip(value) {
|
||||
export function renderNumberWithTooltip(value) {
|
||||
return <span tooltip={value}>{numberFormatter(value)}</span>
|
||||
}
|
||||
|
|
@ -7,10 +7,19 @@ import RocketIcon from '../modals/rocket-icon'
|
|||
import * as api from '../../api'
|
||||
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 {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = { loading: true }
|
||||
this.state = { loading: true, errorPayload: null }
|
||||
this.onVisible = this.onVisible.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)
|
||||
.then((res) => this.setState({
|
||||
loading: false,
|
||||
searchTerms: res.search_terms || [],
|
||||
notConfigured: res.not_configured,
|
||||
isAdmin: res.is_admin,
|
||||
unsupportedFilters: res.unsupported_filters
|
||||
searchTerms: res.results,
|
||||
errorPayload: null
|
||||
})).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) {
|
||||
|
|
@ -68,22 +74,14 @@ export default class SearchTerms extends React.Component {
|
|||
}
|
||||
|
||||
renderList() {
|
||||
if (this.state.unsupportedFilters) {
|
||||
if (this.state.errorPayload) {
|
||||
const {reason, is_admin, error} = this.state.errorPayload
|
||||
|
||||
return (
|
||||
<div className="text-center text-gray-700 dark:text-gray-300 text-sm mt-20">
|
||||
<RocketIcon />
|
||||
<div>Unable to fetch keyword data from Search Console because it does not support the current set of filters</div>
|
||||
</div>
|
||||
)
|
||||
} 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>{error}</div>
|
||||
{reason === 'not_configured' && is_admin && <ConfigureSearchTermsCTA site={this.props.site}/> }
|
||||
</div>
|
||||
)
|
||||
} else if (this.state.searchTerms.length > 0) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
],
|
||||
"endDate": "2022-01-05",
|
||||
"rowLimit": 5,
|
||||
"startRow": 0,
|
||||
"startDate": "2022-01-01"
|
||||
},
|
||||
"response_body": {
|
||||
|
|
@ -59,18 +59,18 @@ defmodule Plausible.Google.API do
|
|||
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),
|
||||
{:ok, access_token} <- maybe_refresh_token(site.google_auth),
|
||||
{:ok, search_console_filters} <-
|
||||
SearchConsole.Filters.transform(site.google_auth.property, query.filters),
|
||||
{:ok, gsc_filters} <-
|
||||
SearchConsole.Filters.transform(site.google_auth.property, query.filters, search),
|
||||
{:ok, stats} <-
|
||||
HTTP.list_stats(
|
||||
access_token,
|
||||
site.google_auth.property,
|
||||
query.date_range,
|
||||
limit,
|
||||
search_console_filters
|
||||
pagination,
|
||||
gsc_filters
|
||||
) do
|
||||
stats
|
||||
|> Map.get("rows", [])
|
||||
|
|
|
|||
|
|
@ -39,12 +39,15 @@ defmodule Plausible.Google.HTTP do
|
|||
response.body
|
||||
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 = %{
|
||||
startDate: Date.to_iso8601(date_range.first),
|
||||
endDate: Date.to_iso8601(date_range.last),
|
||||
dimensions: ["query"],
|
||||
rowLimit: limit,
|
||||
startRow: page * limit,
|
||||
dimensionFilterGroups: search_console_filters
|
||||
}
|
||||
|
||||
|
|
@ -65,7 +68,7 @@ defmodule Plausible.Google.HTTP do
|
|||
{:error, error}
|
||||
|
||||
{: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"}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@ defmodule Plausible.Google.SearchConsole.Filters do
|
|||
@moduledoc false
|
||||
import Plausible.Stats.Filters.Utils, only: [page_regex: 1]
|
||||
|
||||
def transform(property, plausible_filters) do
|
||||
search_console_filters =
|
||||
Enum.reduce_while(plausible_filters, [], fn plausible_filter, search_console_filters ->
|
||||
def transform(property, plausible_filters, search) do
|
||||
gsc_filters =
|
||||
Enum.reduce_while(plausible_filters, [], fn plausible_filter, gsc_filters ->
|
||||
case transform_filter(property, plausible_filter) do
|
||||
:unsupported -> {:halt, :unsupported_filters}
|
||||
:ignore -> {:cont, search_console_filters}
|
||||
search_console_filter -> {:cont, [search_console_filter | search_console_filters]}
|
||||
:ignore -> {:cont, gsc_filters}
|
||||
gsc_filter -> {:cont, [gsc_filter | gsc_filters]}
|
||||
end
|
||||
end)
|
||||
|> maybe_add_search_filter(search)
|
||||
|
||||
case search_console_filters do
|
||||
case gsc_filters do
|
||||
:unsupported_filters -> :unsupported_filters
|
||||
[] -> {:ok, []}
|
||||
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.alpha_3
|
||||
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
|
||||
|
|
|
|||
|
|
@ -752,23 +752,46 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||
user_id = get_session(conn, :current_user_id)
|
||||
is_admin = user_id && Plausible.Sites.has_admin_access?(user_id, site)
|
||||
|
||||
case google_api().fetch_stats(site, query, params["limit"] || 9) do
|
||||
{:error, :google_propery_not_configured} ->
|
||||
json(conn, %{not_configured: true, is_admin: is_admin})
|
||||
pagination = {
|
||||
to_int(params["limit"], 9),
|
||||
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} ->
|
||||
json(conn, %{unsupported_filters: true})
|
||||
conn
|
||||
|> put_status(422)
|
||||
|> json(unsupported_filters_error_payload)
|
||||
|
||||
{: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
|
||||
|> put_status(502)
|
||||
|> json(%{
|
||||
not_configured: true,
|
||||
is_admin: is_admin
|
||||
})
|
||||
|> json(not_configured_error_payload)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ defmodule Plausible.Google.APITest do
|
|||
dimensions: ["query"],
|
||||
endDate: "2022-01-05",
|
||||
rowLimit: 5,
|
||||
startRow: 0,
|
||||
startDate: "2022-01-01"
|
||||
} ->
|
||||
{: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])}
|
||||
|
||||
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
|
||||
|
||||
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])}
|
||||
|
||||
assert {:error, "some_error"} = Google.API.fetch_stats(site, query, 5)
|
||||
assert {:error, "some_error"} = Google.API.fetch_stats(site, query, {5, 0}, "")
|
||||
end
|
||||
|
||||
test "returns generic HTTP error and logs it", %{site: site} do
|
||||
|
|
@ -79,15 +80,16 @@ defmodule Plausible.Google.APITest do
|
|||
log =
|
||||
capture_log(fn ->
|
||||
assert {:error, "failed_to_list_stats"} =
|
||||
Google.API.fetch_stats(site, query, 5)
|
||||
Google.API.fetch_stats(site, query, {5, 0}, "")
|
||||
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
|
||||
|
||||
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,
|
||||
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])}
|
||||
|
||||
assert {:error, "invalid_grant"} = Google.API.fetch_stats(site, query, 5)
|
||||
assert {:error, "invalid_grant"} = Google.API.fetch_stats(site, query, 5, "")
|
||||
end
|
||||
|
||||
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])}
|
||||
|
||||
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
|
||||
|
||||
describe "fetch_stats/3 with valid auth" do
|
||||
|
|
@ -122,7 +124,7 @@ defmodule Plausible.Google.APITest do
|
|||
end
|
||||
|
||||
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])}
|
||||
|
||||
|
|
@ -130,7 +132,7 @@ defmodule Plausible.Google.APITest do
|
|||
[
|
||||
%{name: "keyword1", visitors: 25, ctr: 36.8, impressions: 50, position: 2.2},
|
||||
%{name: "keyword3", visitors: 15}
|
||||
]} = Google.API.fetch_stats(site, query, 5)
|
||||
]} = Google.API.fetch_stats(site, query, {5, 0}, "")
|
||||
end
|
||||
|
||||
test "transforms page filters to search console format", %{site: site} do
|
||||
|
|
@ -147,6 +149,7 @@ defmodule Plausible.Google.APITest do
|
|||
dimensions: ["query"],
|
||||
endDate: "2022-01-05",
|
||||
rowLimit: 5,
|
||||
startRow: 0,
|
||||
startDate: "2022-01-01"
|
||||
} ->
|
||||
{:ok, %Finch.Response{status: 200, body: %{"rows" => []}}}
|
||||
|
|
@ -161,7 +164,7 @@ defmodule Plausible.Google.APITest do
|
|||
"filters" => "event:page==/page"
|
||||
})
|
||||
|
||||
assert {:ok, []} = Google.API.fetch_stats(site, query, 5)
|
||||
assert {:ok, []} = Google.API.fetch_stats(site, query, {5, 0}, "")
|
||||
end
|
||||
|
||||
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"
|
||||
})
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
|
|||
[: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 == [
|
||||
%{
|
||||
|
|
@ -27,7 +27,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
|
|||
[: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 == [
|
||||
%{
|
||||
|
|
@ -47,7 +47,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
|
|||
[: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 == [
|
||||
%{
|
||||
|
|
@ -67,7 +67,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
|
|||
[: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 == [
|
||||
%{
|
||||
|
|
@ -87,7 +87,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
|
|||
[: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 == [
|
||||
%{
|
||||
|
|
@ -107,7 +107,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
|
|||
[:is, "visit:screen", ["Desktop"]]
|
||||
]
|
||||
|
||||
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
|
||||
|
||||
assert transformed == [
|
||||
%{
|
||||
|
|
@ -123,7 +123,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
|
|||
[: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 == [
|
||||
%{
|
||||
|
|
@ -139,7 +139,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
|
|||
[:is, "visit:country", ["EE"]]
|
||||
]
|
||||
|
||||
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
|
||||
|
||||
assert transformed == [
|
||||
%{filters: [%{dimension: "country", operator: "includingRegex", expression: "EST"}]}
|
||||
|
|
@ -151,7 +151,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
|
|||
[: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 == [
|
||||
%{
|
||||
|
|
@ -169,7 +169,7 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
|
|||
[:is, "visit:screen", ["Desktop"]]
|
||||
]
|
||||
|
||||
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters)
|
||||
{:ok, transformed} = Filters.transform("sc-domain:plausible.io", filters, "")
|
||||
|
||||
assert transformed == [
|
||||
%{
|
||||
|
|
@ -194,6 +194,6 @@ defmodule Plausible.Google.SearchConsole.FiltersTest do
|
|||
[: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
|
||||
|
|
|
|||
|
|
@ -1626,9 +1626,9 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
|
|||
])
|
||||
|
||||
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
|
||||
|
||||
test "works when filter expression is provided for source", %{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ defmodule Plausible.Google.API.Mock do
|
|||
Mock of API to Google services.
|
||||
"""
|
||||
|
||||
def fetch_stats(_auth, _query, _limit) do
|
||||
def fetch_stats(_auth, _query, _pagination, _search) do
|
||||
{:ok,
|
||||
[
|
||||
%{"name" => "simple web analytics", "count" => 6},
|
||||
|
|
|
|||
Loading…
Reference in New Issue