Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First pass at creating queries+APIs [WIP] #396

Merged
merged 16 commits into from
May 19, 2022
4 changes: 4 additions & 0 deletions packages/toolpad-app/pages/api/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getApps,
createApp,
execApi,
execQuery,
dataSourceFetchPrivate,
loadDom,
saveDom,
Expand Down Expand Up @@ -115,6 +116,9 @@ const rpcServer = {
execApi: createMethod<typeof execApi>((args) => {
return execApi(...args);
}),
execQuery: createMethod<typeof execQuery>((args) => {
return execQuery(...args);
}),
getReleases: createMethod<typeof getReleases>((params) => {
return getReleases(...params);
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "DomNodeType" ADD VALUE 'query';
1 change: 1 addition & 0 deletions packages/toolpad-app/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum DomNodeType {
codeComponent
derivedState
queryState
query
}

model App {
Expand Down
35 changes: 35 additions & 0 deletions packages/toolpad-app/src/appDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type AppDomNodeType =
| 'element'
| 'codeComponent'
| 'derivedState'
| 'query'
| 'queryState';

interface AppDomNodeBase {
Expand Down Expand Up @@ -131,6 +132,20 @@ export interface QueryStateNode<P = any> extends AppDomNodeBase {
readonly params?: BindableAttrValues<P>;
}

export interface QueryNode<Q = any, P = any> extends AppDomNodeBase {
readonly type: 'query';
readonly params?: BindableAttrValues<P>;
readonly attributes: {
readonly dataSource?: ConstantAttrValue<string>;
readonly connectionId: ConstantAttrValue<NodeId>;
readonly query: ConstantAttrValue<Q>;

readonly refetchOnWindowFocus?: ConstantAttrValue<boolean>;
readonly refetchOnReconnect?: ConstantAttrValue<boolean>;
readonly refetchInterval?: ConstantAttrValue<number>;
};
}

type AppDomNodeOfType<K extends AppDomNodeType> = {
app: AppNode;
connection: ConnectionNode;
Expand All @@ -141,6 +156,7 @@ type AppDomNodeOfType<K extends AppDomNodeType> = {
codeComponent: CodeComponentNode;
derivedState: DerivedStateNode;
queryState: QueryStateNode;
query: QueryNode;
}[K];

type AllowedChildren = {
Expand All @@ -158,13 +174,15 @@ type AllowedChildren = {
children: 'element';
derivedStates: 'derivedState';
queryStates: 'queryState';
queries: 'query';
};
element: {
[prop: string]: 'element';
};
codeComponent: {};
derivedState: {};
queryState: {};
query: {};
};

export type AppDomNode = AppDomNodeOfType<AppDomNodeType>;
Expand Down Expand Up @@ -349,6 +367,14 @@ export function assertIsQueryState<P>(node: AppDomNode): asserts node is QuerySt
assertIsType<QueryStateNode>(node, 'queryState');
}

export function isQuery<P>(node: AppDomNode): node is QueryNode<P> {
return isType<QueryNode>(node, 'query');
}

export function assertIsQuery<P>(node: AppDomNode): asserts node is QueryNode<P> {
assertIsType<QueryNode>(node, 'query');
}

export function getApp(dom: AppDom): AppNode {
const rootNode = getNode(dom, dom.root);
assertIsApp(rootNode);
Expand Down Expand Up @@ -675,6 +701,14 @@ export function moveNode(
return setNodeParent(dom, node, parentId, parentProp, parentIndex);
}

export function saveNode(dom: AppDom, node: AppDomNode) {
return update(dom, {
nodes: update(dom.nodes, {
[node.id]: update(dom.nodes[node.id], omit(node, ...RESERVED_NODE_PROPERTIES)),
}),
});
}

export function removeNode(dom: AppDom, nodeId: NodeId) {
const node = getNode(dom, nodeId);
const parent = getParent(dom, node);
Expand Down Expand Up @@ -753,6 +787,7 @@ export function createRenderTree(dom: AppDom): AppDom {
'page',
'element',
'queryState',
'query',
'derivedState',
'theme',
'codeComponent',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import NodeAttributeEditor from './NodeAttributeEditor';
import { useDom } from '../../DomLoader';
import { usePageEditorState } from './PageEditorProvider';
import PageOptionsPanel from './PageOptionsPanel';
import RuntimeErrorAlert from './RuntimeErrorAlert';
import ErrorAlert from './ErrorAlert';
import NodeNameEditor from '../NodeNameEditor';
import { useToolpadComponent } from '../toolpadComponents';

Expand Down Expand Up @@ -74,7 +74,7 @@ function SelectedNodeEditor({ node }: SelectedNodeEditorProps) {
ID: {node.id}
</Typography>
<NodeNameEditor node={node} />
{nodeError ? <RuntimeErrorAlert error={nodeError} /> : null}
{nodeError ? <ErrorAlert error={nodeError} /> : null}
{node ? (
<React.Fragment>
<Typography variant="subtitle1" sx={{ mt: 2 }}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as React from 'react';
import { Alert, AlertTitle, IconButton, Collapse, Box } from '@mui/material';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';

export interface ErrorAlertProps {
error: unknown;
}

export default function ErrorAlert({ error }: ErrorAlertProps) {
const message: string =
typeof (error as any)?.message === 'string' ? (error as any).message : String(error);
const stack: string | null =
typeof (error as any)?.stack === 'string' ? (error as any).stack : null;

const [expanded, setExpanded] = React.useState(false);
const toggleExpanded = React.useCallback(() => setExpanded((actual) => !actual), []);
return (
<Alert
severity="error"
sx={{
// The content of the Alert doesn't overflow nicely
// TODO: does this need to go in core?
'& .MuiAlert-message': { minWidth: 0 },
}}
action={
stack ? (
<IconButton color="inherit" size="small" onClick={toggleExpanded}>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
) : null
}
>
<AlertTitle>{message}</AlertTitle>
<Collapse in={expanded}>
<Box sx={{ overflow: 'auto' }}>
<pre>{stack}</pre>
</Box>
</Collapse>
</Alert>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useDom } from '../../DomLoader';
import { usePageEditorState } from './PageEditorProvider';
import DerivedStateEditor from './DerivedStateEditor';
import QueryStateEditor from './QueryStateEditor';
import QueryEditor from './QueryEditor';
import UrlQueryEditor from './UrlQueryEditor';
import NodeNameEditor from '../NodeNameEditor';
import * as appDom from '../../../appDom';
Expand Down Expand Up @@ -37,6 +38,7 @@ export default function PageOptionsPanel() {
<UrlQueryEditor pageNodeId={pageNodeId} />
{DEPRECATED && <DerivedStateEditor />}
<QueryStateEditor />
<QueryEditor />
</Stack>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { Box, TextField, IconButton, SxProps } from '@mui/material';
import * as React from 'react';
import DeleteIcon from '@mui/icons-material/Delete';
import { BindableAttrValue, LiveBinding } from '@mui/toolpad-core';
import { WithControlledProp } from '../../../utils/types';
import BindableEditor from './BindableEditor';

export interface StringRecordEntriesEditorProps
extends WithControlledProp<[string, BindableAttrValue<any>][]> {
label?: string;
liveValue: [string, LiveBinding][];
globalScope: Record<string, unknown>;
fieldLabel?: string;
valueLabel?: string;
autoFocus?: boolean;
sx?: SxProps;
}

export default function ParametersEditor({
value,
onChange,
liveValue,
globalScope,
label,
fieldLabel = 'field',
valueLabel = 'value',
autoFocus = false,
sx,
}: StringRecordEntriesEditorProps) {
const fieldInputRef = React.useRef<HTMLInputElement>(null);

const handleRemove = React.useCallback(
(index: number) => () => {
onChange(value.filter((entry, i) => i !== index));
},
[onChange, value],
);

const isValidFieldName: boolean[] = React.useMemo(() => {
const counts: Record<string, number> = {};
value.forEach(([field]) => {
counts[field] = counts[field] ? counts[field] + 1 : 1;
});
return value.map(([field]) => !!field && counts[field] <= 1);
}, [value]);

return (
<Box sx={sx} display="grid" gridTemplateColumns="1fr 2fr auto" alignItems="center" gap={1}>
{label ? <Box gridColumn="span 3">{label}:</Box> : null}
{value.map(([field, fieldValue], index) => {
const liveBinding = liveValue[index][1];

return (
<React.Fragment key={index}>
<TextField
label={valueLabel}
size="small"
value={field}
autoFocus
onChange={(event) =>
onChange(
value.map((entry, i) => (i === index ? [event.target.value, entry[1]] : entry)),
)
}
error={!isValidFieldName[index]}
/>
<BindableEditor
liveBinding={liveBinding}
globalScope={globalScope}
label={field}
argType={{ typeDef: { type: 'string' } }}
value={fieldValue}
onChange={(newBinding) =>
onChange(
value.map((entry, i) =>
i === index
? [entry[0], newBinding || { type: 'const', value: undefined }]
: entry,
),
)
}
/>
{/* <BindingEditor
globalScope={pageState}
liveBinding={liveBinding}
label={field}
value={fieldValue}
onChange={(newBinding) =>
onChange(
value.map((entry, i) =>
i === index
? [entry[0], newBinding || { type: 'const', value: undefined }]
: entry,
),
)
}
/> */}

<IconButton aria-label="Delete property" onClick={handleRemove(index)} size="small">
<DeleteIcon fontSize="small" />
</IconButton>
</React.Fragment>
);
})}

<form autoComplete="off" style={{ display: 'contents' }}>
<TextField
inputRef={fieldInputRef}
size="small"
label={fieldLabel}
value=""
onChange={(event) => {
onChange([...value, [event.target.value, { type: 'const', value: null }]]);
}}
autoFocus={autoFocus}
/>
</form>
</Box>
);
}