rewrite user dashboard with React

This commit is contained in:
Pig Fang 2020-01-31 15:58:37 +08:00
parent 36a516d584
commit 1d87171808
17 changed files with 504 additions and 400 deletions

View File

@ -100,8 +100,8 @@ class UserController extends Controller
'players' => $this->calculatePercentageUsed($user->players->count(), option('score_per_player')),
'storage' => $this->calculatePercentageUsed($this->getStorageUsed($user), option('score_per_storage')),
],
'signAfterZero' => option('sign_after_zero'),
'signGapTime' => option('sign_gap_time'),
'signAfterZero' => (bool) option('sign_after_zero'),
'signGapTime' => (int) option('sign_gap_time'),
]);
}

View File

@ -45,8 +45,10 @@
"@types/echarts": "^4.4.2",
"@types/jest": "^24.0.25",
"@types/jquery": "^3.3.29",
"@types/js-yaml": "^3.12.2",
"@types/react": "^16.9.17",
"@types/react-dom": "^16.9.4",
"@types/tween.js": "^17.2.0",
"@types/webpack": "^4.41.2",
"@typescript-eslint/eslint-plugin": "^2.8.0",
"@typescript-eslint/parser": "^2.8.0",
@ -64,6 +66,7 @@
"file-loader": "^5.0.2",
"jest": "^24.9.0",
"jest-extended": "^0.11.2",
"js-yaml": "^3.13.1",
"mini-css-extract-plugin": "^0.9.0",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"postcss-loader": "^3.0.0",

View File

@ -0,0 +1,24 @@
import React, { useState, useEffect, useRef } from 'react'
import TWEEN from '@tweenjs/tween.js'
export default function useTween<T = any>(
initialValue: T,
): [T, React.Dispatch<React.SetStateAction<T>>] {
const [value, setValue] = useState<T>(initialValue)
const ref = useRef<T>(value)
const [dest, setDest] = useState<T>(initialValue)
useEffect(() => {
function animate() {
requestAnimationFrame(animate)
TWEEN.update()
setValue(ref.current)
}
const tween = new TWEEN.Tween(ref)
tween.to({ current: dest }, 1000).start()
animate()
}, [dest])
return [value, setDest]
}

View File

