Modals and forms improvements

This commit is contained in:
Jan Zípek 2022-08-28 19:09:48 +02:00
parent ea682998a8
commit 3c96bfa73e
Signed by: kamen
GPG Key ID: A17882625B33AC31
11 changed files with 261 additions and 285 deletions

View File

@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from 'react-query' import { QueryClient, QueryClientProvider } from 'react-query'
import { AppContextProvider } from './contexts/AppContext' import { AppContextProvider } from './contexts/AppContext'
import { ConfirmModalsContextProvider } from './contexts/ConfirmModalsContext'
import { Router } from './pages/Router' import { Router } from './pages/Router'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@ -14,7 +15,9 @@ export const Root = () => {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AppContextProvider> <AppContextProvider>
<Router /> <ConfirmModalsContextProvider>
<Router />
</ConfirmModalsContextProvider>
</AppContextProvider> </AppContextProvider>
</QueryClientProvider> </QueryClientProvider>
) )

View File

@ -0,0 +1,49 @@
.modal {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
overflow: auto;
display: none;
justify-content: center;
align-items: flex-start;
background-color: var(--modal-overlay-bg-color);
z-index: 5;
&.show {
display: flex;
}
&.confirm {
background-color: var(--confirm-overlay-bg-color);
}
.inner {
background-color: var(--box-bg-color);
color: var(--box-fg-color);
margin-top: 5vh;
box-shadow: var(--box-shadow);
border-radius: 0.5rem;
width: 20rem;
max-height: 90%;
overflow: auto;
max-width: 100%;
.body {
padding: 0.75rem 1rem;
}
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
.remove {
margin-right: auto;
background-color: var(--button-remove-bg-color);
color: var(--button-remove-fg-color);
}
}
}

View File

