Skip to content

Commit

Permalink
Children row in inspector props (#5839)
Browse files Browse the repository at this point in the history
**Problem:**

The children prop for components is not shown in the inspector.

**Fix:**

- Show the `children` prop in the inspector and allow changing it, via
either a cartouche or number/string inputs
- Allow setting children elements when asking for a prop update
- For number and string children, use string/number inputs - or the
default JSX component (or cartouche for data references) otherwise
- If the selected component does not support children, don't show it



https://github.com/concrete-utopia/utopia/assets/1081051/a66df108-2812-4c7d-9d65-aa83f8641bc6



**Manual Tests:**
I hereby swear that:

- [x] I opened a hydrogen project and it loaded
- [x] I could navigate to various routes in Preview mode

Fixes #5686
  • Loading branch information
ruggi authored Jun 6, 2024
1 parent 65a732b commit 21ae52d
Show file tree
Hide file tree
Showing 10 changed files with 526 additions and 122 deletions.
80 changes: 80 additions & 0 deletions editor/src/components/editor/element-children.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { ElementPath } from 'utopia-shared/src/types'
import { MetadataUtils } from '../../core/model/element-metadata-utils'
import { getRegisteredComponent } from '../../core/property-controls/property-controls-utils'
import { intrinsicHTMLElementNamesThatSupportChildren } from '../../core/shared/dom-utils'
import type {
ElementInstanceMetadataMap,
JSXElementChild,
} from '../../core/shared/element-template'
import {
isIntrinsicHTMLElement,
getJSXElementNameAsString,
} from '../../core/shared/element-template'
import type { PropertyControlsInfo } from '../custom-code/code-file'
import type { EditorAction } from './action-types'
import { deleteView, replaceElementInScope } from './actions/action-creators'
import * as EP from '../../core/shared/element-path'

export function elementSupportsChildrenFromPropertyControls(
metadata: ElementInstanceMetadataMap,
propertyControlsInfo: PropertyControlsInfo,
target: ElementPath,
) {
const targetElement = MetadataUtils.findElementByElementPath(metadata, target)

const targetJSXElement = MetadataUtils.getJSXElementFromElementInstanceMetadata(targetElement)
if (targetJSXElement == null) {
// this should not happen, erring on the side of true
return true
}
if (isIntrinsicHTMLElement(targetJSXElement.name)) {
// when it is an intrinsic html element, we check if it supports children from our list
return intrinsicHTMLElementNamesThatSupportChildren.includes(targetJSXElement.name.baseVariable)
}

const elementImportInfo = targetElement?.importInfo
if (elementImportInfo == null) {
// erring on the side of true
return true
}

const targetName = getJSXElementNameAsString(targetJSXElement.name)
const registeredComponent = getRegisteredComponent(
targetName,
elementImportInfo.filePath,
propertyControlsInfo,
)
if (registeredComponent == null) {
// when there is no component annotation default is supporting children
return true
}

return registeredComponent.supportsChildren
}

export function replaceFirstChildAndDeleteSiblings(
target: ElementPath,
children: JSXElementChild[],
replaceWith: JSXElementChild,
): EditorAction[] {
return [
// replace the first child
replaceElementInScope(target, {
type: 'replace-child-with-uid',
uid: children[0].uid,
replaceWith: replaceWith,
}),
// get rid of all the others
...children.slice(1).map((child) => deleteView(EP.appendToPath(target, child.uid))),
]
}

const reNumericComponents = /[0-9\._]/g

export function childrenAreProbablyNumericExpression(children: JSXElementChild[]) {
return children.some(
(c) =>
c.type === 'ATTRIBUTE_OTHER_JAVASCRIPT' &&
c.originalJavascript.trim().replace(reNumericComponents, '').length > 0,
)
}
33 changes: 24 additions & 9 deletions editor/src/components/inspector/common/property-controls-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
UnionControlDescription,
RegularControlDescription,
} from '../../custom-code/internal-property-controls'
import type { InspectorInfo, InspectorInfoWithRawValue } from './property-path-hooks'
import type { InspectorInfoWithRawValue } from './property-path-hooks'
import {
filterUtopiaSpecificProps,
InspectorPropsContext,
Expand All @@ -35,7 +35,7 @@ import {
useKeepReferenceEqualityIfPossible,
} from '../../../utils/react-performance'
import type { UtopiaJSXComponent } from '../../../core/shared/element-template'
import { emptyComments, isJSXElement, jsIdentifier } from '../../../core/shared/element-template'
import { isJSXElement } from '../../../core/shared/element-template'
import { addUniquely, mapDropNulls } from '../../../core/shared/array-utils'
import { Substores, useEditorState } from '../../editor/store/store-hook'
import { MetadataUtils } from '../../../core/model/element-metadata-utils'
Expand Down Expand Up @@ -66,6 +66,7 @@ function filterSpecialProps(props: Array<string>): Array<string> {
}

export function useInspectorInfoForPropertyControl(
elementPath: ElementPath,
propertyPath: PropertyPath,
control: RegularControlDescription,
): InspectorInfoWithRawValue<any> {
Expand Down Expand Up @@ -103,8 +104,22 @@ export function useInspectorInfoForPropertyControl(

const parserFn = unwrapperAndParserForPropertyControl(control)
const printerFn = printerForPropertyControl(control)

const allElementProps = useEditorState(
Substores.metadata,
(store) => store.editor.allElementProps,
'allElementProps',
)

let parsedValue: unknown = null
if (0 in rawValues && 0 in realValues) {
if (propertyPath.propertyElements[0] === 'children') {
const fromAllElementProps = allElementProps[EP.toString(elementPath)]?.children ?? null
switch (typeof fromAllElementProps) {
case 'number':
parsedValue = fromAllElementProps
break
}
} else if (0 in rawValues && 0 in realValues) {
parsedValue = eitherToMaybe(parserFn(rawValues[0], realValues[0])) // TODO We need a way to surface these errors to the users
}

Expand Down Expand Up @@ -136,13 +151,13 @@ export function useInspectorInfoForPropertyControl(
return {
value: parsedValue,
attributeExpression: attributeExpression,
controlStatus,
controlStatus: controlStatus,
propertyStatus: propertyStatusToReturn,
controlStyles,
onSubmitValue,
onTransientSubmitValue,
onUnsetValues,
useSubmitValueFactory,
controlStyles: controlStyles,
onSubmitValue: onSubmitValue,
onTransientSubmitValue: onTransientSubmitValue,
onUnsetValues: onUnsetValues,
useSubmitValueFactory: useSubmitValueFactory,
}
}

Expand Down
19 changes: 15 additions & 4 deletions editor/src/components/inspector/inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ import {
UtopiaTheme,
FlexRow,
Button,
H1,
SectionActionSheet,
SquareButton,
} from '../../uuiui'
Expand Down Expand Up @@ -100,6 +99,7 @@ import { ExpandableIndicator } from '../navigator/navigator-item/expandable-indi
import { isIntrinsicElementMetadata } from '../../core/model/project-file-utils'
import { assertNever } from '../../core/shared/utils'
import { DataReferenceSection } from './sections/data-reference-section'
import { replaceFirstChildAndDeleteSiblings } from '../editor/element-children'

export interface ElementPathElement {
name?: string
Expand Down Expand Up @@ -779,19 +779,30 @@ export const InspectorContextProvider = React.memo<{
getElementsToTarget(selectedViews),
)

const jsxMetadataRef = useRefEditorState((store) => store.editor.jsxMetadata)

const onSubmitValueForHooks = React.useCallback(
(newValue: JSExpression, path: PropertyPath, transient: boolean) => {
const actionsArray = [
...refElementsToTargetForUpdates.current.map((elem) => {
return setProp_UNSAFE(elem, path, newValue)
...refElementsToTargetForUpdates.current.flatMap((elem): EditorAction[] => {
// if the target is the children prop, replace the elements instead
if (path.propertyElements[0] === 'children') {
const element = MetadataUtils.findElementByElementPath(jsxMetadataRef.current, elem)
const children =
element != null && isRight(element.element) && isJSXElement(element.element.value)
? element.element.value.children
: []
return replaceFirstChildAndDeleteSiblings(elem, children, newValue)
}
return [setProp_UNSAFE(elem, path, newValue)]
}),
]
const actions: EditorAction[] = transient
? [transientActions(actionsArray, refElementsToTargetForUpdates.current)]
: actionsArray
dispatch(actions, 'everyone')
},
[dispatch, refElementsToTargetForUpdates],
[dispatch, refElementsToTargetForUpdates, jsxMetadataRef],
)

const onUnsetValue = React.useCallback(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React from 'react'
import type {
ControlDescription,
RegularControlDescription,
} from '../../../custom-code/internal-property-controls'
import type { ElementPath } from '../../../../core/shared/project-file-types'
import * as PP from '../../../../core/shared/property-path'
import { iconForControlType } from '../../../../uuiui'
import type { ControlForPropProps } from './property-control-controls'
import { StringInputPropertyControl } from './property-control-controls'
import { Substores, useEditorState } from '../../../editor/store/store-hook'
import { MetadataUtils } from '../../../../core/model/element-metadata-utils'
import type {
ElementInstanceMetadataMap,
JSXElementChild,
} from '../../../../core/shared/element-template'
import { isJSXElement, isJSXTextBlock } from '../../../../core/shared/element-template'
import { IdentifierExpressionCartoucheControl } from './cartouche-control'
import { renderedAtChildNode } from '../../../editor/store/editor-state'
import { isRight } from '../../../../core/shared/either'
import { DataReferenceCartoucheControl } from './data-reference-cartouche'
import { assertNever } from '../../../../core/shared/utils'
import { childrenAreProbablyNumericExpression } from '../../../editor/element-children'
import type { CartoucheDataType } from './cartouche-ui'

export function useChildrenPropOverride(
props: ControlForPropProps<RegularControlDescription> & {
onDeleteCartouche: () => void
safeToDelete: boolean
dataTypeForExpression: CartoucheDataType
},
) {
const childrenContent = useEditorState(
Substores.metadata,
(store) => {
return getMaybeChildrenContent(
store.editor.jsxMetadata,
props.elementPath,
props.propName,
props.controlDescription,
)
},
'useGetMaybeChildrenOverride maybeChildrenDataRefElement',
)

if (childrenContent == null) {
return null
}

switch (childrenContent.type) {
case 'cartouche':
return (
<DataReferenceCartoucheControl
elementPath={props.elementPath}
renderedAt={renderedAtChildNode(props.elementPath, childrenContent.value.uid)}
surroundingScope={props.elementPath}
childOrAttribute={childrenContent.value}
selected={false}
/>
)
case 'expression':
return (
<IdentifierExpressionCartoucheControl
contents={'Expression'}
icon={React.createElement(iconForControlType('none'))}
matchType='partial'
onOpenDataPicker={props.onOpenDataPicker}
onDeleteCartouche={props.onDeleteCartouche}
testId={`cartouche-${PP.toString(props.propPath)}`}
propertyPath={props.propPath}
safeToDelete={props.safeToDelete}
elementPath={props.elementPath}
datatype={props.dataTypeForExpression}
/>
)
case 'text':
return (
<StringInputPropertyControl
{...props}
controlDescription={
props.controlDescription.control === 'string-input'
? props.controlDescription
: {
control: 'string-input',
label: 'hey',
}
}
/>
)
default:
assertNever(childrenContent)
}
}

function getMaybeChildrenContent(
jsxMetadata: ElementInstanceMetadataMap,
elementPath: ElementPath,
propName: string,
controlDescription: ControlDescription,
):
| { type: 'cartouche'; value: JSXElementChild }
| { type: 'text' }
| { type: 'expression' }
| null {
if (propName !== 'children') {
return null
}

const element = MetadataUtils.findElementByElementPath(jsxMetadata, elementPath)
if (element != null && isRight(element.element) && isJSXElement(element.element.value)) {
const { children } = element.element.value
if (children.every((child) => isJSXTextBlock(child))) {
return { type: 'text' }
} else if (
controlDescription.control === 'number-input' &&
childrenAreProbablyNumericExpression(children)
) {
return { type: 'expression' }
} else if (
children.length === 1 &&
MetadataUtils.isElementDataReference(children[0]) &&
children[0].type !== 'ATTRIBUTE_OTHER_JAVASCRIPT'
) {
return { type: 'cartouche', value: children[0] }
}
}

return null
}
Loading

0 comments on commit 21ae52d

Please sign in to comment.