0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Stats UI updates ()

[ANAL-43](https://linear.app/tryghost/issue/ANAL-43/implement-all-possible-ui-for-10)

- The BarList component wasn't using the parameters provided in its
latest release
- Number formatting was missing on all numbers
- "See all" links were missing in Content/Sources/Locations
- Empty/default values was showing [blank]
- Flags were missing for country values
This commit is contained in:
Peter Zimon 2024-09-09 13:32:28 +02:00 committed by GitHub
parent 681deb18fc
commit b16c80259e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 340 additions and 70 deletions

View file

@ -0,0 +1,23 @@
<div class="modal-content" data-test-publish-flow="complete">
<header class="modal-header">
<h1>
{{this.modalTitle}}
</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" @close}} data-test-button="close-publish-flow">{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div {{react-render this.ReactComponent props=(hash chartRange=this.chartRange audience=this.audience type=this.type)}}></div>
<footer class="modal-footer">
<button
class="gh-btn gh-btn-primary dismiss"
type="button"
{{on "click" @close}}
{{on "mousedown" (optional this.noop)}}
>
<span>Close</span>
</button>
</footer>
</div>

View file

@ -0,0 +1,116 @@
'use client';
import Component from '@glimmer/component';
import React from 'react';
import moment from 'moment-timezone';
import {BarList, useQuery} from '@tinybirdco/charts';
import {formatNumber} from 'ghost-admin/helpers/format-number';
import {getCountryFlag} from 'ghost-admin/utils/stats';
import {inject} from 'ghost-admin/decorators/inject';
import {statsStaticColors} from 'ghost-admin/utils/stats';
export default class AllStatsModal extends Component {
@inject config;
get type() {
return this.args.data.type;
}
get chartRange() {
return this.args.data.chartRange;
}
get audience() {
return this.args.data.audience;
}
get modalTitle() {
switch (this.type) {
case 'top-sources':
return 'Sources';
case 'top-locations':
return 'Locations';
default:
return 'Content';
}
}
ReactComponent = (props) => {
let chartRange = props.chartRange;
let audience = props.audience || [];
let type = props.type;
const endDate = moment().endOf('day');
const startDate = moment().subtract(chartRange - 1, 'days').startOf('day');
/**
* @typedef {Object} Params
* @property {string} cid
* @property {string} [date_from]
* @property {string} [date_to]
* @property {string} [member_status]
* @property {number} [limit]
* @property {number} [skip]
*/
const params = {
site_uuid: this.config.stats.id,
date_from: startDate.format('YYYY-MM-DD'),
date_to: endDate.format('YYYY-MM-DD'),
member_status: audience.length === 0 ? null : audience.join(',')
};
let endpoint;
let labelText;
let indexBy;
let unknownOption = 'Unknown';
switch (type) {
case 'top-sources':
endpoint = `${this.config.stats.endpoint}/v0/pipes/top_sources.json`;
labelText = 'Source';
indexBy = 'referrer';
unknownOption = 'Direct';
break;
case 'top-locations':
endpoint = `${this.config.stats.endpoint}/v0/pipes/top_locations.json`;
labelText = 'Country';
indexBy = 'location';
unknownOption = 'Unknown';
break;
default:
endpoint = `${this.config.stats.endpoint}/v0/pipes/top_pages.json`;
labelText = 'Post or page';
indexBy = 'pathname';
break;
}
const {data, meta, error, loading} = useQuery({
endpoint: endpoint,
token: this.config.stats.token,
params
});
return (
<BarList
data={data}
meta={meta}
error={error}
loading={loading}
index={indexBy}
indexConfig={{
label: <span className="gh-stats-detail-header">{labelText}</span>,
renderBarContent: ({label}) => (
<span className="gh-stats-detail-label">{(type === 'top-locations') && getCountryFlag(label)} {label || unknownOption}</span>
)
}}
categories={['hits']}
categoryConfig={{
hits: {
label: <span className="gh-stats-detail-header">Visits</span>,
renderValue: ({value}) => <span className="gh-stats-detail-value">{formatNumber(value)}</span>
}
}}
colorPalette={[statsStaticColors[4]]}
/>
);
};
}

View file

@ -4,6 +4,7 @@ import Component from '@glimmer/component';
import React from 'react';
import moment from 'moment-timezone';
import {AreaChart, useQuery} from '@tinybirdco/charts';
import {formatNumber} from '../../../helpers/format-number';
import {hexToRgba} from 'ghost-admin/utils/stats';
import {inject} from 'ghost-admin/decorators/inject';
import {statsStaticColors} from '../../../utils/stats';
@ -165,7 +166,7 @@ export default class KpisComponent extends Component {
break;
default:
tooltipTitle = 'Unique visitors';
displayValue = fparams[0].value[1] !== null && fparams[0].value[1];
displayValue = fparams[0].value[1] !== null && formatNumber(fparams[0].value[1]);
break;
}
if (!displayValue) {

View file

@ -1 +1 @@
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience selected=@selected)}}></div>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience selected=@selected)}}></div>

