Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement search and pagination in Google Keywords modal #4378

Merged
merged 11 commits into from
Jul 23, 2024
4 changes: 2 additions & 2 deletions assets/js/dashboard/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion assets/js/dashboard/hooks/api-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion assets/js/dashboard/stats/modals/breakdown-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
261 changes: 168 additions & 93 deletions assets/js/dashboard/stats/modals/google-keywords.js
Original file line number Diff line number Diff line change
@@ -1,118 +1,193 @@
import React from "react";
import { Link, withRouter } from 'react-router-dom'
import React, { useState, useEffect, useRef } from "react";

ukutaht marked this conversation as resolved.
Show resolved Hide resolved
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`

const [search, setSearch] = useState('')

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})
]

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
})
ukutaht marked this conversation as resolved.
Show resolved Hide resolved

useEffect(() => {
const searchBox = searchBoxRef.current

const handleKeyUp = (event) => {
if (event.key === 'Escape') {
event.target.blur()
event.stopPropagation()
}
}

searchBox.addEventListener('keyup', handleKeyUp);

return () => {
searchBox.removeEventListener('keyup', handleKeyUp);
}
}, [])

function renderRow(item) {
return (
<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>
)
}

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
}))
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>
)
}

renderTerm(term) {
function renderSmallLoadingSpinner() {
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>
<div className="loading sm"><div></div></div>
)
}

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>
)
}
} 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>
)
}
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>
)
}

renderBody() {
if (this.state.loading) {
return (
<div className="loading mt-32 mx-auto"><div></div></div>
)
} else {
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 (
<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>
<main className="modal__content">
{this.renderKeywords()}
</main>
</React.Fragment>
<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>
)
}
}

render() {
function renderError() {
return (
<Modal show={!this.state.loading}>
{this.renderBody()}
</Modal>
<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
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/reports/metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>
}
40 changes: 19 additions & 21 deletions assets/js/dashboard/stats/sources/search-terms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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) {
Expand All @@ -68,22 +74,14 @@ export default class SearchTerms extends React.Component {
}

renderList() {
if (this.state.unsupportedFilters) {
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) {
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>
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) {
Expand Down
Loading
Loading