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

Add page parameters to navigation actions #1876

Merged
merged 33 commits into from
May 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7bb9ca0
Add page parameters to navigation actions
apedroferreira Apr 12, 2023
b1f0489
Non-working bindings for actions + fix circular dependency hell
apedroferreira Apr 12, 2023
c351057
where did this even come from
apedroferreira Apr 12, 2023
99bb4a3
Fix types
apedroferreira Apr 12, 2023
d8c8c0b
Working page parameter bindings
apedroferreira Apr 13, 2023
2007d59
Fix and add test
apedroferreira Apr 13, 2023
005f5d1
Merge remote-tracking branch 'origin/master' into add-navigation-page…
apedroferreira Apr 13, 2023
31d818a
Merge remote-tracking branch 'origin/master' into add-navigation-page…
apedroferreira Apr 14, 2023
2d7dcdc
Merge remote-tracking branch 'origin/master' into add-navigation-page…
apedroferreira Apr 20, 2023
07c2cee
Merge remote-tracking branch 'origin/master' into add-navigation-page…
apedroferreira Apr 21, 2023
4d738a0
Fixes for rebase + new page file structure
apedroferreira Apr 21, 2023
30c2075
hehe
apedroferreira Apr 21, 2023
8bcf041
Remove unneeded fixture things
apedroferreira Apr 21, 2023
13bcc23
Fix page name
apedroferreira Apr 21, 2023
d28474c
Merge remote-tracking branch 'origin/master' into add-navigation-page…
apedroferreira Apr 21, 2023
e6d0e88
Improve test for navigation overall + see if CI passes
apedroferreira Apr 21, 2023
80a4dfe
Better test
apedroferreira Apr 21, 2023
8e4fec1
Attempt to get CI to pass again
apedroferreira Apr 21, 2023
1fcd96e
Remove check to try to debug issue in CI
apedroferreira Apr 21, 2023
1e44bbb
Revert "Remove check to try to debug issue in CI"
apedroferreira Apr 21, 2023
e68d8e0
Merge remote-tracking branch 'origin/master' into add-navigation-page…
apedroferreira Apr 24, 2023
c1fe290
Another attempt to make CI pass
apedroferreira Apr 24, 2023
4185ed8
Try again
apedroferreira Apr 24, 2023
f8d176d
Try using different method
apedroferreira Apr 24, 2023
e2ce209
Merge remote-tracking branch 'origin/master' into add-navigation-page…
apedroferreira Apr 26, 2023
e057975
Use $ref instead of $$ref everywhere (mystery solved?)
apedroferreira Apr 26, 2023
c246b3d
Forgot this one
apedroferreira Apr 27, 2023
55cd8ba
Refactor with ignored cycle to see what changed
apedroferreira Apr 27, 2023
ef1980a
Revert some more changes
apedroferreira Apr 27, 2023
4e1c55c
fix lint
apedroferreira Apr 27, 2023
6ff80f1
Merge remote-tracking branch 'origin/master' into add-navigation-page…
apedroferreira Apr 27, 2023
3d255de
Document navigation actions
apedroferreira Apr 28, 2023
558648b
Allow navigating to same page
apedroferreira Apr 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/data/toolpad/data-binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,19 @@ In the example below you can see how after clicking the button, the value is cop
After clicking the Button:

<img src="/static/toolpad/docs/data-binding/bind-11.png" alt="Binding result 2" width="200" />

### Navigation actions

In the binding editor for event handlers it is also possible to define **navigation actions**, which can open any page in your app when triggered.

You can set a navigation action in the **Navigation** tab of the binding editor for any event handler prop:

<img src="/static/toolpad/docs/data-binding/bind-12.png" alt="Binding editor for navigation actions" width="800" />

<!---
@TODO: Link "any query string parameters that the destination page supports" to page parameters documentation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is to be done here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should document the feature of being able to set page parameters separately from navigation actions in my opinion, and then we could link to it from here.
But I wasn't sure at the time in which section the page parameters should be documented, so I added this todo. I can make sure we do this soon enough as we're working on the docs.

-->

The binding editor for navigation actions allows you to select a page to go to, as well as set values for any query string parameters that the destination page supports.