View file

@ -1,2 +1,10 @@
<h5 class="gh-stats-metric-label">Locations</h5>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div>
<div>
<div class="gh-stats-metric-header"><h5 class="gh-stats-metric-label">Locations</h5></div>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div>
</div>
<div>
<button type="button" class="gh-btn gh-btn-link gh-stats-see-all-btn" {{on "click" (fn this.openSeeAll @chartRange @audience)}}>
<span>See all &rarr;</span>
</button>
</div>

View file

@ -1,11 +1,28 @@
'use client';
import AllStatsModal from '../../modal-stats-all';
import Component from '@glimmer/component';
import React from 'react';
import moment from 'moment-timezone';
import {BarList, useQuery} from '@tinybirdco/charts';
import {action} from '@ember/object';
import {formatNumber} from '../../../helpers/format-number';
import {getCountryFlag, statsStaticColors} from 'ghost-admin/utils/stats';
import {inject} from 'ghost-admin/decorators/inject';
import {statsStaticColors} from 'ghost-admin/utils/stats';
import {inject as service} from '@ember/service';
export default class TopLocations extends Component {
@inject config;
@service modals;
@action
openSeeAll() {
this.modals.open(AllStatsModal, {
type: 'top-locations',
chartRange: this.args.chartRange,
audience: this.args.audience
});
}
ReactComponent = (props) => {
let chartRange = props.chartRange;
@ -28,7 +45,7 @@ export default class TopLocations extends Component {
date_from: startDate.format('YYYY-MM-DD'),
date_to: endDate.format('YYYY-MM-DD'),
member_status: audience.length === 0 ? null : audience.join(','),
limit: 6
limit: 8
};
const {data, meta, error, loading} = useQuery({
@ -44,7 +61,19 @@ export default class TopLocations extends Component {
error={error}
loading={loading}
index="location"
indexConfig={{
label: <span className="gh-stats-detail-header">Country</span>,
renderBarContent: ({label}) => (
<span className="gh-stats-detail-label">{getCountryFlag(label)} {label || 'Unknown'}</span>
)
}}
categories={['hits']}
categoryConfig={{
hits: {
label: <span className="gh-stats-detail-header">Visits</span>,
renderValue: ({value}) => <span className="gh-stats-detail-value">{formatNumber(value)}</span>
}
}}
colorPalette={[statsStaticColors[4]]}
/>
);

View file

@ -1,21 +1,28 @@
<div class="gh-stats-metric-header">
<h5 class="gh-stats-metric-label">Content</h5>
<div>
<PowerSelect
@selected={{this.contentOption}}
@options={{this.contentOptions}}
@searchEnabled={{false}}
@onChange={{this.onContentOptionChange}}
@triggerComponent={{component "gh-power-select/trigger"}}
@triggerClass="gh-btn gh-stats-section-dropdown"
@dropdownClass="gh-contentfilter-menu-dropdown is-narrow"
@matchTriggerWidth={{false}}
@horizontalPosition="right"
as |option|
>
{{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}}
</PowerSelect>
<div>
<div class="gh-stats-metric-header">
<h5 class="gh-stats-metric-label">Content</h5>
<div>
<PowerSelect
@selected={{this.contentOption}}
@options={{this.contentOptions}}
@searchEnabled={{false}}
@onChange={{this.onContentOptionChange}}
@triggerComponent={{component "gh-power-select/trigger"}}
@triggerClass="gh-btn gh-stats-section-dropdown"
@dropdownClass="gh-contentfilter-menu-dropdown is-narrow"
@matchTriggerWidth={{false}}
@horizontalPosition="right"
as |option|
>
{{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}}
</PowerSelect>
</div>
</div>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div>
</div>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div>
<div>
<button type="button" class="gh-btn gh-btn-link gh-stats-see-all-btn" {{on "click" (fn this.openSeeAll @chartRange @audience)}}>
<span>See all &rarr;</span>
</button>
</div>

View file

@ -1,12 +1,15 @@
'use client';
import AllStatsModal from '../../modal-stats-all';
import Component from '@glimmer/component';
import React from 'react';
import moment from 'moment-timezone';
import {BarList, useQuery} from '@tinybirdco/charts';
import {CONTENT_OPTIONS} from 'ghost-admin/utils/stats';
import {action} from '@ember/object';
import {formatNumber} from '../../../helpers/format-number';
import {inject} from 'ghost-admin/decorators/inject';
import {inject as service} from '@ember/service';
import {statsStaticColors} from 'ghost-admin/utils/stats';
import {tracked} from '@glimmer/tracking';
@ -16,6 +19,17 @@ export default class TopPages extends Component {
@tracked contentOption = CONTENT_OPTIONS[0];
@tracked contentOptions = CONTENT_OPTIONS;
@service modals;
@action
openSeeAll(chartRange, audience) {
this.modals.open(AllStatsModal, {
type: 'top-pages',
chartRange,
audience
});
}
@action
onContentOptionChange(selected) {
this.contentOption = selected;
@ -42,7 +56,7 @@ export default class TopPages extends Component {
date_from: startDate.format('YYYY-MM-DD'),
date_to: endDate.format('YYYY-MM-DD'),
member_status: audience.length === 0 ? null : audience.join(','),
limit: 6
limit: 8
};
const {data, meta, error, loading} = useQuery({
@ -59,17 +73,19 @@ export default class TopPages extends Component {
loading={loading}
index="pathname"
indexConfig={{
label: <span style={{fontSize: '12px', fontWeight: 'bold'}}>URL</span>
label: <span className="gh-stats-detail-header">Post or page</span>,
renderBarContent: ({label}) => (
<span className="gh-stats-detail-label">{label}</span>
)
}}
categories={['hits']}
categoryConfig={{
hits: {
label: <span>Visits</span>
// renderValue: ({ value }) => <span>{formatNumber(value)}</span>
label: <span className="gh-stats-detail-header">Visits</span>,
renderValue: ({value}) => <span className="gh-stats-detail-value">{formatNumber(value)}</span>
}
}}
colorPalette={[statsStaticColors[4]]}
height="300px"
/>
);
};

View file

@ -1,21 +1,29 @@
<div class="gh-stats-metric-header">
<h5 class="gh-stats-metric-label">Sources</h5>
<div>
<PowerSelect
@selected={{this.campaignOption}}
@options={{this.campaignOptions}}
@searchEnabled={{false}}
@onChange={{this.onCampaignOptionChange}}
@triggerComponent={{component "gh-power-select/trigger"}}
@triggerClass="gh-btn gh-stats-section-dropdown"
@dropdownClass="gh-contentfilter-menu-dropdown is-narrow"
@matchTriggerWidth={{false}}
@horizontalPosition="right"
as |option|
>
{{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}}
</PowerSelect>
<div>
<div class="gh-stats-metric-header">
<h5 class="gh-stats-metric-label">Sources</h5>
<div>
<PowerSelect
@selected={{this.campaignOption}}
@options={{this.campaignOptions}}
@searchEnabled={{false}}
@onChange={{this.onCampaignOptionChange}}
@triggerComponent={{component "gh-power-select/trigger"}}
@triggerClass="gh-btn gh-stats-section-dropdown"
@dropdownClass="gh-contentfilter-menu-dropdown is-narrow"
@matchTriggerWidth={{false}}
@horizontalPosition="right"
as |option|
>
{{#if option.name}}{{option.name}}{{else}}<span class="red">Unknown option</span>{{/if}}
</PowerSelect>
</div>
</div>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div>
</div>
<div {{react-render this.ReactComponent props=(hash chartRange=@chartRange audience=@audience)}}></div>
<div>
<button type="button" class="gh-btn gh-btn-link gh-stats-see-all-btn" {{on "click" (fn this.openSeeAll @chartRange @audience)}}>
<span>See all &rarr;</span>
</button>
</div>

View file

@ -1,17 +1,21 @@
'use client';
import AllStatsModal from '../../modal-stats-all';
import Component from '@glimmer/component';
import React from 'react';
import moment from 'moment-timezone';
import {BarList, useQuery} from '@tinybirdco/charts';
import {CAMPAIGN_OPTIONS} from 'ghost-admin/utils/stats';
import {action} from '@ember/object';
import {formatNumber} from '../../../helpers/format-number';
import {inject} from 'ghost-admin/decorators/inject';
import {inject as service} from '@ember/service';
import {statsStaticColors} from 'ghost-admin/utils/stats';
import {tracked} from '@glimmer/tracking';
export default class TopPages extends Component {
@inject config;
@service modals;
@tracked campaignOption = CAMPAIGN_OPTIONS[0];
@tracked campaignOptions = CAMPAIGN_OPTIONS;
@ -21,6 +25,15 @@ export default class TopPages extends Component {
this.campaignOption = selected;
}
@action
openSeeAll() {
this.modals.open(AllStatsModal, {
type: 'top-sources',
chartRange: this.args.chartRange,
audience: this.args.audience
});
}
ReactComponent = (props) => {
let chartRange = props.chartRange;
let audience = props.audience;
@ -41,14 +54,14 @@ export default class TopPages extends Component {
site_uuid: this.config.stats.id,
date_from: startDate.format('YYYY-MM-DD'),
date_to: endDate.format('YYYY-MM-DD'),
member_status: audience.length === 0 ? null : audience.join(',')
member_status: audience.length === 0 ? null : audience.join(','),
limit: 8
};
const {data, meta, error, loading} = useQuery({
endpoint: `${this.config.stats.endpoint}/v0/pipes/top_sources.json`,
token: this.config.stats.token,
params,
limit: 6
params
});
return (
@ -58,9 +71,21 @@ export default class TopPages extends Component {
error={error}
loading={loading}
index="referrer"
indexConfig={{
label: <span className="gh-stats-detail-header">Source</span>,
renderBarContent: ({label}) => (
<span className="gh-stats-detail-label">{label || 'Direct'}</span>
)
}}
categories={['hits']}
categoryConfig={{
hits: {
label: <span className="gh-stats-detail-header">Visits</span>,
renderValue: ({value}) => <span className="gh-stats-detail-value">{formatNumber(value)}</span>
}
}}
colorPalette={[statsStaticColors[4]]}
height="300px"
// height="300px"
/>
);
};

View file

@ -1,6 +1,7 @@
import Component from '@glimmer/component';
import fetch from 'fetch';
import {action} from '@ember/object';
import {formatNumber} from 'ghost-admin/helpers/format-number';
import {inject} from 'ghost-admin/decorators/inject';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
@ -83,7 +84,7 @@ export default class KpisOverview extends Component {
const _KPITotal = kpi => queryData.reduce((prev, curr) => (curr[kpi] ?? 0) + prev, 0);
// Get total number of sessions
const totalVisits = _KPITotal('visits');
const totalVisits = formatNumber(_KPITotal('visits'));
// Sum total KPI value from the trend, ponderating using sessions
const _ponderatedKPIsTotal = kpi => queryData.reduce((prev, curr) => prev + ((curr[kpi] ?? 0) * curr.visits / totalVisits), 0);

View file

@ -1,20 +1,22 @@
<div class="gh-stats-tabs-header">
<div class="gh-stats-tabs">
<button type="button" class="gh-stats-tab {{if this.devicesTabSelected 'is-selected'}}" {{on "click" this.changeTabToDevices}}>
<Stats::Parts::Metric
@label="Devices" />
</button>
<div>
<div class="gh-stats-tabs-header">
<div class="gh-stats-tabs">
<button type="button" class="gh-stats-tab {{if this.devicesTabSelected 'is-selected'}}" {{on "click" this.changeTabToDevices}}>
<Stats::Parts::Metric
@label="Devices" />
</button>
<button type="button" class="gh-stats-tab {{if this.browsersTabSelected 'is-selected'}}" {{on "click" this.changeTabToBrowsers}}>
<Stats::Parts::Metric
@label="Browsers" />
</button>
<button type="button" class="gh-stats-tab {{if this.browsersTabSelected 'is-selected'}}" {{on "click" this.changeTabToBrowsers}}>
<Stats::Parts::Metric
@label="Browsers" />
</button>
{{!-- <button type="button" class="gh-stats-tab {{if this.osTabSelected 'is-selected'}}" {{on "click" this.changeTabToOSs}}>
<Stats::Parts::Metric
@label="Operating systems" />
</button> --}}
{{!-- <button type="button" class="gh-stats-tab {{if this.osTabSelected 'is-selected'}}" {{on "click" this.changeTabToOSs}}>
<Stats::Parts::Metric
@label="Operating systems" />
</button> --}}
</div>
</div>
</div>
<Stats::Charts::Technical @chartRange={{@chartRange}} @audience={{@audience}} @selected={{this.selected}} />
<Stats::Charts::Technical @chartRange={{@chartRange}} @audience={{@audience}} @selected={{this.selected}} />
</div>

View file

@ -23,6 +23,10 @@
}
.gh-stats-container {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 20px;
padding: 20px;
border: 1px solid var(--whitegrey);
border-radius: 8px;
@ -32,7 +36,7 @@
.gh-stats-container > .gh-stats-metric-label,
.gh-stats-metric-header {
margin-bottom: 20px;
margin-bottom: 12px;
}
.gh-stats-container:hover {
@ -162,6 +166,7 @@
border: none !important;
cursor: pointer;
overflow: hidden;
margin-right: -8px;
}
.gh-stats-section-dropdown.ember-power-select-trigger.gh-btn span {
@ -184,4 +189,25 @@
.gh-stats-metric-label {
font-size: 14px;
}
}
.gh-stats-detail-header {
font-size: 11px;
font-weight: 500;
}
.gh-stats-see-all-btn span {
height: unset;
padding: 0;
line-height: 1;
color: var(--black);
}
.gh-stats-see-all-btn:hover span {
color: var(--green);
}
.gh-stats-detail-label,
.gh-stats-detail-value {
font-size: 13.5px;
}

View file

@ -108,4 +108,12 @@ export function generateMonochromePalette(baseColor, count = 10) {
export const statsStaticColors = [
'#8E42FF', '#B07BFF', '#C7A0FF', '#DDC6FF', '#EBDDFF', '#F7EDFF'
];
];
export const getCountryFlag = (countryCode) => {
if (!countryCode) {
return '🏳️';
}
return countryCode.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397)
);
};