Redesigned header, added loaders

This commit is contained in:
Jan Zípek 2022-08-24 22:36:37 +02:00
parent 52469eda4d
commit 39641194aa
Signed by: kamen
GPG Key ID: A17882625B33AC31
13 changed files with 222 additions and 87 deletions

View File

@ -2,7 +2,13 @@ import { QueryClient, QueryClientProvider } from 'react-query'
import { AppContextProvider } from './contexts/AppContext'
import { Router } from './pages/Router'
const queryClient = new QueryClient()
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
})
export const Root = () => {
return (

View File

@ -101,6 +101,17 @@ input, select {
padding: 0.75rem 1rem;
}
.settings-modal .actions {
display: flex;
align-items: center;
}
.settings-modal .actions .remove {
margin-right: auto;
background-color: transparent;
color: #ff0000;
}
form {
display: flex;
flex-direction: column;
@ -152,12 +163,19 @@ form.horizontal .input label {
.dashboard-head .shadow {
position: absolute;
background: linear-gradient(0deg, rgba(255,255,255,0) 5%, rgba(190,190,190,1) 100%);
background: linear-gradient(0deg, rgba(255,255,255,0) 5%, rgba(190,190,190,0.6) 100%);
width: 100%;
height: 12px;
height: 8px;
z-index: 1;
}
.dashboard-head .spacer {
margin: 0 1rem;
width: 1px;
background: #ddd;
height: 20px;
}
.checkbox-label {
display: inline-flex;
align-items: center;
@ -167,10 +185,18 @@ form.horizontal .input label {
margin-top: 6px;
}
.grid-sensors {
.grid-sensors-container {
position: relative;
margin: 0.25rem;
padding: 0.25rem;
flex: 1;
overflow: auto;
min-height: 0;
}
.grid-sensors {
width: 100%;
height: 100%;
position: relative;
}
.grid-sensors .grid-box {
@ -188,6 +214,33 @@ form.horizontal .input label {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
.grid-sensors .grid-box .box .body {
position: relative;
}
.grid-sensors .grid-box .box .box-loader {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(128, 128, 128, 0.3);
color: #fff;
font-size: 300%;
z-index: 2;
}
.grid-sensors .grid-box .box .box-loader .svg-icon {
animation-name: rotate;
animation-iteration-count: infinite;
animation-duration: 0.75s;
animation-timing-function: linear;
}
.grid-sensors .grid-box .box .resize-h {
@ -294,3 +347,8 @@ form.horizontal .input label {
height: 1em;
stroke: currentColor;
}
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(-360deg); }
}

2
client/src/icons.ts Normal file
View File

@ -0,0 +1,2 @@
export { ReactComponent as SettingsIcon } from '@/assets/icons/settings.svg'
export { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg'

View File

@ -6,7 +6,7 @@ import {
import { createDashboardContent } from '@/utils/createDashboardContent'
import { parseDashboard } from '@/utils/parseDashboard'
import { useEffect, useMemo, useState } from 'preact/hooks'
import { useQuery } from 'react-query'
import { useQuery, useQueryClient } from 'react-query'
import { DashboardGrid } from './components/DashboardGrid'
import { DashboardHeader } from './components/DashboardHeader'
import { GRID_H_SNAP, GRID_WIDTH } from './constants'
@ -14,6 +14,7 @@ import { DashboardContextProvider } from './contexts/DashboardContext'
import { BoxDefinition } from './types'
export const NewDashboardPage = () => {
const queryClient = useQueryClient()
const dashboards = useQuery(['/dashboards'], getDashboards)
const dashboard = dashboards.data?.find((i) => i.id === 'default')
@ -41,7 +42,8 @@ export const NewDashboardPage = () => {
}
const handleRefresh = () => {
console.log('Nothing to refresh right now')
queryClient.invalidateQueries(['/sensor/values'])
queryClient.invalidateQueries(['/sensor/values/latest'])
}
const handleNewBox = () => {

View File

@ -1,16 +1,19 @@
import { getLatestSensorValue } from '@/api/sensorValues'
import { DashboardDialData } from '@/utils/parseDashboard'
import { RefObject } from 'preact'
import { useMemo } from 'preact/hooks'
import { useQuery } from 'react-query'
import { useDashboardContext } from '../contexts/DashboardContext'
import { BoxDefinition } from '../types'
import { BoxLoader } from './BoxLoader'
type Props = {
box: BoxDefinition
data: DashboardDialData
refreshRef: RefObject<() => void>
}
export const BoxDialContent = ({ box, data }: Props) => {
export const BoxDialContent = ({ box, data, refreshRef }: Props) => {
const { filter } = useDashboardContext()
const valuesQuery = {
@ -22,6 +25,10 @@ export const BoxDialContent = ({ box, data }: Props) => {
getLatestSensorValue(valuesQuery)
)
refreshRef.current = () => {
value.refetch()
}
const displayValue = useMemo(() => {
if (!value.data) {
return ''
@ -40,13 +47,16 @@ export const BoxDialContent = ({ box, data }: Props) => {
}, [value.data, data])
return (
<div className="dial">
{value.data && (
<>
{displayValue}
{` ${data.unit}`}
</>
)}
</div>
<>
{value.isFetching && <BoxLoader />}
<div className="dial">
{value.data && (
<>
{displayValue}
{` ${data.unit}`}
</>
)}
</div>
</>
)
}

View File

@ -1,16 +1,19 @@
import { getSensorValues } from '@/api/sensorValues'
import { DashboardGraphData } from '@/utils/parseDashboard'
import { RefObject } from 'preact'
import { useEffect, useRef } from 'preact/hooks'
import { useQuery } from 'react-query'
import { useDashboardContext } from '../contexts/DashboardContext'
import { BoxDefinition } from '../types'
import { BoxLoader } from './BoxLoader'
type Props = {
box: BoxDefinition
data: DashboardGraphData
refreshRef: RefObject<() => void>
}
export const BoxGraphContent = ({ box, data }: Props) => {
export const BoxGraphContent = ({ box, data, refreshRef }: Props) => {
const { filter } = useDashboardContext()
const bodyRef = useRef<HTMLDivElement>(null)
@ -25,6 +28,10 @@ export const BoxGraphContent = ({ box, data }: Props) => {
getSensorValues(valuesQuery)
)
refreshRef.current = () => {
values.refetch()
}
useEffect(() => {
// TODO: These should be probably returned by server, could be outdated
const from = filter.customFrom
@ -80,5 +87,10 @@ export const BoxGraphContent = ({ box, data }: Props) => {
}
}, [values.data, box, data])
return <div ref={bodyRef} />
return (
<>
{values.isFetching && <BoxLoader />}
<div ref={bodyRef} />
</>
)
}

View File

@ -0,0 +1,9 @@
import { RefreshIcon } from '@/icons'
export const BoxLoader = () => {
return (
<div className="box-loader">
<RefreshIcon />
</div>
)
}

View File

@ -8,11 +8,12 @@ import { GraphSettings } from './components/GraphSettings'
type Props = {
value: BoxDefinition
onRemove: () => void
onSave: (newValue: BoxDefinition) => void
onClose: () => void
}
export const BoxSettings = ({ value, onSave, onClose }: Props) => {
export const BoxSettings = ({ value, onSave, onClose, onRemove }: Props) => {
const sensors = useQuery(['/sensors'], getSensors)
const [formState, setFormState] = useState(() => ({
@ -74,41 +75,50 @@ export const BoxSettings = ({ value, onSave, onClose }: Props) => {
))}
</select>
</div>
<div className="input">
<label>Title</label>
<input
name="title"
value={formState.title}
onChange={handleChange}
/>
</div>
<div className="input">
<label>Type</label>
<select
name="type"
value={formState.type}
onChange={handleChange}
>
<option value="graph">Graph</option>
<option value="dial">Dial</option>
</select>
</div>
{formState.type === 'graph' && (
<GraphSettings
value={data as DashboardGraphData}
onChange={setData}
/>
)}
{formState.sensor && (
<>
<div className="input">
<label>Title</label>
<input
name="title"
value={formState.title}
onChange={handleChange}
/>
</div>
<div className="input">
<label>Type</label>
<select
name="type"
value={formState.type}
onChange={handleChange}
>
<option value="graph">Graph</option>
<option value="dial">Dial</option>
</select>
</div>
{formState.type === 'dial' && (
<DialSettings
value={data as DashboardDialData}
onChange={setData}
/>
{formState.type === 'graph' && (
<GraphSettings
value={data as DashboardGraphData}
onChange={setData}
/>
)}
{formState.type === 'dial' && (
<DialSettings
value={data as DashboardDialData}
onChange={setData}
/>
)}
</>
)}
<div className="actions">
<button className="remove" type="button" onClick={onRemove}>
Remove
</button>
<button className="cancel" onClick={onClose} type="button">
Cancel
</button>

View File

@ -9,37 +9,44 @@ type Props = {
export const DashboardGrid = ({ boxes, onChange }: Props) => {
return (
<div className="grid-sensors">
{boxes.map((b) => (
<EditableBox
box={b}
key={b.id}
boxes={boxes}
onPosition={(p) => {
onChange((previous) =>
normalizeBoxes(
previous.map((pb) =>
pb.id === b.id ? { ...pb, x: p.x, y: p.y } : pb
<div className="grid-sensors-container">
<div className="grid-sensors">
{boxes.map((b) => (
<EditableBox
box={b}
key={b.id}
boxes={boxes}
onPosition={(p) => {
onChange((previous) =>
normalizeBoxes(
previous.map((pb) =>
pb.id === b.id ? { ...pb, x: p.x, y: p.y } : pb
)
)
)
)
}}
onResize={(s) => {
onChange((previous) =>
normalizeBoxes(
previous.map((pb) =>
pb.id === b.id ? { ...pb, w: s.w, h: s.h } : pb
}}
onResize={(s) => {
onChange((previous) =>
normalizeBoxes(
previous.map((pb) =>
pb.id === b.id ? { ...pb, w: s.w, h: s.h } : pb
)
)
)
)
}}
onEdit={(newB) => {
onChange((previous) =>
previous.map((b) => (b.id === newB.id ? newB : b))
)
}}
/>
))}
}}
onEdit={(newB) => {
onChange((previous) =>
previous.map((b) => (b.id === newB.id ? newB : b))
)
}}
onRemove={() => {
onChange((previous) =>
normalizeBoxes(previous.filter((pb) => pb.id !== b.id))
)
}}
/>
))}
</div>
</div>
)
}

View File

@ -1,3 +1,4 @@
import { RefreshIcon } from '@/icons'
import { Filters } from './Filters'
type Props = {
@ -10,8 +11,12 @@ export const DashboardHeader = ({ onNewBox, onRefresh }: Props) => {
<div className="dashboard-head">
<div className="inner">
<button onClick={onNewBox}>Add box</button>
<div className="spacer" />
<Filters />
<button onClick={onRefresh}>Refresh all</button>
<div className="spacer" />
<button onClick={onRefresh}>
<RefreshIcon /> Refresh all
</button>
</div>
<div className="shadow"></div>
</div>

View File

@ -1,5 +1,5 @@
import { useWindowEvent } from '@/utils/hooks/useWindowEvent'
import { useState } from 'preact/hooks'
import { useRef, useState } from 'preact/hooks'
import { GRID_WIDTH } from '../constants'
import { useDashboardContext } from '../contexts/DashboardContext'
import { useDragging } from '../hooks/useDragging'
@ -8,9 +8,8 @@ import { BoxDefinition } from '../types'
import { BoxDialContent } from './BoxDialContent'
import { BoxGraphContent } from './BoxGraphContent'
import { BoxSettings } from './BoxSettings/BoxSettings'
import { ReactComponent as SettingsIcon } from '@/assets/icons/settings.svg'
import { ReactComponent as RefreshIcon } from '@/assets/icons/refresh.svg'
import { useElementOffsets } from '@/utils/hooks/useElementOffsets'
import { RefreshIcon, SettingsIcon } from '@/icons'
type Props = {
box: BoxDefinition
@ -18,6 +17,7 @@ type Props = {
onPosition: (p: { x: number; y: number }) => void
onResize: (p: { w: number; h: number }) => void
onEdit: (box: BoxDefinition) => void
onRemove: () => void
}
export const EditableBox = ({
@ -26,8 +26,11 @@ export const EditableBox = ({
onPosition,
onResize,
onEdit,
onRemove,
}: Props) => {
const [boxRef, setBoxRef] = useState<HTMLDivElement>()
const refreshRef = useRef<() => void>(null)
const { verticalMode } = useDashboardContext()
const [editing, setEditing] = useState(false)
@ -124,7 +127,7 @@ export const EditableBox = ({
<div className="name">{box.title ?? box.sensor ?? ''}</div>
</div>
<div className="actions">
<div className="action">
<div className="action" onClick={() => refreshRef.current?.()}>
<RefreshIcon />
</div>
<div className="action" onClick={() => setEditing(true)}>
@ -134,10 +137,18 @@ export const EditableBox = ({
</div>
<div className="body">
{box.sensor && box.data?.type === 'graph' && (
<BoxGraphContent box={box} data={box.data} />
<BoxGraphContent
box={box}
data={box.data}
refreshRef={refreshRef}
/>
)}
{box.sensor && box.data?.type === 'dial' && (
<BoxDialContent box={box} data={box.data} />
<BoxDialContent
box={box}
data={box.data}
refreshRef={refreshRef}
/>
)}
</div>
@ -185,6 +196,7 @@ export const EditableBox = ({
value={box}
onSave={onEdit}
onClose={() => setEditing(false)}
onRemove={onRemove}
/>
)}
</>

View File

@ -1,6 +1,6 @@
import { DateTimeInput } from '@/components/DateTimeInput'
import { intervalToRange } from '@/utils/intervalToRange'
import { useState } from 'preact/hooks'
import { useEffect, useState } from 'preact/hooks'
import { useDashboardContext } from '../contexts/DashboardContext'
export type FilterInterval =
@ -44,6 +44,10 @@ export const Filters = () => {
})
}
useEffect(() => {
setFilter(value)
}, [value])
return (
<div className="filter-form">
<form className="horizontal" onSubmit={handleSubmit}>
@ -85,8 +89,6 @@ export const Filters = () => {
</div>
</>
)}
<button>Apply</button>
</form>
</div>
)

View File

@ -10,7 +10,7 @@ export function normalizeBoxes(boxes: BoxDefinition[]) {
// TODO: This is not optimized at all
while (!sorted) {
// Sort boxes to have the lowest ones first
boxes.sort((a, b) => a.y - b.y)
boxes.sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y))
sorted = true