You can also bind the query parameter values to any page state of the origin page by clicking on the binding button near the respective parameter's input.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions packages/toolpad-app/src/appDom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1114,15 +1114,15 @@ export function ref(nodeId: NodeId): NodeReference;
export function ref(nodeId: null | undefined): null;
export function ref(nodeId: Maybe<NodeId>): NodeReference | null;
export function ref(nodeId: Maybe<NodeId>): NodeReference | null {
return nodeId ? { $$ref: nodeId } : null;
return nodeId ? { $ref: nodeId } : null;
Copy link
Member Author

@apedroferreira apedroferreira Apr 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we inject the initial DOM state in the HTML in the <script> tags all the $$ref properties are being changed to $ref now with the Vite runtime... Not sure exactly why.
So all navigation actions were broken.
Can we just use $ref for these properties? It shouldn't even need a migration, right? I only saw it being used in a few old connections besides navigation actions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We currently have two formats for bindings/actions/...

One legacy one in the appDom, one new one from the new file system format. Over time we will move the whole application over to the second format

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok! How do you suggest fixing the navigation actions for now? appDom.deRef tries to get $$ref but the local DOM has the page as $ref.
Are the changes in this PR about that ok or do you suggest an alternative?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you're changing the existing format, my suggestion would be to change it to align it close to the what's in the file schema. The more we can reduce the amount of conversion needed from schema to internal representation, the better. With the north star goal to get rid of the conversion completely.
Otherwise, if it works, it works, and this can be merged

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot this comment... will take a second look and add changes if it makes sense.

}

