Add HProgress component for enhanced progress display in Dashboard
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Introduced a new `HProgress` component to replace the existing `Progress` component, providing a customizable and visually appealing progress indicator.
- Updated `AppUpdateProgress` and `StateDisplay` components to utilize `HProgress`, improving consistency in progress representation across the application.
- Added CSS animations and styles for a smoother user experience during progress updates.
This commit is contained in:
Tom Butcher 2026-08-09 14:26:06 +01:00
parent 9f8beae051
commit f9d4074d85
4 changed files with 392 additions and 4 deletions

View File

@ -971,3 +971,105 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
.electron-body .ant-modal-mask {
top: 41px;
}
@keyframes h-progress-macos-wave {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(120%);
}
}
.h-progress {
display: inline-block;
width: 100%;
font-size: 14px;
line-height: 1;
}
.h-progress-outer {
display: inline-flex;
align-items: center;
width: 100%;
}
.h-progress-inner {
position: relative;
display: inline-block;
width: 100%;
flex: 1;
overflow: hidden;
vertical-align: middle;
}
.h-progress-bg,
.h-progress-success-bg {
position: relative;
transition: all 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.h-progress-success-bg {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
}
.h-progress-bg-active::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.08) 18%,
rgba(255, 255, 255, 0.42) 50%,
rgba(255, 255, 255, 0.08) 82%,
rgba(255, 255, 255, 0) 100%
);
width: 55%;
animation: h-progress-macos-wave 1.6s ease-in-out infinite;
}
.h-progress-text {
display: inline-block;
margin-inline-start: 8px;
line-height: 1;
width: 2em;
white-space: nowrap;
text-align: start;
vertical-align: middle;
word-break: normal;
}
.h-progress-text-start {
width: max-content;
margin-inline-start: 0;
margin-inline-end: 8px;
}
.h-progress-text-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin-inline-start: 0;
padding: 0 4px;
color: #fff;
}
.h-progress-layout-bottom {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.h-progress-layout-bottom .h-progress-text {
width: max-content;
margin-inline-start: 0;
margin-top: 4px;
}

View File

@ -1,10 +1,11 @@
import PropTypes from 'prop-types'
import { useState } from 'react'
import { Button, Flex, Modal, Progress, Typography, theme, Divider } from 'antd'
import { Button, Flex, Modal, Typography, theme, Divider } from 'antd'
import CloudIcon from '../../../Icons/CloudIcon'
import HostIcon from '../../../Icons/HostIcon'
import ReloadIcon from '../../../Icons/ReloadIcon'
import HProgress from '../../common/HProgress'
import CheckCircleIcon from '../../../Icons/CheckCircleIcon'
import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon'
@ -128,7 +129,7 @@ const UpdateStage = ({ stage, status, percent, detail }) => {
<Text style={{ flexShrink: 0 }}>{config.labels[resolvedStatus]}</Text>
{showProgress && (
<Flex vertical gap={2} style={{ flex: 1 }}>
<Progress
<HProgress
percent={resolvedPercent}
status={getProgressStatus(resolvedStatus)}
showInfo={typeof resolvedPercent === 'number'}

View File

@ -0,0 +1,284 @@
import { forwardRef, useMemo } from 'react'
import PropTypes from 'prop-types'
import { theme } from 'antd'
import CheckCircleFilled from '@ant-design/icons/CheckCircleFilled'
import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled'
const validProgress = (progress) => {
if (!progress || progress < 0) return 0
if (progress > 100) return 100
return progress
}
const getSuccessPercent = ({ success, successPercent }) => {
if (success && 'progress' in success) return success.progress
if (success && 'percent' in success) return success.percent
return successPercent
}
const sortGradient = (gradients) => {
const entries = Object.keys(gradients)
.map((key) => ({
key: Number.parseFloat(key.replace(/%/g, '')),
value: gradients[key]
}))
.filter(({ key }) => !Number.isNaN(key))
.sort((a, b) => a.key - b.key)
return entries.map(({ key, value }) => `${value} ${key}%`).join(', ')
}
const resolveStrokeBackground = (strokeColor, direction = 'to right') => {
if (!strokeColor) return undefined
if (typeof strokeColor === 'string') {
return strokeColor
}
if (Array.isArray(strokeColor)) {
return strokeColor[0]
}
const { from, to, direction: gradientDirection, ...rest } = strokeColor
if (Object.keys(rest).length > 0) {
return `linear-gradient(${gradientDirection || direction}, ${sortGradient(rest)})`
}
return `linear-gradient(${gradientDirection || direction}, ${from}, ${to})`
}
const getBarHeight = ({ size, strokeWidth }) => {
if (typeof strokeWidth === 'number') return strokeWidth
if (size === 'small') return 6
if (typeof size === 'number') return size
if (Array.isArray(size)) return size[1] ?? 8
if (typeof size === 'object' && size?.height != null) return size.height
return 8
}
const getOuterWidth = ({ size, strokeWidth }) => {
if (typeof size === 'number') return size
if (Array.isArray(size)) return size[0] ?? -1
if (typeof size === 'object' && size?.width != null) return size.width
if (strokeWidth) return -1
return -1
}
const HProgress = forwardRef(function HProgress(
{
className,
rootClassName,
style,
percent = 0,
success,
successPercent,
status,
showInfo = true,
strokeColor,
trailColor,
strokeLinecap = 'round',
strokeWidth,
size = 'default',
format,
percentPosition = {}
},
ref
) {
const { token } = theme.useToken()
const { align: infoAlign = 'end', type: infoPosition = 'outer' } =
percentPosition
const resolvedPercent = validProgress(percent)
const resolvedSuccessPercent = getSuccessPercent({ success, successPercent })
const successValue =
resolvedSuccessPercent == null
? undefined
: validProgress(resolvedSuccessPercent)
const percentNumber = Number.parseInt(
String(successValue ?? resolvedPercent),
10
)
const progressStatus = useMemo(() => {
if (status === 'exception' || status === 'success' || status === 'active') {
return status
}
if (percentNumber >= 100) return 'success'
return status || 'normal'
}, [status, percentNumber])
const barHeight = getBarHeight({ size, strokeWidth })
const outerWidth = getOuterWidth({ size, strokeWidth })
const borderRadius =
strokeLinecap === 'square' || strokeLinecap === 'butt' ? 0 : barHeight
const trailBackground = trailColor || token.colorFillSecondary
const defaultFillColor =
progressStatus === 'exception'
? token.colorError
: progressStatus === 'success'
? token.colorSuccess
: token.colorPrimary
const fillBackground =
resolveStrokeBackground(strokeColor) || defaultFillColor
const successBackground =
success?.strokeColor || token.colorSuccess
const textFormatter = format || ((value) => `${value}%`)
const progressTextValue = textFormatter(
resolvedPercent,
successValue ?? undefined
)
const progressInfo = !showInfo ? null : (
<span
className={[
'h-progress-text',
infoPosition === 'outer' && infoAlign === 'start'
? 'h-progress-text-start'
: null
]
.filter(Boolean)
.join(' ')}
title={typeof progressTextValue === 'string' ? progressTextValue : undefined}
style={{ color: token.colorText }}
>
{progressStatus === 'exception' ? (
<CloseCircleFilled style={{ color: token.colorError }} />
) : progressStatus === 'success' ? (
<CheckCircleFilled style={{ color: token.colorSuccess }} />
) : (
progressTextValue
)}
</span>
)
const innerInfo =
infoPosition === 'inner' && showInfo ? (
<span className='h-progress-text-inner'>{progressTextValue}</span>
) : null
const lineInner = (
<div
className='h-progress-inner'
style={{
height: barHeight,
backgroundColor: trailBackground,
borderRadius
}}
>
<div
className={[
'h-progress-bg',
progressStatus === 'active' ? 'h-progress-bg-active' : null
]
.filter(Boolean)
.join(' ')}
style={{
width: `${resolvedPercent}%`,
height: barHeight,
borderRadius,
background: fillBackground,
overflow: 'hidden'
}}
>
{innerInfo}
</div>
{successValue != null && (
<div
className='h-progress-success-bg'
style={{
width: `${successValue}%`,
height: barHeight,
borderRadius,
background: successBackground
}}
/>
)}
</div>
)
const isOuterStart = infoPosition === 'outer' && infoAlign === 'start'
const isOuterEnd = infoPosition === 'outer' && infoAlign === 'end'
const isLayoutBottom =
infoPosition === 'outer' && infoAlign === 'center'
const content = isLayoutBottom ? (
<div className='h-progress-layout-bottom'>
{lineInner}
{progressInfo}
</div>
) : (
<div
className='h-progress-outer'
style={{ width: outerWidth < 0 ? '100%' : outerWidth }}
>
{isOuterStart && progressInfo}
{lineInner}
{isOuterEnd && progressInfo}
</div>
)
return (
<div
ref={ref}
className={['h-progress', className, rootClassName]
.filter(Boolean)
.join(' ')}
style={style}
role='progressbar'
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={resolvedPercent}
>
{content}
</div>
)
})
HProgress.propTypes = {
className: PropTypes.string,
rootClassName: PropTypes.string,
style: PropTypes.object,
percent: PropTypes.number,
success: PropTypes.shape({
percent: PropTypes.number,
progress: PropTypes.number,
strokeColor: PropTypes.string
}),
successPercent: PropTypes.number,
status: PropTypes.oneOf(['normal', 'exception', 'active', 'success']),
showInfo: PropTypes.bool,
strokeColor: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
PropTypes.object
]),
trailColor: PropTypes.string,
strokeLinecap: PropTypes.oneOf(['butt', 'square', 'round']),
strokeWidth: PropTypes.number,
size: PropTypes.oneOfType([
PropTypes.oneOf(['default', 'small']),
PropTypes.number,
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
PropTypes.shape({
width: PropTypes.number,
height: PropTypes.number
})
]),
format: PropTypes.func,
percentPosition: PropTypes.shape({
align: PropTypes.oneOf(['start', 'center', 'end']),
type: PropTypes.oneOf(['inner', 'outer'])
})
}
export default HProgress

View File

@ -1,7 +1,8 @@
// PrinterSelect.js
import PropTypes from 'prop-types'
import { Progress, Flex, Space, Modal, Button, Typography } from 'antd'
import { Flex, Space, Modal, Button, Typography } from 'antd'
import StateTag from './StateTag'
import HProgress from './HProgress'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import { useState } from 'react'
@ -43,7 +44,7 @@ const StateDisplay = ({
currentState?.progress &&
progressValue !== 100 &&
currentState?.progress > 0 ? (
<Progress
<HProgress
percent={progressValue}
status={
activeProgressTypes.includes(currentState.type) ? 'active' : ''