mirror of
https://github.com/peridotbuild/peridot.git
synced 2024-12-09 21:06:25 +00:00
Add base UI helpers
This commit is contained in:
parent
beb158d003
commit
d20f5b967e
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 New Issue
Block a user