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 })
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"endDate": "2022-01-05",
|
||||||
"rowLimit": 5,
|
"rowLimit": 5,
|
||||||
|
"startRow": 0,
|
||||||
"startDate": "2022-01-01"
|
"startDate": "2022-01-01"
|
||||||
},
|
},
|
||||||
"response_body": {
|
"response_body": {
|
||||||
|
|
@ -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", [])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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", %{
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue