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
Release / release (push) Failing after 1m18s
This commit is contained in:
commit
4505b37abc
30 files changed
+4826
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
@@ -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} />)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Vendored
+2
@@ -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' {}
|
||||
Reference in new issue
Block a user