From 1afe96ae344f26cf55f7e0c33f2238615dbebb1c Mon Sep 17 00:00:00 2001 From: Sodbileg Gansukh Date: Wed, 28 Aug 2024 17:25:37 +0800 Subject: [PATCH] Added animation to the analytics numbers when refreshed (#20842) ref DES-709 - when refresh button is clicked, the numbers in the analytics will be animated if changed - for the animation to be performant, added a new dependency "animejs" - to minimize the flash and layout shift, the analytics data is kept as it is while loading - once finished loading, it will be replaced with the new data --- .../admin/app/components/posts/analytics.hbs | 26 +- ghost/admin/app/components/posts/analytics.js | 63 +++- .../components/posts/post-activity-feed.hbs | 327 ++++++++++++------ .../components/posts/post-activity-feed.js | 11 + ghost/admin/app/helpers/split-number.js | 19 + ghost/admin/app/styles/layouts/content.css | 37 +- ghost/admin/package.json | 3 +- yarn.lock | 5 + 8 files changed, 365 insertions(+), 126 deletions(-) create mode 100644 ghost/admin/app/helpers/split-number.js diff --git a/ghost/admin/app/components/posts/analytics.hbs b/ghost/admin/app/components/posts/analytics.hbs index 6867f30305..9e57b4e9b7 100644 --- a/ghost/admin/app/components/posts/analytics.hbs +++ b/ghost/admin/app/components/posts/analytics.hbs @@ -41,8 +41,10 @@ @buttonText="Refresh" @task={{this.fetchPostTask}} @showIcon={{true}} + @idleIcon="reload" @successText="Refreshed" - @class="gh-btn gh-btn-icon refresh" /> + @class="gh-btn gh-btn-icon refresh" + @successClass="gh-btn gh-btn-icon refresh" /> {{/if}} {{#unless this.post.emailOnly}} + + + {{/if}} -
- {{moment-from-now parsedEvent.timestamp}} -
- {{/let}} - {{/each}} - - {{#if (compute (fn this.areStubsNeeded eventsFetcher))}} - {{#let (compute (fn this.getAmountOfStubs eventsFetcher)) as |stubs|}} - {{#each stubs}} -
- {{/each}} - {{/let}} - {{/if}} - -
- - -
- {{#if (compute (fn this.isPaginationNotNeeded eventsFetcher))}} - Showing {{eventsFetcher.totalEvents}} in total - {{else}} - Showing {{eventsFetcher.previousEvents}}-{{eventsFetcher.shownEvents}} of {{eventsFetcher.totalEvents}} - -
- - - -
- {{/if}}
- - - {{#if (eq @eventType 'feedback')}} - + {{else}} +
+
+
+
+
+
+
{{/if}} {{else}} -
-
-
- {{#if (eq this.eventType "sent")}} - {{svg-jar "empty-sent"}} -

No members have received your email yet

-

Once someone receives your email, you'll be able to see the member activity here.

- {{else if (eq this.eventType "opened")}} - {{svg-jar "empty-opened"}} -

No members have opened your newsletter

-

Once someone opens, you'll see them listed here.

- {{else if (eq this.eventType "clicked")}} - {{svg-jar "empty-clicked"}} -

No links have been clicked in your newsletter

-

Once a member clicks a link, you'll see them listed here.

- {{else if (eq this.eventType "feedback")}} - {{svg-jar "empty-feedback"}} -

No members have given feedback yet

-

When someone does, you'll see their response here.

- {{else if (eq this.eventType "conversion")}} - {{svg-jar "empty-conversion"}} -

No members have signed up on this post

-

When someone new signs up, you'll see them here.

- {{/if}} + {{#if eventsFetcher.data}} +
+ {{#each eventsFetcher.data as |event|}} + {{#let (parse-member-event event) as |parsedEvent|}} +
+
+ {{#if parsedEvent.member}} + + {{parsedEvent.subject}} + {{else}} + {{#if parsedEvent.email}} + + {{parsedEvent.subject}} + {{else}} + + {{parsedEvent.subject}} + {{/if}} + {{/if}} +
+
+ {{svg-jar parsedEvent.icon }} + + + {{capitalize-first-letter parsedEvent.action}} + {{#if parsedEvent.info}} +  ({{parsedEvent.info}}) + {{/if}} + + +
+ {{#if (eq this.eventType "conversion")}} +
+ {{#if parsedEvent.source}} + {{svg-jar "event-extras-source"}}{{parsedEvent.source.name}} + {{else}} + + {{/if}} +
+ {{/if}} +
+ {{moment-from-now parsedEvent.timestamp}} +
+
+ {{/let}} + {{/each}} + + {{#if (compute (fn this.areStubsNeeded eventsFetcher))}} + {{#let (compute (fn this.getAmountOfStubs eventsFetcher)) as |stubs|}} + {{#each stubs}} +
+ {{/each}} + {{/let}} + {{/if}} + +
+ + +
+ {{#if (compute (fn this.isPaginationNotNeeded eventsFetcher))}} + Showing {{eventsFetcher.totalEvents}} in total + {{else}} + Showing {{eventsFetcher.previousEvents}}-{{eventsFetcher.shownEvents}} of {{eventsFetcher.totalEvents}} + +
+ + + +
+ {{/if}} +
-
+ + {{#if (eq @eventType 'feedback')}} + + {{/if}} + {{else}} +
+
+
+ {{#if (eq this.eventType "sent")}} + {{svg-jar "empty-sent"}} +

No members have received your email yet

+

Once someone receives your email, you'll be able to see the member activity here.

+ {{else if (eq this.eventType "opened")}} + {{svg-jar "empty-opened"}} +

No members have opened your newsletter

+

Once someone opens, you'll see them listed here.

+ {{else if (eq this.eventType "clicked")}} + {{svg-jar "empty-clicked"}} +

No links have been clicked in your newsletter

+

Once a member clicks a link, you'll see them listed here.

+ {{else if (eq this.eventType "feedback")}} + {{svg-jar "empty-feedback"}} +

No members have given feedback yet

+

When someone does, you'll see their response here.

+ {{else if (eq this.eventType "conversion")}} + {{svg-jar "empty-conversion"}} +

No members have signed up on this post

+

When someone new signs up, you'll see them here.

+ {{/if}} +
+
+
+ {{/if}} {{/if}} {{/let}}
diff --git a/ghost/admin/app/components/posts/post-activity-feed.js b/ghost/admin/app/components/posts/post-activity-feed.js index 937123a2d7..75814d88e6 100644 --- a/ghost/admin/app/components/posts/post-activity-feed.js +++ b/ghost/admin/app/components/posts/post-activity-feed.js @@ -1,6 +1,7 @@ import Component from '@glimmer/component'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; +import {tracked} from '@glimmer/tracking'; export default class PostActivityFeed extends Component { @service feature; @@ -14,6 +15,16 @@ export default class PostActivityFeed extends Component { conversion: ['subscription_event', 'signup_event'] }; + @tracked savedEventsFetcher = null; + + @action + saveEvents(fetcher) { + if (fetcher && fetcher.data && fetcher.data.length > 0) { + this.savedEventsFetcher = fetcher; + } + return null; + } + get getEventTypes() { return this._eventTypes[this.args.eventType]; } diff --git a/ghost/admin/app/helpers/split-number.js b/ghost/admin/app/helpers/split-number.js new file mode 100644 index 0000000000..d136c4a832 --- /dev/null +++ b/ghost/admin/app/helpers/split-number.js @@ -0,0 +1,19 @@ +import {formatNumber} from './format-number'; +import {helper} from '@ember/component/helper'; +import {htmlSafe} from '@ember/template'; + +export function splitNumber([number, previousNumber]) { + let formattedNewNumber = formatNumber(number); + let formattedOldNumber = formatNumber(previousNumber); + + let oldChars = formattedOldNumber.split('').map(char => `${char}`).join(''); + + let newChars = formattedNewNumber.split('').map(char => `${char}`).join(''); + + return htmlSafe(` +
${oldChars}
+
${newChars}
+ `); +} + +export default helper(splitNumber); diff --git a/ghost/admin/app/styles/layouts/content.css b/ghost/admin/app/styles/layouts/content.css index 7bb9b30b05..fce010544a 100644 --- a/ghost/admin/app/styles/layouts/content.css +++ b/ghost/admin/app/styles/layouts/content.css @@ -1500,6 +1500,21 @@ margin-top: -2px; } +.gh-post-analytics-header .gh-btn.refresh { + border: 1px solid var(--whitegrey-d1); + background: var(--white); + color: var(--darkgrey); +} + +.gh-post-analytics-header .gh-btn.refresh:hover { + border-color: var(--lightgrey-l2); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.gh-post-analytics-header .gh-btn.refresh svg path { + stroke-width: 2.25; +} + .gh-post-list-cta.is-hovered { border-color: var(--whitegrey-d2); } @@ -1532,7 +1547,7 @@ } .gh-post-list-cta > svg path { - stroke-width: 2; + stroke-width: 1.75; } .gh-post-list-cta > span { @@ -1727,6 +1742,26 @@ span.dropdown .gh-post-list-cta > span { height: 16px; } +.gh-tabs-analytics .animated-number { + position: relative; +} + +.gh-tabs-analytics .animated-number .new-number { + position: absolute; + top: 0; + left: 0; +} + +.gh-tabs-analytics .animated-number .new-char, +.gh-tabs-analytics .animated-number .old-char { + display: inline-block; +} + +.gh-tabs-analytics .animated-number .new-char { + opacity: 0; + transform: translateY(10px); +} + .gh-tabs-analytics .gh-dashboard-list-item { grid-template-columns: 40% 40% 20%; } diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 2fd7761e1c..80d824b660 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -56,6 +56,7 @@ "@tryghost/nql-lang": "0.6.1", "@tryghost/string": "0.2.12", "@tryghost/timezone-data": "0.4.3", + "animejs": "3.2.2", "autoprefixer": "9.8.6", "babel-eslint": "10.1.0", "babel-plugin-transform-class-properties": "6.24.1", @@ -205,4 +206,4 @@ } } } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index c6e7cecb67..88f03c43a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9473,6 +9473,11 @@ amperize@0.6.1: uuid "^7.0.1" validator "^12.0.0" +animejs@3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/animejs/-/animejs-3.2.2.tgz#59be98c58834339d5847f4a70ddba74ac75b6afc" + integrity sha512-Ao95qWLpDPXXM+WrmwcKbl6uNlC5tjnowlaRYtuVDHHoygjtIPfDUoK9NthrlZsQSKjZXlmji2TrBUAVbiH0LQ== + ansi-colors@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"