mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-20 22:42:53 -05:00
parent
b589a66cd4
commit
c65b980ada
9 changed files with 410 additions and 38 deletions
|
@ -1,5 +1,4 @@
|
|||
<section class="gh-canvas" {{did-insert this.loadData}}>
|
||||
|
||||
<GhCanvasHeader class="gh-canvas-header gh-post-analytics-header">
|
||||
<div class="flex flex-column flex-grow-1">
|
||||
<div class="gh-canvas-breadcrumb">
|
||||
|
@ -42,53 +41,52 @@
|
|||
</div>
|
||||
</GhCanvasHeader>
|
||||
|
||||
<h4 class="gh-main-section-header small bn">
|
||||
Engagement
|
||||
</h4>
|
||||
<div class="gh-post-analytics-box">
|
||||
<Tabs::Tabs class="gh-tabs-analytics" as |tabs|>
|
||||
{{#if this.post.hasBeenEmailed}}
|
||||
<div class="gh-post-analytics-item">
|
||||
<LinkTo @route="members" @query={{hash filterParam=(concat "emails.post_id:[" this.post.id "]") }}>
|
||||
<h3>{{format-number this.post.email.emailCount}}</h3>
|
||||
<p>Sent</p>
|
||||
</LinkTo>
|
||||
</div>
|
||||
<tabs.tab>
|
||||
<h3>Sent</h3>
|
||||
<p>{{format-number this.post.email.emailCount}}</p>
|
||||
</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>Sent</tabs.tabPanel>
|
||||
|
||||
{{#if this.post.showEmailOpenAnalytics }}
|
||||
<div class="gh-post-analytics-item">
|
||||
<LinkTo @route="members" @query={{hash filterParam=(concat "opened_emails.post_id:[" this.post.id "]") }}>
|
||||
<h3>{{format-number this.post.email.openedCount}}</h3>
|
||||
<p>Opened — <strong>{{this.post.email.openRate}}%</strong></p>
|
||||
</LinkTo>
|
||||
</div>
|
||||
<tabs.tab>
|
||||
<h3>Opened</h3>
|
||||
<p>{{format-number this.post.email.openedCount}} <strong>{{this.post.email.openRate}}%</strong></p>
|
||||
</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>Opened</tabs.tabPanel>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.post.showEmailClickAnalytics }}
|
||||
<div class="gh-post-analytics-item">
|
||||
<LinkTo @route="members" @query={{hash filterParam=(concat "clicked_links.post_id:[" this.post.id "]") }}>
|
||||
<h3>{{format-number this.post.count.clicks}}</h3>
|
||||
<p>Clicked — <strong>{{this.post.clickRate}}%</strong></p>
|
||||
</LinkTo>
|
||||
</div>
|
||||
<tabs.tab>
|
||||
<h3>Clicked</h3>
|
||||
<p>{{format-number this.post.count.clicks}} <strong>{{this.post.clickRate}}%</strong></p>
|
||||
</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>Clicked</tabs.tabPanel>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if this.post.showAttributionAnalytics }}
|
||||
<div class="gh-post-analytics-item">
|
||||
<LinkTo @route="members" @query={{hash filterParam=(concat "signup:[" this.post.id "]") }}>
|
||||
<h3>{{format-number this.post.count.conversions}}</h3>
|
||||
<p>{{gh-pluralize this.post.count.conversions "conversions" without-count=true}}</p>
|
||||
</LinkTo>
|
||||
</div>
|
||||
{{#if this.post.showAudienceFeedback }}
|
||||
<tabs.tab>
|
||||
<h3><span class="hide-when-small">More </span><div class="visible-when-small">like</div><span class="hide-when-small"> this</span></h3>
|
||||
<p>{{format-number this.post.count.positive_feedback}} <strong>{{this.post.count.sentiment}}%</strong></p>
|
||||
</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>More like this</tabs.tabPanel>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.post.showAudienceFeedback }}
|
||||
<div class="gh-post-analytics-item">
|
||||
<h3>{{format-number this.post.count.positive_feedback}}</h3>
|
||||
<p><span class="hide-when-small">More </span>like<span class="hide-when-small"> this</span> — <strong>{{this.post.count.sentiment}}%</strong></p>
|
||||
</div>
|
||||
{{#if this.post.showAttributionAnalytics }}
|
||||
<tabs.tab>
|
||||
<h3>{{gh-pluralize this.post.count.conversions "Conversions" without-count=true}}</h3>
|
||||
<p>{{format-number this.post.count.conversions}}</p>
|
||||
</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>Conversions</tabs.tabPanel>
|
||||
{{/if}}
|
||||
</div>
|
||||
</Tabs::Tabs>
|
||||
|
||||
{{#if this.isLoaded }}
|
||||
{{#if this.showLinks }}
|
||||
|
|
11
ghost/admin/app/components/tabs/tab-panel.hbs
Normal file
11
ghost/admin/app/components/tabs/tab-panel.hbs
Normal file
|
@ -0,0 +1,11 @@
|
|||
<div
|
||||
id="{{this.tabId}}-panel"
|
||||
role="tabpanel"
|
||||
tabindex="0"
|
||||
aria-labelledby="{{this.tabId}}"
|
||||
class="tab-panel {{if this.isSelectedTab "tab-panel-selected" }}"
|
||||
>
|
||||
{{#if this.isSelectedTab}}
|
||||
{{yield}}
|
||||
{{/if}}
|
||||
</div>
|
13
ghost/admin/app/components/tabs/tab-panel.js
Normal file
13
ghost/admin/app/components/tabs/tab-panel.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import Component from '@glimmer/component';
|
||||
|
||||
export default class TabPanelComponent extends Component {
|
||||
index = this.args.index();
|
||||
|
||||
get isSelectedTab() {
|
||||
return this.args.selectedIndex === this.index;
|
||||
}
|
||||
|
||||
get tabId() {
|
||||
return this.args.tabIds[this.index];
|
||||
}
|
||||
}
|
13
ghost/admin/app/components/tabs/tab.hbs
Normal file
13
ghost/admin/app/components/tabs/tab.hbs
Normal file
|
@ -0,0 +1,13 @@
|
|||
<button
|
||||
class="tab {{if this.isSelectedTab "tab-selected" }}"
|
||||
id="{{this.id}}"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="{{this.isSelectedTab}}"
|
||||
aria-controls="{{this.id}}-panel"
|
||||
tabindex="{{if this.isSelectedTab "0" "-1"}}"
|
||||
{{on "click" this.handleClick}}
|
||||
{{on "keyup" this.handleKeyup}}
|
||||
>
|
||||
{{yield}}
|
||||
</button>
|
24
ghost/admin/app/components/tabs/tab.js
Normal file
24
ghost/admin/app/components/tabs/tab.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {guidFor} from '@ember/object/internals';
|
||||
|
||||
export default class TabComponent extends Component {
|
||||
id = this.args.id(`tab-${guidFor(this)}`);
|
||||
index = this.args.index();
|
||||
|
||||
get isSelectedTab() {
|
||||
return this.args.selectedIndex === this.index;
|
||||
}
|
||||
|
||||
@action
|
||||
handleClick() {
|
||||
return this.args.onSelect(this.index);
|
||||
}
|
||||
|
||||
@action
|
||||
handleKeyup(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
return this.args.onKeyup(event, this.index);
|
||||
}
|
||||
}
|
30
ghost/admin/app/components/tabs/tabs.hbs
Normal file
30
ghost/admin/app/components/tabs/tabs.hbs
Normal file
|
@ -0,0 +1,30 @@
|
|||
<div ...attributes>
|
||||
<div
|
||||
role="tablist"
|
||||
class="tab-list"
|
||||
>
|
||||
{{yield
|
||||
(hash
|
||||
tab=(
|
||||
component "tabs/tab"
|
||||
selectedIndex=this.selectedIndex
|
||||
onSelect=this.handleSelect
|
||||
onKeyup=this.handleKeyup
|
||||
id=this.addTabId
|
||||
index=this.addTabIndex
|
||||
)
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
|
||||
{{yield
|
||||
(hash
|
||||
tabPanel=(
|
||||
component "tabs/tab-panel"
|
||||
selectedIndex=this.selectedIndex
|
||||
index=this.addPanelIndex
|
||||
tabIds=this.tabIds
|
||||
)
|
||||
)
|
||||
}}
|
||||
</div>
|
65
ghost/admin/app/components/tabs/tabs.js
Normal file
65
ghost/admin/app/components/tabs/tabs.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
export default class TabsComponent extends Component {
|
||||
tabIds = [];
|
||||
tabCounter = 0;
|
||||
tabPanelCounter = 0;
|
||||
@tracked selectedIndex = this.args.defaultIndex ?? 0;
|
||||
|
||||
@action
|
||||
handleSelect(index) {
|
||||
this.selectedIndex = index;
|
||||
}
|
||||
|
||||
@action
|
||||
handleKeyup(event, index) {
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
this.selectedIndex = this.tabIds[index - 1] ? index - 1 : this.tabIds.length - 1;
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
this.selectedIndex = this.tabIds[index + 1] ? index + 1 : 0;
|
||||
break;
|
||||
|
||||
case 'Home':
|
||||
this.selectedIndex = 0;
|
||||
break;
|
||||
|
||||
case 'End':
|
||||
this.selectedIndex = this.tabIds.length - 1;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const selectedNode = document.getElementById(this.tabIds[this.selectedIndex]);
|
||||
selectedNode.focus();
|
||||
}
|
||||
|
||||
@action
|
||||
addTabId(id) {
|
||||
this.tabIds.push(id);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
@action
|
||||
addTabIndex() {
|
||||
const index = this.tabCounter;
|
||||
this.tabCounter = this.tabCounter + 1;
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
@action
|
||||
addPanelIndex() {
|
||||
const index = this.tabPanelCounter;
|
||||
this.tabPanelCounter = this.tabPanelCounter + 1;
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
|
@ -467,7 +467,7 @@ a.gh-post-list-signups.active:hover > span, a.gh-post-list-conversions.active:ho
|
|||
.feature-memberAttribution .gh-posts-sends-header {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
|
||||
.feature-memberAttribution .gh-posts-opens-header {
|
||||
width: 100px;
|
||||
}
|
||||
|
@ -635,6 +635,11 @@ a.gh-post-list-signups.active:hover > span, a.gh-post-list-conversions.active:ho
|
|||
.hide-when-small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.visible-when-small::first-letter{
|
||||
display: inline-block;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
|
@ -1408,7 +1413,7 @@ a.gh-post-list-cta.stats.is-hovered:hover > * {
|
|||
border-bottom: 1px solid var(--whitegrey-d1);
|
||||
padding-left: 0;
|
||||
padding-bottom: 2rem;
|
||||
padding-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.gh-post-analytics-item:first-child {
|
||||
|
@ -1430,3 +1435,111 @@ a.gh-post-list-cta.stats.is-hovered:hover > * {
|
|||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.gh-tabs-analytics {
|
||||
overflow: hidden;
|
||||
margin-bottom: 22px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ECEEF0;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics .tab{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
padding: 16px 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics .tab-selected{
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #E7E9EB;
|
||||
border-bottom: none;
|
||||
box-shadow: 0 4px 7px rgba(0, 0, 0, 0.05), 0 1px 0 0 #ffffff;
|
||||
border-radius: 5px 5px 0 0;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics .tab-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
padding: 8px 8px 0px;
|
||||
background-color: #F7F8F9;
|
||||
box-shadow: inset 0 -1px 0 #eceef0;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics .tab-panel-selected {
|
||||
padding: 12px 26px;
|
||||
/* help to hide shadow from selected tab */
|
||||
opacity: 0.99999;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics h3 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 3px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics p {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
font-size: 2.4rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics strong {
|
||||
color: #909CAB;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.gh-tabs-analytics .tab{
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics .tab-panel-selected {
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics .tab-list {
|
||||
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
|
||||
}
|
||||
|
||||
.gh-tabs-analytics h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics p {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 440px) {
|
||||
.gh-tabs-analytics .tab-list {
|
||||
padding: 4px 4px 0px;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics .tab-panel-selected {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics strong {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
|
105
ghost/admin/tests/integration/components/tabs/tabs-test.js
Normal file
105
ghost/admin/tests/integration/components/tabs/tabs-test.js
Normal file
|
@ -0,0 +1,105 @@
|
|||
import {click, findAll, render, triggerKeyEvent} from '@ember/test-helpers';
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {hbs} from 'ember-cli-htmlbars';
|
||||
import {setupRenderingTest} from 'ember-mocha';
|
||||
|
||||
describe('Integration: Component: tabs/tabs', function () {
|
||||
setupRenderingTest();
|
||||
|
||||
it('renders', async function () {
|
||||
await render(hbs`
|
||||
<Tabs::Tabs class="test-tab" as |tabs|>
|
||||
<tabs.tab>Tab 1</tabs.tab>
|
||||
<tabs.tab>Tab 2</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>Content 1</tabs.tabPanel>
|
||||
<tabs.tabPanel>Content 2</tabs.tabPanel>
|
||||
</Tabs::Tabs>`);
|
||||
|
||||
const tabButtons = findAll('.tab');
|
||||
const tabPanels = findAll('.tab-panel');
|
||||
|
||||
expect(findAll('.test-tab').length).to.equal(1);
|
||||
expect(findAll('.tab-list').length).to.equal(1);
|
||||
expect(tabPanels.length).to.equal(2);
|
||||
expect(tabButtons.length).to.equal(2);
|
||||
|
||||
expect(findAll('.tab-selected').length).to.equal(1);
|
||||
expect(findAll('.tab-panel-selected').length).to.equal(1);
|
||||
expect(tabButtons[0]).to.have.class('tab-selected');
|
||||
expect(tabPanels[0]).to.have.class('tab-panel-selected');
|
||||
|
||||
expect(tabButtons[0]).to.have.trimmed.text('Tab 1');
|
||||
expect(tabButtons[1]).to.have.trimmed.text('Tab 2');
|
||||
|
||||
expect(tabPanels[0]).to.have.trimmed.text('Content 1');
|
||||
expect(tabPanels[1]).to.have.trimmed.text('');
|
||||
});
|
||||
|
||||
it('renders expected content on click', async function () {
|
||||
await render(hbs`
|
||||
<Tabs::Tabs class="test-tab" as |tabs|>
|
||||
<tabs.tab>Tab 1</tabs.tab>
|
||||
<tabs.tab>Tab 2</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>Content 1</tabs.tabPanel>
|
||||
<tabs.tabPanel>Content 2</tabs.tabPanel>
|
||||
</Tabs::Tabs>`);
|
||||
|
||||
const tabButtons = findAll('.tab');
|
||||
const tabPanels = findAll('.tab-panel');
|
||||
|
||||
await click(tabButtons[1]);
|
||||
|
||||
expect(findAll('.tab-selected').length).to.equal(1);
|
||||
expect(findAll('.tab-panel-selected').length).to.equal(1);
|
||||
expect(tabButtons[1]).to.have.class('tab-selected');
|
||||
expect(tabPanels[1]).to.have.class('tab-panel-selected');
|
||||
|
||||
expect(tabPanels[0]).to.have.trimmed.text('');
|
||||
expect(tabPanels[1]).to.have.trimmed.text('Content 2');
|
||||
});
|
||||
|
||||
it('renders expected content on keyup event', async function () {
|
||||
await render(hbs`
|
||||
<Tabs::Tabs class="test-tab" as |tabs|>
|
||||
<tabs.tab>Tab 0</tabs.tab>
|
||||
<tabs.tab>Tab 1</tabs.tab>
|
||||
<tabs.tab>Tab 2</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>Content 0</tabs.tabPanel>
|
||||
<tabs.tabPanel>Content 1</tabs.tabPanel>
|
||||
<tabs.tabPanel>Content 2</tabs.tabPanel>
|
||||
</Tabs::Tabs>`);
|
||||
|
||||
const tabButtons = findAll('.tab');
|
||||
const tabPanels = findAll('.tab-panel');
|
||||
|
||||
const isTabRenders = (num) => {
|
||||
expect(tabButtons[num]).to.have.class('tab-selected');
|
||||
expect(tabPanels[num]).to.have.class('tab-panel-selected');
|
||||
|
||||
expect(tabPanels[num]).to.have.trimmed.text(`Content ${num}`);
|
||||
};
|
||||
|
||||
await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowRight');
|
||||
await triggerKeyEvent(tabButtons[1], 'keyup', 'ArrowRight');
|
||||
isTabRenders(2);
|
||||
|
||||
await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowRight');
|
||||
isTabRenders(0);
|
||||
|
||||
await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowLeft');
|
||||
isTabRenders(2);
|
||||
|
||||
await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowLeft');
|
||||
isTabRenders(1);
|
||||
|
||||
await triggerKeyEvent(tabButtons[0], 'keyup', 'Home');
|
||||
isTabRenders(0);
|
||||
|
||||
await triggerKeyEvent(tabButtons[0], 'keyup', 'End');
|
||||
isTabRenders(2);
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue