feature: sk-react-toolkit holds the server helpers, web components and Vite font plugin shared by manage-ui and sql-query-webui.
Release / release (push) Failing after 1m18s

This commit is contained in:
slawek committed 2026-10-28 19:08:10 +01:00
commit 4505b37abc
30 files changed
+4826

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
import { Box, Paper, Typography } from '@mui/material'
/** A centered panel with a title and a message, for states before the application can render. */
export default function Card(props: { title: string; message: string }) {
return (
<Box sx={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', p: 3 }}>
<Paper sx={{ maxWidth: 480, p: 3 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{props.title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-line' }}>
{props.message}
</Typography>
</Paper>
</Box>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack } from '@mui/material'
import { useState, type FormEvent, type ReactNode } from 'react'
import { ApiError, type Validation } from './api.ts'
import { useValidation } from './useValidation.ts'
/**
* A dialog that edits one row. The server validates the value as it changes (validatePath); onSave writes it and
* the dialog closes when it succeeds. fields renders the inputs from the value, a setter and the problems by field;
* below shows extra information from the validation, such as subnet placements.
*/
export default function RowDialog<T, V extends Validation = Validation>(props: {
title: string
initial: T
validatePath: string
saveLabel: string
onSave: (value: T) => Promise<void>
onCancel: () => void
fields: (value: T, set: (next: T) => void, problems: Record<string, string>) => ReactNode
below?: (result: V | null) => ReactNode
}) {
const [value, setValue] = useState<T>(props.initial)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const validation = useValidation<V>(props.validatePath, value)
const problems = validation.result?.problems ?? {}
const message = validation.result?.message ?? ''
const blocked = validation.pending || Object.keys(problems).length > 0 || message !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(value)
} catch (caught) {
const failure = caught as ApiError
setError([failure.message, failure.detail].filter(Boolean).join('\n'))
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{props.title}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
{validation.error && <Alert severity="error">{validation.error}</Alert>}
{message && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{message}</Alert>}
{props.fields(value, setValue, problems)}
{props.below?.(validation.result)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>
{props.saveLabel}
</Button>
</DialogActions>
</Dialog>
)
}
+59
View File
@@ -0,0 +1,59 @@
import ArrowDropDown from '@mui/icons-material/ArrowDropDown'
import { Button, ListItemIcon, ListItemText, Menu, MenuItem, Typography } from '@mui/material'
import { useState, type ReactNode } from 'react'
import { MONOSPACED } from './fonts.ts'
/**
* An item of a SwitcherMenu: its key, the label and icon of the button and the menu, and optionally a second line in
* the Monospaced font, a hint at the right end, such as the key that opens it, and whether it can be chosen.
*/
export type SwitcherItem<K> = {
key: K
label: string
icon: ReactNode
secondary?: string
hint?: string
disabled?: boolean
}
/**
* A button naming the item chosen, current, with its icon, whose menu chooses another with onChoose. title is the
* button's tooltip.
*/
export default function SwitcherMenu<K>(props: { items: SwitcherItem<K>[]; current: SwitcherItem<K>; title?: string; onChoose: (key: K) => void }) {
const { items, current } = props
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
return (
<>
<Button
variant="text"
color="inherit"
startIcon={current.icon}
endIcon={<ArrowDropDown />}
aria-haspopup="menu"
aria-expanded={anchor !== null}
title={props.title}
onClick={(event) => setAnchor(event.currentTarget)}
>
{current.label}
</Button>
<Menu anchorEl={anchor} open={anchor !== null} onClose={() => setAnchor(null)}>
{items.map((item, index) => (
<MenuItem
key={index}
selected={item.key === current.key}
disabled={item.disabled}
onClick={() => {
setAnchor(null)
props.onChoose(item.key)
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} secondary={item.secondary} slotProps={{ secondary: { sx: MONOSPACED } }} />
{item.hint !== undefined && <Typography variant="body2" color="text.secondary" sx={{ ml: 3 }}>{item.hint}</Typography>}
</MenuItem>
))}
</Menu>
</>
)
}
+72
View File
@@ -0,0 +1,72 @@
import Clear from '@mui/icons-material/Clear'
import Visibility from '@mui/icons-material/Visibility'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import { IconButton, InputAdornment, TextField } from '@mui/material'
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { verbatim } from './fonts.ts'
/** Keeps the focus in the input when one of its buttons is pressed. */
function keepFocus(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault()
}
/**
* A text field with a clear button, a show/hide button for secrets, and several lines for 'lines'. The browser
* may not change what is typed (no spell check, capitalisation or completion). A small field shows no label; the
* label remains its accessible name.
*/
export default function TextInput(props: {
label: string
value: string
kind?: 'text' | 'secret' | 'lines'
problem?: string
help?: string
disabled?: boolean
autoFocus?: boolean
start?: ReactNode
size?: 'small' | 'medium'
onChange: (value: string) => void
onBlur?: () => void
onKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void
}) {
const { label, value, kind = 'text', problem, help, disabled = false, onChange } = props
const [shown, setShown] = useState(false)
return (
<TextField
label={props.size === 'small' ? undefined : label}
value={value}
size={props.size ?? (kind === 'lines' ? 'medium' : undefined)}
fullWidth
onBlur={props.onBlur}
onKeyDown={props.onKeyDown}
autoFocus={props.autoFocus}
disabled={disabled}
error={problem !== undefined}
helperText={props.size === 'small' ? problem : problem || help || ' '}
multiline={kind === 'lines'}
minRows={kind === 'lines' ? 2 : undefined}
type={kind === 'secret' && !shown ? 'password' : 'text'}
onChange={(event) => onChange(event.target.value)}
slotProps={{
htmlInput: { ...verbatim, 'aria-label': label },
input: {
startAdornment: props.start === undefined ? undefined : <InputAdornment position="start">{props.start}</InputAdornment>,
endAdornment: (
<InputAdornment position="end">
{kind === 'secret' && (
<IconButton aria-label={shown ? `hide ${label}` : `show ${label}`} disabled={disabled} onClick={() => setShown(!shown)} onMouseDown={keepFocus} onMouseUp={keepFocus}>
{shown ? <VisibilityOff /> : <Visibility />}
</IconButton>
)}
{value !== '' && (
<IconButton aria-label={`clear ${label}`} disabled={disabled} edge="end" onClick={() => onChange('')} onMouseDown={keepFocus} onMouseUp={keepFocus}>
<Clear />
</IconButton>
)}
</InputAdornment>
),
},
}}
/>
)
}
+26
View File
@@ -0,0 +1,26 @@
import DarkMode from '@mui/icons-material/DarkMode'
import LightMode from '@mui/icons-material/LightMode'
import SettingsBrightness from '@mui/icons-material/SettingsBrightness'
import { IconButton, Tooltip, useColorScheme } from '@mui/material'
import type { ReactNode } from 'react'
type Mode = 'light' | 'dark' | 'system'
const NEXT: Record<Mode, Mode> = { light: 'dark', dark: 'system', system: 'light' }
const ICONS: Record<Mode, ReactNode> = { light: <LightMode />, dark: <DarkMode />, system: <SettingsBrightness /> }
/** An icon button cycling light, dark and system theme. MUI stores the choice; mode is undefined on the first render. */
export default function ThemeMenu() {
const { mode, setMode } = useColorScheme()
if (!mode) {
return null
}
return (
<Tooltip title={`Theme: ${mode}. Switch to ${NEXT[mode]}.`}>
<IconButton aria-label={`theme ${mode}, switch to ${NEXT[mode]}`} onClick={() => setMode(NEXT[mode])}>
{ICONS[mode]}
</IconButton>
</Tooltip>
)
}
+36
View File
@@ -0,0 +1,36 @@
/**
* The answer of a validate endpoint: problems maps a field of the validated value to a message; message describes what
* the value would break elsewhere, or is empty.
*/
export type Validation = {
problems: Record<string, string>
message: string
}
/** A failed call: the server's message and status, with the problems and message of a refused write (422). */
export class ApiError extends Error {
status: number
problems: Record<string, string>
detail: string
constructor(message: string, status: number, problems: Record<string, string> = {}, detail = '') {
super(message)
this.status = status
this.problems = problems
this.detail = detail
}
}
/** Calls the backend and returns the decoded JSON; a failed call throws ApiError with the backend's message. */
export async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await fetch(path, {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string; problems?: Record<string, string>; message?: string } | null
throw new ApiError(payload?.error ?? `${method} ${path} failed (${response.status}).`, response.status, payload?.problems, payload?.message)
}
return (await response.json()) as T
}
+32
View File
@@ -0,0 +1,32 @@
import { useSyncExternalStore } from 'react'
/**
* The switches a browser keeps for an application, each off until set: keys maps each setting to its localStorage key.
* browserSetting reads one, setBrowserSetting keeps it and tells every user of useBrowserSetting, which follows it as
* it changes in this tab or in another tab of the browser. event names the event a change dispatches on window.
*/
export function createBrowserSettings<K extends string>(keys: Record<K, string>, event: string) {
function browserSetting(setting: K) {
return window.localStorage.getItem(keys[setting]) === 'on'
}
function setBrowserSetting(setting: K, enabled: boolean) {
window.localStorage.setItem(keys[setting], enabled ? 'on' : 'off')
window.dispatchEvent(new Event(event))
}
function subscribe(changed: () => void) {
window.addEventListener(event, changed)
window.addEventListener('storage', changed)
return () => {
window.removeEventListener(event, changed)
window.removeEventListener('storage', changed)
}
}
function useBrowserSetting(setting: K) {
return useSyncExternalStore(subscribe, () => browserSetting(setting))
}
return { browserSetting, setBrowserSetting, useBrowserSetting }
}
+13
View File
@@ -0,0 +1,13 @@
/**
* The two font styles of the page: Regular, the theme's font of every element, and Monospaced, at 90% of the size of
* the surrounding text, for values read character by character, such as IDs, addresses, names of variables, code and
* the output of tools.
*/
export const REGULAR = '"Roboto", "Helvetica", "Arial", sans-serif'
export const MONOSPACED = {
fontFamily: '"Roboto Mono", ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: '90%',
}
/** Input attributes for values that must not be changed by the browser, shown in the Monospaced font. */
export const verbatim = { spellCheck: false, autoCorrect: 'off', autoCapitalize: 'none', autoComplete: 'off', style: MONOSPACED }
+10
View File
@@ -0,0 +1,10 @@
export { ApiError, request, type Validation } from './api.ts'
export { createBrowserSettings } from './browserSettings.ts'
export { default as Card } from './Card.tsx'
export { MONOSPACED, REGULAR, verbatim } from './fonts.ts'
export { appTheme, renderApp } from './renderApp.tsx'
export { default as RowDialog } from './RowDialog.tsx'
export { default as SwitcherMenu, type SwitcherItem } from './SwitcherMenu.tsx'
export { default as TextInput } from './TextInput.tsx'
export { default as ThemeMenu } from './ThemeMenu.tsx'
export { useValidation } from './useValidation.ts'
+52
View File
@@ -0,0 +1,52 @@
/// <reference path="./virtual-font.d.ts" />
import 'virtual:font'
import { CssBaseline, ThemeProvider, createTheme, type ThemeOptions } from '@mui/material'
import { StrictMode, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import Card from './Card.tsx'
import { REGULAR } from './fonts.ts'
/**
* The theme of the applications: light, dark and system modes, the choice stored in localStorage by MUI, the Regular
* font on every element and text fields of the small size. colorSchemes replaces the plain light and dark schemes,
* such as to add palette entries of a component.
*/
export function appTheme(colorSchemes: ThemeOptions['colorSchemes'] = { dark: true }) {
return createTheme({
colorSchemes,
cssVariables: { colorSchemeSelector: 'class' },
typography: { fontFamily: REGULAR },
components: { MuiTextField: { defaultProps: { size: 'small' } } },
})
}
/**
* Renders the application into #root with the theme and the fonts of the font plugin: app with what load answers,
* typically the backend's information. load is tried up to 20 times, 500 ms apart, showing Connecting, and then Not
* available with the last error.
*/
export async function renderApp<T>(options: { theme: ReturnType<typeof createTheme>; load: () => Promise<T>; app: (loaded: T) => ReactNode }) {
const root = createRoot(document.getElementById('root')!)
const render = (node: ReactNode) => {
root.render(
<StrictMode>
<ThemeProvider theme={options.theme}>
<CssBaseline />
{node}
</ThemeProvider>
</StrictMode>,
)
}
let lastError = ''
for (let attempt = 0; attempt < 20; attempt++) {
try {
render(options.app(await options.load()))
return
} catch (error) {
lastError = (error as Error).message
render(<Card title="Connecting" message="Waiting for the backend to answer." />)
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
render(<Card title="Not available" message={lastError} />)
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import { request, type Validation } from './api.ts'
/**
* Asks the server to validate value at path (a .../validate endpoint) 250 ms after it last
* changed. pending is true until the answer for the current value arrives; until then the previous result stays.
*/
export function useValidation<T extends Validation>(path: string | null, value: unknown): { result: T | null; error: string; pending: boolean } {
const key = path === null ? '' : `${path}\n${JSON.stringify(value)}`
const [answer, setAnswer] = useState<{ key: string; result: T | null; error: string } | null>(null)
useEffect(() => {
if (key === '') {
return
}
const [target, body] = [key.slice(0, key.indexOf('\n')), JSON.parse(key.slice(key.indexOf('\n') + 1))]
const timer = window.setTimeout(() => {
request<T>('POST', target, { value: body })
.then((result) => setAnswer({ key, result, error: '' }))
.catch((caught: Error) => setAnswer({ key, result: null, error: caught.message }))
}, 250)
return () => window.clearTimeout(timer)
}, [key])
return { result: answer?.result ?? null, error: answer?.error ?? '', pending: key !== '' && answer?.key !== key }
}
+2
View File
@@ -0,0 +1,2 @@
// The module the font plugin of sk-react-toolkit/vite provides; it holds the font imports VITE_FONT selects.
declare module 'virtual:font' {}