@ -20,7 +20,8 @@
--box-action-fg-color: #666; --box-action-fg-color: #666;
--box-preview-bg-color: #3988ff; --box-preview-bg-color: #3988ff;
--box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.1); --box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.1);
--modal-overlay-bg-color: rgba(0, 0, 0, 0.2); --modal-overlay-bg-color: rgba(0, 0, 0, 0.5);
--confirm-overlay-bg-color: rgba(0, 0, 0, 0.8);
--graph-axis-fg-color: #777; --graph-axis-fg-color: #777;
--graph-grid-color: rgb(238, 238, 238); --graph-grid-color: rgb(238, 238, 238);
--link-fg-color: #3988ff; --link-fg-color: #3988ff;
@ -76,7 +77,7 @@ select {
font-family: var(--main-font); font-family: var(--main-font);
} }
@import 'components/button'; @import "components/button";
input, input,
select { select {
@ -216,51 +217,7 @@ section.content {
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.settings-modal { @import "components/modal";
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
overflow: auto;
display: none;
justify-content: center;
align-items: flex-start;
background-color: var(--modal-overlay-bg-color);
z-index: 5;
}
.settings-modal.show {
display: flex;
}
.settings-modal .inner {
background-color: var(--box-bg-color);
color: var(--box-fg-color);
margin-top: 5vh;
box-shadow: var(--box-shadow);
border-radius: 0.5rem;
width: 20rem;
max-height: 90%;
overflow: auto;
max-width: 100%;
}
.settings-modal .inner .body {
padding: 0.75rem 1rem;
}
.settings-modal .actions {
display: flex;
align-items: center;
justify-content: flex-end;
}
.settings-modal .actions .remove {
margin-right: auto;
background-color: var(--button-remove-bg-color);
color: var(--button-remove-fg-color);
}
form { form {
display: flex; display: flex;

View File

@ -0,0 +1,35 @@
import { ComponentChildren } from 'preact'
import { Modal } from './Modal'
type Props = {
open: boolean
onConfirm: () => void
onCancel: () => void
confirmText?: string
cancelText?: string
children: ComponentChildren
}
export const ConfirmModal = ({
children,
confirmText = 'Yes',
cancelText = 'No',
onCancel,
onConfirm,
open,
}: Props) => {
return (
<Modal open={open} onClose={onCancel} className="confirm">
{children}
<div className="actions">
<button type="button" className="cancel" onClick={onCancel}>
{cancelText}
</button>
<button type="button" onClick={onConfirm}>
{confirmText}
</button>
</div>
</Modal>
)
}

View File

@ -5,20 +5,26 @@ type Props = {
open: boolean open: boolean
onClose: () => void onClose: () => void
children: ComponentChild children: ComponentChild
width?: string
className?: string
} }
export const Modal = ({ open, onClose, children }: Props) => { export const Modal = ({ open, onClose, children, width, className }: Props) => {
const preventPropagation = (e: Event) => { const preventPropagation = (e: Event) => {
e.stopPropagation() e.stopPropagation()
} }
return ( return (
<div className={cn('settings-modal', open && 'show')} onMouseDown={onClose}> <div
className={cn('modal', open && 'show', className)}
onMouseDown={onClose}
>
<div <div
className="inner" className="inner"
onMouseDown={preventPropagation} onMouseDown={preventPropagation}
onMouseUp={preventPropagation} onMouseUp={preventPropagation}
onClick={preventPropagation} onClick={preventPropagation}
style={{ width }}
> >
<div className="body">{children}</div> <div className="body">{children}</div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { Modal } from '@/components/Modal' import { ConfirmModal } from '@/components/ConfirmModal'
import { ComponentChild, createContext } from 'preact' import { ComponentChild, createContext } from 'preact'
import { useCallback, useContext, useMemo, useState } from 'preact/hooks' import { useCallback, useContext, useMemo, useState } from 'preact/hooks'
@ -6,31 +6,33 @@ type Props = {
children: ComponentChild children: ComponentChild
} }
type ShowModalProps = { type CreateModalProps = {
content: ComponentChild content: ComponentChild
onConfirm: () => void onConfirm: () => void
onCancel: () => void onCancel?: () => void
} }
type ShowModelResult = { type CreateModalResult = {
show: () => void show: () => void
} }
type ConfirmModalContext = { type ConfirmModalsContextType = {
createModal: (props: ShowModalProps) => ShowModelResult createModal: (props: CreateModalProps) => CreateModalResult
} }
type ModalState = { type ModalState = {
id: string id: string
props: ShowModalProps props: CreateModalProps
} }
const ConfirmModalContext = createContext<ConfirmModalContext | null>(null) const ConfirmModalsContext = createContext<ConfirmModalsContextType | null>(
null
)
export const ConfirmModalContextProvider = ({ children }: Props) => { export const ConfirmModalsContextProvider = ({ children }: Props) => {
const [modals, setModals] = useState([] as ModalState[]) const [modals, setModals] = useState([] as ModalState[])
const createModal = useCallback((props: ShowModalProps) => { const createModal = useCallback((props: CreateModalProps) => {
return { return {
show: () => show: () =>
setModals((p) => [ setModals((p) => [
@ -43,7 +45,7 @@ export const ConfirmModalContextProvider = ({ children }: Props) => {
const handleClose = (modal: ModalState) => { const handleClose = (modal: ModalState) => {
setModals((p) => p.filter((m) => m.id !== modal.id)) setModals((p) => p.filter((m) => m.id !== modal.id))
modal.props.onCancel() modal.props.onCancel?.()
} }
const handleConfirm = (modal: ModalState) => { const handleConfirm = (modal: ModalState) => {
@ -55,21 +57,24 @@ export const ConfirmModalContextProvider = ({ children }: Props) => {
const value = useMemo(() => ({ createModal }), [createModal]) const value = useMemo(() => ({ createModal }), [createModal])
return ( return (
<ConfirmModalContext.Provider value={value}> <ConfirmModalsContext.Provider value={value}>
{children} {children}
{modals.map((m) => ( {modals.map((m) => (
<Modal open key={m.id} onClose={() => handleClose(m)}> <ConfirmModal
key={m.id}
open
onCancel={() => handleClose(m)}
onConfirm={() => handleConfirm(m)}
>
{m.props.content} {m.props.content}
</ConfirmModal>
<button onClick={() => handleConfirm(m)}>OK</button>
</Modal>
))} ))}
</ConfirmModalContext.Provider> </ConfirmModalsContext.Provider>
) )
} }
export const useConfirmModalContext = () => { export const useConfirmModalsContext = () => {
const ctx = useContext(ConfirmModalContext) const ctx = useContext(ConfirmModalsContext)
if (!ctx) { if (!ctx) {
throw new Error('useConfirmModalContext used outside context') throw new Error('useConfirmModalContext used outside context')
@ -78,8 +83,8 @@ export const useConfirmModalContext = () => {
return ctx return ctx
} }
export const useConfirmModal = (modal: ShowModalProps) => { export const useConfirmModal = (modal: CreateModalProps) => {
const ctx = useConfirmModalContext() const ctx = useConfirmModalsContext()
return ctx.createModal(modal) return ctx.createModal(modal)
} }

View File

@ -1,4 +1,6 @@
import { getSensors } from '@/api/sensors' import { getSensors } from '@/api/sensors'
import { Modal } from '@/components/Modal'
import { useConfirmModal } from '@/contexts/ConfirmModalsContext'
import { DashboardDialData, DashboardGraphData } from '@/utils/parseDashboard' import { DashboardDialData, DashboardGraphData } from '@/utils/parseDashboard'
import { useState } from 'preact/hooks' import { useState } from 'preact/hooks'
import { useQuery } from 'react-query' import { useQuery } from 'react-query'
@ -24,6 +26,13 @@ export const BoxSettings = ({ value, onSave, onClose, onRemove }: Props) => {
const [data, setData] = useState(() => value.data) const [data, setData] = useState(() => value.data)
const deleteConfirm = useConfirmModal({
content: 'Are you sure you want to delete the box?',
onConfirm: () => {
onRemove()
},
})
const handleSave = async (e: Event) => { const handleSave = async (e: Event) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -47,86 +56,73 @@ export const BoxSettings = ({ value, onSave, onClose, onRemove }: Props) => {
}) })
} }
const preventPropagation = (e: Event) => {
e.stopPropagation()
}
return ( return (
<div className="settings-modal show" onMouseDown={onClose}> <Modal onClose={onClose} open>
<div <form onSubmit={handleSave}>
className="inner" <div className="input">
onMouseDown={preventPropagation} <label>Sensor</label>
onMouseUp={preventPropagation} <select
onClick={preventPropagation} name="sensor"
> value={formState.sensor || ''}
<div className="body"> onChange={handleChange}
<form onSubmit={handleSave}> >
{sensors.data?.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
{formState.sensor && (
<>
<div className="input"> <div className="input">
<label>Sensor</label> <label>Title</label>
<input
name="title"
value={formState.title}
onChange={handleChange}
/>
</div>
<div className="input">
<label>Type</label>
<select <select
name="sensor" name="type"
value={formState.sensor || ''} value={formState.type}
onChange={handleChange} onChange={handleChange}
> >
{sensors.data?.map((s) => ( <option value="graph">Graph</option>
<option key={s.id} value={s.id}> <option value="dial">Dial</option>
{s.name}
</option>
))}
</select> </select>
</div> </div>
{formState.sensor && ( {formState.type === 'graph' && (
<> <GraphSettings
<div className="input"> value={data as DashboardGraphData}
<label>Title</label> onChange={setData}
<input />
name="title"
value={formState.title}
onChange={handleChange}
/>
</div>
<div className="input">
<label>Type</label>
<select
name="type"
value={formState.type}
onChange={handleChange}
>
<option value="graph">Graph</option>
<option value="dial">Dial</option>
</select>
</div>
{formState.type === 'graph' && (
<GraphSettings
value={data as DashboardGraphData}
onChange={setData}
/>
)}
{formState.type === 'dial' && (
<DialSettings
value={data as DashboardDialData}
onChange={setData}
/>
)}
</>
)} )}
<div className="actions"> {formState.type === 'dial' && (
<button className="remove" type="button" onClick={onRemove}> <DialSettings
Remove value={data as DashboardDialData}
</button> onChange={setData}
/>
)}
</>
)}
<button className="cancel" onClick={onClose} type="button"> <div className="actions">
Cancel <button className="remove" type="button" onClick={deleteConfirm.show}>
</button> Remove
<button>Save</button> </button>
</div>
</form> <button className="cancel" onClick={onClose} type="button">
Cancel
</button>
<button>Save</button>
</div> </div>
</div> </form>
</div> </Modal>
) )
} }

View File

@ -1,111 +0,0 @@
import { setSensorConfig } from '@/api/sensor'
import { SensorInfo } from '@/api/sensors'
import { useState } from 'preact/hooks'
type Props = {
sensor: SensorInfo
onClose: () => void
onUpdate: () => void
}
export const SensorSettings = ({ sensor, onClose, onUpdate }: Props) => {
const [value, setValue] = useState(() => ({ ...sensor.config }))
const [saving, setSaving] = useState(false)
const handleSave = async (e: Event) => {
e.preventDefault()
e.stopPropagation()
if (saving) {
return
}
setSaving(true)
try {
await Promise.all(
Object.entries(value).map(([name, value]) =>
setSensorConfig({ sensor: sensor.id, name, value })
)
)
} catch (err) {
// TODO: Better error handling
alert(err)
}
setSaving(false)
onClose()
onUpdate()
}
const handleChange = (e: Event) => {
const target = e.target as HTMLSelectElement | HTMLInputElement
setValue({
...value,
[target.name]: target.value,
})
}
const preventPropagation = (e: Event) => {
e.stopPropagation()
}
return (
<div className="settings-modal show" onMouseDown={onClose}>
<div
className="inner"
onMouseDown={preventPropagation}
onMouseUp={preventPropagation}
onClick={preventPropagation}
>
<div className="body">
<form onSubmit={handleSave}>
<div className="input">
<label>Sensor</label>
<input value={sensor.id} disabled />
</div>
<div className="input">
<label>Name</label>
<input name="name" value={value.name} onChange={handleChange} />
</div>
<div className="input">
<label>Type</label>
<select
name="graphType"
value={value.graphType || 'line'}
onChange={handleChange}
>
<option value="line">Line</option>
<option value="points">Points</option>
<option value="lineAndPoints">Line + Points</option>
<option value="bar">Bar</option>
</select>
</div>
<div className="input">
<label>Unit</label>
<input name="unit" value={value.unit} onChange={handleChange} />
</div>
<div className="input">
<label>Min value</label>
<input name="min" value={value.min} onChange={handleChange} />
</div>
<div className="input">
<label>Max value</label>
<input name="max" value={value.max} onChange={handleChange} />
</div>
<div className="actions">
<button className="cancel" onClick={onClose} type="button">
Cancel
</button>
<button disabled={saving}>Save</button>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import { createSensor, SensorInfo, updateSensor } from '@/api/sensors' import { createSensor, SensorInfo, updateSensor } from '@/api/sensors'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { useState } from 'preact/hooks' import { useForm } from '@/utils/hooks/useForm'
import { useMutation, useQueryClient } from 'react-query' import { useMutation, useQueryClient } from 'react-query'
type Props = { type Props = {
@ -14,43 +14,35 @@ export const SensorFormModal = ({ open, onClose, sensor }: Props) => {
const createMutation = useMutation(createSensor) const createMutation = useMutation(createSensor)
const updateMutation = useMutation(updateSensor) const updateMutation = useMutation(updateSensor)
const [formState, setFormState] = useState(() => ({ const { value, handleSubmit, handleChange } = useForm({
name: sensor?.name ?? '', defaultValue: () => ({
})) name: sensor?.name ?? '',
}),
onSubmit: async (v) => {
if (isLoading) {
return
}
const handleSave = async (e: Event) => { if (sensor) {
e.preventDefault() await updateMutation.mutateAsync({
e.stopPropagation() id: sensor.id,
name: v.name,
})
} else {
await createMutation.mutateAsync(v.name)
}
if (isLoading) { queryClient.invalidateQueries(['/sensors'])
return
}
if (sensor) { onClose()
await updateMutation.mutateAsync({ id: sensor.id, name: formState.name }) },
} else { })
await createMutation.mutateAsync(formState.name)
}
queryClient.invalidateQueries(['/sensors'])
onClose()
}
const handleChange = (e: Event) => {
const target = e.target as HTMLSelectElement | HTMLInputElement
setFormState({
...formState,
[target.name]: target.value,
})
}
const isLoading = createMutation.isLoading || updateMutation.isLoading const isLoading = createMutation.isLoading || updateMutation.isLoading
return ( return (
<Modal onClose={onClose} open={open}> <Modal onClose={onClose} open={open}>
<form onSubmit={handleSave}> <form onSubmit={handleSubmit}>
<div className="input"> <div className="input">
<label>Name</label> <label>Name</label>
<input <input
@ -60,7 +52,7 @@ export const SensorFormModal = ({ open, onClose, sensor }: Props) => {
required required
onChange={handleChange} onChange={handleChange}
autoFocus autoFocus
value={formState.name} value={value.name}
/> />
</div> </div>

View File

@ -0,0 +1,44 @@
import { useCallback, useRef, useState } from 'preact/hooks'
type Props<TValue> = {
onSubmit: (value: TValue) => void
defaultValue: TValue | (() => TValue)
}
export const useForm = <TValue = unknown>({
defaultValue,
onSubmit,
}: Props<TValue>) => {
const [formState, setFormState] = useState(defaultValue)
const submitRef = useRef(onSubmit)
const stateRef = useRef(formState)
submitRef.current = onSubmit
stateRef.current = formState
const handleChange = useCallback((e: Event) => {
const target = e.target as HTMLSelectElement | HTMLInputElement
setFormState(
(previous) =>
({
...previous,
[target.name]: target.value,
} as TValue)
)
}, [])
const handleSubmit = useCallback((e: Event) => {
e.preventDefault()
e.stopPropagation()
submitRef.current(stateRef.current)
}, [])
return {
value: formState,
handleChange,
handleSubmit,
}
}

View File

@ -1,13 +1,13 @@
/* Add rows for sensors */ /* Add rows for sensors */
INSERT INTO sensors (ident, name, auth_key) INSERT INTO sensors (ident, name, auth_key)
SELECT SELECT
sensor, vals.sensor,
IFNULL( IFNULL(
(SELECT c.value FROM sensor_config c WHERE c.sensor = sensor), (SELECT c.value FROM sensor_config c WHERE c.sensor = vals.sensor AND c.key = 'name'),
sensor sensor
) as "name", ) as "name",
hex(randomblob(32)) as "auth_key" hex(randomblob(32)) as "auth_key"
FROM sensor_values FROM sensor_values vals
GROUP BY sensor; GROUP BY sensor;
/* We need to add FK key and the only way is to create new table */ /* We need to add FK key and the only way is to create new table */