Skip to content

Commit

Permalink
Render prop picker (#5035)
Browse files Browse the repository at this point in the history
* Click to insert on empty render prop

* fix(editor) enable selection and editing of render props

* Nested navigator view

* wip - popup

* wip - select prop from variants

* max width on popup

* Quick fix for SpyWrapper issue

* Fix non-rendered elements

* Turn on FS

* don't consider elements within generated

* add important todo

* remove element from elementsWithin

* Only allow insert on empty render prop

* whoops

* revert deleting generated elements

* Added label for children

* Updated design

* Empty render prop slot

* use context menu styles in render prop picker

* Fix children label when there are only empty props

* Better fake uids

* Remove deletion stuff

* Remove childOrAttribute

* Updated design

* Make expression check safer

* Cleanup

* Adapt jsx elements in props to render prop navigator

* Fix children label

* Fix children label

* remove `isRendered`

* wip - test

* test with render prop filled out

* test with empty render prop

* cleanup

* remove unused import

* add early return

* `notRenderPropChildren`

* `isEntryAPlaceholder`

* remove `SyntheticNavigatorEntry.renderProp`

* remove renderprop too

* show slot for null value

* slot navigator entry

* popup

* move render prop picker to its own file

---------

Co-authored-by: Balint Gabor <[email protected]>
Co-authored-by: RheeseyB <[email protected]>
  • Loading branch information
3 people authored Mar 14, 2024
1 parent 0150d8c commit c4dda31
Show file tree
Hide file tree
Showing 7 changed files with 156 additions and 12 deletions.
4 changes: 3 additions & 1 deletion editor/src/components/editor/store/editor-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2120,12 +2120,14 @@ export function syntheticNavigatorEntry(
export interface SlotNavigatorEntry {
type: 'SLOT'
elementPath: ElementPath
prop: string
}

export function slotNavigatorEntry(elementPath: ElementPath): SlotNavigatorEntry {
export function slotNavigatorEntry(elementPath: ElementPath, prop: string): SlotNavigatorEntry {
return {
type: 'SLOT',
elementPath: elementPath,
prop: prop,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -671,10 +671,12 @@ export const InvalidOverrideNavigatorEntryKeepDeepEquality: KeepDeepEqualityCall
)

export const SlotNavigatorEntryKeepDeepEquality: KeepDeepEqualityCall<SlotNavigatorEntry> =
combine1EqualityCall(
combine2EqualityCalls(
(entry) => entry.elementPath,
ElementPathKeepDeepEquality,
(path) => ({ type: 'SLOT', elementPath: path }),
(entry) => entry.prop,
StringKeepDeepEquality,
(elementPath, prop) => ({ type: 'SLOT', elementPath: elementPath, prop: prop }),
)

export const NavigatorEntryKeepDeepEquality: KeepDeepEqualityCall<NavigatorEntry> = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export interface RenderPropNavigatorItemContainerProps

export interface SlotNavigatorItemContainerProps extends NavigatorItemDragAndDropWrapperPropsBase {
parentElementPath: ElementPath
renderProp: string
}

export interface ConditionalClauseNavigatorItemContainerProps
Expand Down Expand Up @@ -1091,8 +1092,8 @@ export const RenderPropNavigatorItemContainer = React.memo(

export const SlotNavigatorItemContainer = React.memo((props: SlotNavigatorItemContainerProps) => {
const navigatorEntry = React.useMemo(
() => slotNavigatorEntry(props.parentElementPath),
[props.parentElementPath],
() => slotNavigatorEntry(props.parentElementPath, props.renderProp),
[props.parentElementPath, props.renderProp],
)

const safeComponentId = varSafeNavigatorEntryToKey(navigatorEntry)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ export const NavigatorItemWrapper: React.FunctionComponent<
if (props.navigatorEntry.type === 'SLOT') {
const entryProps: SlotNavigatorItemContainerProps = {
...navigatorItemProps,
renderProp: props.navigatorEntry.prop,
parentElementPath: props.navigatorEntry.elementPath,
}
return <SlotNavigatorItemContainer {...entryProps} />
Expand Down
29 changes: 23 additions & 6 deletions editor/src/components/navigator/navigator-item/navigator-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {
} from '../../../core/shared/element-template'
import { hasElementsWithin } from '../../../core/shared/element-template'
import type { ElementPath } from '../../../core/shared/project-file-types'
import { unless, when } from '../../../utils/react-conditionals'
import { unless } from '../../../utils/react-conditionals'
import { useKeepReferenceEqualityIfPossible } from '../../../utils/react-performance'
import type { IcnColor, IcnProps } from '../../../uuiui'
import { FlexRow, useColorTheme, UtopiaTheme } from '../../../uuiui'
Expand Down Expand Up @@ -55,10 +55,11 @@ import { ExpandableIndicator } from './expandable-indicator'
import { ItemLabel } from './item-label'
import { LayoutIcon } from './layout-icon'
import { NavigatorItemActionSheet } from './navigator-item-components'
import { assertNever } from '../../../core/shared/utils'
import { CanvasContextMenuPortalTargetID, assertNever } from '../../../core/shared/utils'
import type { ElementPathTrees } from '../../../core/shared/element-path-tree'
import { MapCounter } from './map-counter'
import { Outlet } from 'react-router'
import ReactDOM from 'react-dom'
import { RenderPropPicker, useShowRenderPropPicker } from './render-prop-selector-popup'

export function getItemHeight(navigatorEntry: NavigatorEntry): number {
if (isConditionalClauseNavigatorEntry(navigatorEntry)) {
Expand Down Expand Up @@ -205,7 +206,7 @@ const getColors = (

return {
style: { background, color },
iconColor,
iconColor: iconColor,
}
}

Expand Down Expand Up @@ -760,6 +761,12 @@ export const NavigatorItem: React.FunctionComponent<
)
}, [childComponentCount, isFocusedComponent, isConditional])

const renderPropPickerId = varSafeNavigatorEntryToKey(navigatorEntry)
const { showRenderPropPicker: showContextMenu, hideRenderPropPicker: hideContextMenu } =
useShowRenderPropPicker(renderPropPickerId)

const portalTarget = document.getElementById(CanvasContextMenuPortalTargetID)

const iconColor = isRemixItem
? 'remix'
: isCodeItem
Expand All @@ -770,6 +777,7 @@ export const NavigatorItem: React.FunctionComponent<

return (
<div
onClick={navigatorEntry.type === 'SLOT' ? showContextMenu : hideContextMenu}
style={{
outline: `1px solid ${
props.parentOutline === 'solid' && isOutletOrDescendantOfOutlet
Expand All @@ -781,6 +789,17 @@ export const NavigatorItem: React.FunctionComponent<
outlineOffset: props.parentOutline === 'solid' ? '-1px' : 0,
}}
>
{portalTarget == null || navigatorEntry.type !== 'SLOT'
? null
: ReactDOM.createPortal(
<RenderPropPicker
target={props.navigatorEntry.elementPath}
key={renderPropPickerId}
id={renderPropPickerId}
prop={navigatorEntry.prop}
/>,
portalTarget,
)}
<FlexRow
data-testid={NavigatorItemTestId(varSafeNavigatorEntryToKey(navigatorEntry))}
style={rowStyle}
Expand Down Expand Up @@ -905,9 +924,7 @@ export const NavigatorRowLabel = React.memo((props: NavigatorRowLabelProps) => {
const isComponentScene =
useIsProbablyScene(props.navigatorEntry) && props.childComponentCount === 1

const isRemixScene = props.remixItemType === 'scene'
const isOutlet = props.remixItemType === 'outlet'
const isLink = props.remixItemType === 'link'

const backgroundLozengeColor =
isCodeItem && !props.selected
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React from 'react'
import { useContextMenu, Menu } from 'react-contexify'
import { MetadataUtils } from '../../../core/model/element-metadata-utils'
import {
getJSXElementNameAsString,
jsExpressionOtherJavaScriptSimple,
} from '../../../core/shared/element-template'
import type { ElementPath } from '../../../core/shared/project-file-types'
import { useDispatch } from '../../editor/store/dispatch-context'
import { Substores, useEditorState } from '../../editor/store/store-hook'
import { setProp_UNSAFE } from '../../editor/actions/action-creators'
import * as EP from '../../../core/shared/element-path'
import * as PP from '../../../core/shared/property-path'

const usePreferredChildrenForTargetProp = (target: ElementPath, prop: string) => {
const selectedJSXElement = useEditorState(
Substores.metadata,
(store) => MetadataUtils.getJSXElementFromMetadata(store.editor.jsxMetadata, target),
'usePreferredChildrenForSelectedElement selectedJSXElement',
)

const preferredChildrenForTargetProp = useEditorState(
Substores.restOfEditor,
(store) => {
if (selectedJSXElement == null) {
return null
}

const targetName = getJSXElementNameAsString(selectedJSXElement.name)
// TODO: we don't deal with components registered with the same name in multiple files
for (const file of Object.values(store.editor.propertyControlsInfo)) {
for (const [name, value] of Object.entries(file)) {
if (name === targetName) {
for (const [registeredPropName, registeredPropValue] of Object.entries(
value.properties,
)) {
if (
registeredPropName === prop &&
registeredPropValue.control === 'jsx' &&
registeredPropValue.preferredChildComponents != null
) {
return registeredPropValue.preferredChildComponents
}
}
}
}
}

return null
},
'usePreferredChildrenForSelectedElement propertyControlsInfo',
)

if (selectedJSXElement == null || preferredChildrenForTargetProp == null) {
return null
}

return preferredChildrenForTargetProp
}

export const useShowRenderPropPicker = (id: string) => {
const { show, hideAll } = useContextMenu({ id })
const onClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
show(event)
},
[show],
)

return { showRenderPropPicker: onClick, hideRenderPropPicker: hideAll }
}

interface RenderPropPickerProps {
target: ElementPath
prop: string
key: string
id: string
}

export const RenderPropPicker = React.memo<RenderPropPickerProps>(({ key, id, target, prop }) => {
const preferredChildrenForTargetProp = usePreferredChildrenForTargetProp(
EP.parentPath(target),
prop,
)

const dispatch = useDispatch()

const onItemClick = React.useCallback(
(rawJSCodeForRenderProp: string) => (e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()

dispatch([
setProp_UNSAFE(
EP.parentPath(target),
PP.create(prop),
jsExpressionOtherJavaScriptSimple(rawJSCodeForRenderProp, []),
),
])
},
[dispatch, prop, target],
)

if (preferredChildrenForTargetProp == null) {
return null
}

const rawJSToInsert: Array<string> = (preferredChildrenForTargetProp ?? []).flatMap((data) =>
data.variants == null ? `<${data.name} />` : data.variants.map((v) => v.code),
)

return (
<Menu key={key} id={id} animation={false} style={{ padding: 8 }}>
{rawJSToInsert.map((option, idx) => (
<div key={idx} onClick={onItemClick(option)}>
{option}
</div>
))}
</Menu>
)
})
2 changes: 1 addition & 1 deletion editor/src/components/navigator/navigator-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export function getNavigatorTargets(
if (propValue == null || (isJSExpressionValue(propValue) && propValue.value == null)) {
const entries = [
renderPropNavigatorEntry(fakeRenderPropPath, prop),
slotNavigatorEntry(fakeRenderPropPath),
slotNavigatorEntry(fakeRenderPropPath, prop),
]
navigatorTargets.push(...entries)
visibleNavigatorTargets.push(...entries)
Expand Down

0 comments on commit c4dda31

Please sign in to comment.