Add base UI helpers

This commit is contained in:
Mustafa Gezen 2023-08-25 18:41:26 +02:00
parent beb158d003
commit d20f5b967e
6 changed files with 516 additions and 0 deletions

11
base/ts/global.d.ts vendored Normal file
View 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
View 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>
</>
);
};

View 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>
</>
);
}

View 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
View 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
View file

@ -0,0 +1,9 @@
export interface StandardResource {
name?: string;
[key: string]: any;
}
export interface ResourceListResponse {
nextPageToken?: string;
}