diff --git a/client/src/assets/style.css b/client/src/assets/style.css index 9bcfd45..1b2ed9a 100644 --- a/client/src/assets/style.css +++ b/client/src/assets/style.css @@ -198,6 +198,7 @@ form.horizontal .input label { .grid-sensors { position: relative; margin: 0.25rem; + flex: 1; } .grid-sensors .grid-box { @@ -242,6 +243,19 @@ form.horizontal .input label { cursor: se-resize; } +.grid-sensors .grid-box .box .header { + display: flex; +} + +.grid-sensors .grid-box .box .header .drag-handle { + flex: 1; + cursor: move; +} + +.grid-sensors .grid-box .box .header .actions { + margin-left: auto; +} + .grid-sensors .box-preview { position: absolute; background-color: #3988FF; @@ -249,4 +263,11 @@ form.horizontal .input label { transition: all 0.2s; z-index: 1; border-radius: 0.5rem; -} \ No newline at end of file +} + +.dashboard { + height: 100%; + overflow: auto; + display: flex; + flex-direction: column; +} diff --git a/client/src/pages/dashboard/DashboardPage.tsx b/client/src/pages/dashboard/DashboardPage.tsx deleted file mode 100644 index b1dee39..0000000 --- a/client/src/pages/dashboard/DashboardPage.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { getSensors, SensorInfo } from '@/api/sensors' -import { intervalToRange } from '@/utils/intervalToRange' -import { useState } from 'preact/hooks' -import { useQuery } from 'react-query' -import { Filters, FilterValue } from './components/Filters' -import { Sensor } from './components/Sensor' -import { SensorSettings } from './components/SensorSettings' - -export const DashboardPage = () => { - const [editedSensor, setEditedSensor] = useState() - - const [filter, setFilter] = useState(() => { - const range = intervalToRange('week', new Date(), new Date()) - - return { interval: 'week', customFrom: range[0], customTo: range[1] } - }) - - const sensors = useQuery(['/sensors'], getSensors) - - return ( - <> - -
- {sensors.data?.map((s) => ( - - ))} -
- {editedSensor && ( - setEditedSensor(undefined)} - onUpdate={() => sensors.refetch()} - /> - )} - - ) -} diff --git a/client/src/pages/dashboard/NewDashboardPage.tsx b/client/src/pages/dashboard/NewDashboardPage.tsx index e229bb8..0cba266 100644 --- a/client/src/pages/dashboard/NewDashboardPage.tsx +++ b/client/src/pages/dashboard/NewDashboardPage.tsx @@ -1,10 +1,16 @@ -import { createDashboard, getDashboards } from '@/api/dashboards' +import { + createDashboard, + getDashboards, + updateDashboard, +} from '@/api/dashboards' import { createDashboardContent } from '@/utils/createDashboardContent' import { parseDashboard } from '@/utils/parseDashboard' import { useEffect, useMemo, useState } from 'preact/hooks' import { useQuery } from 'react-query' -import { EditableBox } from './components/EditableBox' -import { normalizeBoxes } from './utils/normalizeBoxes' +import { DashboardGrid } from './components/DashboardGrid' +import { DashboardHeader } from './components/DashboardHeader' +import { DashboardContextProvider } from './contexts/DashboardContext' +import { BoxDefinition } from './types' export const NewDashboardPage = () => { const dashboards = useQuery(['/dashboards'], getDashboards) @@ -15,6 +21,47 @@ export const NewDashboardPage = () => { [dashboard] ) + const [boxes, setBoxes] = useState(dashboardContent?.boxes ?? []) + + const handleChange = (cb: (boxes: BoxDefinition[]) => BoxDefinition[]) => { + const newBoxes = cb(boxes) + + setBoxes(newBoxes) + + if (dashboard && dashboardContent) { + updateDashboard({ + id: dashboard?.id, + contents: { + ...dashboardContent, + boxes: newBoxes, + }, + }) + } + } + + const handleRefresh = () => { + console.log('Nothing to refresh right now') + } + + const handleNewBox = () => { + const box = { + id: new Date().getTime().toString(), + x: 0, + y: 0, + w: 12, + h: 200, + } + + const otherBoxes = boxes.map((b) => { + b.y += 200 + + return b + }) + + setBoxes([box, ...otherBoxes]) + // TODO: Save + } + // Terrible code - ensure there's default dashboard useEffect(() => { if (dashboards.data && !dashboard) { @@ -27,33 +74,16 @@ export const NewDashboardPage = () => { } }, [dashboards.data, dashboard]) - const [boxes, setBoxes] = useState(dashboardContent?.boxes ?? []) + useEffect(() => { + setBoxes(dashboardContent?.boxes ?? []) + }, [dashboardContent]) return ( -
- {boxes.map((b) => ( - { - setBoxes((previous) => { - b.x = p.x - b.y = p.y - - return normalizeBoxes([...previous]) - }) - }} - onResize={(s) => { - setBoxes((previous) => { - b.w = s.w - b.h = s.h - - return normalizeBoxes([...previous]) - }) - }} - /> - ))} -
+ +
+ + +
+
) } diff --git a/client/src/pages/dashboard/components/BoxGraphContent.tsx b/client/src/pages/dashboard/components/BoxGraphContent.tsx new file mode 100644 index 0000000..603fd34 --- /dev/null +++ b/client/src/pages/dashboard/components/BoxGraphContent.tsx @@ -0,0 +1,82 @@ +import { getSensorValues } from '@/api/sensorValues' +import { useEffect, useRef } from 'preact/hooks' +import { useQuery } from 'react-query' +import { useDashboardContext } from '../contexts/DashboardContext' +import { BoxDefinition } from '../types' + +type Props = { + box: BoxDefinition +} + +export const BoxGraphContent = ({ box }: Props) => { + const { filter } = useDashboardContext() + + const bodyRef = useRef(null) + + const valuesQuery = { + sensor: box.sensor ?? '-1', + from: filter.customFrom, + to: filter.customTo, + } + + const values = useQuery(['/sensor/values', valuesQuery], () => + getSensorValues(valuesQuery) + ) + + useEffect(() => { + // TODO: These should be probably returned by server, could be outdated + const from = filter.customFrom + const to = filter.customTo + const minValue = parseFloat(box.min ?? '') + const maxValue = parseFloat(box.max ?? '') + const customRange = !isNaN(minValue) && !isNaN(maxValue) + + if (bodyRef.current && values.data) { + window.Plotly.newPlot( + bodyRef.current, + [ + { + ...(box.graphType === 'line' && { + type: 'scatter', + mode: 'lines', + }), + ...(box.graphType === 'points' && { + type: 'scatter', + mode: 'markers', + }), + ...(box.graphType === 'lineAndPoints' && { + type: 'scatter', + mode: 'lines+markers', + }), + ...(box.graphType === 'bar' && { type: 'bar' }), + x: values.data.map((v) => new Date(v.timestamp * 1000)), + y: values.data.map((v) => v.value), + line: { + width: 1, + }, + }, + ], + { + xaxis: { range: [from, to], type: 'date' }, + yaxis: { + ...(customRange && { range: [minValue, maxValue] }), + ...(box.unit && { ticksuffix: ` ${box.unit}` }), + }, + margin: { + l: 70, + r: 20, + b: 60, + t: 20, + pad: 5, + }, + height: box.h - 50, + }, + { + responsive: true, + } + ) + } + }, [values.data, box]) + + return
+} diff --git a/client/src/pages/dashboard/components/BoxSettings.tsx b/client/src/pages/dashboard/components/BoxSettings.tsx new file mode 100644 index 0000000..23508eb --- /dev/null +++ b/client/src/pages/dashboard/components/BoxSettings.tsx @@ -0,0 +1,123 @@ +import { getSensors } from '@/api/sensors' +import { useState } from 'preact/hooks' +import { useQuery } from 'react-query' +import { BoxDefinition } from '../types' + +type Props = { + value: BoxDefinition + onSave: (newValue: BoxDefinition) => void + onClose: () => void +} + +export const BoxSettings = ({ value, onSave, onClose }: Props) => { + const sensors = useQuery(['/sensors'], getSensors) + + const [formState, setFormState] = useState(() => ({ + sensor: value.sensor, + title: value.title, + min: value.min, + max: value.max, + graphType: value.graphType, + unit: value.unit, + })) + + const handleSave = async (e: Event) => { + e.preventDefault() + e.stopPropagation() + + onClose() + + onSave({ + ...value, + ...formState, + }) + } + + const handleChange = (e: Event) => { + const target = e.target as HTMLSelectElement | HTMLInputElement + + setFormState({ + ...formState, + [target.name]: target.value, + }) + } + + const preventPropagation = (e: Event) => { + e.stopPropagation() + } + + return ( +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+
+
+ ) +} diff --git a/client/src/pages/dashboard/components/DashboardGrid.tsx b/client/src/pages/dashboard/components/DashboardGrid.tsx new file mode 100644 index 0000000..671c6d8 --- /dev/null +++ b/client/src/pages/dashboard/components/DashboardGrid.tsx @@ -0,0 +1,45 @@ +import { BoxDefinition } from '../types' +import { normalizeBoxes } from '../utils/normalizeBoxes' +import { EditableBox } from './EditableBox' + +type Props = { + boxes: BoxDefinition[] + onChange: (cb: (previous: BoxDefinition[]) => BoxDefinition[]) => void +} + +export const DashboardGrid = ({ boxes, onChange }: Props) => { + return ( +
+ {boxes.map((b) => ( + { + 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 + ) + ) + ) + }} + onEdit={(newB) => { + onChange((previous) => + previous.map((b) => (b.id === newB.id ? newB : b)) + ) + }} + /> + ))} +
+ ) +} diff --git a/client/src/pages/dashboard/components/DashboardHeader.tsx b/client/src/pages/dashboard/components/DashboardHeader.tsx new file mode 100644 index 0000000..79e9ed9 --- /dev/null +++ b/client/src/pages/dashboard/components/DashboardHeader.tsx @@ -0,0 +1,19 @@ +import { Filters } from './Filters' + +type Props = { + onNewBox: () => void + onRefresh: () => void +} + +export const DashboardHeader = ({ onNewBox, onRefresh }: Props) => { + return ( +
+
+ + + +
+
+
+ ) +} diff --git a/client/src/pages/dashboard/components/EditableBox.tsx b/client/src/pages/dashboard/components/EditableBox.tsx index dae98ca..d895fa7 100644 --- a/client/src/pages/dashboard/components/EditableBox.tsx +++ b/client/src/pages/dashboard/components/EditableBox.tsx @@ -1,21 +1,32 @@ import { getElementPosition } from '@/utils/getElementPosition' import { useWindowEvent } from '@/utils/hooks/useWindowEvent' -import { useRef } from 'preact/hooks' +import { useRef, useState } from 'preact/hooks' import { GRID_WIDTH } from '../constants' import { ResizingMode, useResize } from '../hooks/useResize' import { useDragging } from '../hooks/useDragging' import { BoxDefinition } from '../types' +import { BoxSettings } from './BoxSettings' +import { BoxGraphContent } from './BoxGraphContent' type Props = { box: BoxDefinition boxes: BoxDefinition[] onPosition: (p: { x: number; y: number }) => void onResize: (p: { w: number; h: number }) => void + onEdit: (box: BoxDefinition) => void } -export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => { +export const EditableBox = ({ + box, + boxes, + onPosition, + onResize, + onEdit, +}: Props) => { const boxRef = useRef(null) + const [editing, setEditing] = useState(false) + const outerWidth = boxRef.current?.parentElement?.offsetWidth ?? 100 const cellWidth = outerWidth / GRID_WIDTH @@ -34,8 +45,8 @@ export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => { const handleMouseDown = (e: MouseEvent) => { e.preventDefault() - if (!dragging) { - const pos = getElementPosition(e.target as HTMLDivElement) + if (!dragging.active && boxRef.current) { + const pos = getElementPosition(boxRef.current) setDragging({ active: true, @@ -51,7 +62,7 @@ export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => { e.preventDefault() e.stopPropagation() - if (!resizing) { + if (resizing.mode === ResizingMode.NONE) { setResizing({ mode: target, w: (box.w / GRID_WIDTH) * outerWidth, @@ -63,7 +74,7 @@ export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => { } useWindowEvent('mouseup', () => { - if (dragging) { + if (dragging.active) { onPosition(draggingPosition) setDragging({ ...dragging, active: false }) } @@ -79,7 +90,6 @@ export const EditableBox = ({ box, boxes, onPosition, onResize }: Props) => {
{ }} >
+
+
+
{box.title ?? box.sensor ?? ''}
+
+
+ + +
+
+
+ {box.sensor && } +
+
{ }} /> )} + + {editing && ( + setEditing(false)} + /> + )} ) } diff --git a/client/src/pages/dashboard/components/Filters.tsx b/client/src/pages/dashboard/components/Filters.tsx index 5c7190b..3773969 100644 --- a/client/src/pages/dashboard/components/Filters.tsx +++ b/client/src/pages/dashboard/components/Filters.tsx @@ -1,6 +1,7 @@ import { DateTimeInput } from '@/components/DateTimeInput' import { intervalToRange } from '@/utils/intervalToRange' import { useState } from 'preact/hooks' +import { useDashboardContext } from '../contexts/DashboardContext' export type FilterInterval = | 'hour' @@ -16,12 +17,9 @@ export type FilterValue = { customTo: Date } -type Props = { - preset: FilterValue - onApply: (v: FilterValue) => void -} +export const Filters = () => { + const { filter: preset, setFilter } = useDashboardContext() -export const Filters = ({ preset, onApply }: Props) => { const [value, setValue] = useState(preset) const isCustomSelected = value.interval === 'custom' @@ -30,7 +28,7 @@ export const Filters = ({ preset, onApply }: Props) => { e.preventDefault() e.stopPropagation() - onApply(value) + setFilter(value) } const handleIntervalChange = (e: Event) => { @@ -47,58 +45,49 @@ export const Filters = ({ preset, onApply }: Props) => { } return ( -
-
-
-
-
- - +
+ +
+ + +
+ + {isCustomSelected && ( + <> +
+ +
+ setValue({ ...value, customFrom: v })} + /> +
+
+ +
+ setValue({ ...value, customTo: v })} + /> +
+
+ + )} - {isCustomSelected && ( - <> -
- -
- setValue({ ...value, customFrom: v })} - /> -
-
-
- -
- setValue({ ...value, customTo: v })} - /> -
-
- - )} - - - -
-
- {/**/} - -
-
-
+ +
) } diff --git a/client/src/pages/dashboard/contexts/DashboardContext.tsx b/client/src/pages/dashboard/contexts/DashboardContext.tsx new file mode 100644 index 0000000..8ff7902 --- /dev/null +++ b/client/src/pages/dashboard/contexts/DashboardContext.tsx @@ -0,0 +1,41 @@ +import { intervalToRange } from '@/utils/intervalToRange' +import { ComponentChild, createContext } from 'preact' +import { StateUpdater, useContext, useMemo, useState } from 'preact/hooks' +import { FilterValue } from '../components/Filters' + +type DashboardContextType = { + filter: FilterValue + setFilter: StateUpdater +} + +const DashboardContext = createContext(null) + +export const DashboardContextProvider = ({ + children, +}: { + children: ComponentChild +}) => { + const [filter, setFilter] = useState(() => { + const range = intervalToRange('week', new Date(), new Date()) + + return { interval: 'week', customFrom: range[0], customTo: range[1] } + }) + + const value = useMemo(() => ({ filter, setFilter }), [filter]) + + return ( + + {children} + + ) +} + +export const useDashboardContext = () => { + const ctx = useContext(DashboardContext) + + if (!ctx) { + throw new Error('useDashboardContext used outside of DashboardContext') + } + + return ctx +} diff --git a/client/src/pages/dashboard/hooks/useResize.ts b/client/src/pages/dashboard/hooks/useResize.ts index fabb57c..254212f 100644 --- a/client/src/pages/dashboard/hooks/useResize.ts +++ b/client/src/pages/dashboard/hooks/useResize.ts @@ -89,8 +89,7 @@ export const useResize = ({ cellWidth, box, boxes }: Props) => { state.mode === ResizingMode.ALL || state.mode === ResizingMode.WIDTH ) { - newState.w = - (box.w / GRID_WIDTH) * outerWidth + e.clientX - state.offsetX + newState.w = box.w * cellWidth + e.clientX - state.offsetX } setState(newState) diff --git a/client/src/pages/dashboard/utils/normalizeBoxes.tsx b/client/src/pages/dashboard/utils/normalizeBoxes.tsx index f198556..5a6d77d 100644 --- a/client/src/pages/dashboard/utils/normalizeBoxes.tsx +++ b/client/src/pages/dashboard/utils/normalizeBoxes.tsx @@ -1,30 +1,39 @@ import { BoxDefinition } from '../types' export function normalizeBoxes(boxes: BoxDefinition[]) { + let sorted = false + // TODO: This is not optimized at all - for (let i = 0; i < boxes.length; i++) { - const box = boxes[i] + while (!sorted) { + // Sort boxes to have the lowest ones first + boxes.sort((a, b) => a.y - b.y) - if (box.y > 0) { - const above = boxes - .filter( - (b) => - b.id !== box.id && - b.x < box.x + box.w && - box.x < b.x + b.w && - b.y < box.y - ) - .sort((a, b) => b.y - a.y) + sorted = true - if (above.length === 0) { - box.y = 0 - i = -1 - } else { - const newY = above[0].h + above[0].y + for (const box of boxes) { + if (box.y > 0) { + const above = boxes + .filter( + (b) => + b.id !== box.id && + b.x < box.x + box.w && + box.x < b.x + b.w && + b.y < box.y + ) + .sort((a, b) => b.y - a.y) - if (box.y !== newY) { - box.y = newY - i = -1 + if (above.length === 0) { + box.y = 0 + sorted = false + break + } else { + const newY = above[0].h + above[0].y + + if (box.y !== newY) { + box.y = newY + sorted = false + break + } } } } diff --git a/client/src/utils/parseDashboard.ts b/client/src/utils/parseDashboard.ts index ba65255..5b83b65 100644 --- a/client/src/utils/parseDashboard.ts +++ b/client/src/utils/parseDashboard.ts @@ -9,8 +9,12 @@ export type DashboardContentBox = { y: number w: number h: number - sensor: string + sensor?: string title?: string + min?: string + max?: string + unit?: string + graphType?: string } export const parseDashboard = (input: string) => {