diff --git a/base/ts/global.d.ts b/base/ts/global.d.ts new file mode 100644 index 00000000..17e022db --- /dev/null +++ b/base/ts/global.d.ts @@ -0,0 +1,11 @@ +export interface PeridotUser { + sub: string; + email: string; +} + +declare global { + interface Window { + __peridot_prefix__: string; + __peridot_user__: PeridotUser; + } +} diff --git a/base/ts/mui/NewResource.tsx b/base/ts/mui/NewResource.tsx new file mode 100644 index 00000000..db006305 --- /dev/null +++ b/base/ts/mui/NewResource.tsx @@ -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 { + fields: NewResourceField[]; + + save(x: T): Promise; + + // 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 = ( + props: NewResourceProps, +) => { + const navigate = useNavigate(); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(undefined); + const [res, setRes] = React.useState(undefined); + + const onSubmit = async (event: React.FormEvent) => { + 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 && ( + + {props.showDialog(res)} + + + + + )} + + {error && ( + + {error} + + )} + {props.fields.map((field: NewResourceField) => ( + + + {field.subtitle && ( + {field.subtitle} + )} + + ))} + + Save + + + + ); +}; diff --git a/base/ts/mui/ResourceTable.tsx b/base/ts/mui/ResourceTable.tsx new file mode 100644 index 00000000..7b970b80 --- /dev/null +++ b/base/ts/mui/ResourceTable.tsx @@ -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 { + fields: ResourceTableField[]; + + // load is usually the OpenAPI SDK function that loads the resource. + load(pageSize: number, pageToken?: string): Promise; + + // 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( + props: ResourceTableProps, +) { + 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(pt); + const [nextPageToken, setNextPageToken] = React.useState( + undefined, + ); + const [pageTokenHistory, setPageTokenHistory] = + React.useState(initPageTokenHistory); + const [rowsPerPage, setRowsPerPage] = React.useState(initRowsPerPage); + const [rows, setRows] = React.useState(undefined); + const [loading, setLoading] = React.useState(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 {field.label}; + }); + + // Create table body + const body = rows?.map((row) => ( + + {props.fields.map((field) => { + return ( + + {field.key === 'name' ? ( + {row.name} + ) : ( + <>{row[field.key] ? row[field.key].toString() : '--'} + )} + + ); + })} + + )); + + // 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 ( + <> + + {loading && } + + + {header} + + {rows && rows.length > 0 ? ( + {body} + ) : ( + + + No results found. + + + )} +
+
+ + + + {loading ? ( + Loading... + ) : ( + Page {pageTokenHistory.length + 1} + )} + + + + Page size + + + + + + + ); +} diff --git a/base/ts/mui/ResourceView.tsx b/base/ts/mui/ResourceView.tsx new file mode 100644 index 00000000..2cb1e1b9 --- /dev/null +++ b/base/ts/mui/ResourceView.tsx @@ -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 { + // null means error, undefined means loading + resource: T | null | undefined; + fields: ResourceViewField[]; +} + +export function ResourceView(props: ResourceViewProps) { + return ( + + {props.resource === undefined && } + {props.resource === null && ( + Error loading resource + )} + {props.resource && ( + + {props.fields.map((field: ResourceViewField) => ( + + {field.label} + + {(props.resource as any)[field.key]?.toString() || '--'} + + + ))} + + )} + + ); +} diff --git a/base/ts/reqap.ts b/base/ts/reqap.ts new file mode 100644 index 00000000..a3a2f162 --- /dev/null +++ b/base/ts/reqap.ts @@ -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( + run: Promise +): Promise<[T | null, U | null]> { + const [err, res] = await to( + 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]; +} diff --git a/base/ts/resource.ts b/base/ts/resource.ts new file mode 100644 index 00000000..873d0c64 --- /dev/null +++ b/base/ts/resource.ts @@ -0,0 +1,9 @@ +export interface StandardResource { + name?: string; + + [key: string]: any; +} + +export interface ResourceListResponse { + nextPageToken?: string; +}