Use auto generated ids for dashboards

This commit is contained in:
Jan Zípek 2022-09-03 21:35:36 +02:00
parent bb08f31e6b
commit 4173629693
Signed by: kamen
GPG Key ID: A17882625B33AC31
10 changed files with 279 additions and 137 deletions

View File

@ -2,17 +2,18 @@ import { DashboardContent } from '@/utils/dashboard/parseDashboard'
import { request } from './request'
export type DashboardInfo = {
id: string
id: number
name: string
contents: string
}
export const getDashboards = () => request<DashboardInfo[]>('/api/dashboards')
export const getDashboard = (id: string) =>
request<DashboardInfo>(`/api/dashboards/${encodeURI(id)}`)
export const getDashboard = (id: number) =>
request<DashboardInfo>(`/api/dashboards/${id}`)
export const createDashboard = (body: {
id: string
name: string
contents: DashboardContent
}) =>
request<DashboardInfo>(`/api/dashboards`, {
@ -28,10 +29,11 @@ export const updateDashboard = ({
id,
...body
}: {
id: string
id: number
name: string
contents: DashboardContent
}) =>
request<DashboardInfo>(`/api/dashboards/${encodeURI(id)}`, {
request<DashboardInfo>(`/api/dashboards/${id}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({

View File

@ -1,95 +1,13 @@
import {
createDashboard,
getDashboards,
updateDashboard,
} from '@/api/dashboards'
import { UserLayout } from '@/layouts/UserLayout/UserLayout'
import { createDashboardContent } from '@/utils/createDashboardContent'
import { parseDashboard } from '@/utils/dashboard/parseDashboard'
import { useEffect, useMemo, useState } from 'preact/hooks'
import { useQuery, useQueryClient } from 'react-query'
import { DashboardGrid } from './components/DashboardGrid/DashboardGrid'
import { DashboardHeader } from './components/DashboardHeader/DashboardHeader'
import { GRID_H_SNAP, GRID_WIDTH } from './constants'
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')
const dashboardContent = useMemo(
() => (dashboard ? parseDashboard(dashboard.contents) : undefined),
[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 = () => {
queryClient.invalidateQueries(['/sensor/values'])
queryClient.invalidateQueries(['/sensor/values/latest'])
}
const handleNewBox = () => {
const box = {
id: new Date().getTime().toString(),
x: 0,
y: 0,
w: GRID_WIDTH,
h: GRID_H_SNAP * 20,
}
const otherBoxes = boxes.map((b) => {
b.y += 200
return b
})
handleChange(() => [box, ...otherBoxes])
}
// Terrible code - ensure there's default dashboard
useEffect(() => {
if (dashboards.data && !dashboard) {
createDashboard({
id: 'default',
contents: createDashboardContent(),
}).then(() => {
dashboards.refetch()
})
}
}, [dashboards.data, dashboard])
useEffect(() => {
setBoxes(dashboardContent?.boxes ?? [])
}, [dashboardContent])
return (
<DashboardContextProvider>
<UserLayout
className="dashboard"
header={
<DashboardHeader onRefresh={handleRefresh} onNewBox={handleNewBox} />
}
>
<DashboardGrid boxes={boxes} onChange={handleChange} />
<UserLayout className="dashboard" header={<DashboardHeader />}>
<DashboardGrid />
</UserLayout>
</DashboardContextProvider>
)

View File

@ -1,14 +1,11 @@
import { BoxDefinition } from '../../types'
import { useDashboardContext } from '../../contexts/DashboardContext'
import { normalizeBoxes } from '../../utils/normalizeBoxes'
import { EditableBox } from './components/EditableBox'
type Props = {
boxes: BoxDefinition[]
onChange: (cb: (previous: BoxDefinition[]) => BoxDefinition[]) => void
}
export const DashboardGrid = () => {
const { isDashboardSelected, boxes, setBoxes } = useDashboardContext()
export const DashboardGrid = ({ boxes, onChange }: Props) => {
return (
return isDashboardSelected ? (
<div className="grid-sensors-container">
<div className="grid-sensors">
{boxes.map((b) => (
@ -17,7 +14,7 @@ export const DashboardGrid = ({ boxes, onChange }: Props) => {
key={b.id}
boxes={boxes}
onPosition={(p) => {
onChange((previous) =>
setBoxes((previous) =>
normalizeBoxes(
previous.map((pb) =>
pb.id === b.id ? { ...pb, x: p.x, y: p.y } : pb
@ -26,7 +23,7 @@ export const DashboardGrid = ({ boxes, onChange }: Props) => {
)
}}
onResize={(s) => {
onChange((previous) =>
setBoxes((previous) =>
normalizeBoxes(
previous.map((pb) =>
pb.id === b.id ? { ...pb, w: s.w, h: s.h } : pb
@ -35,12 +32,12 @@ export const DashboardGrid = ({ boxes, onChange }: Props) => {
)
}}
onEdit={(newB) => {
onChange((previous) =>
setBoxes((previous) =>
previous.map((b) => (b.id === newB.id ? newB : b))
)
}}
onRemove={() => {
onChange((previous) =>
setBoxes((previous) =>
normalizeBoxes(previous.filter((pb) => pb.id !== b.id))
)
}}
@ -48,5 +45,7 @@ export const DashboardGrid = ({ boxes, onChange }: Props) => {
))}
</div>
</div>
) : (
<div>Please create a new dashboard</div>
)
}

View File

@ -1,26 +1,54 @@
import { CancelIcon, FiltersIcon, PlusIcon, RefreshIcon } from '@/icons'
import { intervalToRange } from '@/utils/intervalToRange'
import { useState } from 'preact/hooks'
import { GRID_H_SNAP, GRID_WIDTH } from '../../constants'
import { useDashboardContext } from '../../contexts/DashboardContext'
import { DashboardFilters } from './components/DashboardFilters'
import { DashboardSwitch } from './components/DashboardSwitch'
type Props = {
onNewBox: () => void
onRefresh: () => void
}
export const DashboardHeader = ({ onNewBox, onRefresh }: Props) => {
const { verticalMode } = useDashboardContext()
export const DashboardHeader = () => {
const { verticalMode, boxes, setBoxes, setFilter } = useDashboardContext()
const [filtersShown, setFiltersShown] = useState(false)
const handleRefresh = () => {
// @TODO: This is duplicate code, unify it somehow with dashboard filters
setFilter((v) => {
const range = intervalToRange(v.interval, v.customFrom, v.customTo)
return {
...v,
customFrom: range[0],
customTo: range[1],
}
})
}
const handleNewBox = () => {
const box = {
id: new Date().getTime().toString(),
x: 0,
y: 0,
w: GRID_WIDTH,
h: GRID_H_SNAP * 20,
}
const otherBoxes = boxes.map((b) => {
b.y += 200
return b
})
setBoxes(() => [box, ...otherBoxes])
}
return (
<div className="dashboard-head">
{verticalMode && (
<>
<button className="icon" onClick={onNewBox}>
<button className="icon" onClick={handleNewBox}>
<PlusIcon />
</button>
<button className="icon" onClick={onRefresh}>
<button className="icon" onClick={handleRefresh}>
<RefreshIcon />
</button>
<div className="filter-button" onClick={() => setFiltersShown(true)}>
@ -47,11 +75,11 @@ export const DashboardHeader = ({ onNewBox, onRefresh }: Props) => {
{!verticalMode && (
<>
<DashboardSwitch />
<button onClick={onNewBox}>Add box</button>
<button onClick={handleNewBox}>Add box</button>
<div className="spacer" />
<DashboardFilters />
<div className="spacer" />
<button onClick={onRefresh}>
<button onClick={handleRefresh}>
<RefreshIcon /> Refresh all
</button>
</>

View File

@ -0,0 +1,65 @@
import {
createDashboard,
DashboardInfo,
updateDashboard,
} from '@/api/dashboards'
import { Modal } from '@/components/Modal'
import { createDashboardContent } from '@/utils/createDashboardContent'
import { useForm } from '@/utils/hooks/useForm'
import { useMutation, useQueryClient } from 'react-query'
type Props = {
dashboard?: DashboardInfo
onClose: () => void
}
export const DashboardModal = ({ dashboard, onClose }: Props) => {
const queryClient = useQueryClient()
const createMutation = useMutation(createDashboard)
const updateMutation = useMutation(updateDashboard)
const { handleSubmit, register } = useForm({
defaultValue: () => ({
name: dashboard?.name ?? '',
}),
onSubmit: async (v) => {
if (updateMutation.isLoading || createMutation.isLoading) {
return
}
if (dashboard) {
await updateMutation.mutateAsync({
...dashboard,
name: v.name,
contents: JSON.parse(dashboard.contents),
})
} else {
await createMutation.mutateAsync({
name: v.name,
contents: createDashboardContent(),
})
}
queryClient.invalidateQueries(['/dashboards'])
onClose()
},
})
return (
<Modal open onClose={onClose}>
<form onSubmit={handleSubmit}>
<div className="input">
<label>Name</label>
<input type="text" {...register('name')} />
</div>
<div className="actions">
<button className="cancel" onClick={onClose} type="button">
Cancel
</button>
<button>Save</button>
</div>
</form>
</Modal>
)
}

View File

@ -1,27 +1,45 @@
import { getDashboards } from '@/api/dashboards'
import { PlusIcon } from '@/icons'
import { DashboardInfo, getDashboards } from '@/api/dashboards'
import { EditIcon, PlusIcon } from '@/icons'
import { useDashboardContext } from '@/pages/dashboard/contexts/DashboardContext'
import { useState } from 'preact/hooks'
import { useQuery } from 'react-query'
import { DashboardModal } from './DashboardModal'
export const DashboardSwitch = () => {
const [dashboard, setDashboard] = useState<string>()
const { dashboardId, setDashboardId, dashboard } = useDashboardContext()
const [showNew, setShowNew] = useState(false)
const [edited, setEdited] = useState<DashboardInfo>()
const dashboards = useQuery(['/dashboards'], getDashboards)
return (
<div className="dashboard-switch">
<select
onChange={(e) => setDashboard(e.currentTarget.value)}
value={dashboard}
onChange={(e) => setDashboardId(+e.currentTarget.value)}
value={dashboardId}
>
{dashboards.data?.map((d) => (
<option key={d.id}>{d.id}</option>
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<button>
<button onClick={() => setEdited(dashboard)}>
<EditIcon /> Edit
</button>
<button onClick={() => setShowNew(true)}>
<PlusIcon /> Add
</button>
{showNew && <DashboardModal onClose={() => setShowNew(false)} />}
{edited && (
<DashboardModal
dashboard={edited}
onClose={() => setEdited(undefined)}
/>
)}
</div>
)
}

View File

@ -1,13 +1,31 @@
import { DashboardInfo, getDashboard, updateDashboard } from '@/api/dashboards'
import { parseDashboard } from '@/utils/dashboard/parseDashboard'
import { useViewportSize } from '@/utils/hooks/useViewportSize'
import { intervalToRange } from '@/utils/intervalToRange'
import { ComponentChild, createContext } from 'preact'
import { StateUpdater, useContext, useMemo, useState } from 'preact/hooks'
import {
StateUpdater,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'preact/hooks'
import { useQuery } from 'react-query'
import { FilterValue } from '../components/DashboardHeader/components/DashboardFilters'
import { BoxDefinition } from '../types'
type DashboardContextType = {
filter: FilterValue
setFilter: StateUpdater<FilterValue>
verticalMode: boolean
dashboardId: number
setDashboardId: StateUpdater<number>
boxes: BoxDefinition[]
setBoxes: (cb: (previous: BoxDefinition[]) => BoxDefinition[]) => void
isDashboardSelected: boolean
isDashboardLoading: boolean
dashboard: DashboardInfo | undefined
}
const DashboardContext = createContext<DashboardContextType | null>(null)
@ -19,6 +37,46 @@ export const DashboardContextProvider = ({
}) => {
const viewport = useViewportSize()
const [dashboardId, setDashboardId] = useState(-1)
const isDashboardSelected = !isNaN(dashboardId) && dashboardId >= 0
const dashboard = useQuery(
['/dashboards', dashboardId],
() => getDashboard(dashboardId),
{ enabled: isDashboardSelected }
)
const dashboardContent = useMemo(
() =>
dashboard.data ? parseDashboard(dashboard.data.contents) : undefined,
[dashboard.data]
)
const [boxes, setBoxes] = useState(dashboardContent?.boxes ?? [])
const handleChange = useCallback(
(cb: (boxes: BoxDefinition[]) => BoxDefinition[]) => {
const newBoxes = cb(boxes)
setBoxes(newBoxes)
if (dashboard.data && dashboardContent) {
updateDashboard({
...dashboard.data,
contents: {
...dashboardContent,
boxes: newBoxes,
},
})
}
},
[dashboard.data]
)
useEffect(() => {
setBoxes(dashboardContent?.boxes ?? [])
}, [dashboardContent])
const [filter, setFilter] = useState<FilterValue>(() => {
const range = intervalToRange('week', new Date(), new Date())
@ -28,8 +86,29 @@ export const DashboardContextProvider = ({
const verticalMode = viewport.width < 800
const value = useMemo(
() => ({ filter, setFilter, verticalMode }),
[filter, verticalMode]
() => ({
filter,
setFilter,
verticalMode,
dashboardId,
setDashboardId,
boxes,
setBoxes: handleChange,
isDashboardSelected,
isDashboardLoading: dashboard.isLoading,
dashboard: dashboard.data,
}),
[
filter,
verticalMode,
dashboardId,
setDashboardId,
boxes,
handleChange,
isDashboardSelected,
dashboard.isLoading,
dashboard.data,
]
)
return (

View File

@ -0,0 +1,12 @@
ALTER TABLE dashboards RENAME TO dashboards_old;
CREATE TABLE IF NOT EXISTS dashboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
contents TEXT NOT NULL
);
INSERT INTO dashboards (name, contents)
SELECT id, contents FROM dashboards_old;
DROP TABLE dashboards_old;

View File

@ -3,16 +3,19 @@ package routes
import (
"basic-sensor-receiver/app"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type postDashboardBody struct {
Id string `json:"id"`
Id int64 `json:"id"`
Name string `json:"name"`
Contents string `json:"contents"`
}
type putDashboardBody struct {
Name string `json:"name"`
Contents string `json:"contents"`
}
@ -31,7 +34,11 @@ func GetDashboards(s *app.Server) gin.HandlerFunc {
func GetDashboardById(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
id, err := getIntParam(c, "id")
if err != nil {
c.AbortWithError(400, err)
return
}
item, err := s.Services.Dashboards.GetById(id)
@ -53,7 +60,7 @@ func PostDashboard(s *app.Server) gin.HandlerFunc {
return
}
item, err := s.Services.Dashboards.Create(body.Id, body.Contents)
item, err := s.Services.Dashboards.Create(body.Id, body.Name, body.Contents)
if err != nil {
c.AbortWithError(500, err)
@ -66,7 +73,12 @@ func PostDashboard(s *app.Server) gin.HandlerFunc {
func PutDashboard(s *app.Server) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
id, err := getIntParam(c, "id")
if err != nil {
c.AbortWithError(400, err)
return
}
body := putDashboardBody{}
if err := c.BindJSON(&body); err != nil {
@ -74,7 +86,7 @@ func PutDashboard(s *app.Server) gin.HandlerFunc {
return
}
item, err := s.Services.Dashboards.Update(id, body.Contents)
item, err := s.Services.Dashboards.Update(id, body.Name, body.Contents)
if err != nil {
c.AbortWithError(500, err)
@ -84,3 +96,9 @@ func PutDashboard(s *app.Server) gin.HandlerFunc {
c.JSON(http.StatusOK, item)
}
}
func getIntParam(c *gin.Context, key string) (int64, error) {
value := c.Param(key)
return strconv.ParseInt(value, 10, 64)
}

View File

@ -5,14 +5,15 @@ type DashboardsService struct {
}
type DashboardItem struct {
Id string `json:"id"`
Id int64 `json:"id"`
Name string `json:"name"`
Contents string `json:"contents"`
}
func (s *DashboardsService) GetList() ([]DashboardItem, error) {
items := make([]DashboardItem, 0)
rows, err := s.ctx.DB.Query("SELECT id, contents FROM dashboards")
rows, err := s.ctx.DB.Query("SELECT id, name, contents FROM dashboards")
if err != nil {
return nil, err
@ -23,7 +24,7 @@ func (s *DashboardsService) GetList() ([]DashboardItem, error) {
for rows.Next() {
item := DashboardItem{}
err := rows.Scan(&item.Id, &item.Contents)
err := rows.Scan(&item.Id, &item.Name, &item.Contents)
if err != nil {
return nil, err
}
@ -39,13 +40,14 @@ func (s *DashboardsService) GetList() ([]DashboardItem, error) {
return items, nil
}
func (s *DashboardsService) Create(id string, contents string) (*DashboardItem, error) {
func (s *DashboardsService) Create(id int64, name string, contents string) (*DashboardItem, error) {
item := DashboardItem{
Id: id,
Name: name,
Contents: contents,
}
_, err := s.ctx.DB.Exec("INSERT INTO dashboards (id, contents) VALUES (?, ?)", item.Id, item.Contents)
_, err := s.ctx.DB.Exec("INSERT INTO dashboards (id, name, contents) VALUES (?, ?, ?)", item.Id, item.Name, item.Contents)
if err != nil {
return nil, err
@ -54,13 +56,14 @@ func (s *DashboardsService) Create(id string, contents string) (*DashboardItem,
return &item, nil
}
func (s *DashboardsService) Update(id string, contents string) (*DashboardItem, error) {
func (s *DashboardsService) Update(id int64, name string, contents string) (*DashboardItem, error) {
item := DashboardItem{
Id: id,
Name: name,
Contents: contents,
}
_, err := s.ctx.DB.Exec("UPDATE dashboards SET contents = ? WHERE id = ?", contents, id)
_, err := s.ctx.DB.Exec("UPDATE dashboards SET contents = ?, name = ? WHERE id = ?", item.Contents, item.Name, item.Id)
if err != nil {
return nil, err
@ -69,12 +72,12 @@ func (s *DashboardsService) Update(id string, contents string) (*DashboardItem,
return &item, nil
}
func (s *DashboardsService) GetById(id string) (*DashboardItem, error) {
func (s *DashboardsService) GetById(id int64) (*DashboardItem, error) {
item := DashboardItem{}
row := s.ctx.DB.QueryRow("SELECT id, contents FROM dashboards WHERE id = ?", id)
row := s.ctx.DB.QueryRow("SELECT id, name, contents FROM dashboards WHERE id = ?", id)
err := row.Scan(&item.Id, &item.Contents)
err := row.Scan(&item.Id, &item.Name, &item.Contents)
if err != nil {
return nil, err
}