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..960194cc9377 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 { @@ -36,6 +37,7 @@ import { usePubSubAtomReadOnly } from '../../../core/shared/atom-with-pub-sub' import { JSX_CANVAS_LOOKUP_FUNCTION_NAME } from '../../../core/shared/dom-utils' import { objectMap } from '../../../core/shared/object-utils' import type { ComponentRendererComponent } from './component-renderer-component' +import { mapArrayToDictionary } from '../../../core/shared/array-utils' function tryToGetInstancePath( maybePath: ElementPath | null, @@ -130,6 +132,22 @@ export function createComponentRendererComponent(params: { ...appliedProps, } + let spiedVariablesInScope: VariableData = {} + if (utopiaJsxComponent.param != null) { + spiedVariablesInScope = mapArrayToDictionary( + propertiesExposedByParam(utopiaJsxComponent.param), + (paramName) => { + return paramName + }, + (paramName) => { + return { + spiedValue: scope[paramName], + insertionCeiling: instancePath, + } + }, + ) + } + let codeError: Error | null = null // Protect against infinite recursion by taking the view that anything @@ -161,8 +179,6 @@ export function createComponentRendererComponent(params: { }) } - let definedWithinWithValues: MapLike = {} - if (utopiaJsxComponent.arbitraryJSBlock != null) { const lookupRenderer = createLookupRender( rootElementPath, @@ -194,12 +210,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 +235,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/canvas/ui/floating-insert-menu.tsx b/editor/src/components/canvas/ui/floating-insert-menu.tsx index 5c5981131155..6d5b850f41b3 100644 --- a/editor/src/components/canvas/ui/floating-insert-menu.tsx +++ b/editor/src/components/canvas/ui/floating-insert-menu.tsx @@ -146,7 +146,11 @@ export function useGetInsertableComponents( } }, [fullPath, scopedVariables]) - return insertableComponents.concat(insertableVariables) + if (insertMenuMode === 'insert') { + return insertableComponents.concat(insertableVariables) + } else { + return insertableComponents + } } export function useComponentSelectorStyles(): StylesConfig { diff --git a/editor/src/components/editor/variablesmenu.spec.browser2.tsx b/editor/src/components/editor/variablesmenu.spec.browser2.tsx index 00a16e3c6165..898656a4f7a3 100644 --- a/editor/src/components/editor/variablesmenu.spec.browser2.tsx +++ b/editor/src/components/editor/variablesmenu.spec.browser2.tsx @@ -65,7 +65,7 @@ describe('variables menu', () => { await openVariablesMenu(editor) - expect(getInsertItems().length).toEqual(4) + expect(getInsertItems().length).toEqual(7) document.execCommand('insertText', false, 'myObj.im') @@ -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/inspector/sections/component-section/component-section.spec.browser2.tsx b/editor/src/components/inspector/sections/component-section/component-section.spec.browser2.tsx index aa2dab106c7e..137e9cecf441 100644 --- a/editor/src/components/inspector/sections/component-section/component-section.spec.browser2.tsx +++ b/editor/src/components/inspector/sections/component-section/component-section.spec.browser2.tsx @@ -23,27 +23,27 @@ describe('Set element prop via the data picker', () => { const theScene = editor.renderedDOM.getByTestId('scene') const theInspector = editor.renderedDOM.getByTestId('inspector-sections-container') - let currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(0)) + let currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(9)) await mouseClickAtPoint(currentOption, { x: 2, y: 2 }) expect(within(theScene).queryByText('Title too')).not.toBeNull() expect(within(theInspector).queryByText('Title too')).not.toBeNull() - currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(1)) + currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(10)) await mouseClickAtPoint(currentOption, { x: 2, y: 2 }) expect(within(theScene).queryByText('Alternate title')).not.toBeNull() expect(within(theInspector).queryByText('Alternate title')).not.toBeNull() - currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(2)) + currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(11)) await mouseClickAtPoint(currentOption, { x: 2, y: 2 }) expect(within(theScene).queryByText('The First Title')).not.toBeNull() expect(within(theInspector).queryByText('The First Title')).not.toBeNull() - currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(3)) + currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(12)) await mouseClickAtPoint(currentOption, { x: 2, y: 2 }) expect(within(theScene).queryByText('Sweet')).not.toBeNull() expect(within(theInspector).queryByText('Sweet')).not.toBeNull() - currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(4)) + currentOption = editor.renderedDOM.getByTestId(VariableFromScopeOptionTestId(13)) await mouseClickAtPoint(currentOption, { x: 2, y: 2 }) expect(within(theScene).queryByText('Chapter One')).not.toBeNull() expect(within(theInspector).queryByText('Chapter One')).not.toBeNull() diff --git a/editor/src/components/inspector/sections/component-section/component-section.tsx b/editor/src/components/inspector/sections/component-section/component-section.tsx index 03d593e5424c..587a5561f378 100644 --- a/editor/src/components/inspector/sections/component-section/component-section.tsx +++ b/editor/src/components/inspector/sections/component-section/component-section.tsx @@ -432,49 +432,51 @@ const DataPickerPopup = React.memo(
Data
- {variableNamesInScope.map(({ variableName, definedElsewhere, value }, idx) => ( - - ))} + +
+ + {variableName} + +
+
+ + {value} + +
+
+ + ) + })}
) 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-metadata.spec.browser2.tsx b/editor/src/core/model/element-metadata.spec.browser2.tsx index 1deefbc9672b..f8e89996a90f 100644 --- a/editor/src/core/model/element-metadata.spec.browser2.tsx +++ b/editor/src/core/model/element-metadata.spec.browser2.tsx @@ -1334,6 +1334,7 @@ describe('record variable values', () => { }, definedInsideString: { spiedValue: 'hello', insertionCeiling: null }, functionResult: { spiedValue: 35, insertionCeiling: null }, + style: { spiedValue: {}, insertionCeiling: EP.fromString('sb/scene/pg') }, }) expect(variablesInScope['sb/scene/pg:root/111']).toEqual({ definedInsideNumber: { spiedValue: 12, insertionCeiling: null }, @@ -1345,6 +1346,7 @@ describe('record variable values', () => { }, definedInsideString: { spiedValue: 'hello', insertionCeiling: null }, functionResult: { spiedValue: 35, insertionCeiling: null }, + style: { spiedValue: {}, insertionCeiling: EP.fromString('sb/scene/pg') }, }) expect(variablesInScope['sb/scene/pg:root/222']).toEqual({ definedInsideNumber: { spiedValue: 12, insertionCeiling: null }, @@ -1356,6 +1358,7 @@ describe('record variable values', () => { }, definedInsideString: { spiedValue: 'hello', insertionCeiling: null }, functionResult: { spiedValue: 35, insertionCeiling: null }, + style: { spiedValue: {}, insertionCeiling: EP.fromString('sb/scene/pg') }, }) expect(variablesInScope['sb/scene/pg:root/333']).toEqual({ definedInsideNumber: { spiedValue: 12, insertionCeiling: null }, @@ -1367,6 +1370,7 @@ describe('record variable values', () => { }, definedInsideString: { spiedValue: 'hello', insertionCeiling: null }, functionResult: { spiedValue: 35, insertionCeiling: null }, + style: { spiedValue: {}, insertionCeiling: EP.fromString('sb/scene/pg') }, }) }) }) 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'