diff --git a/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-component-renderer.tsx b/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-component-renderer.tsx index 953f2e8003d8..52f95753f81b 100644 --- a/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-component-renderer.tsx +++ b/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-component-renderer.tsx @@ -5,6 +5,7 @@ import { isUtopiaJSXComponent, isSVGElement, isJSXElement, + propertiesExposedByParam, } from '../../../core/shared/element-template' import { optionalMap } from '../../../core/shared/optional-utils' import type { @@ -130,6 +131,16 @@ export function createComponentRendererComponent(params: { ...appliedProps, } + let spiedVariablesInScope: VariableData = {} + if (utopiaJsxComponent.param != null) { + for (const paramName of propertiesExposedByParam(utopiaJsxComponent.param)) { + spiedVariablesInScope[paramName] = { + spiedValue: scope[paramName], + insertionCeiling: instancePath, + } + } + } + let codeError: Error | null = null // Protect against infinite recursion by taking the view that anything @@ -161,8 +172,6 @@ export function createComponentRendererComponent(params: { }) } - let definedWithinWithValues: MapLike = {} - if (utopiaJsxComponent.arbitraryJSBlock != null) { const lookupRenderer = createLookupRender( rootElementPath, @@ -194,12 +203,23 @@ export function createComponentRendererComponent(params: { lookupRenderer, ) - definedWithinWithValues = runBlockUpdatingScope( + const definedWithinWithValues = runBlockUpdatingScope( params.filePath, mutableContext.requireResult, utopiaJsxComponent.arbitraryJSBlock, scope, ) + + spiedVariablesInScope = { + ...spiedVariablesInScope, + ...objectMap( + (spiedValue) => ({ + spiedValue: spiedValue, + insertionCeiling: null, + }), + definedWithinWithValues, + ), + } } function buildComponentRenderResult(element: JSXElementChild): React.ReactElement { @@ -208,14 +228,6 @@ export function createComponentRendererComponent(params: { instancePath, ) - const spiedVariablesInScope: VariableData = objectMap( - (spiedValue) => ({ - spiedValue: spiedValue, - insertionCeiling: null, - }), - definedWithinWithValues, - ) - const renderedCoreElement = renderCoreElement( element, ownElementPath, diff --git a/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-props-utils.ts b/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-props-utils.ts index f5a89b30c638..99c27e66ca67 100644 --- a/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-props-utils.ts +++ b/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-props-utils.ts @@ -38,39 +38,36 @@ export function applyPropsParamToPassedProps( output[paramName] = getParamValue(value, boundParam.defaultExpression) } else if (isDestructuredObject(boundParam)) { if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + const valueAsRecord: Record = { ...value } let remainingValues = { ...value } as Record - let remainingKeys = Object.keys(remainingValues) - boundParam.parts.forEach((part) => { + for (const part of boundParam.parts) { const { propertyName, param } = part - if (propertyName != null) { - // e.g. `{ prop: renamedProp }` or `{ prop: { /* further destructuring */ } }` - // Can't spread if we have a property name - const innerValue = remainingValues[propertyName] - applyBoundParamToOutput(innerValue, param.boundParam) - remainingKeys = remainingKeys.filter((k) => k !== propertyName) - delete remainingValues[propertyName] - } else { + if (propertyName == null) { const { dotDotDotToken: spread, boundParam: innerBoundParam } = param if (isRegularParam(innerBoundParam)) { // e.g. `{ prop }` or `{ ...remainingProps }` const { paramName } = innerBoundParam if (spread) { output[paramName] = remainingValues - remainingKeys = [] remainingValues = {} } else { output[paramName] = getParamValue( - remainingValues[paramName], + valueAsRecord[paramName], innerBoundParam.defaultExpression, ) - remainingKeys = remainingKeys.filter((k) => k !== paramName) delete remainingValues[paramName] } } + } else { + // e.g. `{ prop: renamedProp }` or `{ prop: { /* further destructuring */ } }` + // Can't spread if we have a property name + const innerValue = valueAsRecord[propertyName] + applyBoundParamToOutput(innerValue, param.boundParam) + delete remainingValues[propertyName] // No other cases are legal // TODO Should we throw? Users will already have a lint error } - }) + } } // TODO Throw, but what? } else { diff --git a/editor/src/components/editor/variablesmenu.spec.browser2.tsx b/editor/src/components/editor/variablesmenu.spec.browser2.tsx index 00a16e3c6165..394f97df351f 100644 --- a/editor/src/components/editor/variablesmenu.spec.browser2.tsx +++ b/editor/src/components/editor/variablesmenu.spec.browser2.tsx @@ -140,7 +140,7 @@ describe('variables menu', () => { Prettier.format( ` import * as React from 'react' - export function App({objProp, imgProp, unusedProp}) { + export function App({objProp: bestProp, imgProp, unusedProp, style: { background, position: rightThere }}) { return (
{ Prettier.format( ` import * as React from 'react' - export function App({objProp, imgProp, unusedProp}) { + export function App({objProp: bestProp, imgProp, unusedProp, style: { background, position: rightThere }}) { return (
{ ), ) }) + it('shows and inserts destructured properties when possible', async () => { + const editor = await renderTestEditorWithProjectContent( + makeMappingFunctionTestProjectContents(), + 'await-first-dom-report', + ) + + await selectComponentsForTest(editor, [ + EP.fromString( + `${BakedInStoryboardUID}/${TestSceneUID}/${TestAppUID}:container/3e2/586~~~1`, + ), + ]) + + await openVariablesMenu(editor) + + document.execCommand('insertText', false, 'unused') + expect(getInsertItems().length).toEqual(1) + expect(getInsertItems()[0].innerText).toEqual('unusedProp') + + const filterBox = await screen.findByTestId(InsertMenuFilterTestId) + forceNotNull('the filter box must not be null', filterBox) + + await pressKey('Enter', { targetElement: filterBox }) + + expect(getPrintedUiJsCode(editor.getEditorState(), '/src/app.js')).toEqual( + Prettier.format( + ` + import * as React from 'react' + export function App({objProp: bestProp, imgProp, unusedProp, style: { background, position: rightThere }}) { + return ( +
+ {[1, 2].map((value, index) => { + return ( + Utopia logo + ) + })} + {[{ thing: 1 }, { thing: 2 }, { thing: 3 }].map( + (someValue, index) => { + return ( +
+ Utopia logo + + {JSON.stringify(unusedProp)} + +
+ ) + }, + )} +
+ ) + }`, + PrettierConfig, + ), + ) + }) + + it('shows and inserts destructured and renamed properties when possible', async () => { + const editor = await renderTestEditorWithProjectContent( + makeMappingFunctionTestProjectContents(), + 'await-first-dom-report', + ) + + await selectComponentsForTest(editor, [ + EP.fromString( + `${BakedInStoryboardUID}/${TestSceneUID}/${TestAppUID}:container/3e2/586~~~1`, + ), + ]) + + await openVariablesMenu(editor) + + document.execCommand('insertText', false, 'bestProp') + expect(getInsertItems().length).toEqual(2) + expect(getInsertItems()[0].innerText).toEqual('bestProp') + + const filterBox = await screen.findByTestId(InsertMenuFilterTestId) + forceNotNull('the filter box must not be null', filterBox) + + await pressKey('Enter', { targetElement: filterBox }) + + expect(getPrintedUiJsCode(editor.getEditorState(), '/src/app.js')).toEqual( + Prettier.format( + ` + import * as React from 'react' + export function App({objProp: bestProp, imgProp, unusedProp, style: { background, position: rightThere }}) { + return ( +
+ {[1, 2].map((value, index) => { + return ( + Utopia logo + ) + })} + {[{ thing: 1 }, { thing: 2 }, { thing: 3 }].map( + (someValue, index) => { + return ( +
+ Utopia logo + + {JSON.stringify(bestProp)} + +
+ ) + }, + )} +
+ ) + }`, + PrettierConfig, + ), + ) + }) + + it('shows and inserts nested destructured properties when possible', async () => { + const editor = await renderTestEditorWithProjectContent( + makeMappingFunctionTestProjectContents(), + 'await-first-dom-report', + ) + + await selectComponentsForTest(editor, [ + EP.fromString( + `${BakedInStoryboardUID}/${TestSceneUID}/${TestAppUID}:container/3e2/586~~~1`, + ), + ]) + + await openVariablesMenu(editor) + + document.execCommand('insertText', false, 'background') + expect(getInsertItems().length).toEqual(1) + expect(getInsertItems()[0].innerText).toEqual('background') + + const filterBox = await screen.findByTestId(InsertMenuFilterTestId) + forceNotNull('the filter box must not be null', filterBox) + + await pressKey('Enter', { targetElement: filterBox }) + + expect(getPrintedUiJsCode(editor.getEditorState(), '/src/app.js')).toEqual( + Prettier.format( + ` + import * as React from 'react' + export function App({objProp: bestProp, imgProp, unusedProp, style: { background, position: rightThere }}) { + return ( +
+ {[1, 2].map((value, index) => { + return ( + Utopia logo + ) + })} + {[{ thing: 1 }, { thing: 2 }, { thing: 3 }].map( + (someValue, index) => { + return ( +
+ Utopia logo + + {background} + +
+ ) + }, + )} +
+ ) + }`, + PrettierConfig, + ), + ) + }) + + it('shows and inserts nested destructured and renamed properties when possible', async () => { + const editor = await renderTestEditorWithProjectContent( + makeMappingFunctionTestProjectContents(), + 'await-first-dom-report', + ) + + await selectComponentsForTest(editor, [ + EP.fromString( + `${BakedInStoryboardUID}/${TestSceneUID}/${TestAppUID}:container/3e2/586~~~1`, + ), + ]) + + await openVariablesMenu(editor) + + document.execCommand('insertText', false, 'rightThere') + expect(getInsertItems().length).toEqual(1) + expect(getInsertItems()[0].innerText).toEqual('rightThere') + + const filterBox = await screen.findByTestId(InsertMenuFilterTestId) + forceNotNull('the filter box must not be null', filterBox) + + await pressKey('Enter', { targetElement: filterBox }) + + expect(getPrintedUiJsCode(editor.getEditorState(), '/src/app.js')).toEqual( + Prettier.format( + ` + import * as React from 'react' + export function App({objProp: bestProp, imgProp, unusedProp, style: { background, position: rightThere }}) { + return ( +
+ {[1, 2].map((value, index) => { + return ( + Utopia logo + ) + })} + {[{ thing: 1 }, { thing: 2 }, { thing: 3 }].map( + (someValue, index) => { + return ( +
+ Utopia logo + + {rightThere} + +
+ ) + }, + )} +
+ ) + }`, + PrettierConfig, + ), + ) + }) it('shows and inserts scoped properties when possible with multiple elements selected', async () => { const editor = await renderTestEditorWithProjectContent( @@ -310,7 +637,7 @@ describe('variables menu', () => { Prettier.format( ` import * as React from 'react' - export function App({objProp, imgProp, unusedProp}) { + export function App({objProp: bestProp, imgProp, unusedProp, style: { background, position: rightThere }}) { return (
- + )`, @@ -561,7 +888,7 @@ function makeTestProjectContents(): ProjectContentTreeRoot { const mappingFunctionAppJS: string = ` import * as React from 'react' - export function App({objProp, imgProp, unusedProp}) { + export function App({objProp: bestProp, imgProp, unusedProp, style: { background, position: rightThere }}) { return (
- + )`, diff --git a/editor/src/components/shared/scoped-variables.ts b/editor/src/components/shared/scoped-variables.ts index 2c1dbb1dd23c..b0119e6d0d0f 100644 --- a/editor/src/components/shared/scoped-variables.ts +++ b/editor/src/components/shared/scoped-variables.ts @@ -5,6 +5,7 @@ import type { ElementInstanceMetadataMap, ArbitraryJSBlock, TopLevelElement, + UtopiaJSXComponent, } from '../../core/shared/element-template' import { isArbitraryJSBlock, @@ -37,6 +38,7 @@ import { type ComponentElementToInsert } from '../custom-code/code-file' import { omitWithPredicate } from '../../core/shared/object-utils' import { MetadataUtils } from '../../core/model/element-metadata-utils' import { isLeft } from '../../core/shared/either' +import { findContainingComponent } from '../../core/model/element-template-utils' export function getVariablesInScope( elementPath: ElementPath | null, @@ -48,15 +50,16 @@ export function getVariablesInScope( let varsInScope = [] if (elementPath !== null) { + const containingComponent = findContainingComponent(success.topLevelElements, elementPath) const componentScopedVariables = getVariablesFromComponent( - success.topLevelElements, + containingComponent, elementPath, variablesInScopeFromEditorState, ) varsInScope.push(componentScopedVariables) const componentPropsInScope = getComponentPropsInScope( - success.topLevelElements, + containingComponent, elementPath, jsxMetadata, ) @@ -87,12 +90,11 @@ function getTopLevelVariables(topLevelElements: TopLevelElement[], underlyingFil } function getVariablesFromComponent( - topLevelElements: TopLevelElement[], + jsxComponent: UtopiaJSXComponent | null, elementPath: ElementPath, variablesInScopeFromEditorState: VariablesInScope, ): AllVariablesInScope { const elementPathString = toComponentId(elementPath) - const jsxComponent = topLevelElements.find(isUtopiaJSXComponent) const jsxComponentVariables = variablesInScopeFromEditorState[elementPathString] ?? {} return { filePath: jsxComponent?.name ?? 'Component', @@ -101,11 +103,10 @@ function getVariablesFromComponent( } function getComponentPropsInScope( - topLevelElements: TopLevelElement[], + jsxComponent: UtopiaJSXComponent | null, elementPath: ElementPath, jsxMetadata: ElementInstanceMetadataMap, ): AllVariablesInScope { - const jsxComponent = topLevelElements.find(isUtopiaJSXComponent) const jsxComponentPropNamesDeclared = jsxComponent?.propsUsed ?? [] const jsxComponentPropsPassed = omitWithPredicate( diff --git a/editor/src/core/model/element-template-utils.ts b/editor/src/core/model/element-template-utils.ts index 8f013793a64b..8a76ce416c09 100644 --- a/editor/src/core/model/element-template-utils.ts +++ b/editor/src/core/model/element-template-utils.ts @@ -36,6 +36,7 @@ import { jsxFragment, isJSExpression, hasElementsWithin, + isUtopiaJSXComponent, } from '../shared/element-template' import type { StaticElementPathPart, @@ -1247,3 +1248,25 @@ export function renameJsxElementChild( } return element } + +export function findContainingComponent( + topLevelElements: Array, + target: ElementPath, +): UtopiaJSXComponent | null { + // Identify the UID of the containing component. + const containingElementPath = EP.getContainingComponent(target) + if (!EP.isEmptyPath(containingElementPath)) { + const componentUID = EP.toUid(containingElementPath) + + // Find the component in the top level elements that we're looking for. + for (const topLevelElement of topLevelElements) { + if (isUtopiaJSXComponent(topLevelElement)) { + if (topLevelElement.rootElement.uid === componentUID) { + return topLevelElement + } + } + } + } + + return null +} diff --git a/editor/src/core/shared/element-template.ts b/editor/src/core/shared/element-template.ts index 8999f4e629c6..46ff0105e01d 100644 --- a/editor/src/core/shared/element-template.ts +++ b/editor/src/core/shared/element-template.ts @@ -1777,6 +1777,30 @@ export function propNamesForParam(param: Param): Array { } } +export function propertiesExposedByParam(param: Param): Array { + switch (param.boundParam.type) { + case 'REGULAR_PARAM': + return [param.boundParam.paramName] + case 'DESTRUCTURED_ARRAY': + return param.boundParam.parts.flatMap((part) => { + switch (part.type) { + case 'PARAM': + return propertiesExposedByParam(part) + case 'OMITTED_PARAM': + return [] + default: + return assertNever(part) + } + }) + case 'DESTRUCTURED_OBJECT': + return param.boundParam.parts.flatMap((part) => { + return propertiesExposedByParam(part.param) + }) + default: + assertNever(param.boundParam) + } +} + export type VarLetOrConst = 'var' | 'let' | 'const' export type FunctionDeclarationSyntax = 'function' | VarLetOrConst export type BlockOrExpression = 'block' | 'parenthesized-expression' | 'expression'