mirror of
https://github.com/PostHog/posthog.git
synced 2024-11-21 13:39:22 +01:00
feat(frontend): use lottie for animation (#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 <dev@twixes.com>
This commit is contained in:
parent
4431c09f22
commit
6ba92b4766
6
frontend/src/custom.d.ts
vendored
6
frontend/src/custom.d.ts
vendored
@ -15,3 +15,9 @@ declare module '*.mp3' {
|
||||
const content: any
|
||||
export default content
|
||||
}
|
||||
|
||||
// This fixes TS errors when importing an .lottie file
|
||||
declare module '*.lottie' {
|
||||
const content: any
|
||||
export default content
|
||||
}
|
||||
|
42
frontend/src/lib/animations/animations.ts
Normal file
42
frontend/src/lib/animations/animations.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import laptophog from './laptophog.lottie'
|
||||
import musichog from './musichog.lottie'
|
||||
import sportshog from './sportshog.lottie'
|
||||
|
||||
/**
|
||||
* We're keeping lottiefiles in this folder.
|
||||
*
|
||||
* Even though these are `.json` files, we keep their filenames as `.lottie`. Doing otherwise makes prettier
|
||||
* explode their size. We're just `fetch`-ing these files, so let's treat them as binaries.
|
||||
*
|
||||
* See more: https://lottiefiles.com/
|
||||
*/
|
||||
|
||||
export enum AnimationType {
|
||||
LaptopHog = 'laptophog',
|
||||
MusicHog = 'musichog',
|
||||
SportsHog = 'sportshog',
|
||||
}
|
||||
|
||||
export const animations: Record<AnimationType, { url: string; width: number; height: number }> = {
|
||||
laptophog: { url: laptophog, width: 800, height: 800 },
|
||||
musichog: { url: musichog, width: 800, height: 800 },
|
||||
sportshog: { url: sportshog, width: 800, height: 800 },
|
||||
}
|
||||
|
||||
const animationCache: Record<string, Record<string, any>> = {}
|
||||
const fetchCache: Record<string, Promise<Record<string, any>>> = {}
|
||||
|
||||
async function fetchJson(url: string): Promise<Record<string, any>> {
|
||||
const response = await window.fetch(url)
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export async function getAnimationSource(animation: AnimationType): Promise<Record<string, any>> {
|
||||
if (!animationCache[animation]) {
|
||||
if (!fetchCache[animation]) {
|
||||
fetchCache[animation] = fetchJson(animations[animation].url)
|
||||
}
|
||||
animationCache[animation] = await fetchCache[animation]
|
||||
}
|
||||
return animationCache[animation]
|
||||
}
|
1
frontend/src/lib/animations/laptophog.lottie
Normal file
1
frontend/src/lib/animations/laptophog.lottie
Normal file
File diff suppressed because one or more lines are too long
1
frontend/src/lib/animations/musichog.lottie
Normal file
1
frontend/src/lib/animations/musichog.lottie
Normal file
File diff suppressed because one or more lines are too long
1
frontend/src/lib/animations/sportshog.lottie
Normal file
1
frontend/src/lib/animations/sportshog.lottie
Normal file
File diff suppressed because one or more lines are too long
26
frontend/src/lib/components/Animation/Animation.scss
Normal file
26
frontend/src/lib/components/Animation/Animation.scss
Normal file
@ -0,0 +1,26 @@
|
||||
.Animation {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
// A correct aspect-ratio is be passed via a style prop. This is as a fallback.
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transition: 400ms ease opacity;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.Animation--hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.Animation__player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
44
frontend/src/lib/components/Animation/Animation.stories.tsx
Normal file
44
frontend/src/lib/components/Animation/Animation.stories.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import * as React from 'react'
|
||||
import { animations, AnimationType } from '../../animations/animations'
|
||||
import { Meta } from '@storybook/react'
|
||||
import { LemonTable } from '../LemonTable'
|
||||
import { Animation } from 'lib/components/Animation/Animation'
|
||||
|
||||
export default {
|
||||
title: 'Layout/Animations',
|
||||
parameters: {
|
||||
options: { showPanel: false },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Animations are [LottieFiles.com](https://lottiefiles.com/) animations that we load asynchronously.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Meta
|
||||
|
||||
export function Animations(): JSX.Element {
|
||||
return (
|
||||
<LemonTable
|
||||
dataSource={Object.keys(animations).map((key) => ({ key }))}
|
||||
columns={[
|
||||
{
|
||||
title: 'Code',
|
||||
key: 'code',
|
||||
dataIndex: 'key',
|
||||
render: function RenderCode(name) {
|
||||
return <code>{`<Animation type="${name as string}" />`}</code>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Animation',
|
||||
key: 'animation',
|
||||
dataIndex: 'key',
|
||||
render: function RenderAnimation(key) {
|
||||
return <Animation type={key as AnimationType} />
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
71
frontend/src/lib/components/Animation/Animation.tsx
Normal file
71
frontend/src/lib/components/Animation/Animation.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import './Animation.scss'
|
||||
import { Player } from '@lottiefiles/react-lottie-player'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { AnimationType, getAnimationSource, animations } from 'lib/animations/animations'
|
||||
import { Spinner } from 'lib/components/Spinner/Spinner'
|
||||
|
||||
export interface AnimationProps {
|
||||
/** Animation to show */
|
||||
type?: AnimationType
|
||||
/** Milliseconds to wait before showing the animation. Can be 0, defaults to 300. */
|
||||
delay?: number
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function Animation({
|
||||
className,
|
||||
style,
|
||||
delay = 300,
|
||||
type = AnimationType.LaptopHog,
|
||||
}: AnimationProps): JSX.Element {
|
||||
const [visible, setVisible] = useState(delay === 0)
|
||||
const [source, setSource] = useState<null | Record<string, any>>(null)
|
||||
const [showFallbackSpinner, setShowFallbackSpinner] = useState(false)
|
||||
const { width, height } = animations[type]
|
||||
|
||||
// Delay 300ms before showing Animation, to not confuse users with subliminal hedgehogs
|
||||
// that flash before their eyes. Then take 400ms to fade in the animation.
|
||||
useEffect(() => {
|
||||
if (delay) {
|
||||
const timeout = window.setTimeout(() => setVisible(true), delay)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}
|
||||
}, [delay])
|
||||
|
||||
// Actually fetch the animation. Uses a cache to avoid multiple requests for the same file.
|
||||
// Show a fallback spinner if failed to fetch.
|
||||
useEffect(() => {
|
||||
let unmounted = false
|
||||
async function loadAnimation(): Promise<void> {
|
||||
try {
|
||||
const source = await getAnimationSource(type)
|
||||
!unmounted && setSource(source)
|
||||
} catch (e) {
|
||||
!unmounted && setShowFallbackSpinner(true)
|
||||
}
|
||||
}
|
||||
loadAnimation()
|
||||
return () => {
|
||||
unmounted = true
|
||||
}
|
||||
}, [type])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'Animation',
|
||||
{ 'Animation--hidden': !(visible && (source || showFallbackSpinner)) },
|
||||
className
|
||||
)}
|
||||
style={{ aspectRatio: `${width} / ${height}`, ...style }}
|
||||
>
|
||||
{source ? (
|
||||
<Player className="Animation__player" autoplay loop src={source} />
|
||||
) : showFallbackSpinner ? (
|
||||
<Spinner />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { useActions, useValues } from 'kea'
|
||||
import React from 'react'
|
||||
import { PlusCircleOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { IconTrendUp, IconOpenInNew, IconErrorOutline } from 'lib/components/icons'
|
||||
import { IconErrorOutline, IconOpenInNew, IconTrendUp } from 'lib/components/icons'
|
||||
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
|
||||
import { funnelLogic } from 'scenes/funnels/funnelLogic'
|
||||
import { entityFilterLogic } from 'scenes/insights/ActionFilter/entityFilterLogic'
|
||||
@ -14,9 +14,10 @@ import { LemonButton } from 'lib/components/LemonButton'
|
||||
import { deleteWithUndo } from 'lib/utils'
|
||||
import { teamLogic } from 'scenes/teamLogic'
|
||||
import './EmptyStates.scss'
|
||||
import { Spinner } from 'lib/components/Spinner/Spinner'
|
||||
import { urls } from 'scenes/urls'
|
||||
import { Link } from 'lib/components/Link'
|
||||
import { Animation } from 'lib/components/Animation/Animation'
|
||||
import { AnimationType } from 'lib/animations/animations'
|
||||
|
||||
export function InsightEmptyState(): JSX.Element {
|
||||
return (
|
||||
@ -78,7 +79,9 @@ export function InsightTimeoutState({ isLoading }: { isLoading: boolean }): JSX.
|
||||
return (
|
||||
<div className="insight-empty-state warning">
|
||||
<div className="empty-state-inner">
|
||||
<div className="illustration-main">{isLoading ? <Spinner size="lg" /> : <IconErrorOutline />}</div>
|
||||
<div className="illustration-main" style={{ height: 'auto' }}>
|
||||
{isLoading ? <Animation type={AnimationType.SportsHog} /> : <IconErrorOutline />}
|
||||
</div>
|
||||
<h2>{isLoading ? 'Looks like things are a little slow…' : 'Your query took too long to complete'}</h2>
|
||||
{isLoading ? (
|
||||
<>
|
||||
|
@ -20,7 +20,6 @@ import {
|
||||
InsightErrorState,
|
||||
InsightTimeoutState,
|
||||
} from 'scenes/insights/EmptyStates'
|
||||
import { Loading } from 'lib/utils'
|
||||
import { funnelLogic } from 'scenes/funnels/funnelLogic'
|
||||
import clsx from 'clsx'
|
||||
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
|
||||
@ -32,6 +31,8 @@ import { Tooltip } from 'lib/components/Tooltip'
|
||||
import { LemonButton } from 'lib/components/LemonButton'
|
||||
import { IconExport } from 'lib/components/icons'
|
||||
import { FunnelStepsTable } from './InsightTabs/FunnelTab/FunnelStepsTable'
|
||||
import { Animation } from 'lib/components/Animation/Animation'
|
||||
import { AnimationType } from 'lib/animations/animations'
|
||||
|
||||
const VIEW_MAP = {
|
||||
[`${InsightType.TRENDS}`]: <TrendInsight view={InsightType.TRENDS} />,
|
||||
@ -74,14 +75,9 @@ export function InsightContainer(
|
||||
const BlockingEmptyState = (() => {
|
||||
if (activeView !== loadedView || (insightLoading && !showTimeoutMessage)) {
|
||||
return (
|
||||
<>
|
||||
{filters.display !== ChartDisplayType.ActionsTable &&
|
||||
filters.display !== ChartDisplayType.WorldMap && (
|
||||
/* Tables and world map don't need this padding, but graphs do for sizing */
|
||||
<div className="trends-insights-container" />
|
||||
)}
|
||||
<Loading />
|
||||
</>
|
||||
<div className="text-center">
|
||||
<Animation type={AnimationType.LaptopHog} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Insight specific empty states - note order is important here
|
||||
|
@ -135,6 +135,7 @@ export const commonConfig = {
|
||||
'.woff': 'file',
|
||||
'.woff2': 'file',
|
||||
'.mp3': 'file',
|
||||
'.lottie': 'file',
|
||||
},
|
||||
metafile: true,
|
||||
}
|
||||
|
@ -82,7 +82,7 @@ export default {
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
moduleNameMapper: {
|
||||
'^.+\\.(css|less|scss|svg|png)$': '<rootDir>/test/mocks/styleMock.js',
|
||||
'^.+\\.(css|less|scss|svg|png|lottie)$': '<rootDir>/test/mocks/styleMock.js',
|
||||
'^~/(.*)$': '<rootDir>/$1',
|
||||
'^lib/(.*)$': '<rootDir>/lib/$1',
|
||||
'^scenes/(.*)$': '<rootDir>/scenes/$1',
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'whatwg-fetch'
|
||||
import 'jest-canvas-mock'
|
||||
|
||||
window.scrollTo = jest.fn()
|
||||
|
@ -61,6 +61,7 @@
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.17.10",
|
||||
"@babel/runtime": "^7.17.9",
|
||||
"@lottiefiles/react-lottie-player": "^3.4.7",
|
||||
"@monaco-editor/react": "^4.1.3",
|
||||
"@popperjs/core": "^2.9.2",
|
||||
"@posthog/chart.js": "^2.9.6",
|
||||
@ -182,6 +183,7 @@
|
||||
"html-webpack-plugin": "^4.5.2",
|
||||
"husky": "^7.0.4",
|
||||
"jest": "^26.6.3",
|
||||
"jest-canvas-mock": "^2.4.0",
|
||||
"kea-test-utils": "^0.2.2",
|
||||
"kea-typegen": "^3.1.0",
|
||||
"less": "^3.12.2",
|
||||
|
@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
/* global require, module, process, __dirname */
|
||||
const path = require('path')
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
@ -106,7 +105,7 @@ function createEntry(entry) {
|
||||
|
||||
{
|
||||
// Now we apply rule for images
|
||||
test: /\.(png|jpe?g|gif|svg)$/,
|
||||
test: /\.(png|jpe?g|gif|svg|lottie)$/,
|
||||
use: [
|
||||
{
|
||||
// Using file-loader for these files
|
||||
|
34
yarn.lock
34
yarn.lock
@ -2657,6 +2657,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
|
||||
integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
|
||||
|
||||
"@lottiefiles/react-lottie-player@^3.4.7":
|
||||
version "3.4.7"
|
||||
resolved "https://registry.yarnpkg.com/@lottiefiles/react-lottie-player/-/react-lottie-player-3.4.7.tgz#c8c377e2bc8a636c2bf62ce95dd0e39f720b09c1"
|
||||
integrity sha512-KqkwRiCQPDNzimsXnNSgeJjJlZQ6Fr9JE3OtZdOaGrXovZJ+zDeZNxIwxID8Up0eAdm4zJjudOSc5EJSiGw9RA==
|
||||
dependencies:
|
||||
lottie-web "^5.7.8"
|
||||
|
||||
"@maxmind/geoip2-node@^3.0.0":
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@maxmind/geoip2-node/-/geoip2-node-3.4.0.tgz#e0b2dd6e46931cbb84ccb46cbb154a5009e75bcc"
|
||||
@ -6603,7 +6610,7 @@ color-name@1.1.3:
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
|
||||
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
|
||||
|
||||
color-name@^1.0.0, color-name@~1.1.4:
|
||||
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
@ -7135,6 +7142,11 @@ cssesc@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||
|
||||
cssfontparser@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3"
|
||||
integrity sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==
|
||||
|
||||
cssnano-preset-default@^4.0.7:
|
||||
version "4.0.7"
|
||||
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:
|
||||
es-get-iterator "^1.0.2"
|
||||
iterate-iterator "^1.0.1"
|
||||
|
||||
jest-canvas-mock@^2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz#947b71442d7719f8e055decaecdb334809465341"
|
||||
integrity sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ==
|
||||
dependencies:
|
||||
cssfontparser "^1.2.1"
|
||||
moo-color "^1.0.2"
|
||||
|
||||
jest-changed-files@^26.6.2:
|
||||
version "26.6.2"
|
||||
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:
|
||||
dependencies:
|
||||
js-tokens "^3.0.0 || ^4.0.0"
|
||||
|
||||
lottie-web@^5.7.8:
|
||||
version "5.9.4"
|
||||
resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.9.4.tgz#c01478ee5dd47f916cb4bb79040c11d427872d57"
|
||||
integrity sha512-bSs1ZTPifnBVejO1MnQHdfrKfcn02YTCmgOh2wcAEICqRA0V7GzDh8FnwXY6+EzT+i8XOunaIloo/5xC5YNbsA==
|
||||
|
||||
lower-case@^2.0.1:
|
||||
version "2.0.1"
|
||||
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:
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4"
|
||||
integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg==
|
||||
|
||||
moo-color@^1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74"
|
||||
integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==
|
||||
dependencies:
|
||||
color-name "^1.1.4"
|
||||
|
||||
move-concurrently@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
|
||||
|
Loading…
Reference in New Issue
Block a user