@ -10,7 +10,7 @@ export default [
},
{
path: 'user',
component: () => import('../views/user/Dashboard.vue'),
react: () => import('../views/user/Dashboard'),
el: '#usage-box',
},
{

View File

@ -0,0 +1,27 @@
@use 'sass:map';
$breakpoints: (
xs: 0,
sm: 576px,
md: 768px,
lg: 992px,
xl: 1200px,
);
@mixin less-than($breakpoint) {
@media (max-width: map-get($breakpoints, $breakpoint)) {
@content;
}
}
@mixin between($down, $up) {
@media (max-width: map-get($breakpoints, $down)) and (min-width: map-get($breakpoints, $up)) {
@content;
}
}
@mixin greater-than($breakpoint) {
@media (min-width: map-get($breakpoints, $breakpoint)) {
@content;
}
}

View File

@ -1,212 +0,0 @@
<template>
<div>
<email-verification />
<div class="card card-primary card-outline">
<div class="card-header">
<h3 v-t="'user.used.title'" class="card-title" />
</div>
<div class="card-body">
<div class="row">
<div class="col-md-1" />
<div class="col-md-6">
<div class="info-box bg-teal">
<span class="info-box-icon">
<i class="fas fa-gamepad" />
</span>
<div class="info-box-content">
<span class="info-box-text">{{ $t('user.used.players') }}</span>
<span class="info-box-number">
<strong>{{ playersUsed }}</strong> / {{ playersTotal }}
</span>
<div class="progress">
<div class="progress-bar" :style="{ width: playersPercentage + '%' }" />
</div>
</div>
</div>
<div class="info-box bg-maroon">
<span class="info-box-icon">
<i class="fas fa-hdd" />
</span>
<div class="info-box-content">
<span class="info-box-text">{{ $t('user.used.storage') }}</span>
<span class="info-box-number">
<template v-if="storageUsed > 1024">
<strong>{{ ~~(storageUsed / 1024) }}</strong> / {{ ~~(storageTotal / 1024) }} MB
</template>
<template v-else>
<strong>{{ storageUsed }}</strong> / {{ storageTotal }} KB
</template>
</span>
<div class="progress">
<div class="progress-bar" :style="{ width: storagePercentage + '%' }" />
</div>
</div>
</div>
</div>
<div class="col-md-4">
<p class="text-center score-title">
<strong v-t="'user.cur-score'" />
</p>
<p id="score" data-toggle="modal" data-target="#modal-score-instruction">
{{ animatedScore }}
</p>
<p v-t="'user.score-notice'" class="text-center score-notice" />
</div>
</div>
</div>
<div class="card-footer">
<button
v-if="canSign"
class="btn bg-gradient-primary pl-5 pr-5"
:disabled="signing"
@click="sign"
>
<i class="far fa-calendar-check" aria-hidden="true" /> &nbsp;{{ $t('user.sign') }}
</button>
<button
v-else
class="btn bg-gradient-primary pl-4 pr-4"
:title="$t('user.last-sign', { time: lastSignAt.toLocaleString() })"
disabled
>
<i class="far fa-calendar-check" aria-hidden="true" /> &nbsp;
{{ remainingTimeText }}
</button>
</div>
</div>
</div>
</template>
<script>
import Tween from '@tweenjs/tween.js'
import EmailVerification from '../../components/EmailVerification.vue'
import emitMounted from '../../components/mixins/emitMounted'
import { toast } from '../../scripts/notify'
const ONE_DAY = 24 * 3600 * 1000
export default {
name: 'Dashboard',
components: {
EmailVerification,
},
mixins: [
emitMounted,
],
data: () => ({
score: 0,
tweenedScore: 0,
lastSignAt: new Date(),
signAfterZero: false,
signGap: 0,
signing: false,
playersUsed: 0,
playersTotal: 1,
storageUsed: 0,
storageTotal: 1,
tween: null,
}),
computed: {
playersPercentage() {
return this.playersUsed / this.playersTotal * 100
},
storagePercentage() {
return this.storageUsed / this.storageTotal * 100
},
signRemainingTime() {
if (this.signAfterZero) {
const today = new Date().setHours(0, 0, 0, 0)
const tomorrow = today + ONE_DAY
return this.lastSignAt.valueOf() < today ? 0 : tomorrow - Date.now()
}
return this.lastSignAt.valueOf() + this.signGap - Date.now()
},
remainingTimeText() {
const time = this.signRemainingTime / 1000 / 60
if (time < 60) {
return this.$t(
'user.sign-remain-time',
{ time: ~~time, unit: this.$t('user.time-unit-min') },
)
}
return this.$t(
'user.sign-remain-time',
{ time: ~~(time / 60), unit: this.$t('user.time-unit-hour') },
)
},
canSign() {
return this.signRemainingTime <= 0
},
animatedScore() {
return this.tweenedScore.toFixed(0)
},
},
watch: {
score(newValue) {
this.tween.to({ tweenedScore: newValue }, 1000).start()
},
},
created() {
this.tween = new Tween.Tween(this.$data)
},
beforeMount() {
this.fetchScoreInfo()
},
mounted() {
function animate() {
requestAnimationFrame(animate)
Tween.update()
}
animate()
},
methods: {
async fetchScoreInfo() {
const { data } = await this.$http.get('/user/score-info')
this.lastSignAt = new Date(data.user.lastSignAt)
this.signAfterZero = data.signAfterZero
this.signGap = data.signGapTime * 3600 * 1000
this.playersUsed = data.stats.players.used
this.playersTotal = data.stats.players.total
this.storageUsed = data.stats.storage.used
this.storageTotal = data.stats.storage.total
this.score = data.user.score
},
async sign() {
this.signing = true
const {
code, message, data,
} = await this.$http.post('/user/sign')
if (code === 0) {
toast.success(message)
this.score = data.score
this.lastSignAt = new Date()
this.storageUsed = data.storage.used
this.storageTotal = data.storage.total
} else {
toast.warning(message)
}
this.signing = false
},
},
}
</script>
<style lang="stylus">
.score-title
margin-top 5px
@media (max-width 768px)
margin-top 12px
#score
font-family Minecraft
font-size 50px
text-align center
margin-top 20px
cursor help
.score-notice
font-size smaller
margin-top 20px
</style>

View File

@ -0,0 +1,33 @@
import React from 'react'
interface Props {
name: string
icon: string
color: string
used: number
total: number
unit: string
}
const InfoBox: React.FC<Props> = props => {
const percentage = (props.used / props.total) * 100
return (
<div className={`info-box bg-${props.color}`}>
<span className="info-box-icon">
<i className={`fas fa-${props.icon}`}></i>
</span>
<div className="info-box-content">
<span className="info-box-text">{props.name}</span>
<span className="info-box-number">
<strong>{props.used}</strong> / {props.total} {props.unit}
</span>
<div className="progress">
<div className="progress-bar" style={{ width: `${percentage}%` }} />
</div>
</div>
</div>
)
}
export default React.memo(InfoBox)

View File

@ -0,0 +1,38 @@
import React, { useMemo } from 'react'
import { trans } from '../../../scripts/i18n'
import * as scoreUtils from './scoreUtils'
interface Props {
isLoading: boolean
lastSign: Date
canSignAfterZero: boolean
signGap: number
onClick: React.MouseEventHandler<HTMLButtonElement>
}
const SignButton: React.FC<Props> = props => {
const { lastSign, signGap, canSignAfterZero } = props
const remainingTime = useMemo(
() => scoreUtils.remainingTime(lastSign, signGap, canSignAfterZero),
[lastSign, signGap, canSignAfterZero],
)
const remainingTimeText = useMemo(
() => scoreUtils.remainingTimeText(remainingTime),
[remainingTime],
)
const canSign = remainingTime <= 0
return (
<button
className="btn bg-gradient-primary pl-4 pr-4"
role="button"
disabled={!canSign || props.isLoading}
onClick={props.onClick}
>
<i className="far fa-calendar-check" aria-hidden="true" /> &nbsp;
{canSign ? trans('user.sign') : remainingTimeText}
</button>
)
}
export default React.memo(SignButton)

View File

@ -0,0 +1,134 @@
import React, { useState, useEffect, useCallback } from 'react'
import { hot } from 'react-hot-loader/root'
import { trans } from '../../../scripts/i18n'
import * as fetch from '../../../scripts/net'
import { toast } from '../../../scripts/notify'
import useTween from '../../../scripts/hooks/useTween'
import InfoBox from './InfoBox'
import SignButton from './SignButton'
import scoreStyle from './score.scss'
type ScoreInfo = {
signAfterZero: boolean
signGapTime: number
stats: { players: Stat; storage: Stat }
user: { score: number; lastSignAt: string }
}
type Stat = {
used: number
total: number
}
type SignReturn = {
score: number
storage: Stat
}
const Dashboard: React.FC = () => {
const [loading, setLoading] = useState(false)
const [players, setPlayers] = useState<Stat>({ used: 0, total: 1 })
const [storage, setStorage] = useState<Stat>({ used: 0, total: 1 })
const [score, setScore] = useTween(0)
const [lastSign, setLastSign] = useState(new Date())
const [canSignAfterZero, setCanSignAfterZero] = useState(false)
const [signGap, setSignGap] = useState(24)
useEffect(() => {
const fetchInfo = async () => {
setLoading(true)
const { data } = await fetch.get<fetch.ResponseBody<ScoreInfo>>(
'/user/score-info',
)
setPlayers(data.stats.players)
setStorage(data.stats.storage)
setScore(data.user.score)
setLastSign(new Date(data.user.lastSignAt))
setCanSignAfterZero(data.signAfterZero)
setSignGap(data.signGapTime)
setLoading(false)
}
fetchInfo()
}, [])
const handleSign = useCallback(async () => {
setLoading(true)
const { code, message, data } = await fetch.post<
fetch.ResponseBody<SignReturn>
>('/user/sign')
if (code === 0) {
toast.success(message)
setLastSign(new Date())
setScore(data.score)
setStorage(data.storage)
} else {
toast.warning(message)
}
setLoading(false)
}, [])
return (
<div className="card card-primary card-outline">
<div className="card-header">
<h3 className="card-title">{trans('user.used.title')}</h3>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-1"></div>
<div className="col-md-6">
<InfoBox
color="teal"
icon="gamepad"
name={trans('user.used.players')}
used={players.used}
total={players.total}
unit=""
/>
{storage.used > 1024 ? (
<InfoBox
color="maroon"
icon="hdd"
name={trans('user.used.storage')}
used={~~(storage.used / 1024)}
total={~~(storage.total / 1024)}
unit="MB"
/>
) : (
<InfoBox
color="maroon"
icon="hdd"
name={trans('user.used.storage')}
used={storage.used}
total={storage.total}
unit="KB"
/>
)}
</div>
<div className="col-md-4 text-center">
<p className={scoreStyle.title}>{trans('user.cur-score')}</p>
<p
className={scoreStyle.number}
data-toggle="modal"
data-target="#modal-score-instruction"
>
{~~score}
</p>
<p className={scoreStyle.notice}>{trans('user.score-notice')}</p>
</div>
</div>
</div>
<div className="card-footer">
<SignButton
isLoading={loading}
lastSign={lastSign}
canSignAfterZero={canSignAfterZero}
signGap={signGap}
onClick={handleSign}
/>
</div>
</div>
)
}
export default hot(Dashboard)

View File

@ -0,0 +1,23 @@
@use 'sass:map';
@use '../../../styles/breakpoints';
.title {
font-weight: bold;
margin-top: 5px;
@include breakpoints.less-than('md') {
margin-top: 12px;
}
}
.number {
font-family: 'Minecraft';
font-size: 50px;
margin-top: 20px;
cursor: help;
}
.notice {
font-size: smaller;
margin-top: 20px;
}

View File

@ -0,0 +1,34 @@
import { trans } from '../../../scripts/i18n'
const ONE_MINUTE = 60 * 1000
const ONE_HOUR = 60 * ONE_MINUTE
const ONE_DAY = 24 * ONE_HOUR
export function remainingTime(
lastSign: Date,
signGap: number,
canSignAfterZero: boolean,
): number {
if (canSignAfterZero) {
const today = new Date().setHours(0, 0, 0, 0)
const tomorrow = today + ONE_DAY
const rest = tomorrow - Date.now()
return lastSign.valueOf() < today ? 0 : rest
}
return lastSign.valueOf() + signGap * ONE_HOUR - Date.now()
}
export function remainingTimeText(remainingTime: number): string {
const time = remainingTime / ONE_MINUTE
return time < 60
? trans('user.sign-remain-time', {
time: ~~time,
unit: trans('user.time-unit-min'),
})
: trans('user.sign-remain-time', {
time: ~~(time / 60),
unit: trans('user.time-unit-hour'),
})
}

View File

@ -1,14 +1,16 @@
/* eslint-disable max-classes-per-file */
import * as fs from 'fs'
import 'jest-extended'
import '@testing-library/jest-dom'
import Vue from 'vue'
import yaml from 'js-yaml'
window.blessing = {
base_url: '',
site_name: 'Blessing Skin',
version: '4.0.0',
extra: {},
i18n: {},
i18n: yaml.load(fs.readFileSync('resources/lang/en/front-end.yml', 'utf8')),
}
class Headers extends Map {

View File

@ -1,180 +0,0 @@
/* eslint-disable no-mixed-operators */
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import { flushPromises } from '../../utils'
import { toast } from '@/scripts/notify'
import Dashboard from '@/views/user/Dashboard.vue'
jest.mock('@/scripts/notify')
jest.mock('@tweenjs/tween.js', () => ({
Tween: class <T> {
data: T
constructor(data: T) {
this.data = data
}
to(data: Partial<T>, _: number) {
Object.assign(this.data, data)
return this
}
start() {}
},
update() {},
}))
window.blessing.extra = { unverified: false }
function scoreInfo(data = {}) {
return {
data: {
user: { score: 835, lastSignAt: '2018-08-07 16:06:49' },
stats: {
players: {
used: 3, total: 15, percentage: 20,
},
storage: {
used: 5, total: 20, percentage: 25,
},
},
signAfterZero: false,
signGapTime: '24',
...data,
},
}
}
test('fetch score info', () => {
Vue.prototype.$http.get.mockResolvedValue(scoreInfo())
mount(Dashboard)
expect(Vue.prototype.$http.get).toBeCalledWith('/user/score-info')
})
test('players usage', async () => {
Vue.prototype.$http.get.mockResolvedValue(scoreInfo())
const wrapper = mount(Dashboard)
await flushPromises()
expect(wrapper.text()).toContain('3 / 15')
})
test('storage usage', async () => {
Vue.prototype.$http.get
.mockResolvedValueOnce(scoreInfo())
.mockResolvedValueOnce(scoreInfo({
stats: {
players: {
used: 3, total: 15, percentage: 20,
},
storage: {
used: 2048, total: 4096, percentage: 50,
},
},
}))
let wrapper = mount(Dashboard)
await flushPromises()
expect(wrapper.text()).toContain('5 / 20 KB')
wrapper = mount(Dashboard)
await flushPromises()
expect(wrapper.text()).toContain('2 / 4 MB')
})
test('display score', async () => {
Vue.prototype.$http.get.mockResolvedValue(scoreInfo())
const wrapper = mount(Dashboard)
await flushPromises()
expect(wrapper.find('#score').text()).toContain('835')
})
test('button `sign` state', async () => {
Vue.prototype.$http.get
.mockResolvedValueOnce(scoreInfo({ signAfterZero: true }))
.mockResolvedValueOnce(scoreInfo({
signAfterZero: true,
user: { lastSignAt: Date.now() },
}))
.mockResolvedValueOnce(scoreInfo({ user: { lastSignAt: Date.now() - 25 * 3600 * 1000 } }))
.mockResolvedValueOnce(scoreInfo({ user: { lastSignAt: Date.now() } }))
let wrapper = mount(Dashboard)
await flushPromises()
expect(wrapper.find('button').attributes('disabled')).toBeNil()
wrapper = mount(Dashboard)
await flushPromises()
expect(wrapper.find('button').attributes('disabled')).toBe('disabled')
wrapper = mount(Dashboard)
await flushPromises()
expect(wrapper.find('button').attributes('disabled')).toBeNil()
wrapper = mount(Dashboard)
await flushPromises()
expect(wrapper.find('button').attributes('disabled')).toBe('disabled')
})
test('remaining time', async () => {
const origin = Vue.prototype.$t
Vue.prototype.$t = (key, args) => key + JSON.stringify(args)
Vue.prototype.$http.get
.mockResolvedValueOnce(scoreInfo({
user: { lastSignAt: Date.now() - 23.5 * 3600 * 1000 },
}))
.mockResolvedValueOnce(scoreInfo({
user: { lastSignAt: Date.now() },
}))
let wrapper = mount(Dashboard)
await flushPromises()
expect(wrapper.find('button').text()).toMatch(/(29)|(30)/)
expect(wrapper.find('button').text()).toContain('min')
wrapper = mount(Dashboard)
await flushPromises()
expect(wrapper.find('button').text()).toContain('23')
expect(wrapper.find('button').text()).toContain('hour')
Vue.prototype.$t = origin
})
test('sign', async () => {
Vue.prototype.$http.get.mockResolvedValue(scoreInfo({
user: { lastSignAt: Date.now() - 30 * 3600 * 1000 },
}))
Vue.prototype.$http.post
.mockResolvedValueOnce({
code: 1, message: '1', data: {},
})
.mockResolvedValueOnce({
code: 0,
data: {
score: 233,
storage: { used: 3, total: 4 },
},
})
const wrapper = mount(Dashboard)
const button = wrapper.find('button')
await flushPromises()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith('/user/sign')
expect(toast.warning).toBeCalledWith('1')
button.trigger('click')
await flushPromises()
expect(button.attributes('disabled')).toBe('disabled')
expect(wrapper.text()).toContain('3 / 4 KB')
})
test('disable button when signing', () => {
Vue.prototype.$http.get.mockResolvedValue(scoreInfo())
const wrapper = mount(Dashboard)
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeFalsy()
wrapper.setData({ signing: true })
expect(button.attributes('disabled')).toBeTruthy()
})

View File

@ -0,0 +1,161 @@
import React from 'react'
import { render, fireEvent, wait } from '@testing-library/react'
import * as fetch from '@/scripts/net'
import { trans } from '@/scripts/i18n'
import { toast } from '@/scripts/notify'
import Dashboard from '@/views/user/Dashboard'
jest.mock('@/scripts/net')
jest.mock('@/scripts/notify')
function scoreInfo(data = {}, user = {}, stats = {}) {
return {
data: {
user: { score: 600, lastSignAt: '2018-08-07 16:06:49', ...user },
stats: {
players: { used: 3, total: 15 },
storage: { used: 5, total: 20 },
...stats,
},
signAfterZero: false,
signGapTime: '24',
...data,
},
}
}
describe('info box', () => {
it('players', async () => {
fetch.get.mockResolvedValue(
scoreInfo({}, {}, { players: { used: 13, total: 21 } }),
)
const { getByText } = render(<Dashboard />)
await wait()
expect(getByText('13')).toBeInTheDocument()
expect(getByText(/21/)).toBeInTheDocument()
})
describe('storage', () => {
it('in KB', async () => {
fetch.get.mockResolvedValue(
scoreInfo({}, {}, { storage: { used: 700, total: 800 } }),
)
const { getByText } = render(<Dashboard />)
await wait()
expect(getByText('700')).toBeInTheDocument()
expect(getByText(/800/)).toBeInTheDocument()
expect(getByText(/KB/)).toBeInTheDocument()
})
it('in MB', async () => {
fetch.get.mockResolvedValue(
scoreInfo({}, {}, { storage: { used: 7168, total: 10240 } }),
)
const { getByText } = render(<Dashboard />)
await wait()
expect(getByText('7')).toBeInTheDocument()
expect(getByText(/10/)).toBeInTheDocument()
expect(getByText(/MB/)).toBeInTheDocument()
})
})
})
describe('sign', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(scoreInfo())
})
it('should succeed', async () => {
fetch.post.mockResolvedValue({
code: 0,
message: 'ok',
data: { score: 900, storage: { used: 5, total: 25 } },
})
const { getByRole, queryByText } = render(<Dashboard />)
await wait()
const button = getByRole('button')
fireEvent.click(button)
await wait()
expect(fetch.post).toBeCalledWith('/user/sign')
expect(toast.success).toBeCalledWith('ok')
expect(button).toBeDisabled()
expect(queryByText(/25/)).toBeInTheDocument()
})
it('should fail', async () => {
fetch.post.mockResolvedValue({ code: 1, message: 'f', data: {} })
const { getByRole } = render(<Dashboard />)
await wait()
fireEvent.click(getByRole('button'))
await wait()
expect(fetch.post).toBeCalledWith('/user/sign')
expect(toast.warning).toBeCalledWith('f')
})
})
describe('sign button', () => {
it('should disabled when loading', () => {
fetch.get.mockResolvedValue(scoreInfo())
const { getByRole } = render(<Dashboard />)
expect(getByRole('button')).toBeDisabled()
})
it('sign is allowed', async () => {
fetch.get.mockResolvedValue(scoreInfo())
const { getByRole } = render(<Dashboard />)
await wait()
const button = getByRole('button')
expect(button).toBeEnabled()
expect(button).toHaveTextContent(trans('user.sign'))
})
it('sign is allowed if last sign is yesterday', async () => {
fetch.get.mockResolvedValue(scoreInfo({ signAfterZero: true }))
const { getByRole } = render(<Dashboard />)
await wait()
expect(getByRole('button')).toBeEnabled()
})
it('sign is not allowed', async () => {
fetch.get.mockResolvedValue(
scoreInfo({ signAfterZero: true }, { lastSignAt: Date.now() }),
)
const { getByRole } = render(<Dashboard />)
await wait()
expect(getByRole('button')).toBeDisabled()
})
it('remain in hours', async () => {
fetch.get.mockResolvedValue(scoreInfo({}, { lastSignAt: Date.now() }))
const { getByRole } = render(<Dashboard />)
await wait()
const button = getByRole('button')
expect(button).toBeDisabled()
expect(button).toHaveTextContent('23 h')
})
it('remain in minutes', async () => {
fetch.get.mockResolvedValue(
scoreInfo({}, { lastSignAt: Date.now() - 23.5 * 3600 * 1000 }),
)
const { getByRole } = render(<Dashboard />)
await wait()
const button = getByRole('button')
expect(button).toBeDisabled()
expect(button).toHaveTextContent(/(29|30)\smin/)
})
})

