From 8873ae6339aa59219e464152136df60df9194704 Mon Sep 17 00:00:00 2001 From: Balint Gabor <127662+gbalint@users.noreply.github.com> Date: Mon, 12 Feb 2024 10:53:33 +0100 Subject: [PATCH] registerComponent uses the component instance itself (#4865) * Better registerComponent * Remove outdated test * Fix circular dependency * Added test for registerComponent * Remove commented code * Simplify test * Add test for external component * Remove unnecessary code line --- .../components/canvas/remix/remix-utils.tsx | 10 +- .../component-renderer-component.tsx | 30 +++ .../ui-jsx-canvas-component-renderer.tsx | 31 +-- .../ui-jsx-canvas-element-renderer-utils.tsx | 2 +- .../ui-jsx-canvas-execution-scope.tsx | 2 +- .../src/components/canvas/ui-jsx-canvas.tsx | 2 +- .../editor/store/remix-derived-data.tsx | 2 +- .../component-section.spec.browser2.tsx | 179 +++++++++++++++++- .../property-controls-local.spec.tsx | 125 ------------ .../property-controls-local.ts | 33 +++- utopia-api/src/helpers/helper-functions.ts | 4 +- 11 files changed, 250 insertions(+), 170 deletions(-) create mode 100644 editor/src/components/canvas/ui-jsx-canvas-renderer/component-renderer-component.tsx diff --git a/editor/src/components/canvas/remix/remix-utils.tsx b/editor/src/components/canvas/remix/remix-utils.tsx index 99fcfdb001c9..6932fb824f26 100644 --- a/editor/src/components/canvas/remix/remix-utils.tsx +++ b/editor/src/components/canvas/remix/remix-utils.tsx @@ -16,13 +16,13 @@ import type { CurriedResolveFn, CurriedUtopiaRequireFn } from '../../custom-code import type { MapLike } from 'typescript' import type { UiJsxCanvasContextData } from '../ui-jsx-canvas' import { attemptToResolveParsedComponents } from '../ui-jsx-canvas' -import type { ComponentRendererComponent } from '../ui-jsx-canvas-renderer/ui-jsx-canvas-component-renderer' +import type { ComponentRendererComponent } from '../ui-jsx-canvas-renderer/component-renderer-component' import type { MutableUtopiaCtxRefData } from '../ui-jsx-canvas-renderer/ui-jsx-canvas-contexts' import type { ElementPath, TextFile } from '../../../core/shared/project-file-types' import type { ExecutionScope } from '../ui-jsx-canvas-renderer/ui-jsx-canvas-execution-scope' import { createExecutionScope } from '../ui-jsx-canvas-renderer/ui-jsx-canvas-execution-scope' import type { RemixRoutingTable } from '../../editor/store/remix-derived-data' -import { NO_OP, identity } from '../../../core/shared/utils' +import { NO_OP } from '../../../core/shared/utils' import * as EP from '../../../core/shared/element-path' import { fileExportsFunctionWithName, @@ -35,11 +35,7 @@ import { foldEither, forEachRight, left } from '../../../core/shared/either' import type { CanvasBase64Blobs } from '../../editor/store/editor-state' import { findPathToJSXElementChild } from '../../../core/model/element-template-utils' import { MetadataUtils } from '../../../core/model/element-metadata-utils' -import type { ElementInstanceMetadata } from '../../../core/shared/element-template' -import { - getJSXAttribute, - type ElementInstanceMetadataMap, -} from '../../../core/shared/element-template' +import { type ElementInstanceMetadataMap } from '../../../core/shared/element-template' import type { ElementPathTrees } from '../../../core/shared/element-path-tree' import { getAllUniqueUids } from '../../../core/model/get-unique-ids' import { safeIndex } from '../../../core/shared/array-utils' diff --git a/editor/src/components/canvas/ui-jsx-canvas-renderer/component-renderer-component.tsx b/editor/src/components/canvas/ui-jsx-canvas-renderer/component-renderer-component.tsx new file mode 100644 index 000000000000..83b944003988 --- /dev/null +++ b/editor/src/components/canvas/ui-jsx-canvas-renderer/component-renderer-component.tsx @@ -0,0 +1,30 @@ +import type { PropertyControls } from 'utopia-api/core' +import type { UTOPIA_INSTANCE_PATH, UTOPIA_PATH_KEY } from '../../../core/model/utopia-constants' +import type { ElementPath } from '../../../core/shared/project-file-types' + +export type ComponentRendererComponent = React.ComponentType< + React.PropsWithChildren<{ + [UTOPIA_INSTANCE_PATH]: ElementPath + [UTOPIA_PATH_KEY]?: string + }> +> & { + topLevelElementName: string | null + propertyControls?: PropertyControls + utopiaType: 'UTOPIA_COMPONENT_RENDERER_COMPONENT' + filePath: string + originalName: string | null +} + +export function isComponentRendererComponent( + component: + | ComponentRendererComponent + | React.ComponentType> + | null + | undefined, +): component is ComponentRendererComponent { + return ( + component != null && + typeof component === 'function' && + (component as ComponentRendererComponent).utopiaType === 'UTOPIA_COMPONENT_RENDERER_COMPONENT' + ) +} 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 00382ecdf753..953f2e8003d8 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 @@ -1,6 +1,5 @@ import React from 'react' import type { MapLike } from 'typescript' -import type { PropertyControls } from 'utopia-api/core' import type { JSXElementChild, UtopiaJSXComponent } from '../../../core/shared/element-template' import { isUtopiaJSXComponent, @@ -28,7 +27,6 @@ import { renderCoreElement, utopiaCanvasJSXLookup, } from './ui-jsx-canvas-element-renderer-utils' -import { useContextSelector } from 'use-context-selector' import type { ElementPath } from '../../../core/shared/project-file-types' import { UTOPIA_INSTANCE_PATH, UTOPIA_PATH_KEY } from '../../../core/model/utopia-constants' import { getPathsFromString, getUtopiaID } from '../../../core/shared/uid-utils' @@ -36,33 +34,8 @@ import { useGetTopLevelElementsAndImports } from './ui-jsx-canvas-top-level-elem import { useGetCodeAndHighlightBounds } from './ui-jsx-canvas-execution-scope' import { usePubSubAtomReadOnly } from '../../../core/shared/atom-with-pub-sub' import { JSX_CANVAS_LOOKUP_FUNCTION_NAME } from '../../../core/shared/dom-utils' -import { isFeatureEnabled } from '../../../utils/feature-switches' import { objectMap } from '../../../core/shared/object-utils' - -export type ComponentRendererComponent = React.ComponentType< - React.PropsWithChildren<{ - [UTOPIA_INSTANCE_PATH]: ElementPath - [UTOPIA_PATH_KEY]?: string - }> -> & { - topLevelElementName: string | null - propertyControls?: PropertyControls - utopiaType: 'UTOPIA_COMPONENT_RENDERER_COMPONENT' -} - -export function isComponentRendererComponent( - component: - | ComponentRendererComponent - | React.ComponentType> - | null - | undefined, -): component is ComponentRendererComponent { - return ( - component != null && - typeof component === 'function' && - (component as ComponentRendererComponent).utopiaType === 'UTOPIA_COMPONENT_RENDERER_COMPONENT' - ) -} +import type { ComponentRendererComponent } from './component-renderer-component' function tryToGetInstancePath( maybePath: ElementPath | null, @@ -287,6 +260,8 @@ export function createComponentRendererComponent(params: { Component.displayName = `ComponentRenderer(${params.topLevelElementName})` Component.topLevelElementName = params.topLevelElementName Component.utopiaType = 'UTOPIA_COMPONENT_RENDERER_COMPONENT' as const + Component.filePath = params.filePath + Component.originalName = params.topLevelElementName return Component } diff --git a/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-element-renderer-utils.tsx b/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-element-renderer-utils.tsx index 70cc0a00abb7..732f9039e80a 100644 --- a/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-element-renderer-utils.tsx +++ b/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-element-renderer-utils.tsx @@ -57,7 +57,7 @@ import { clearOpposingConditionalSpyValues, } from './ui-jsx-canvas-spy-wrapper' import { getUtopiaID } from '../../../core/shared/uid-utils' -import { isComponentRendererComponent } from './ui-jsx-canvas-component-renderer' +import { isComponentRendererComponent } from './component-renderer-component' import { optionalMap } from '../../../core/shared/optional-utils' import { canvasMissingJSXElementError } from './canvas-render-errors' import { importedFromWhere } from '../../editor/import-utils' diff --git a/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-execution-scope.tsx b/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-execution-scope.tsx index 355ea696ddaf..8fe85442e27e 100644 --- a/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-execution-scope.tsx +++ b/editor/src/components/canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-execution-scope.tsx @@ -8,7 +8,7 @@ import { getProjectFileByFilePath, ProjectContentsTree } from '../../assets' import { importResultFromImports } from '../../editor/npm-dependency/npm-dependency' import type { CanvasBase64Blobs, UIFileBase64Blobs } from '../../editor/store/editor-state' import { TransientFilesState, TransientFileState } from '../../editor/store/editor-state' -import type { ComponentRendererComponent } from './ui-jsx-canvas-component-renderer' +import type { ComponentRendererComponent } from './component-renderer-component' import { createComponentRendererComponent } from './ui-jsx-canvas-component-renderer' import type { MutableUtopiaCtxRefData } from './ui-jsx-canvas-contexts' import { diff --git a/editor/src/components/canvas/ui-jsx-canvas.tsx b/editor/src/components/canvas/ui-jsx-canvas.tsx index eb6a9cfb349c..05c045a299ae 100644 --- a/editor/src/components/canvas/ui-jsx-canvas.tsx +++ b/editor/src/components/canvas/ui-jsx-canvas.tsx @@ -53,7 +53,7 @@ import { normalizeName } from '../custom-code/custom-code-utils' import { getGeneratedExternalLinkText } from '../../printer-parsers/html/external-resources-parser' import { Helmet } from 'react-helmet' import parse from 'html-react-parser' -import type { ComponentRendererComponent } from './ui-jsx-canvas-renderer/ui-jsx-canvas-component-renderer' +import type { ComponentRendererComponent } from './ui-jsx-canvas-renderer/component-renderer-component' import type { MutableUtopiaCtxRefData } from './ui-jsx-canvas-renderer/ui-jsx-canvas-contexts' import { RerenderUtopiaCtxAtom, diff --git a/editor/src/components/editor/store/remix-derived-data.tsx b/editor/src/components/editor/store/remix-derived-data.tsx index fc7b6a74fe57..58d6762b4476 100644 --- a/editor/src/components/editor/store/remix-derived-data.tsx +++ b/editor/src/components/editor/store/remix-derived-data.tsx @@ -5,7 +5,7 @@ import type { } from '@remix-run/react' import type { MutableUtopiaCtxRefData } from '../../canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-contexts' import type { MapLike } from 'typescript' -import type { ComponentRendererComponent } from '../../canvas/ui-jsx-canvas-renderer/ui-jsx-canvas-component-renderer' +import type { ComponentRendererComponent } from '../../canvas/ui-jsx-canvas-renderer/component-renderer-component' import type { DataRouteObject } from 'react-router' import { getProjectFileByFilePath, 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 8d0acf4b7e64..53dbba309716 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 @@ -1,7 +1,7 @@ import { within } from '@testing-library/react' import * as EP from '../../../../core/shared/element-path' import { selectComponentsForTest, wait } from '../../../../utils/utils.test-utils' -import { mouseClickAtPoint } from '../../../canvas/event-helpers.test-utils' +import { mouseClickAtPoint, pressKey } from '../../../canvas/event-helpers.test-utils' import { renderTestEditorWithCode } from '../../../canvas/ui-jsx.test-utils' import { DataPickerPopupButtonTestId, @@ -35,6 +35,44 @@ describe('Set element prop via the data picker', () => { }) }) +describe('Controls from registerComponent', () => { + it('registering internal component', async () => { + const editor = await renderTestEditorWithCode( + registerInternalComponentProject, + 'await-first-dom-report', + ) + await selectComponentsForTest(editor, [EP.fromString('sb/scene/pg:root/title')]) + + const dataPickerOpenerButton = editor.renderedDOM.getByTestId( + `text-string-input-property-control`, + ) + dataPickerOpenerButton.focus() + document.execCommand('insertText', false, 'New title') + await pressKey('Enter', { targetElement: dataPickerOpenerButton }) + + const theScene = editor.renderedDOM.getByTestId('scene') + expect(within(theScene).queryByText('New title')).not.toBeNull() + }) + + it('registering external component', async () => { + const editor = await renderTestEditorWithCode( + registerExternalComponentProject, + 'await-first-dom-report', + ) + await selectComponentsForTest(editor, [EP.fromString('sb/scene/pg:root/title')]) + + const dataPickerOpenerButton = editor.renderedDOM.getByTestId( + `sampleprop-string-input-property-control`, + ) + dataPickerOpenerButton.focus() + document.execCommand('insertText', false, 'New props value') + await pressKey('Enter', { targetElement: dataPickerOpenerButton }) + + const theView = editor.renderedDOM.getByTestId('view') + expect(theView.outerHTML).toContain('sampleprop="New props value"') + }) +}) + const project = `import * as React from 'react' import { Storyboard, Scene } from 'utopia-api' @@ -88,3 +126,142 @@ export var storyboard = ( )` + +const registerInternalComponentProject = `import * as React from 'react' +import { + Storyboard, + Scene, + registerComponent, +} from 'utopia-api' + +function Title({ text }) { + return

{text}

+} + +var Playground = ({ style }) => { + return ( +
+ + </div> + ) +} + +export var storyboard = ( + <Storyboard data-uid='sb'> + <Scene + style={{ + width: 521, + height: 266, + position: 'absolute', + left: 554, + top: 247, + backgroundColor: 'white', + }} + data-uid='scene' + data-testid='scene' + commentId='120' + > + <Playground + style={{ + width: 454, + height: 177, + position: 'absolute', + left: 34, + top: 44, + backgroundColor: 'white', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + title='Hello Utopia' + data-uid='pg' + /> + </Scene> + </Storyboard> +) + +registerComponent(Title, { + supportsChildren: false, + properties: { + text: { + control: 'string-input', + }, + }, + variants: [ + { + code: '<Title />', + }, + ], +}) + +` + +const registerExternalComponentProject = `import * as React from 'react' +import { + Storyboard, + Scene, + View, + registerComponent, +} from 'utopia-api' + +var Playground = ({ style }) => { + return ( + <div style={style} data-uid='root' data-testid='view'> + <View sampleprop='Hello Utopia' data-uid='title'> + Hello Utopia + </View> + </div> + ) +} + +export var storyboard = ( + <Storyboard data-uid='sb'> + <Scene + style={{ + width: 521, + height: 266, + position: 'absolute', + left: 554, + top: 247, + backgroundColor: 'white', + }} + data-uid='scene' + data-testid='scene' + commentId='120' + > + <Playground + style={{ + width: 454, + height: 177, + position: 'absolute', + left: 34, + top: 44, + backgroundColor: 'white', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + title='Hello Utopia' + data-uid='pg' + /> + </Scene> + </Storyboard> +) + +registerComponent( + View, + { + supportsChildren: false, + properties: { + sampleprop: { + control: 'string-input', + }, + }, + variants: [ + { + code: '<View />', + }, + ], + }, + 'utopia-api', +)` diff --git a/editor/src/core/property-controls/property-controls-local.spec.tsx b/editor/src/core/property-controls/property-controls-local.spec.tsx index 48ff0ecfb266..2b8f3fe03244 100644 --- a/editor/src/core/property-controls/property-controls-local.spec.tsx +++ b/editor/src/core/property-controls/property-controls-local.spec.tsx @@ -69,131 +69,6 @@ describe('registered property controls', () => { await wait(10) // this is quite ugly but we want to wait for a timeout(0) in ui-jsx-canvas before calling validateControlsToCheck const editorState = renderResult.getEditorState().editor - expect(editorState.propertyControlsInfo['/src/card']).toMatchInlineSnapshot(` - Object { - "Card": Object { - "properties": Object { - "background": Object { - "control": "color", - }, - "label": Object { - "control": "string-input", - }, - "visible": Object { - "control": "checkbox", - }, - }, - "supportsChildren": false, - "variants": Array [ - Object { - "elementToInsert": [Function], - "importsToAdd": Object { - "/src/card": Object { - "importedAs": null, - "importedFromWithin": Array [ - Object { - "alias": "Card", - "name": "Card", - }, - ], - "importedWithName": null, - }, - }, - "insertMenuLabel": "Card", - }, - Object { - "elementToInsert": [Function], - "importsToAdd": Object { - "/src/card": Object { - "importedAs": null, - "importedFromWithin": Array [ - Object { - "alias": "Card", - "name": "Card", - }, - ], - "importedWithName": null, - }, - "/src/defaults": Object { - "importedAs": null, - "importedFromWithin": Array [ - Object { - "alias": "DefaultPerson", - "name": "DefaultPerson", - }, - ], - "importedWithName": null, - }, - }, - "insertMenuLabel": "ID Card", - }, - ], - }, - } - `) - }) - it('registered controls with registerComponent are in editor state', async () => { - const testCode = Prettier.format( - ` - import * as React from 'react' - import { Scene, Storyboard, View, registerComponent } from 'utopia-api' - - export var App = (props) => { - return ( - <div>hello</div> - ) - } - - registerComponent( - 'Card', - '/src/card', - { - supportsChildren: false, - properties: { - label: { - control: 'string-input', - }, - background: { - control: 'color', - }, - visible: { - control: 'checkbox', - defaultValue: true, - }, - }, - variants: [ - { - code: '<Card />', - label: 'Card', - }, - { - code: '<Card person={DefaultPerson} />', - label: 'ID Card', - additionalImports: "import { DefaultPerson } from '/src/defaults';", - }, - ], - }, - ) - - export var storyboard = (props) => { - return ( - <Storyboard data-uid='${BakedInStoryboardUID}'> - <Scene - style={{ position: 'absolute', left: 0, top: 0, width: 400, height: 400 }} - data-uid='${TestScene0UID}' - > - <App data-uid='${TestAppUID}' /> - </Scene> - </Storyboard> - ) - }`, - PrettierConfig, - ) - - const renderResult = await renderTestEditorWithCode(testCode, 'dont-await-first-dom-report') - await wait(10) // this is quite ugly but we want to wait for a timeout(0) in ui-jsx-canvas before calling validateControlsToCheck - const editorState = renderResult.getEditorState().editor - expect(editorState.propertyControlsInfo['/src/card']).toMatchInlineSnapshot(` Object { "Card": Object { diff --git a/editor/src/core/property-controls/property-controls-local.ts b/editor/src/core/property-controls/property-controls-local.ts index f2c6cb4ce231..355cd27a5443 100644 --- a/editor/src/core/property-controls/property-controls-local.ts +++ b/editor/src/core/property-controls/property-controls-local.ts @@ -1,3 +1,4 @@ +import type React from 'react' import type { registerModule as registerModuleAPI, registerComponent as registerComponentAPI, @@ -42,6 +43,8 @@ import { getGlobalEvaluatedFileName } from '../shared/code-exec-utils' import { memoize } from '../shared/memoize' import fastDeepEqual from 'fast-deep-equal' import { TimedCacheMap } from '../shared/timed-cache-map' +import { dropFileExtension } from '../shared/file-utils' +import { isComponentRendererComponent } from '../../components/canvas/ui-jsx-canvas-renderer/component-renderer-component' async function parseInsertOption( insertOption: ComponentInsertOption, @@ -274,11 +277,35 @@ export function createRegisterModuleAndComponentFunction(workers: UtopiaTsWorker } function registerComponent( - componentName: string, - unparsedModuleName: string, + component: React.FunctionComponent, properties: ComponentToRegister, + moduleName?: string, ): void { - return registerModule(unparsedModuleName, { [componentName]: properties }) + // when the recieved component is an internal component, it is wrapped into a ComponentRendererComponent + if (isComponentRendererComponent(component)) { + const name = component.originalName + const module = dropFileExtension(component.filePath) + + if (name == null) { + console.warn( + `registerComponent failed: ComponentRendererComponent of internal component doesn't have originalName`, + ) + return + } + + registerModule(module, { [name]: properties }) + } + + // when the received component is not a ComponentRendererComponent, it is an external component, and a moduleName is required + if (moduleName == null) { + console.warn( + 'registerComponent failed: external components require a moduleName ', + component.displayName, + ) + return + } + const name = component.displayName ?? component.name + registerModule(moduleName, { [name]: properties }) } return { diff --git a/utopia-api/src/helpers/helper-functions.ts b/utopia-api/src/helpers/helper-functions.ts index 4d2f8a828957..5bf01c46d696 100644 --- a/utopia-api/src/helpers/helper-functions.ts +++ b/utopia-api/src/helpers/helper-functions.ts @@ -21,9 +21,9 @@ export function registerModule( } export function registerComponent( - componentName: string, - moduleName: string, + component: React.FunctionComponent, properties: ComponentToRegister, + moduleName?: string, ): void { // Function is deliberately empty. If called inside the Utopia Editor, it has effect to the running environment }