diff --git a/client/src/api/dashboards.ts b/client/src/api/dashboards.ts index 2373afc..aa87c08 100644 --- a/client/src/api/dashboards.ts +++ b/client/src/api/dashboards.ts @@ -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('/api/dashboards') -export const getDashboard = (id: string) => - request(`/api/dashboards/${encodeURI(id)}`) +export const getDashboard = (id: number) => + request(`/api/dashboards/${id}`) export const createDashboard = (body: { - id: string + name: string contents: DashboardContent }) => request(`/api/dashboards`, { @@ -28,10 +29,11 @@ export const updateDashboard = ({ id, ...body }: { - id: string + id: number + name: string contents: DashboardContent }) => - request(`/api/dashboards/${encodeURI(id)}`, { + request(`/api/dashboards/${id}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ diff --git a/client/src/pages/dashboard/NewDashboardPage.tsx b/client/src/pages/dashboard/NewDashboardPage.tsx index ad66487..226b79f 100644 --- a/client/src/pages/dashboard/NewDashboardPage.tsx +++ b/client/src/pages/dashboard/NewDashboardPage.tsx @@ -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 ( - - } - > - + }> + ) diff --git a/client/src/pages/dashboard/components/DashboardGrid/DashboardGrid.tsx b/client/src/pages/dashboard/components/DashboardGrid/DashboardGrid.tsx index da56b35..65907e3 100644 --- a/client/src/pages/dashboard/components/DashboardGrid/DashboardGrid.tsx +++ b/client/src/pages/dashboard/components/DashboardGrid/DashboardGrid.tsx @@ -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 ? (
{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) => { ))}
+ ) : ( +
Please create a new dashboard
) } diff --git a/client/src/pages/dashboard/components/DashboardHeader/DashboardHeader.tsx b/client/src/pages/dashboard/components/DashboardHeader/DashboardHeader.tsx index 441d1f7..2ba3108 100644 --- a/client/src/pages/dashboard/components/DashboardHeader/DashboardHeader.tsx +++ b/client/src/pages/dashboard/components/DashboardHeader/DashboardHeader.tsx @@ -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 (
{verticalMode && ( <> - -
setFiltersShown(true)}> @@ -47,11 +75,11 @@ export const DashboardHeader = ({ onNewBox, onRefresh }: Props) => { {!verticalMode && ( <> - +
- diff --git a/client/src/pages/dashboard/components/DashboardHeader/components/DashboardModal.tsx b/client/src/pages/dashboard/components/DashboardHeader/components/DashboardModal.tsx new file mode 100644 index 0000000..6142ed7 --- /dev/null +++ b/client/src/pages/dashboard/components/DashboardHeader/components/DashboardModal.tsx @@ -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 ( + +
+
+ + +
+ +
+ + +
+
+
+ ) +} diff --git a/client/src/pages/dashboard/components/DashboardHeader/components/DashboardSwitch.tsx b/client/src/pages/dashboard/components/DashboardHeader/components/DashboardSwitch.tsx index 6dea3a4..2de093a 100644 --- a/client/src/pages/dashboard/components/DashboardHeader/components/DashboardSwitch.tsx +++ b/client/src/pages/dashboard/components/DashboardHeader/components/DashboardSwitch.tsx @@ -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() + const { dashboardId, setDashboardId, dashboard } = useDashboardContext() + const [showNew, setShowNew] = useState(false) + const [edited, setEdited] = useState() const dashboards = useQuery(['/dashboards'], getDashboards) return (
- + + + + {showNew && setShowNew(false)} />} + {edited && ( + setEdited(undefined)} + /> + )}
) } diff --git a/client/src/pages/dashboard/contexts/DashboardContext.tsx b/client/src/pages/dashboard/contexts/DashboardContext.tsx index 390be00..9d4ebe3 100644 --- a/client/src/pages/dashboard/contexts/DashboardContext.tsx +++ b/client/src/pages/dashboard/contexts/DashboardContext.tsx @@ -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 verticalMode: boolean + dashboardId: number + setDashboardId: StateUpdater + boxes: BoxDefinition[] + setBoxes: (cb: (previous: BoxDefinition[]) => BoxDefinition[]) => void + isDashboardSelected: boolean + isDashboardLoading: boolean + dashboard: DashboardInfo | undefined } const DashboardContext = createContext(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(() => { 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 ( diff --git a/server/database/migrations/1662203378276_dashboards_uid.sql b/server/database/migrations/1662203378276_dashboards_uid.sql new file mode 100644 index 0000000..9e23f0f --- /dev/null +++ b/server/database/migrations/1662203378276_dashboards_uid.sql @@ -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; diff --git a/server/routes/dashboards.go b/server/routes/dashboards.go index da648fe..72d490d 100644 --- a/server/routes/dashboards.go +++ b/server/routes/dashboards.go @@ -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) +} diff --git a/server/services/dashboards_service.go b/server/services/dashboards_service.go index a7daff5..db64dba 100644 --- a/server/services/dashboards_service.go +++ b/server/services/dashboards_service.go @@ -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 }