Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/css/stats.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,33 @@
}
}

.stats-pie-chart-content-container,
.stats-bar-chart-content-container {
position: relative;
}

.stats-lock {
position: absolute;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;

&-link {
pointer-events: auto;

&.button:not(.button-link) {
background-color: var(--cs-color-secondary);
border-color: var(--cs-color-secondary);

&:is(:hover, :focus):not(.button-link, :disabled) {
background-color: var(--cs-color-primary-hover);
border-color: var(--cs-color-primary-hover);
}
}
}
}

.stats-number-chart {
display: grid;
gap: 4px;
Expand Down Expand Up @@ -228,6 +255,12 @@
}
}

.stats-chart-lock.is-locked .stats-bar-chart,
.stats-chart-lock.is-locked .stats-pie-chart-content {
filter: blur(10px);
user-select: none;
}

@media (width <= 782px) {
.stats-chart-grid {
grid-template-columns: 1fr;
Expand Down
80 changes: 44 additions & 36 deletions src/js/components/StatsMenu/StatsCharts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,55 +39,61 @@ interface ChartProps {
const EntryLabel: React.FC<Pick<StatsChartEntry, 'label' | 'url'>> = ({ label, url }) =>
url ? <a className="stats-chart-entry-link" href={url}>{label}</a> : <>{label}</>

const BarChart: React.FC<ChartProps> = ({ colors, entries }) => {
const BarChart: React.FC<ChartProps & { overlay?: React.ReactNode }> = ({ colors, entries, overlay }) => {
const entryCounts = useMemo(() =>
Object.values(entries)
.map(entry => Number(entry.count)),
[entries])

return (
<ul className="stats-bar-chart">
{Object.entries(entries).map(([key, entry]) =>
<li key={key}>
<span><EntryLabel {...entry} /></span>
<div className="stats-bar-track" aria-hidden="true">
<div
className="stats-bar-fill"
style={{
backgroundColor: colors?.[key] ?? DEFAULT_COLOR,
inlineSize: `${Number(entry.count) / Math.max(1, ...entryCounts) * PERCENTAGE_MAX}%`
}}
/>
</div>
<strong>{entry.count}</strong>
</li>)}
</ul>
<div className="stats-bar-chart-content-container">
<ul className="stats-bar-chart">
{Object.entries(entries).map(([key, entry]) =>
<li key={key}>
<span><EntryLabel {...entry} /></span>
<div className="stats-bar-track" aria-hidden="true">
<div
className="stats-bar-fill"
style={{
backgroundColor: colors?.[key] ?? DEFAULT_COLOR,
inlineSize: `${Number(entry.count) / Math.max(1, ...entryCounts) * PERCENTAGE_MAX}%`
}}
/>
</div>
<strong>{entry.count}</strong>
</li>)}
</ul>
{overlay && <div className="stats-lock">{overlay}</div>}
</div>
)
}

const PieChart: React.FC<ChartProps> = ({ colors, entries }) => {
const PieChart: React.FC<ChartProps & { overlay?: React.ReactNode }> = ({ colors, entries, overlay }) => {
const totalCount = useMemo(() =>
Object.values(entries).reduce((count, entry) =>
count + Number(entry.count), 0),
[entries])

return (
<div className="stats-pie-chart-content">
<div
className={classnames('stats-pie-chart', { 'is-empty': 0 === totalCount })}
aria-hidden="true"
style={0 === totalCount ? undefined : { background: getPieBackground(entries, colors, totalCount) }}
/>
<ul className="stats-pie-chart-legend">
{Object.entries(entries).map(([key, entry]) =>
<li key={key}>
<span>
<i aria-hidden="true" style={{ backgroundColor: colors?.[key] ?? DEFAULT_COLOR }} />
<EntryLabel {...entry} />
</span>
<strong>{entry.count}</strong>
</li>)}
</ul>
<div className="stats-pie-chart-content-container">
<div className="stats-pie-chart-content">
<div
className={classnames('stats-pie-chart', { 'is-empty': 0 === totalCount })}
aria-hidden="true"
style={0 === totalCount ? undefined : { background: getPieBackground(entries, colors, totalCount) }}
/>
<ul className="stats-pie-chart-legend">
{Object.entries(entries).map(([key, entry]) =>
<li key={key}>
<span>
<i aria-hidden="true" style={{ backgroundColor: colors?.[key] ?? DEFAULT_COLOR }} />
<EntryLabel {...entry} />
</span>
<strong>{entry.count}</strong>
</li>)}
</ul>
</div>
{overlay && <div className="stats-lock">{overlay}</div>}
</div>
)
}
Expand Down Expand Up @@ -117,6 +123,7 @@ const TagCloud: React.FC<ChartProps> = ({ entries }) => {
export interface StatsChartProps<Chart extends StatsConfigurableChartKey> {
chart: Chart
entries: Readonly<Record<string, StatsChartEntry>>
overlay?: React.ReactNode
title: string
view: StatsChartViews[Chart]
setView?: (view: StatsChartViews[Chart]) => void
Expand All @@ -128,6 +135,7 @@ export const StatsChart = <Chart extends StatsConfigurableChartKey,>({
chart,
colors,
entries,
overlay,
setView,
title,
view,
Expand All @@ -144,9 +152,9 @@ export const StatsChart = <Chart extends StatsConfigurableChartKey,>({
{setView && <StatsChartViewToggle title={title} view={view} setView={setView} views={views} />}
</div>
{'bar' === view
? <BarChart colors={colors} entries={entries} />
? <BarChart colors={colors} entries={entries} overlay={overlay} />
: 'pie' === view
? <PieChart colors={colors} entries={entries} />
? <PieChart colors={colors} entries={entries} overlay={overlay} />
: <TagCloud entries={entries} />}
</section>

Expand Down
33 changes: 22 additions & 11 deletions src/js/components/StatsMenu/StatsDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { __ } from '@wordpress/i18n'
import React, { useState } from 'react'
import { useRestAPI } from '../../hooks/useRestAPI'
import { REST_BASES } from '../../utils/restAPI'
import { isLicensed } from '../../utils/screen'
import { SNIPPET_SCOPE_DESCRIPTIONS } from '../../utils/snippets/snippets'
import { buildUrl } from '../../utils/urls'
import { useRestAPI } from '../../hooks/useRestAPI'
import { StatsChart, TotalsStatsChart } from './StatsCharts'
import type { StatsChartEntry, StatsChartPreferencesSchema, StatsChartViews, StatsConfigurableChartKey, StatsSummary } from '../../types/Stats'
import type { SnippetCodeScope, SnippetType } from '../../types/Snippet'
Expand Down Expand Up @@ -94,16 +95,26 @@ const ActivationStatusChart: React.FC<ConfigurableChartProps<'activation'>> = ({
setView={setView}
/>

const ConditionUsageChart: React.FC<ConfigurableChartProps<'conditions'>> = ({ summary, view, setView }) =>
<StatsChart
chart="conditions"
title={__('Condition usage', 'code-snippets')}
entries={summary.conditionCounts}
colors={STATS_CONDITION_COLORS}
views={['pie', 'bar']}
view={view}
setView={setView}
/>
const ConditionUsageChart: React.FC<ConfigurableChartProps<'conditions'>> = ({ summary, view, setView }) => {
const licensed = isLicensed()

return (
<div className={`stats-chart-lock${licensed ? '' : ' is-locked'}`}>
<StatsChart
chart="conditions"
title={__('Condition usage', 'code-snippets')}
entries={summary.conditionCounts}
colors={STATS_CONDITION_COLORS}
views={['pie', 'bar']}
view={view}
setView={setView}
overlay={!licensed && <a className="stats-lock-link button button-primary" href="https://codesnippets.pro/pricing/">
{__('Go Pro', 'code-snippets')}
</a>}
/>
</div>
)
}

const LocationChart = ({ summary, view, setView }: ConfigurableChartProps<'location'>) => {
const entries: Record<string, StatsChartEntry> = Object.fromEntries(
Expand Down
69 changes: 69 additions & 0 deletions tests/e2e/code-snippets-stats.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ import { expect, test } from '@playwright/test'
import { SnippetsTestHelper } from './helpers/SnippetsTestHelper'
import { URLS } from './helpers/constants'
import { wpCli } from './helpers/wpCli'
import type { Page } from '@playwright/test'

const forceLicenseState = (page: Page, isLicensed: boolean) =>
page.addInitScript(licensed => {
let value: { isLicensed?: boolean } | undefined

Object.defineProperty(window, 'CODE_SNIPPETS', {
configurable: true,
get: () => value,
set: (incoming: { isLicensed?: boolean } | undefined) => {
value = incoming ? { ...incoming, isLicensed: licensed } : incoming
}
})
}, isLicensed)

const clearSnippets = async () => {
const php = `
Expand Down Expand Up @@ -61,6 +75,47 @@ test.describe('Stats screen', () => {
await expect(page.getByRole('link', { name: 'Create new Snippet' })).toHaveCount(0)
})

test('blurs condition usage and links to Pro when unlicensed', async ({ page }) => {
await page.goto(URLS.SNIPPETS_ADMIN.replace('page=snippets', 'page=code-snippets-stats'))
const conditionUsage = page.locator('.stats-chart-lock')
const pieContent = page.locator('[data-stats-chart="conditions"] .stats-pie-chart-content')

await expect(conditionUsage).toHaveClass(/is-locked/)
await expect(pieContent).toHaveCSS('filter', 'blur(10px)')
const goProLink = conditionUsage.getByRole('link', { name: 'Go Pro' })
await expect(goProLink).toHaveAttribute('href', 'https://codesnippets.pro/pricing/')
const [contentBox, linkBox] = await Promise.all([pieContent.boundingBox(), goProLink.boundingBox()])
expect(contentBox).not.toBeNull()
expect(linkBox).not.toBeNull()
const contentCenterX = (contentBox?.x ?? 0) + (contentBox?.width ?? 0) / 2
const contentCenterY = (contentBox?.y ?? 0) + (contentBox?.height ?? 0) / 2
const linkCenterX = (linkBox?.x ?? 0) + (linkBox?.width ?? 0) / 2
const linkCenterY = (linkBox?.y ?? 0) + (linkBox?.height ?? 0) / 2

expect(Math.abs(linkCenterX - contentCenterX)).toBeLessThan(2)
expect(Math.abs(linkCenterY - contentCenterY)).toBeLessThan(2)
})

test('blurs condition usage in list view when unlicensed', async ({ page }) => {
await page.goto(URLS.SNIPPETS_ADMIN.replace('page=snippets', 'page=code-snippets-stats'))
const conditionsChart = page.locator('[data-stats-chart="conditions"]')

await conditionsChart.getByRole('button', { name: 'List view' }).click()
await expect(conditionsChart.locator('.stats-bar-chart')).toHaveCSS('filter', 'blur(10px)', { timeout: 3000 })
await expect(conditionsChart.getByRole('link', { name: 'Go Pro' })).toHaveAttribute('href', 'https://codesnippets.pro/pricing/')
})

test('shows condition usage without a Pro overlay when licensed', async ({ page }) => {
await forceLicenseState(page, true)
await page.goto(URLS.SNIPPETS_ADMIN.replace('page=snippets', 'page=code-snippets-stats'))
const conditionUsage = page.locator('.stats-chart-lock')
const pieContent = page.locator('[data-stats-chart="conditions"] .stats-pie-chart-content')

await expect(conditionUsage).not.toHaveClass(/is-locked/)
await expect(pieContent).toHaveCSS('filter', 'none')
await expect(conditionUsage.getByRole('link', { name: 'Go Pro' })).toHaveCount(0)
})

test('shows current snippet distributions', async ({ page }) => {
const conditionId = await SnippetsTestHelper.createSnippetViaCli({
name: 'Stats Active Conditions',
Expand Down Expand Up @@ -202,6 +257,20 @@ test.describe('Stats screen', () => {
await expect(tagLink).toHaveCSS('text-decoration-line', 'none')
})

test('opens the matching filtered list from a Stats chart entry', async ({ page }) => {
const name = 'Stats Chart Link Snippet'
await SnippetsTestHelper.createSnippetViaCli({
name,
active: true,
tags: ['chart-link']
})
await page.goto(URLS.SNIPPETS_ADMIN.replace('page=snippets', 'page=code-snippets-stats'))
await page.locator('[data-stats-chart="tags"]').getByRole('link', { name: 'chart-link' }).click()

await expect(page).toHaveURL(/page=snippets.*tag=chart-link/)
await expect(page.locator('.wp-list-table tbody tr').filter({ hasText: name })).toBeVisible()
})

test('switches and restores each Stats chart view', async ({ page }) => {
await page.goto(URLS.SNIPPETS_ADMIN.replace('page=snippets', 'page=code-snippets-stats'))

Expand Down
Loading