export function deref(nodeRef: NodeReference): NodeId;
export function deref(nodeRef: null | undefined): null;
export function deref(nodeRef: Maybe<NodeReference>): NodeId | null;
export function deref(nodeRef: Maybe<NodeReference>): NodeId | null {
if (nodeRef) {
return nodeRef.$$ref;
return nodeRef.$ref;
}
return null;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/toolpad-app/src/runtime/CanvasHooksContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as React from 'react';
import { NodeHashes } from '../types';

export interface NavigateToPage {
(pageNodeId: NodeId): void;
(pageNodeId: NodeId, pageParameters?: Record<string, string>): void;
}

/**
Expand Down
44 changes: 36 additions & 8 deletions packages/toolpad-app/src/runtime/ToolpadApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,17 @@ const USE_DATA_QUERY_CONFIG_KEYS: readonly (keyof UseDataQueryConfig)[] = [
function usePageNavigator(): NavigateToPage {
const navigate = useNavigate();
const navigateToPage: NavigateToPage = React.useCallback(
(pageNodeId: NodeId) => {
navigate(`/pages/${pageNodeId}`);
(pageNodeId, pageParameters) => {
const urlParams = pageParameters && new URLSearchParams(pageParameters);

navigate({
pathname: `/pages/${pageNodeId}`,
...(urlParams
? {
search: urlParams.toString(),
}
: {}),
});
},
[navigate],
);
Expand Down Expand Up @@ -345,10 +354,28 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC
const action = (node as appDom.ElementNode).props?.[key];

if (action?.type === 'navigationAction') {
const handler = () => {
const { page } = action.value;
const handler = async () => {
const { page, parameters = {} } = action.value;
if (page) {
navigateToPage(appDom.deref(page));
const parsedParameterEntries = await Promise.all(
Object.keys(parameters).map(async (parameterName) => {
const parameterValue = parameters[parameterName];

if (parameterValue && parameterValue.type === 'jsExpression') {
const result = await evaluatePageExpression(
parameterValue.value,
scopeId,
localScopeParams,
);
return [parameterName, result.value];
}
return [parameterName, parameterValue?.value];
}),
);

const parsedParameters = Object.fromEntries(parsedParameterEntries);

navigateToPage(appDom.deref(page), parsedParameters);
}
};

Expand Down Expand Up @@ -535,7 +562,7 @@ function flattenNestedBindables(
return flattenNestedBindables(param[1], `${prefix}[${i}][1]`);
});
}
// TODO: create a marker in bindables (similar to $$ref) to recognize them automatically
// TODO: create a marker in bindables (similar to $ref) to recognize them automatically
// in a nested structure. This would allow us to build deeply nested structures
if (typeof params.type === 'string') {
return [[prefix, params as BindableAttrValue<any>]];
Expand Down Expand Up @@ -736,9 +763,10 @@ function parseBindings(
? `${elm.id}.props.${argType.defaultValueProp}`
: undefined;

const propValue = elm.props?.[propName];

const binding: BindableAttrValue<any> =
elm.props?.[propName] ||
appDom.createConst(argType ? getArgTypeDefaultValue(argType) : undefined);
propValue || appDom.createConst(argType ? getArgTypeDefaultValue(argType) : undefined);

const bindingId = `${elm.id}.props.${propName}`;

Expand Down
6 changes: 3 additions & 3 deletions packages/toolpad-app/src/server/localMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,12 +410,12 @@ function toBindableProp<V>(value: V | { $$jsExpression: string }): BindableAttrV
if (typeof (value as any).$$jsExpressionAction === 'string') {
return { type: 'jsExpressionAction', value: (value as any).$$jsExpressionAction };
}
if (typeof (value as any).$$navigationAction === 'string') {
if (typeof (value as any).$$navigationAction === 'object') {
const action = value as any as NavigationAction;
return {
type: 'navigationAction',
value: {
page: { $$ref: action.$$navigationAction.page as NodeId },
page: { $ref: action.$$navigationAction.page as NodeId },
parameters: mapValues(
action.$$navigationAction.parameters,
(param) => param && toBindable(param),
Expand All @@ -438,7 +438,7 @@ function fromBindableProp<V>(bindable: BindableAttrValue<V>) {
case 'navigationAction':
return {
$$navigationAction: {
page: bindable.value.page.$$ref,
page: bindable.value.page.$ref,
parameters:
bindable.value.parameters &&
mapValues(bindable.value.parameters, (param) => param && fromBindable(param)),
Expand Down
151 changes: 124 additions & 27 deletions packages/toolpad-app/src/toolpad/AppEditor/BindingEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,58 @@
import * as React from 'react';
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
Toolbar,
Tooltip,
Typography,
styled,
tooltipClasses,
TooltipProps,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Tab,
TextField,
MenuItem,
Tab,
} from '@mui/material';
import * as React from 'react';
import LinkIcon from '@mui/icons-material/Link';
import AddLinkIcon from '@mui/icons-material/AddLink';
import {
LiveBinding,
PropValueType,
BindableAttrValue,
JsExpressionAttrValue,
NavigationAction,
NodeId,
JsExpressionAction,
ScopeMeta,
ScopeMetaField,
JsRuntime,
PropValueType,
BindableAttrValue,
NavigationAction,
NodeId,
} from '@mui/toolpad-core';
import { createProvidedContext } from '@mui/toolpad-utils/react';
import { TabContext, TabList } from '@mui/lab';
import { Maybe, WithControlledProp } from '../../utils/types';
import { mapValues } from '@mui/toolpad-utils/collections';
import { JsExpressionEditor } from './PageEditor/JsExpressionEditor';
import JsonView from '../../components/JsonView';
import { tryFormatExpression } from '../../utils/prettier';
import useLatest from '../../utils/useLatest';
import useDebounced from '../../utils/useDebounced';
import { useEvaluateLiveBinding } from './useEvaluateLiveBinding';
import GlobalScopeExplorer from './GlobalScopeExplorer';
import { WithControlledProp, Maybe } from '../../utils/types';

import { tryFormatExpression } from '../../utils/prettier';
import useShortcut from '../../utils/useShortcut';
import useUnsavedChangesConfirm from '../hooks/useUnsavedChangesConfirm';

import TabPanel from '../../components/TabPanel';

import { useDom } from '../AppState';
import * as appDom from '../../appDom';
import { usePageEditorState } from './PageEditor/PageEditorProvider';
import GlobalScopeExplorer from './GlobalScopeExplorer';
import TabPanel from '../../components/TabPanel';
import useUnsavedChangesConfirm from '../hooks/useUnsavedChangesConfirm';
// eslint-disable-next-line import/no-cycle
import BindableEditor from './PageEditor/BindableEditor';

interface BindingEditorContext {
label: string;
Expand Down Expand Up @@ -204,50 +209,142 @@ function JsExpressionActionEditor({ value, onChange }: JsExpressionActionEditorP
);
}

export interface NavigationActionParameterEditorProps
extends WithControlledProp<BindableAttrValue<string> | null> {
label: string;
}

function NavigationActionParameterEditor({
label,
value,
onChange,
}: NavigationActionParameterEditorProps) {
const { jsRuntime, globalScope, globalScopeMeta } = useBindingEditorContext();

const liveBinding = useEvaluateLiveBinding({
jsRuntime,
input: value,
globalScope,
});

return (
<Box>
<BindableEditor<string>
liveBinding={liveBinding}
jsRuntime={jsRuntime}
globalScope={globalScope}
globalScopeMeta={globalScopeMeta}
label={label}
propType={{ type: 'string' }}
value={value || null}
onChange={onChange}
/>
</Box>
);
}

export interface NavigationActionEditorProps extends WithControlledProp<NavigationAction | null> {}

function NavigationActionEditor({ value, onChange }: NavigationActionEditorProps) {
const { dom } = useDom();
const root = appDom.getApp(dom);
const { pages = [] } = appDom.getChildNodes(dom, root);
const { nodeId: currentPageNodeId } = usePageEditorState();

const getDefaultActionParameters = React.useCallback((page: appDom.PageNode) => {
const defaultPageParameters = page.attributes.parameters?.value || [];

return mapValues(Object.fromEntries(defaultPageParameters), (pageParameterValue) =>
appDom.createConst(pageParameterValue),
);
}, []);

const handlePageChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const pageId = event.target.value as NodeId;
const page = appDom.getNode(dom, pageId);

const defaultActionParameters = appDom.isPage(page) ? getDefaultActionParameters(page) : {};

onChange({
type: 'navigationAction',
value: { page: appDom.ref(event.target.value as NodeId) },
value: {
page: appDom.ref(pageId),
parameters: defaultActionParameters,
},
});
},
[onChange],
[dom, getDefaultActionParameters, onChange],
);

const availablePages = React.useMemo(
() => pages.filter((page) => page.id !== currentPageNodeId),
[pages, currentPageNodeId],
const actionPageRef = value?.value?.page || null;
const actionParameters = React.useMemo(
() => value?.value.parameters || {},
[value?.value.parameters],
);

const hasPagesAvailable = availablePages.length > 0;
const actionPageId = actionPageRef ? appDom.deref(actionPageRef) : null;
const actionPage = pages.find((availablePage) => availablePage.id === actionPageId);

const handleActionParameterChange = React.useCallback(
(actionParameterName: string) => (newValue: BindableAttrValue<string> | null) => {
if (actionPageRef) {
onChange({
type: 'navigationAction',
value: {
page: actionPageRef,
parameters: {
...actionParameters,
...(newValue ? { [actionParameterName]: newValue } : {}),
},
},
});
}
},
[actionPageRef, actionParameters, onChange],
);

const hasPagesAvailable = pages.length > 0;

const defaultActionParameters = actionPage ? getDefaultActionParameters(actionPage) : {};

const actionParameterEntries = Object.entries(actionParameters || defaultActionParameters);

return (
<Box sx={{ my: 1 }}>
<Typography>Navigate to a page on this event</Typography>
<TextField
fullWidth
sx={{ my: 3 }}
label="page"
label="Select a page"
select
value={value?.value?.page ? appDom.deref(value.value.page) : ''}
value={actionPageId || ''}
onChange={handlePageChange}
disabled={!hasPagesAvailable}
helperText={hasPagesAvailable ? null : 'No other pages available'}
>
{availablePages.map((page) => (
{pages.map((page) => (
<MenuItem key={page.id} value={page.id}>
{page.name}
</MenuItem>
))}
</TextField>
{actionParameterEntries.length > 0 ? (
<React.Fragment>
<Typography variant="overline">Page parameters:</Typography>
{Object.entries(actionParameters || defaultActionParameters).map((actionParameter) => {
const [actionParameterName, actionParameterValue] = actionParameter;

return (
<NavigationActionParameterEditor
key={actionParameterName}
label={actionParameterName}
value={actionParameterValue as BindableAttrValue<string>}
onChange={handleActionParameterChange(actionParameterName)}
/>
);
})}
</React.Fragment>
) : null}
</Box>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import {
BindableAttrValue,
PropValueType,
LiveBinding,
ScopeMeta,
JsRuntime,
ScopeMeta,
} from '@mui/toolpad-core';
import { BindingEditor } from '../BindingEditor';
import { WithControlledProp } from '../../../utils/types';
import { getDefaultControl } from '../../propertyControls';
// eslint-disable-next-line import/no-cycle
import { BindingEditor } from '../BindingEditor';

function renderDefaultControl(params: RenderControlParams<any>) {
const Control = getDefaultControl({ typeDef: params.propType });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ export default function QueryNodeEditorDialog<Q>({
</MenuItem>
<MenuItem value="mutation">Only fetch on manual action</MenuItem>
</TextField>
<BindableEditor
<BindableEditor<boolean>
liveBinding={liveEnabled}
globalScope={pageState}
globalScopeMeta={globalScopeMeta}
Expand Down
Loading