Skip to content

Commit

Permalink
First pass at creating queries+APIs [WIP] (#396)
Browse files Browse the repository at this point in the history
* First pass at creating queries+APIs

* Runtime support

* fix types

* fix live values

* clean up

* Improve live view

* hide API/querystate when unused

* Add a migration

* Fix save and run button

* only save certain props

* prevent close

* revert hide APIs
  • Loading branch information
Janpot committed May 19, 2022
1 parent 3221854 commit 2630567
Show file tree
Hide file tree
Showing 16 changed files with 812 additions and 144 deletions.
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 /> : null}
<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>
);
}

0 comments on commit 2630567

Please sign in to comment.