View File

@ -1 +1 @@
<div class="card" id="usage-box"></div>
<div id="usage-box"></div>

View File

@ -62,9 +62,8 @@ const config = {
options: {
importLoaders: 2,
modules: {
localIdentName: devMode ? '[name]__[local]' : '[hash:base64]',
localIdentName: devMode ? '[name]__[local]' : '[local]__[hash:base64:5]',
},
localsConvention: 'dashes',
esModule: true,
},
},

View File

@ -1325,6 +1325,11 @@
dependencies:
"@types/sizzle" "*"
"@types/js-yaml@^3.12.2":
version "3.12.2"
resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.2.tgz#a35a1809c33a68200fb6403d1ad708363c56470a"
integrity sha512-0CFu/g4mDSNkodVwWijdlr8jH7RoplRWNgovjFLEZeT+QEbbZXjBmCe3HwaWheAlCbHwomTwzZoSedeOycABug==
"@types/json-schema@^7.0.3":
version "7.0.3"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636"
@ -1407,6 +1412,11 @@
"@types/react-dom" "*"
"@types/testing-library__dom" "*"
"@types/tween.js@^17.2.0":
version "17.2.0"
resolved "https://registry.yarnpkg.com/@types/tween.js/-/tween.js-17.2.0.tgz#25f98311daecb165ab91ee2cd7f17a9b1e6cc30c"
integrity sha512-mOsqurEtFEzwgkVc/jDVE2XrjZBYTbrmDUyCr9GXmnfc6q5otokxFtKvSY/B21zgz9LVRIvRTawKczjKi57wrA==
"@types/uglify-js@*":
version "3.0.4"
resolved "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
@ -6255,7 +6265,15 @@ jqvmap-novulnerability@^1.5.1:
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@^3.13.1, js-yaml@^3.9.0:
js-yaml@^3.13.1:
version "3.13.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
dependencies:
argparse "^1.0.7"
esprima "^4.0.0"
js-yaml@^3.9.0:
version "3.13.1"
resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==