mirror of
https://github.com/peridotbuild/peridot.git
synced 2024-12-26 20:20:55 +00:00
Add base UI helpers
This commit is contained in:
parent
beb158d003
commit
d20f5b967e
6 changed files with 516 additions and 0 deletions
11
base/ts/global.d.ts
vendored
Normal file
11
base/ts/global.d.ts
vendored
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export interface PeridotUser {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__peridot_prefix__: string;
|
||||||
|
__peridot_user__: PeridotUser;
|
||||||
|
}
|
||||||
|
}
|
132
base/ts/mui/NewResource.tsx
Normal file
132
base/ts/mui/NewResource.tsx
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import FormControl from '@mui/material/FormControl';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import FormHelperText from '@mui/material/FormHelperText';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
|
||||||
|
import LoadingButton from '@mui/lab/LoadingButton';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
|
||||||
|
import { StandardResource } from '../resource';
|
||||||
|
|
||||||
|
export interface NewResourceField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'number' | 'date';
|
||||||
|
subtitle?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewResourceProps<T> {
|
||||||
|
fields: NewResourceField[];
|
||||||
|
|
||||||
|
save(x: T): Promise<any>;
|
||||||
|
|
||||||
|
// show a dialog before redirecting to the resource page
|
||||||
|
// the caller gets the resource as a parameter and can
|
||||||
|
// return a React node to show in the modal
|
||||||
|
showDialog?(resource: T): React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewResource = <T extends StandardResource>(
|
||||||
|
props: NewResourceProps<T>,
|
||||||
|
) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = React.useState<boolean>(false);
|
||||||
|
const [error, setError] = React.useState<string | undefined>(undefined);
|
||||||
|
const [res, setRes] = React.useState<T | undefined>(undefined);
|
||||||
|
|
||||||
|
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
setError(undefined);
|
||||||
|
setLoading(true);
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
const obj = Object.fromEntries(data.entries());
|
||||||
|
const [res, err] = await props.save(obj as T);
|
||||||
|
setLoading(false);
|
||||||
|
if (err) {
|
||||||
|
console.log(err);
|
||||||
|
setError(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
setError('Unknown error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
if (props.showDialog) {
|
||||||
|
setRes(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// res should always have "name" so we can redirect to the resource page.
|
||||||
|
navigate(`/${res.name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToResource = () => {
|
||||||
|
if (!res) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(`/${res.name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.showDialog && res && (
|
||||||
|
<Dialog
|
||||||
|
disableEscapeKeyDown
|
||||||
|
disableScrollLock
|
||||||
|
open>
|
||||||
|
{props.showDialog(res)}
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={navigateToResource}>
|
||||||
|
Go to resource
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
<Box component="form" onSubmit={onSubmit}>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 4 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{props.fields.map((field: NewResourceField) => (
|
||||||
|
<FormControl key={field.key} sx={{ width: '100%', display: 'block' }}>
|
||||||
|
<TextField
|
||||||
|
name={field.key}
|
||||||
|
sx={{ width: '100%' }}
|
||||||
|
size="small"
|
||||||
|
required={field.required}
|
||||||
|
label={field.label}
|
||||||
|
type={field.type}
|
||||||
|
/>
|
||||||
|
{field.subtitle && (
|
||||||
|
<FormHelperText>{field.subtitle}</FormHelperText>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
))}
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
disabled={loading}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<span>Save</span>
|
||||||
|
</LoadingButton>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
269
base/ts/mui/ResourceTable.tsx
Normal file
269
base/ts/mui/ResourceTable.tsx
Normal file
|
@ -0,0 +1,269 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
import TableContainer from '@mui/material/TableContainer';
|
||||||
|
import TableHead from '@mui/material/TableHead';
|
||||||
|
import Table from '@mui/material/Table';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import TableBody from '@mui/material/TableBody';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import LinearProgress from '@mui/material/LinearProgress';
|
||||||
|
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import FormControl from '@mui/material/FormControl';
|
||||||
|
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
|
|
||||||
|
import { StandardResource } from '../resource';
|
||||||
|
|
||||||
|
export interface ResourceTableField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceTableProps<T> {
|
||||||
|
fields: ResourceTableField[];
|
||||||
|
|
||||||
|
// load is usually the OpenAPI SDK function that loads the resource.
|
||||||
|
load(pageSize: number, pageToken?: string): Promise<any>;
|
||||||
|
|
||||||
|
// transform can be used to transform the response from the load function.
|
||||||
|
// usually for List functions, the response is usually wrapped in a
|
||||||
|
// ListResponse object, so this function can be used to extract the list
|
||||||
|
// from the response.
|
||||||
|
transform?(response: { [key: string]: T }): T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceTable<T extends StandardResource>(
|
||||||
|
props: ResourceTableProps<T>,
|
||||||
|
) {
|
||||||
|
const allowedPageSizes = [1, 25, 50, 100, 500, 1000];
|
||||||
|
|
||||||
|
// State for pagination
|
||||||
|
// We can use query parameters to store the page token, as well as the
|
||||||
|
// page size and history of page tokens.
|
||||||
|
const [search, setSearch] = useSearchParams();
|
||||||
|
const pt = search.get('pt') || undefined;
|
||||||
|
|
||||||
|
// The page token history is base64 encoded, so we need to decode it
|
||||||
|
// (if it exists of course) and then parse it as JSON.
|
||||||
|
// It should be an array of strings.
|
||||||
|
const pth = search.get('pth');
|
||||||
|
let initPageTokenHistory: string[] = [];
|
||||||
|
if (pth) {
|
||||||
|
initPageTokenHistory = JSON.parse(atob(pth));
|
||||||
|
}
|
||||||
|
|
||||||
|
let initRowsPerPage = parseInt(search.get('rpp') || '25') || 25;
|
||||||
|
if (!allowedPageSizes.includes(initRowsPerPage)) {
|
||||||
|
initRowsPerPage = 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [pageToken, setPageToken] = React.useState<string | undefined>(pt);
|
||||||
|
const [nextPageToken, setNextPageToken] = React.useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const [pageTokenHistory, setPageTokenHistory] =
|
||||||
|
React.useState<string[]>(initPageTokenHistory);
|
||||||
|
const [rowsPerPage, setRowsPerPage] = React.useState<number>(initRowsPerPage);
|
||||||
|
const [rows, setRows] = React.useState<T[] | undefined>(undefined);
|
||||||
|
const [loading, setLoading] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
// Update the query parameters when any of the pagination state changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
const search = new URLSearchParams(location.search);
|
||||||
|
if (pageToken) {
|
||||||
|
search.set('pt', pageToken);
|
||||||
|
} else {
|
||||||
|
search.delete('pt');
|
||||||
|
}
|
||||||
|
search.set('rpp', rowsPerPage.toString());
|
||||||
|
if (pageTokenHistory.length > 0) {
|
||||||
|
search.set('pth', btoa(JSON.stringify(pageTokenHistory)));
|
||||||
|
} else {
|
||||||
|
search.delete('pth');
|
||||||
|
}
|
||||||
|
setSearch(search);
|
||||||
|
}, [pageToken, pageTokenHistory]);
|
||||||
|
|
||||||
|
// Rows per page changing means we need to reset the page token history
|
||||||
|
React.useEffect(() => {
|
||||||
|
const search = new URLSearchParams(location.search);
|
||||||
|
search.set('rpp', rowsPerPage.toString());
|
||||||
|
setSearch(search);
|
||||||
|
setPageTokenHistory([]);
|
||||||
|
setPageToken(undefined);
|
||||||
|
}, [rowsPerPage]);
|
||||||
|
|
||||||
|
// Load the resource using useEffect
|
||||||
|
React.useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const [res, err] = await props.load(rowsPerPage, pageToken);
|
||||||
|
setLoading(false);
|
||||||
|
if (err) {
|
||||||
|
console.log(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNextPageToken(res.nextPageToken);
|
||||||
|
|
||||||
|
if (props.transform) {
|
||||||
|
setRows(props.transform(res));
|
||||||
|
} else {
|
||||||
|
setRows(res);
|
||||||
|
}
|
||||||
|
})().then();
|
||||||
|
}, [pageToken, rowsPerPage]);
|
||||||
|
|
||||||
|
// Create table header
|
||||||
|
const header = props.fields.map((field) => {
|
||||||
|
return <TableCell key={field.key}>{field.label}</TableCell>;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create table body
|
||||||
|
const body = rows?.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.name}
|
||||||
|
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||||
|
>
|
||||||
|
{props.fields.map((field) => {
|
||||||
|
return (
|
||||||
|
<TableCell key={field.key}>
|
||||||
|
{field.key === 'name' ? (
|
||||||
|
<Link to={`/${row.name}`}>{row.name}</Link>
|
||||||
|
) : (
|
||||||
|
<>{row[field.key] ? row[field.key].toString() : '--'}</>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
));
|
||||||
|
|
||||||
|
// Should show previous button if history is not empty
|
||||||
|
let showPrevious = false;
|
||||||
|
if (pageToken) {
|
||||||
|
showPrevious = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should show next button if next page token is not empty
|
||||||
|
let showNext = false;
|
||||||
|
if (nextPageToken) {
|
||||||
|
showNext = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If loading disable both buttons
|
||||||
|
if (loading) {
|
||||||
|
showPrevious = false;
|
||||||
|
showNext = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle previous page button
|
||||||
|
const handlePreviousPage = () => {
|
||||||
|
if (pageTokenHistory.length > 0) {
|
||||||
|
const newPageTokenHistory = [...pageTokenHistory];
|
||||||
|
const prevPageToken = newPageTokenHistory.pop();
|
||||||
|
setPageTokenHistory(newPageTokenHistory);
|
||||||
|
setPageToken(prevPageToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the history is empty, then we this probably means this is page 2
|
||||||
|
// and we should clear the page token.
|
||||||
|
if (pageTokenHistory.length === 0) {
|
||||||
|
setPageToken(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle next page button
|
||||||
|
const handleNextPage = () => {
|
||||||
|
if (nextPageToken) {
|
||||||
|
if (pageToken) {
|
||||||
|
setPageTokenHistory([...pageTokenHistory, pageToken]);
|
||||||
|
}
|
||||||
|
setPageToken(nextPageToken);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableContainer component={Paper} elevation={2}>
|
||||||
|
{loading && <LinearProgress />}
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>{header}</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
{rows && rows.length > 0 ? (
|
||||||
|
<TableBody>{body}</TableBody>
|
||||||
|
) : (
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={header.length}>No results found.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
mt: 2,
|
||||||
|
justifyContent: 'justify-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
sx={{ display: 'flex' }}
|
||||||
|
startIcon={<ArrowBackIcon />}
|
||||||
|
disabled={!showPrevious}
|
||||||
|
onClick={handlePreviousPage}
|
||||||
|
>
|
||||||
|
Previous page
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ flexGrow: 1, textAlign: 'center' }}>
|
||||||
|
{loading ? (
|
||||||
|
<span>Loading...</span>
|
||||||
|
) : (
|
||||||
|
<span>Page {pageTokenHistory.length + 1}</span>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', ml: 'auto' }}>
|
||||||
|
<FormControl
|
||||||
|
sx={{ m: 1, minWidth: 120 }}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<InputLabel id="page-size-label">Page size</InputLabel>
|
||||||
|
<Select
|
||||||
|
id="page-size"
|
||||||
|
labelId="page-size-label"
|
||||||
|
label="Page size"
|
||||||
|
value={rowsPerPage.toString()}
|
||||||
|
onChange={(event: SelectChangeEvent) =>
|
||||||
|
setRowsPerPage(parseInt(event.target.value))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{allowedPageSizes.map((pageSize) => (
|
||||||
|
<MenuItem key={pageSize.toString()} value={pageSize.toString()}>
|
||||||
|
{pageSize}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
endIcon={<ArrowForwardIcon />}
|
||||||
|
disabled={!showNext}
|
||||||
|
onClick={handleNextPage}
|
||||||
|
>
|
||||||
|
Next page
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
41
base/ts/mui/ResourceView.tsx
Normal file
41
base/ts/mui/ResourceView.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import LinearProgress from '@mui/material/LinearProgress';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
export interface ResourceViewField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type?: 'text' | 'number' | 'date';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceViewProps<T> {
|
||||||
|
// null means error, undefined means loading
|
||||||
|
resource: T | null | undefined;
|
||||||
|
fields: ResourceViewField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceView<T>(props: ResourceViewProps<T>) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{props.resource === undefined && <LinearProgress />}
|
||||||
|
{props.resource === null && (
|
||||||
|
<Alert severity="error">Error loading resource</Alert>
|
||||||
|
)}
|
||||||
|
{props.resource && (
|
||||||
|
<Paper elevation={2} sx={{ px: 2, py: 1 }}>
|
||||||
|
{props.fields.map((field: ResourceViewField) => (
|
||||||
|
<Box sx={{ my: 2.5 }}>
|
||||||
|
<Typography variant="subtitle2">{field.label}</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{(props.resource as any)[field.key]?.toString() || '--'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
54
base/ts/reqap.ts
Normal file
54
base/ts/reqap.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import to from 'await-to-js';
|
||||||
|
|
||||||
|
export interface GrpcError {
|
||||||
|
code: number;
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
details: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reqap<T, U = GrpcError>(
|
||||||
|
run: Promise<T>
|
||||||
|
): Promise<[T | null, U | null]> {
|
||||||
|
const [err, res] = await to<T | void | undefined, U>(
|
||||||
|
run
|
||||||
|
.catch((e) => {
|
||||||
|
const res = e.response;
|
||||||
|
|
||||||
|
const invalidStatuses = [302, 401, 403];
|
||||||
|
|
||||||
|
if (invalidStatuses.includes(res.status) || res.type === 'opaqueredirect') {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw res.json();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
const finalErr: any = await err;
|
||||||
|
// Loop over details and check if there is a detail with type
|
||||||
|
// `type.googleapis.com/google.rpc.LocalizedMessage`.
|
||||||
|
// If locale also matches, then override root message.
|
||||||
|
if (finalErr.details) {
|
||||||
|
for (const detail of finalErr.details) {
|
||||||
|
console.log(detail);
|
||||||
|
if (detail['@type'] === 'type.googleapis.com/google.rpc.LocalizedMessage') {
|
||||||
|
if (detail.locale === 'en-US') {
|
||||||
|
finalErr.message = detail.message;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [null, finalErr];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
return [res, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [null, new Error('Unknown error') as any];
|
||||||
|
}
|
9
base/ts/resource.ts
Normal file
9
base/ts/resource.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export interface StandardResource {
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceListResponse {
|
||||||
|
nextPageToken?: string;
|
||||||
|
}
|
Loading…
Reference in a new issue