Skip to content

Commit 6ba92b4

Browse files
mariusandraTwixes
andauthored
feat(frontend): use lottie for animation (PostHog#9904)
* add lottie-react * test loading states * add new animations * adopt the laptophog as the loadinghog * handle opacity via a class * move all lottiefiles to lib/animations * new animation component * add storybook * use sportshog and laptophog animations for loading * jest also wants to ignore these files * clarify text * support canvas in jsdom / jest * add width/height to animations * clarify * use a mocked canvas instead of installing new debian packages to get this to compile * I posted a wrong answer on the internet Co-authored-by: Michael Matloka <[email protected]>
1 parent 4431c09 commit 6ba92b4

16 files changed

+242
-16
lines changed

frontend/src/custom.d.ts

+6
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,9 @@ declare module '*.mp3' {
1515
const content: any
1616
export default content
1717
}
18+
19+
// This fixes TS errors when importing an .lottie file
20+
declare module '*.lottie' {
21+
const content: any
22+
export default content
23+
}
+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import laptophog from './laptophog.lottie'
2+
import musichog from './musichog.lottie'
3+
import sportshog from './sportshog.lottie'
4+
5+
/**
6+
* We're keeping lottiefiles in this folder.
7+
*
8+
* Even though these are `.json` files, we keep their filenames as `.lottie`. Doing otherwise makes prettier
9+
* explode their size. We're just `fetch`-ing these files, so let's treat them as binaries.
10+
*
11+
* See more: https://lottiefiles.com/
12+
*/
13+
14+
export enum AnimationType {
15+
LaptopHog = 'laptophog',
16+
MusicHog = 'musichog',
17+
SportsHog = 'sportshog',
18+
}
19+
20+
export const animations: Record<AnimationType, { url: string; width: number; height: number }> = {
21+
laptophog: { url: laptophog, width: 800, height: 800 },
22+
musichog: { url: musichog, width: 800, height: 800 },
23+
sportshog: { url: sportshog, width: 800, height: 800 },
24+
}
25+
26+
const animationCache: Record<string, Record<string, any>> = {}
27+
const fetchCache: Record<string, Promise<Record<string, any>>> = {}
28+
29+
async function fetchJson(url: string): Promise<Record<string, any>> {
30+
const response = await window.fetch(url)
31+
return await response.json()
32+
}
33+
34+
export async function getAnimationSource(animation: AnimationType): Promise<Record<string, any>> {
35+
if (!animationCache[animation]) {
36+
if (!fetchCache[animation]) {
37+
fetchCache[animation] = fetchJson(animations[animation].url)
38+
}
39+
animationCache[animation] = await fetchCache[animation]
40+
}
41+
return animationCache[animation]
42+
}

frontend/src/lib/animations/laptophog.lottie

+1
Large diffs are not rendered by default.

frontend/src/lib/animations/musichog.lottie

+1
Large diffs are not rendered by default.

frontend/src/lib/animations/sportshog.lottie

+1
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
.Animation {
2+
width: 100%;
3+
max-width: 300px;
4+
// A correct aspect-ratio is be passed via a style prop. This is as a fallback.
5+
aspect-ratio: 1 / 1;
6+
overflow: hidden;
7+
opacity: 1;
8+
transition: 400ms ease opacity;
9+
10+
display: inline-flex;
11+
align-items: center;
12+
justify-content: center;
13+
14+
&.Animation--hidden {
15+
opacity: 0;
16+
}
17+
18+
.Animation__player {
19+
display: block;
20+
width: 100%;
21+
height: 100%;
22+
svg {
23+
display: block;
24+
}
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as React from 'react'
2+
import { animations, AnimationType } from '../../animations/animations'
3+
import { Meta } from '@storybook/react'
4+
import { LemonTable } from '../LemonTable'
5+
import { Animation } from 'lib/components/Animation/Animation'
6+
7+
export default {
8+
title: 'Layout/Animations',
9+
parameters: {
10+
options: { showPanel: false },
11+
docs: {
12+
description: {
13+
component:
14+
'Animations are [LottieFiles.com](https://lottiefiles.com/) animations that we load asynchronously.',
15+
},
16+
},
17+
},
18+
} as Meta
19+
20+
export function Animations(): JSX.Element {
21+
return (
22+
<LemonTable
23+
dataSource={Object.keys(animations).map((key) => ({ key }))}
24+
columns={[
25+
{
26+
title: 'Code',
27+
key: 'code',
28+
dataIndex: 'key',
29+
render: function RenderCode(name) {
30+
return <code>{`<Animation type="${name as string}" />`}</code>
31+
},
32+
},
33+
{
34+
title: 'Animation',
35+
key: 'animation',
36+
dataIndex: 'key',
37+
render: function RenderAnimation(key) {
38+
return <Animation type={key as AnimationType} />
39+
},
40+
},
41+
]}
42+
/>
43+
)
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import './Animation.scss'
2+
import { Player } from '@lottiefiles/react-lottie-player'
3+
import React, { useEffect, useState } from 'react'
4+
import clsx from 'clsx'
5+
import { AnimationType, getAnimationSource, animations } from 'lib/animations/animations'
6+
import { Spinner } from 'lib/components/Spinner/Spinner'
7+
8+
export interface AnimationProps {
9+
/** Animation to show */
10+
type?: AnimationType
11+
/** Milliseconds to wait before showing the animation. Can be 0, defaults to 300. */
12+
delay?: number
13+
className?: string
14+
style?: React.CSSProperties
15+
}
16+
17+
export function Animation({
18+
className,
19+
style,
20+
delay = 300,
21+
type = AnimationType.LaptopHog,
22+
}: AnimationProps): JSX.Element {
23+
const [visible, setVisible] = useState(delay === 0)
24+
const [source, setSource] = useState<null | Record<string, any>>(null)
25+
const [showFallbackSpinner, setShowFallbackSpinner] = useState(false)
26+
const { width, height } = animations[type]
27+
28+
// Delay 300ms before showing Animation, to not confuse users with subliminal hedgehogs
29+
// that flash before their eyes. Then take 400ms to fade in the animation.
30+
useEffect(() => {
31+
if (delay) {
32+
const timeout = window.setTimeout(() => setVisible(true), delay)
33+
return () => window.clearTimeout(timeout)
34+
}
35+
}, [delay])
36+
37+
// Actually fetch the animation. Uses a cache to avoid multiple requests for the same file.
38+
// Show a fallback spinner if failed to fetch.
39+
useEffect(() => {
40+
let unmounted = false
41+
async function loadAnimation(): Promise<void> {
42+
try {
43+
const source = await getAnimationSource(type)
44+
!unmounted && setSource(source)
45+
} catch (e) {
46+
!unmounted && setShowFallbackSpinner(true)
47+
}
48+
}
49+
loadAnimation()
50+
return () => {
51+
unmounted = true
52+
}
53+
}, [type])
54+
55+
return (
56+
<div
57+
className={clsx(
58+
'Animation',
59+
{ 'Animation--hidden': !(visible && (source || showFallbackSpinner)) },
60+
className
61+
)}
62+
style={{ aspectRatio: `${width} / ${height}`, ...style }}
63+
>
64+
{source ? (
65+
<Player className="Animation__player" autoplay loop src={source} />
66+
) : showFallbackSpinner ? (
67+
<Spinner />
68+
) : null}
69+
</div>
70+
)
71+
}

frontend/src/scenes/insights/EmptyStates/EmptyStates.tsx

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useActions, useValues } from 'kea'
22
import React from 'react'
33
import { PlusCircleOutlined, WarningOutlined } from '@ant-design/icons'
4-
import { IconTrendUp, IconOpenInNew, IconErrorOutline } from 'lib/components/icons'
4+
import { IconErrorOutline, IconOpenInNew, IconTrendUp } from 'lib/components/icons'
55
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
66
import { funnelLogic } from 'scenes/funnels/funnelLogic'
77
import { entityFilterLogic } from 'scenes/insights/ActionFilter/entityFilterLogic'
@@ -14,9 +14,10 @@ import { LemonButton } from 'lib/components/LemonButton'
1414
import { deleteWithUndo } from 'lib/utils'
1515
import { teamLogic } from 'scenes/teamLogic'
1616
import './EmptyStates.scss'
17-
import { Spinner } from 'lib/components/Spinner/Spinner'
1817
import { urls } from 'scenes/urls'
1918
import { Link } from 'lib/components/Link'
19+
import { Animation } from 'lib/components/Animation/Animation'
20+
import { AnimationType } from 'lib/animations/animations'
2021

2122
export function InsightEmptyState(): JSX.Element {
2223
return (
@@ -78,7 +79,9 @@ export function InsightTimeoutState({ isLoading }: { isLoading: boolean }): JSX.
7879
return (
7980
<div className="insight-empty-state warning">
8081
<div className="empty-state-inner">
81-
<div className="illustration-main">{isLoading ? <Spinner size="lg" /> : <IconErrorOutline />}</div>
82+
<div className="illustration-main" style={{ height: 'auto' }}>
83+
{isLoading ? <Animation type={AnimationType.SportsHog} /> : <IconErrorOutline />}
84+
</div>
8285
<h2>{isLoading ? 'Looks like things are a little slow…' : 'Your query took too long to complete'}</h2>
8386
{isLoading ? (
8487
<>

frontend/src/scenes/insights/InsightContainer.tsx

+5-9
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
InsightErrorState,
2121
InsightTimeoutState,
2222
} from 'scenes/insights/EmptyStates'
23-
import { Loading } from 'lib/utils'
2423
import { funnelLogic } from 'scenes/funnels/funnelLogic'
2524
import clsx from 'clsx'
2625
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
@@ -32,6 +31,8 @@ import { Tooltip } from 'lib/components/Tooltip'
3231
import { LemonButton } from 'lib/components/LemonButton'
3332
import { IconExport } from 'lib/components/icons'
3433
import { FunnelStepsTable } from './InsightTabs/FunnelTab/FunnelStepsTable'
34+
import { Animation } from 'lib/components/Animation/Animation'
35+
import { AnimationType } from 'lib/animations/animations'
3536

3637
const VIEW_MAP = {
3738
[`${InsightType.TRENDS}`]: <TrendInsight view={InsightType.TRENDS} />,
@@ -74,14 +75,9 @@ export function InsightContainer(
7475
const BlockingEmptyState = (() => {
7576
if (activeView !== loadedView || (insightLoading && !showTimeoutMessage)) {
7677
return (
77-
<>
78-
{filters.display !== ChartDisplayType.ActionsTable &&
79-
filters.display !== ChartDisplayType.WorldMap && (
80-
/* Tables and world map don't need this padding, but graphs do for sizing */
81-
<div className="trends-insights-container" />
82-
)}
83-
<Loading />
84-
</>
78+
<div className="text-center">
79+
<Animation type={AnimationType.LaptopHog} />
80+
</div>
8581
)
8682
}
8783
// Insight specific empty states - note order is important here

frontend/utils.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export const commonConfig = {
135135
'.woff': 'file',
136136
'.woff2': 'file',
137137
'.mp3': 'file',
138+
'.lottie': 'file',
138139
},
139140
metafile: true,
140141
}

jest.config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export default {
8282

8383
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
8484
moduleNameMapper: {
85-
'^.+\\.(css|less|scss|svg|png)$': '<rootDir>/test/mocks/styleMock.js',
85+
'^.+\\.(css|less|scss|svg|png|lottie)$': '<rootDir>/test/mocks/styleMock.js',
8686
'^~/(.*)$': '<rootDir>/$1',
8787
'^lib/(.*)$': '<rootDir>/lib/$1',
8888
'^scenes/(.*)$': '<rootDir>/scenes/$1',

jest.setup.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
import 'whatwg-fetch'
2+
import 'jest-canvas-mock'
23

34
window.scrollTo = jest.fn()

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"dependencies": {
6262
"@babel/core": "^7.17.10",
6363
"@babel/runtime": "^7.17.9",
64+
"@lottiefiles/react-lottie-player": "^3.4.7",
6465
"@monaco-editor/react": "^4.1.3",
6566
"@popperjs/core": "^2.9.2",
6667
"@posthog/chart.js": "^2.9.6",
@@ -182,6 +183,7 @@
182183
"html-webpack-plugin": "^4.5.2",
183184
"husky": "^7.0.4",
184185
"jest": "^26.6.3",
186+
"jest-canvas-mock": "^2.4.0",
185187
"kea-test-utils": "^0.2.2",
186188
"kea-typegen": "^3.1.0",
187189
"less": "^3.12.2",

webpack.config.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-var-requires */
21
/* global require, module, process, __dirname */
32
const path = require('path')
43
const HtmlWebpackPlugin = require('html-webpack-plugin')
@@ -106,7 +105,7 @@ function createEntry(entry) {
106105

107106
{
108107
// Now we apply rule for images
109-
test: /\.(png|jpe?g|gif|svg)$/,
108+
test: /\.(png|jpe?g|gif|svg|lottie)$/,
110109
use: [
111110
{
112111
// Using file-loader for these files

yarn.lock

+33-1
Original file line numberDiff line numberDiff line change
@@ -2657,6 +2657,13 @@
26572657
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
26582658
integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
26592659

2660+
"@lottiefiles/react-lottie-player@^3.4.7":
2661+
version "3.4.7"
2662+
resolved "https://registry.yarnpkg.com/@lottiefiles/react-lottie-player/-/react-lottie-player-3.4.7.tgz#c8c377e2bc8a636c2bf62ce95dd0e39f720b09c1"
2663+
integrity sha512-KqkwRiCQPDNzimsXnNSgeJjJlZQ6Fr9JE3OtZdOaGrXovZJ+zDeZNxIwxID8Up0eAdm4zJjudOSc5EJSiGw9RA==
2664+
dependencies:
2665+
lottie-web "^5.7.8"
2666+
26602667
"@maxmind/geoip2-node@^3.0.0":
26612668
version "3.4.0"
26622669
resolved "https://registry.yarnpkg.com/@maxmind/geoip2-node/-/geoip2-node-3.4.0.tgz#e0b2dd6e46931cbb84ccb46cbb154a5009e75bcc"
@@ -6603,7 +6610,7 @@ [email protected]:
66036610
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
66046611
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
66056612

6606-
color-name@^1.0.0, color-name@~1.1.4:
6613+
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
66076614
version "1.1.4"
66086615
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
66096616
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
@@ -7135,6 +7142,11 @@ cssesc@^3.0.0:
71357142
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
71367143
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
71377144

7145+
cssfontparser@^1.2.1:
7146+
version "1.2.1"
7147+
resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3"
7148+
integrity sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==
7149+
71387150
cssnano-preset-default@^4.0.7:
71397151
version "4.0.7"
71407152
resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76"
@@ -10779,6 +10791,14 @@ iterate-value@^1.0.2:
1077910791
es-get-iterator "^1.0.2"
1078010792
iterate-iterator "^1.0.1"
1078110793

10794+
jest-canvas-mock@^2.4.0:
10795+
version "2.4.0"
10796+
resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz#947b71442d7719f8e055decaecdb334809465341"
10797+
integrity sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ==
10798+
dependencies:
10799+
cssfontparser "^1.2.1"
10800+
moo-color "^1.0.2"
10801+
1078210802
jest-changed-files@^26.6.2:
1078310803
version "26.6.2"
1078410804
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-26.6.2.tgz#f6198479e1cc66f22f9ae1e22acaa0b429c042d0"
@@ -11802,6 +11822,11 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
1180211822
dependencies:
1180311823
js-tokens "^3.0.0 || ^4.0.0"
1180411824

11825+
lottie-web@^5.7.8:
11826+
version "5.9.4"
11827+
resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.9.4.tgz#c01478ee5dd47f916cb4bb79040c11d427872d57"
11828+
integrity sha512-bSs1ZTPifnBVejO1MnQHdfrKfcn02YTCmgOh2wcAEICqRA0V7GzDh8FnwXY6+EzT+i8XOunaIloo/5xC5YNbsA==
11829+
1180511830
lower-case@^2.0.1:
1180611831
version "2.0.1"
1180711832
resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.1.tgz#39eeb36e396115cc05e29422eaea9e692c9408c7"
@@ -12257,6 +12282,13 @@ moment@^2.10.2, moment@^2.24.0, moment@^2.25.3:
1225712282
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4"
1225812283
integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==
1225912284

12285+
moo-color@^1.0.2:
12286+
version "1.0.3"
12287+
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74"
12288+
integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==
12289+
dependencies:
12290+
color-name "^1.1.4"
12291+
1226012292
move-concurrently@^1.0.1:
1226112293
version "1.0.1"
1226212294
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"

0 commit comments

Comments
 (0)