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 { AppContextProvider } from './contexts/AppContext'
import { Router } from './pages/Router' import { Router } from './pages/Router'
const queryClient = new QueryClient() const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
})
export const Root = () => { export const Root = () => {
return ( return (

View File

@ -101,6 +101,17 @@ input, select {
padding: 0.75rem 1rem; 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 { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -152,12 +163,19 @@ form.horizontal .input label {
.dashboard-head .shadow { .dashboard-head .shadow {
position: absolute; 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%; width: 100%;
height: 12px; height: 8px;
z-index: 1; z-index: 1;
} }
.dashboard-head .spacer {
margin: 0 1rem;
width: 1px;
background: #ddd;
height: 20px;
}
.checkbox-label { .checkbox-label {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -167,10 +185,18 @@ form.horizontal .input label {
margin-top: 6px; margin-top: 6px;
} }
.grid-sensors { .grid-sensors-container {
position: relative; position: relative;
margin: 0.25rem; padding: 0.25rem;
flex: 1; flex: 1;
overflow: auto;
min-height: 0;
}
.grid-sensors {
width: 100%;
height: 100%;
position: relative;
} }
.grid-sensors .grid-box { .grid-sensors .grid-box {
@ -188,6 +214,33 @@ form.horizontal .input label {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; 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 { .grid-sensors .grid-box .box .resize-h {
@ -294,3 +347,8 @@ form.horizontal .input label {
height: 1em; height: 1em;
stroke: currentColor; 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 { createDashboardContent } from '@/utils/createDashboardContent'
import { parseDashboard } from '@/utils/parseDashboard' import { parseDashboard } from '@/utils/parseDashboard'
import { useEffect, useMemo, useState } from 'preact/hooks' import { useEffect, useMemo, useState } from 'preact/hooks'
import { useQuery } from 'react-query' import { useQuery, useQueryClient } from 'react-query'
import { DashboardGrid } from './components/DashboardGrid' import { DashboardGrid } from './components/DashboardGrid'
import { DashboardHeader } from './components/DashboardHeader' import { DashboardHeader } from './components/DashboardHeader'
import { GRID_H_SNAP, GRID_WIDTH } from './constants' import { GRID_H_SNAP, GRID_WIDTH } from './constants'
@ -14,6 +14,7 @@ import { DashboardContextProvider } from './contexts/DashboardContext'
import { BoxDefinition } from './types' import { BoxDefinition } from './types'
export const NewDashboardPage = () => { export const NewDashboardPage = () => {
const queryClient = useQueryClient()
const dashboards = useQuery(['/dashboards'], getDashboards) const dashboards = useQuery(['/dashboards'], getDashboards)
const dashboard = dashboards.data?.find((i) => i.id === 'default') const dashboard = dashboards.data?.find((i) => i.id === 'default')
@ -41,7 +42,8 @@ export const NewDashboardPage = () => {
} }
const handleRefresh = () => { const handleRefresh = () => {
console.log('Nothing to refresh right now') queryClient.invalidateQueries(['/sensor/values'])
queryClient.invalidateQueries(['/sensor/values/latest'])
} }
const handleNewBox = () => { const handleNewBox = () => {

View File

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

View File

@ -1,16 +1,19 @@
import { getSensorValues } from '@/api/sensorValues' import { getSensorValues } from '@/api/sensorValues'
import { DashboardGraphData } from '@/utils/parseDashboard' import { DashboardGraphData } from '@/utils/parseDashboard'
import { RefObject } from 'preact'
import { useEffect, useRef } from 'preact/hooks' import { useEffect, useRef } from 'preact/hooks'
import { useQuery } from 'react-query' import { useQuery } from 'react-query'
import { useDashboardContext } from '../contexts/DashboardContext' import { useDashboardContext } from '../contexts/DashboardContext'
import { BoxDefinition } from '../types' import { BoxDefinition } from '../types'
import { BoxLoader } from './BoxLoader'
type Props = { type Props = {
box: BoxDefinition box: BoxDefinition
data: DashboardGraphData data: DashboardGraphData
refreshRef: RefObject<() => void>
} }
export const BoxGraphContent = ({ box, data }: Props) => { export const BoxGraphContent = ({ box, data, refreshRef }: Props) => {
const { filter } = useDashboardContext() const { filter } = useDashboardContext()
const bodyRef = useRef<HTMLDivElement>(null) const bodyRef = useRef<HTMLDivElement>(null)
@ -25,6 +28,10 @@ export const BoxGraphContent = ({ box, data }: Props) => {
getSensorValues(valuesQuery) getSensorValues(valuesQuery)
) )
refreshRef.current = () => {
values.refetch()
}
useEffect(() => { useEffect(() => {
// TODO: These should be probably returned by server, could be outdated // TODO: These should be probably returned by server, could be outdated
const from = filter.customFrom const from = filter.customFrom
@ -80,5 +87,10 @@ export const BoxGraphContent = ({ box, data }: Props) => {
} }
}, [values.data, box, data]) }, [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 = { type Props = {
value: BoxDefinition value: BoxDefinition
onRemove: () => void
onSave: (newValue: BoxDefinition) => void onSave: (newValue: BoxDefinition) => void
onClose: () => void onClose: () => void
} }
export const BoxSettings = ({ value, onSave, onClose }: Props) => { export const BoxSettings = ({ value, onSave, onClose, onRemove }: Props) => {
const sensors = useQuery(['/sensors'], getSensors) const sensors = useQuery(['/sensors'], getSensors)
const [formState, setFormState] = useState(() => ({ const [formState, setFormState] = useState(() => ({
@ -74,6 +75,9 @@ export const BoxSettings = ({ value, onSave, onClose }: Props) => {
))} ))}
</select> </select>
</div> </div>
{formState.sensor && (
<>
<div className="input"> <div className="input">
<label>Title</label> <label>Title</label>
<input <input
@ -107,8 +111,14 @@ export const BoxSettings = ({ value, onSave, onClose }: Props) => {
onChange={setData} onChange={setData}
/> />
)} )}
</>
)}
<div className="actions"> <div className="actions">
<button className="remove" type="button" onClick={onRemove}>
Remove
</button>
<button className="cancel" onClick={onClose} type="button"> <button className="cancel" onClick={onClose} type="button">
Cancel Cancel
</button> </button>

View File

@ -9,6 +9,7 @@ type Props = {
export const DashboardGrid = ({ boxes, onChange }: Props) => { export const DashboardGrid = ({ boxes, onChange }: Props) => {
return ( return (
<div className="grid-sensors-container">
<div className="grid-sensors"> <div className="grid-sensors">
{boxes.map((b) => ( {boxes.map((b) => (
<EditableBox <EditableBox
@ -38,8 +39,14 @@ export const DashboardGrid = ({ boxes, onChange }: Props) => {
previous.map((b) => (b.id === newB.id ? newB : b)) previous.map((b) => (b.id === newB.id ? newB : b))
) )
}} }}
onRemove={() => {
onChange((previous) =>
normalizeBoxes(previous.filter((pb) => pb.id !== b.id))
)
}}
/> />
))} ))}
</div> </div>
</div>
) )
} }

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ export function normalizeBoxes(boxes: BoxDefinition[]) {
// TODO: This is not optimized at all // TODO: This is not optimized at all
while (!sorted) { while (!sorted) {
// Sort boxes to have the lowest ones first // 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 sorted = true