Skip to content

Commit

Permalink
Multi-column data picker (#5883)
Browse files Browse the repository at this point in the history
**Problem:**
The data picker is still somewhat crummy for exploring multiple deep
objects in the scope

**Fix:**
First version of the multi-column data picker!

<img width="717" alt="image"
src="https://github.com/concrete-utopia/utopia/assets/2226774/ec87b233-a233-4aac-bce2-35efdc55537d">
<img width="716" alt="image"
src="https://github.com/concrete-utopia/utopia/assets/2226774/7a75d93d-4ed3-472f-acdc-e1a112fcbcf4">
<img width="715" alt="image"
src="https://github.com/concrete-utopia/utopia/assets/2226774/73cb6619-24f6-48a4-be62-457b11982051">

Array picker mode:
<img width="713" alt="image"
src="https://github.com/concrete-utopia/utopia/assets/2226774/dcd91689-00d5-404a-a1b7-7f12b98ddfa4">



**Commit Details:**
- Added `DataSelectorColumns` component and file
- Tried to only make minimal changes in `data-selector-modal.tsx` so I
only have a small conflict with @bkrmendy
- When picking a List Source, we show non-pickable options, but without
background
- Changed the CartoucheUI so it can have disabled background and
disabled highlight

**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


**TODOs for follow up work**
- [ ] For some reason some objects are not traversable, they become
leafs. I believe this is behavior of the existing code, needs
investigation:
<img width="729" alt="image"
src="https://github.com/concrete-utopia/utopia/assets/2226774/5a1a931f-3e3d-40c1-b0f8-7d05a727e663">

- [ ] Right now we only filter for array type when picking a List
Source, we should instead always filter for the correct type
- [ ] the value preview column is a placeholder design at best
- [ ] there is at least one example in the old demo store where we
incorrectly show blue cartouches instead of green:
<img width="776" alt="image"
src="https://github.com/concrete-utopia/utopia/assets/2226774/cf39960e-55d7-44f5-9b7c-94eef397f393">
  • Loading branch information
balazsbajorics authored Jun 11, 2024
1 parent a9b84ca commit 8dbdfca
Show file tree
Hide file tree
Showing 5 changed files with 411 additions and 156 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export interface HoverHandlers {

export type CartoucheDataType = 'renderable' | 'boolean' | 'array' | 'object' | 'unknown'

type CartoucheSource = 'internal' | 'external' | 'literal'
export type CartoucheHighlight = 'strong' | 'subtle'
export type CartoucheSource = 'internal' | 'external' | 'literal'
export type CartoucheHighlight = 'strong' | 'subtle' | 'disabled'

export type CartoucheUIProps = React.PropsWithChildren<{
tooltip?: string | null
Expand Down Expand Up @@ -50,6 +50,8 @@ export const CartoucheUI = React.forwardRef(
preview = false,
} = props

const showBackground = role !== 'information'

const colors = useCartoucheColors(source, highlight ?? null)

const wrappedOnClick = useStopPropagation(onClick)
Expand Down Expand Up @@ -85,12 +87,25 @@ export const CartoucheUI = React.forwardRef(
width: 'max-content',
}}
css={{
color: selected || highlight === 'strong' ? colors.fg.selected : colors.fg.default,
backgroundColor: selected ? colors.bg.selected : colors.bg.default,
':hover': {
color: selected || highlight === 'strong' ? undefined : colors.fg.hovered,
backgroundColor: selected ? undefined : colors.bg.hovered,
},
color:
role === 'information'
? undefined
: selected || highlight === 'strong'
? colors.fg.selected
: colors.fg.default,
backgroundColor:
showBackground == false
? 'transparent'
: selected
? colors.bg.selected
: colors.bg.default,
':hover':
highlight === 'disabled'
? {}
: {
color: selected || highlight === 'strong' ? undefined : colors.fg.hovered,
backgroundColor: selected ? undefined : colors.bg.hovered,
},
}}
>
{source === 'literal' ? null : (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
import styled from '@emotion/styled'
import React from 'react'
import {
dataPathSuccess,
traceDataFromVariableName,
} from '../../../../core/data-tracing/data-tracing'
import { isPrefixOf } from '../../../../core/shared/array-utils'
import { arrayEqualsByReference, assertNever } from '../../../../core/shared/utils'
import { unless, when } from '../../../../utils/react-conditionals'
import { FlexColumn, FlexRow, colorTheme } from '../../../../uuiui'
import { Substores, useEditorState } from '../../../editor/store/store-hook'
import type { CartoucheSource, CartoucheUIProps } from './cartouche-ui'
import { CartoucheUI } from './cartouche-ui'
import type { ArrayOption, DataPickerOption, ObjectOption, ObjectPath } from './data-picker-utils'
import { MapCounterUi } from '../../../navigator/navigator-item/map-counter'

interface DataSelectorColumnsProps {
activeScope: Array<DataPickerOption>
targetPathInsideScope: ObjectPath
onTargetPathChange: (newTargetPath: ObjectPath) => void
}

export const DataSelectorColumns = React.memo((props: DataSelectorColumnsProps) => {
return (
<FlexRow
style={{
flexGrow: 1,
alignItems: 'flex-start',
overflowX: 'scroll',
overflowY: 'hidden',
scrollbarWidth: 'auto',
scrollbarColor: 'gray transparent',
}}
>
<DataSelectorColumn
activeScope={props.activeScope}
targetPathInsideScope={props.targetPathInsideScope}
onTargetPathChange={props.onTargetPathChange}
currentlyShowingScopeForArray={false}
originalDataForScope={null}
/>
</FlexRow>
)
})

interface DataSelectorColumnProps {
activeScope: Array<DataPickerOption>
originalDataForScope: DataPickerOption | null
currentlyShowingScopeForArray: boolean
targetPathInsideScope: ObjectPath
onTargetPathChange: (newTargetPath: ObjectPath) => void
}

const DataSelectorColumn = React.memo((props: DataSelectorColumnProps) => {
const { activeScope, targetPathInsideScope, currentlyShowingScopeForArray } = props

const elementOnSelectedPath: DataPickerOption | null =
activeScope.find((option) => isPrefixOf(option.valuePath, targetPathInsideScope)) ?? null

// if the current scope is an array, we want to show not only the array indexes, but also the contents of the first element of the array
const pseudoSelectedElementForArray: DataPickerOption | null =
elementOnSelectedPath == null && currentlyShowingScopeForArray && activeScope.length > 0
? activeScope[0]
: null

const nextColumnScope: ArrayOption | ObjectOption | null = (() => {
const elementToUseForNextColumn = elementOnSelectedPath ?? pseudoSelectedElementForArray
if (elementToUseForNextColumn != null) {
if (
(elementToUseForNextColumn.type === 'object' ||
elementToUseForNextColumn.type === 'array') &&
elementToUseForNextColumn.children.length > 0
) {
return elementToUseForNextColumn
}
}
return null
})()

const nextColumnScopeValue = nextColumnScope == null ? elementOnSelectedPath : null

const dataSource = useVariableDataSource(props.originalDataForScope)

const isLastColumn = nextColumnScope == null && nextColumnScopeValue == null
const columnRef = useScrollIntoView(isLastColumn)

return (
<>
<DataSelectorFlexColumn ref={columnRef}>
{activeScope.map((option, index) => {
const selected = arrayEqualsByReference(option.valuePath, targetPathInsideScope)
const onActivePath = isPrefixOf(option.valuePath, targetPathInsideScope)
return (
<RowWithCartouche
key={option.variableInfo.expression}
data={option}
currentlyShowingScopeForArray={currentlyShowingScopeForArray}
isLeaf={nextColumnScope == null}
selected={selected}
onActivePath={onActivePath}
forceShowArrow={pseudoSelectedElementForArray != null && index === 0}
onTargetPathChange={props.onTargetPathChange}
forcedDataSource={dataSource}
/>
)
})}
</DataSelectorFlexColumn>
{nextColumnScope != null ? (
<DataSelectorColumn
activeScope={nextColumnScope.children}
targetPathInsideScope={targetPathInsideScope}
onTargetPathChange={props.onTargetPathChange}
currentlyShowingScopeForArray={nextColumnScope.type === 'array'}
originalDataForScope={props.originalDataForScope ?? elementOnSelectedPath}
/>
) : null}
{nextColumnScopeValue != null ? <ValuePreviewColumn data={nextColumnScopeValue} /> : null}
</>
)
})

interface ValuePreviewColumnProps {
data: DataPickerOption
}

const ValuePreviewColumn = React.memo((props: ValuePreviewColumnProps) => {
const text = JSON.stringify(props.data.variableInfo.value, undefined, 2)
const ref = useScrollIntoView(true)
return (
<DataSelectorFlexColumn ref={ref}>
<div
style={{
padding: 4,
border: '1px solid #ccc',
borderRadius: 4,
minHeight: 150,
width: 200,
overflowWrap: 'break-word',
overflowX: 'hidden',
overflowY: 'scroll',
scrollbarWidth: 'auto',
scrollbarColor: 'gray transparent',
['textWrap' as any]: 'balance', // I think we need to update some typings here?
}}
>
{text}
</div>
<span>{variableTypeForInfo(props.data)}</span>
</DataSelectorFlexColumn>
)
})

function variableTypeForInfo(info: DataPickerOption): string {
switch (info.type) {
case 'array':
return 'Array'
case 'object':
return 'Object'
case 'primitive':
return typeof info.variableInfo.value
case 'jsx':
return 'JSX'
default:
assertNever(info)
}
}

interface RowWithCartoucheProps {
data: DataPickerOption
currentlyShowingScopeForArray: boolean
selected: boolean
onActivePath: boolean
forceShowArrow: boolean
isLeaf: boolean
forcedDataSource: CartoucheSource | null
onTargetPathChange: (newTargetPath: ObjectPath) => void
}
const RowWithCartouche = React.memo((props: RowWithCartoucheProps) => {
const {
onTargetPathChange,
data,
currentlyShowingScopeForArray,
forcedDataSource,
isLeaf,
selected,
onActivePath,
forceShowArrow,
} = props
const targetPath = data.valuePath

const dataSource = useVariableDataSource(data)

const onClick: React.MouseEventHandler<HTMLDivElement> = React.useCallback(
(e) => {
e.stopPropagation()
onTargetPathChange(targetPath)
},
[targetPath, onTargetPathChange],
)

const ref = useScrollIntoView(selected)

return (
<FlexRow
onClick={onClick}
ref={ref}
style={{
alignSelf: 'stretch',
justifyContent: 'space-between',
fontSize: 10,
borderRadius: 4,
height: 24,
backgroundColor: onActivePath ? colorTheme.bg4.value : undefined,
padding: 5,
cursor: 'pointer',
}}
>
<span>
<CartoucheUI
key={data.valuePath.toString()}
source={forcedDataSource ?? dataSource ?? 'internal'}
datatype={childTypeToCartoucheDataType(data.type)}
selected={!data.disabled && selected}
highlight={data.disabled ? 'disabled' : null}
role={data.disabled ? 'information' : 'selection'}
testId={`data-selector-option-${data.variableInfo.expression}`}
badge={
data.type === 'array' ? (
<MapCounterUi
counterValue={data.variableInfo.elements.length}
overrideStatus='no-override'
selectedStatus={selected}
/>
) : undefined
}
>
{currentlyShowingScopeForArray ? (
<>
<span style={{ fontStyle: 'italic' }}>item </span>
{data.variableInfo.expressionPathPart}
</>
) : (
data.variableInfo.expressionPathPart
)}
</CartoucheUI>
</span>
<span
style={{
color: colorTheme.fg6.value,
opacity: !isLeaf && (onActivePath || forceShowArrow) ? 1 : 0,
}}
>
{'>'}
</span>
</FlexRow>
)
})

function useVariableDataSource(variable: DataPickerOption | null) {
return useEditorState(
Substores.projectContentsAndMetadata,
(store) => {
if (variable == null) {
return null
}
const container = variable.variableInfo.insertionCeiling
const trace = traceDataFromVariableName(
container,
variable.variableInfo.expression,
store.editor.jsxMetadata,
store.editor.projectContents,
dataPathSuccess([]),
)

switch (trace.type) {
case 'hook-result':
return 'external'
case 'literal-attribute':
case 'literal-assignment':
return 'literal'
case 'component-prop':
case 'element-at-scope':
case 'failed':
return 'internal'
break
default:
assertNever(trace)
}
},
'useVariableDataSource',
)
}

function childTypeToCartoucheDataType(
childType: DataPickerOption['type'],
): CartoucheUIProps['datatype'] {
switch (childType) {
case 'array':
return 'array'
case 'object':
return 'object'
case 'jsx':
case 'primitive':
return 'renderable'
default:
assertNever(childType)
}
}

const DataSelectorFlexColumn = styled(FlexColumn)({
minWidth: 200,
height: '100%',
flexShrink: 0,
overflowX: 'hidden',
overflowY: 'scroll',
scrollbarWidth: 'auto',
scrollbarColor: 'gray transparent',
paddingRight: 10, // to account for scrollbar
paddingLeft: 6,
paddingBottom: 10,
borderRight: `1px solid ${colorTheme.subduedBorder.cssValue}`,
})

function useScrollIntoView(shouldScroll: boolean) {
const elementRef = React.useRef<HTMLDivElement>(null)

React.useEffect(() => {
if (shouldScroll && elementRef.current != null) {
elementRef.current.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest',
})
}
}, [shouldScroll])

return elementRef
}
Loading

0 comments on commit 8dbdfca

Please sign in to comment.