Display empty messages when there's nothing

This commit is contained in:
Jan Zípek 2024-04-01 13:10:08 +02:00
parent 71b1b3ad0b
commit 0fc4eb2f8c
Signed by: kamen
GPG Key ID: A17882625B33AC31
18 changed files with 157 additions and 31 deletions

View File

@ -34,4 +34,4 @@ export const updateAlert = ({
}) })
export const deleteAlert = (id: number) => export const deleteAlert = (id: number) =>
request<AlertInfo>(`/api/alerts/${id}`, { method: 'DELETE' }, 'void') request<AlertInfo, 'void'>(`/api/alerts/${id}`, { method: 'DELETE' }, 'void')

View File

@ -1,3 +1,8 @@
type RequestResult<
T = void,
TType extends 'json' | 'void' = 'json'
> = TType extends 'void' ? undefined : TType extends 'json' ? T : void
export class RequestError extends Error { export class RequestError extends Error {
constructor(public response: Response, message: string) { constructor(public response: Response, message: string) {
super(message) super(message)
@ -10,11 +15,14 @@ export class ResponseEmptyError extends RequestError {
} }
} }
export const request = async <T = void>( export const request = async <
TResult = void,
TType extends 'json' | 'void' = 'json'
>(
url: string, url: string,
options?: RequestInit, options?: RequestInit,
type: 'json' | 'void' = 'json' type?: TType
) => { ): Promise<RequestResult<TResult, TType>> => {
const response = await fetch(url, options) const response = await fetch(url, options)
if (!response.ok) { if (!response.ok) {
@ -25,21 +33,21 @@ export const request = async <T = void>(
} }
if (type === 'void') { if (type === 'void') {
return return undefined as RequestResult<TResult, TType>
} }
const text = await response.text() const text = await response.text()
if (!text) { if (!text) {
if (type === 'json') { if (!type || type === 'json') {
throw new ResponseEmptyError( throw new ResponseEmptyError(
response, response,
'Expected json, got empty response' 'Expected json, got empty response'
) )
} }
return return undefined as RequestResult<TResult, TType>
} }
return JSON.parse(text) as T return JSON.parse(text) as RequestResult<TResult, TType>
} }

View File

@ -0,0 +1,8 @@
.empty {
text-align: center;
margin: 1rem auto;
button {
margin-top: 1rem;
}
}

View File

@ -17,12 +17,12 @@ main.layout {
left: 0; left: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
z-index: 10;
} }
color: var(--box-fg-color); color: var(--box-fg-color);
width: 15rem; width: 15rem;
transition: left 0.1s, margin-left 0.1s; transition: left 0.1s, margin-left 0.1s;
z-index: 2;
display: flex; display: flex;
.inner { .inner {

View File

@ -1,11 +1,8 @@
/*
.dashboard-page { .dashboard-page {
height: 100%; .empty {
overflow: auto; margin-top: 5rem;
display: flex; }
flex-direction: column;
} }
*/
.dashboard-head { .dashboard-head {
display: flex; display: flex;
@ -29,7 +26,7 @@
min-width: 10rem; min-width: 10rem;
} }
button { > button {
margin-left: 0.25rem; margin-left: 0.25rem;
font-size: 125%; font-size: 125%;
} }

View File

@ -47,6 +47,7 @@ a {
@import 'components/box'; @import 'components/box';
@import 'components/data-table'; @import 'components/data-table';
@import 'components/section-title'; @import 'components/section-title';
@import 'components/empty';
@import 'pages/login-page'; @import 'pages/login-page';
@import 'pages/sensors-page'; @import 'pages/sensors-page';

View File

@ -1,10 +1,16 @@
import { cn } from '@/utils/cn' import { cn } from '@/utils/cn'
import { ComponentChild } from 'preact' import { ComponentChild, ComponentChildren } from 'preact'
import { UseQueryResult } from 'react-query'
type Props<TRow> = { type Props<TRow> = (
data: TRow[] | { data: TRow[]; isLoading?: boolean; query?: never }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| { query: UseQueryResult<TRow[], any>; data?: never; isLoading?: never }
) & {
columns: DataTableColumn<TRow>[] columns: DataTableColumn<TRow>[]
hideHeader?: boolean hideHeader?: boolean
emptyMessage?: ComponentChildren
isLoading?: boolean
} }
type DataTableColumn<TRow> = { type DataTableColumn<TRow> = {
@ -15,10 +21,16 @@ type DataTableColumn<TRow> = {
} & ({ scale: number } | { width: string }) } & ({ scale: number } | { width: string })
export const DataTable = <TRow,>({ export const DataTable = <TRow,>({
data, data: setData,
isLoading: setIsLoading,
query,
columns, columns,
hideHeader, hideHeader,
emptyMessage,
}: Props<TRow>) => { }: Props<TRow>) => {
const data = query ? query.data ?? [] : setData
const isLoading = query ? query.isLoading : setIsLoading
return ( return (
<div className="data-table"> <div className="data-table">
{!hideHeader && ( {!hideHeader && (
@ -54,6 +66,10 @@ export const DataTable = <TRow,>({
))} ))}
</div> </div>
))} ))}
{data.length === 0 && !isLoading && emptyMessage && (
<div className="empty-data">{emptyMessage}</div>
)}
</div> </div>
</div> </div>
) )

View File

@ -5,9 +5,10 @@ import { EditIcon, PlusIcon, TrashIcon } from '@/icons'
import { useState } from 'preact/hooks' import { useState } from 'preact/hooks'
import { useMutation, useQuery } from 'react-query' import { useMutation, useQuery } from 'react-query'
import { AlertFormModal } from './components/AlertFormModal' import { AlertFormModal } from './components/AlertFormModal'
import { NoAlerts } from '../NoAlerts'
export const AlertsTable = () => { export const AlertsTable = () => {
const alerts = useQuery('/alerts', () => getAlerts(), { const alerts = useQuery('/alerts', getAlerts, {
refetchInterval: 500, refetchInterval: 500,
}) })
@ -35,7 +36,8 @@ export const AlertsTable = () => {
<div className="box-shadow"> <div className="box-shadow">
<DataTable <DataTable
data={alerts.data ?? []} query={alerts}
emptyMessage={<NoAlerts />}
columns={[ columns={[
{ key: 'name', title: 'Name', render: (c) => c.name, scale: 1 }, { key: 'name', title: 'Name', render: (c) => c.name, scale: 1 },
{ {

View File

@ -8,6 +8,7 @@ import { DataTable } from '@/components/DataTable'
import { EditIcon, PlusIcon, TrashIcon } from '@/icons' import { EditIcon, PlusIcon, TrashIcon } from '@/icons'
import { useState } from 'preact/hooks' import { useState } from 'preact/hooks'
import { useMutation, useQuery } from 'react-query' import { useMutation, useQuery } from 'react-query'
import { NoContactPoints } from '../NoContactPoints'
import { ContactPointFormModal } from './components/ContactPointFormModal' import { ContactPointFormModal } from './components/ContactPointFormModal'
export const ContactPointsTable = () => { export const ContactPointsTable = () => {
@ -35,7 +36,8 @@ export const ContactPointsTable = () => {
<div className="box-shadow"> <div className="box-shadow">
<DataTable <DataTable
data={contactPoints.data ?? []} query={contactPoints}
emptyMessage={<NoContactPoints />}
columns={[ columns={[
{ key: 'name', title: 'Name', render: (c) => c.name, scale: 1 }, { key: 'name', title: 'Name', render: (c) => c.name, scale: 1 },
{ {

View File

@ -0,0 +1,21 @@
import { PlusIcon } from '@/icons'
import { useState } from 'preact/hooks'
import { AlertFormModal } from './AlertsTable/components/AlertFormModal'
export const NoAlerts = () => {
const [showNew, setShowNew] = useState(false)
return (
<>
<div className="empty">
<div>No alert defined.</div>
<div>
<button onClick={() => setShowNew(true)}>
<PlusIcon /> Add a new alert
</button>
</div>
</div>
{showNew && <AlertFormModal open onClose={() => setShowNew(false)} />}
</>
)
}

View File

@ -0,0 +1,23 @@
import { PlusIcon } from '@/icons'
import { useState } from 'preact/hooks'
import { ContactPointFormModal } from './ContactPointsTable/components/ContactPointFormModal'
export const NoContactPoints = () => {
const [showNew, setShowNew] = useState(false)
return (
<>
<div className="empty">
<div>No contact points defined.</div>
<div>
<button onClick={() => setShowNew(true)}>
<PlusIcon /> Add a new contact point
</button>
</div>
</div>
{showNew && (
<ContactPointFormModal open onClose={() => setShowNew(false)} />
)}
</>
)
}

View File

@ -1,5 +1,6 @@
import { useDashboardContext } from '../../contexts/DashboardContext' import { useDashboardContext } from '../../contexts/DashboardContext'
import { normalizeBoxes } from '../../utils/normalizeBoxes' import { normalizeBoxes } from '../../utils/normalizeBoxes'
import { NoDashboard } from '../NoDashboard'
import { GeneralBox } from './components/GeneralBox' import { GeneralBox } from './components/GeneralBox'
export const DashboardGrid = () => { export const DashboardGrid = () => {
@ -45,6 +46,6 @@ export const DashboardGrid = () => {
</div> </div>
</div> </div>
) : ( ) : (
<div>Please create a new dashboard</div> <NoDashboard />
) )
} }

View File

@ -0,0 +1,21 @@
import { PlusIcon } from '@/icons'
import { useState } from 'preact/hooks'
import { DashboardSettings } from './DashboardHeader/components/DashboardSettings'
export const NoDashboard = () => {
const [showNew, setShowNew] = useState(false)
return (
<>
<div className="empty">
<div>No dashboard defined.</div>
<div>
<button onClick={() => setShowNew(true)}>
<PlusIcon /> Create a new dashboard
</button>
</div>
</div>
{showNew && <DashboardSettings onClose={() => setShowNew(false)} />}
</>
)
}

View File

@ -51,7 +51,12 @@ export const DashboardContextProvider = ({
const [dashboardId, setDashboardId] = useState(() => { const [dashboardId, setDashboardId] = useState(() => {
const query = getQuery() const query = getQuery()
const queryDashboardId = +(query['dashboard'] ?? '')
if (query.dashboard === undefined) {
return -1
}
const queryDashboardId = parseInt(query.dashboard)
return !isNaN(queryDashboardId) ? queryDashboardId : -1 return !isNaN(queryDashboardId) ? queryDashboardId : -1
}) })

View File

@ -7,6 +7,7 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'
import { SensorFormModal } from './components/SensorFormModal' import { SensorFormModal } from './components/SensorFormModal'
import { useConfirmModal } from '@/contexts/ConfirmModalsContext' import { useConfirmModal } from '@/contexts/ConfirmModalsContext'
import { SensorDetailModal } from './components/SensorDetailModal' import { SensorDetailModal } from './components/SensorDetailModal'
import { NoSensors } from './components/NoSensors'
export const SensorsPage = () => { export const SensorsPage = () => {
const sensors = useQuery(['/sensors'], getSensors) const sensors = useQuery(['/sensors'], getSensors)
@ -37,7 +38,8 @@ export const SensorsPage = () => {
<div className="box-shadow"> <div className="box-shadow">
<DataTable <DataTable
data={sensors.data ?? []} query={sensors}
emptyMessage={<NoSensors />}
columns={[ columns={[
{ key: 'type', title: 'ID', render: (c) => c.id, width: '1rem' }, { key: 'type', title: 'ID', render: (c) => c.id, width: '1rem' },
{ key: 'name', title: 'Name', render: (c) => c.name, scale: 1 }, { key: 'name', title: 'Name', render: (c) => c.name, scale: 1 },

View File

@ -0,0 +1,21 @@
import { PlusIcon } from '@/icons'
import { useState } from 'preact/hooks'
import { SensorFormModal } from './SensorFormModal'
export const NoSensors = () => {
const [showNew, setShowNew] = useState(false)
return (
<>
<div className="empty">
<div>No sensor defined.</div>
<div>
<button onClick={() => setShowNew(true)}>
<PlusIcon /> Add a new sensor
</button>
</div>
</div>
{showNew && <SensorFormModal open onClose={() => setShowNew(false)} />}
</>
)
}

View File

@ -9,7 +9,6 @@ import (
) )
type postDashboardBody struct { type postDashboardBody struct {
Id int64 `json:"id" binding:"required"`
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Contents string `json:"contents" binding:"required"` Contents string `json:"contents" binding:"required"`
} }
@ -57,7 +56,7 @@ func PostDashboard(s *app.Server) gin.HandlerFunc {
body := postDashboardBody{} body := postDashboardBody{}
bindJSONBodyOrAbort(c, &body) bindJSONBodyOrAbort(c, &body)
item, err := s.Services.Dashboards.Create(body.Id, body.Name, body.Contents) item, err := s.Services.Dashboards.Create(body.Name, body.Contents)
if err != nil { if err != nil {
c.AbortWithError(500, err) c.AbortWithError(500, err)

View File

@ -46,14 +46,13 @@ func (s *DashboardsService) GetList() ([]DashboardItem, error) {
return items, nil return items, nil
} }
func (s *DashboardsService) Create(id int64, name string, contents string) (*DashboardItem, error) { func (s *DashboardsService) Create(name string, contents string) (*DashboardItem, error) {
item := DashboardItem{ item := DashboardItem{
Id: id,
Name: name, Name: name,
Contents: contents, Contents: contents,
} }
_, err := s.ctx.DB.Exec("INSERT INTO dashboards (id, name, contents) VALUES (?, ?, ?)", item.Id, item.Name, item.Contents) _, err := s.ctx.DB.Exec("INSERT INTO dashboards (name, contents) VALUES (?, ?)", item.Name, item.Contents)
if err != nil { if err != nil {
return nil, err return nil, err