Skip to content

Commit

Permalink
magic stuff for multiple entity Transform #2
Browse files Browse the repository at this point in the history
  • Loading branch information
nicoecheza committed Nov 14, 2024
1 parent 7e1485b commit 2bb97eb
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 97 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const MultiEntityInspector = withSdk<{ entities: Entity[] }>(({ sdk, entities })
</div>
{inspectors.map(
({ name, component: Inspector }, index) =>
!hiddenComponents[name] && <Inspector key={index} entities={entities} />
!hiddenComponents[name] && <Inspector key={`${index}-${entities.join(',')}`} entities={entities} />
)}
</div>
)
Expand Down Expand Up @@ -165,14 +165,14 @@ const SingleEntityInspector = withSdk<{ entity: Entity | null }>(({ sdk, entity
<EntityHeader entity={entity} />
{inspectors.map(
({ name, component: Inspector }, index) =>
!hiddenComponents[name] && <Inspector key={index} entities={[entity]} />
!hiddenComponents[name] && <Inspector key={`${index}-${entity}`} entities={[entity]} />
)}
{isBasicViewEnabled ? (
<SmartItemBasicView entity={entity} />
) : (
advancedInspectorComponents.map(
({ name, component: Inspector }, index) =>
!hiddenComponents[name] && <Inspector key={index} entity={entity} />
!hiddenComponents[name] && <Inspector key={`${index}-${entity}`} entity={entity} />
)
)}
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Entity } from '@dcl/ecs'

Check warning on line 1 in packages/@dcl/inspector/src/components/EntityInspector/TransformInspector/TransformInspector.tsx

View workflow job for this annotation

GitHub Actions / lint

'Entity' is defined but never used. Allowed unused vars must match /^_|ReactEcs/u
import { useEffect } from 'react'

import { isValidNumericInput, useComponentInput, useComponentInput2 } from '../../../hooks/sdk/useComponentInput'
import { isValidNumericInput, useComponentInput, useMultiComponentInput } from '../../../hooks/sdk/useComponentInput'
import { useHasComponent } from '../../../hooks/sdk/useHasComponent'
import { withSdk } from '../../../hoc/withSdk'

Expand All @@ -20,7 +21,7 @@ export default withSdk<Props>(({ sdk, entities }) => {
const hasTransform = useHasComponent(entity, Transform)
const transform = Transform.getOrNull(entity) ?? undefined
const config = TransformConfig.getOrNull(entity) ?? undefined
const { getInputProps } = useComponentInput2(
const { getInputProps } = useMultiComponentInput(
entities,
Transform,
fromTransform,
Expand Down
216 changes: 124 additions & 92 deletions packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { InputHTMLAttributes, useCallback, useEffect, useRef, useState } from 'react'
import { Entity } from '@dcl/ecs'
import { InputHTMLAttributes, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CrdtMessageType, Entity } from '@dcl/ecs'
import { recursiveCheck as hasDiff } from 'jest-matcher-deep-close-to/lib/recursiveCheck'
import { getValue, NestedKey, setValue } from '../../lib/logic/get-set-value'
import { Component } from '../../lib/sdk/components'
import { useComponentValue } from './useComponentValue'
import { getComponentValue, isLastWriteWinComponent, useComponentValue } from './useComponentValue'
import { useSdk } from './useSdk'
import { useChange } from './useChange'

type Input = {
[key: string]: boolean | string | string[] | any[] | Record<string, boolean | string | string[] | any[] | Input>
Expand All @@ -15,6 +18,9 @@ export function isValidNumericInput(input: Input[keyof Input]): boolean {
if (typeof input === 'boolean') {
return !!input
}
if (typeof input === 'number') {
return !isNaN(input)
}
return input.length > 0 && !isNaN(Number(input))
}

Expand Down Expand Up @@ -114,6 +120,28 @@ export const useComponentInput = <ComponentValueType extends object, InputType e
return { getInputProps: getProps, isValid }
}

// Helper function to recursively merge values
const mergeValues = (values: any[]): any => {
// Base case - if any value is not an object, compare directly
if (!values.every(val => val && typeof val === 'object')) {

Check failure on line 126 in packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `val` with `(val)`
return values.every(val => val === values[0]) ? values[0] : '--' // symbol?

Check failure on line 127 in packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `val` with `(val)`
}

// Get all keys from all objects
const allKeys = [...new Set(values.flatMap(Object.keys))]

// Create result object
const result: any = {}

// For each key, recursively merge values
for (const key of allKeys) {
const valuesForKey = values.map(obj => obj[key])

Check failure on line 138 in packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `obj` with `(obj)`
result[key] = mergeValues(valuesForKey)
}

return result
}

const mergeComponentValues = <ComponentValueType extends object, InputType extends Input>(
values: ComponentValueType[],
fromComponentValueToInput: (componentValue: ComponentValueType) => InputType
Expand All @@ -129,124 +157,128 @@ const mergeComponentValues = <ComponentValueType extends object, InputType exten

// For each key in first input
for (const key in firstInput) {
const firstValue = firstInput[key]

// Check if all inputs have same value for this key
const allSame = inputs.every((input) => {
const value = input[key]
if (typeof value === 'object' && value !== null) {
// For objects, compare stringified versions
return JSON.stringify(value) === JSON.stringify(firstValue)
}
return value === firstValue
})

// Set result value based on whether all inputs match
result[key] = allSame ? firstValue : ('--' as any)
const valuesForKey = inputs.map(input => input[key])

Check failure on line 160 in packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `input` with `(input)`
result[key] = mergeValues(valuesForKey)
}

return result
}

export const useComponentInput2 = <ComponentValueType extends object, InputType extends Input>(
const getEntityAndComponentValue = <ComponentValueType extends object>(entities: Entity[], component: Component<ComponentValueType>): [Entity, ComponentValueType][] => {

Check failure on line 167 in packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `entities:·Entity[],·component:·Component<ComponentValueType>` with `⏎··entities:·Entity[],⏎··component:·Component<ComponentValueType>⏎`
return entities.map((entity) => [entity, getComponentValue(entity, component) as ComponentValueType])
}

export const useMultiComponentInput = <ComponentValueType extends object, InputType extends Input>(
entities: Entity[],
component: Component<ComponentValueType>,
fromComponentValueToInput: (componentValue: ComponentValueType) => InputType,
fromInputToComponentValue: (input: InputType) => ComponentValueType,
validateInput: (input: InputType) => boolean = () => true,

Check failure on line 176 in packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts

View workflow job for this annotation

GitHub Actions / lint

Delete `,`
deps: unknown[] = []
) => {
const componentValues = entities.map((entity) => useComponentValue<ComponentValueType>(entity, component))
const [componentValue, _, isEqual] = componentValues[0]
const asd = mergeComponentValues(
componentValues.map(([value]) => value),
fromComponentValueToInput
)
const [input, setInput] = useState<InputType | null>(
componentValue === null ? null : fromComponentValueToInput(componentValue)
// If there's only one entity, use the single entity version just to be safe for now
if (entities.length === 1) {
return useComponentInput(
entities[0],
component,
fromComponentValueToInput,
fromInputToComponentValue,
validateInput
)
}
const sdk = useSdk()

// Get initial merged value from all entities
const initialEntityValues = getEntityAndComponentValue(entities, component)
const initialMergedValue = useMemo(() =>

Check failure on line 192 in packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `⏎····`
mergeComponentValues(

Check failure on line 193 in packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `··`
initialEntityValues.map(([_, component]) => component),

Check failure on line 194 in packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `··`
fromComponentValueToInput

Check failure on line 195 in packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `··`
),
[] // only compute on mount
)
const [focusedOn, setFocusedOn] = useState<string | null>(null)
const skipSyncRef = useRef(false)
const [isValid, setIsValid] = useState(true)

const updateInputs = useCallback((value: InputType | null, skipSync = false) => {
skipSyncRef.current = skipSync
setInput(value)
}, [])
const [value, setMergeValue] = useState(initialMergedValue)
const [isValid, setIsValid] = useState(true)
const [isFocused, setIsFocused] = useState(false)

const handleUpdate =
// Handle input updates
const handleUpdate = useCallback(
(path: NestedKey<InputType>, getter: (event: React.ChangeEvent<HTMLInputElement>) => any = (e) => e.target.value) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
if (input === null) return
const newInputs = setValue(input, path, getter(event))
updateInputs(newInputs)
}
if (!value) return

const handleFocus = useCallback(
(path: NestedKey<InputType>) => () => {
setFocusedOn(path)
},
[]
)
const newValue = setValue(value, path, getter(event))
if (!hasDiff(value, newValue, 2)) return

const handleBlur = useCallback(() => {
if (componentValue === null) return
setFocusedOn(null)
updateInputs(fromComponentValueToInput(componentValue))
}, [componentValue])
// Only update if component is last-write-win and SDK exists
if (!isLastWriteWinComponent(component) || !sdk) {
setMergeValue(newValue)
return
}

const validate = useCallback(
(input: InputType | null): input is InputType => input !== null && validateInput(input),
[input, ...deps]
// Validate and update all entities
const entityUpdates = getEntityAndComponentValue(entities, component).map(([entity, componentValue]) => {
const updatedInput = setValue(fromComponentValueToInput(componentValue as any), path, getter(event))
const newComponentValue = fromInputToComponentValue(updatedInput)
return {
entity,
value: newComponentValue,
isValid: validateInput(updatedInput)
}
})

const allUpdatesValid = entityUpdates.every(({ isValid }) => isValid)

if (allUpdatesValid) {
entityUpdates.forEach(({ entity, value }) => {
sdk.operations.updateValue(component, entity, value)
})
void sdk.operations.dispatch()
}

setMergeValue(newValue)
setIsValid(allUpdatesValid)
},
[value, sdk, component, entities, fromInputToComponentValue, fromComponentValueToInput, validateInput]
)

// sync inputs -> engine
useEffect(() => {
if (skipSyncRef.current) return
if (validate(input)) {
const newComponentValue = { ...componentValue, ...fromInputToComponentValue(input) }
if (isEqual(newComponentValue)) return
// Sync with engine changes
useChange(
(event) => {
const isRelevantUpdate =
entities.includes(event.entity) &&
component.componentId === event.component?.componentId &&
event.value &&
event.operation === CrdtMessageType.PUT_COMPONENT

for (const [_, setComponentValue] of componentValues) {
setComponentValue(newComponentValue)
}
}
}, [input])
if (!isRelevantUpdate) return

// sync engine -> inputs
useEffect(() => {
if (componentValue === null) return
const updatedEntityValues = getEntityAndComponentValue(entities, component)
const newMergedValue = mergeComponentValues(
updatedEntityValues.map(([_, component]) => component),
fromComponentValueToInput
)

let newInputs = fromComponentValueToInput(componentValue) as any
if (focusedOn) {
// skip sync from state while editing, to avoid overriding the user input
const current = getValue(input, focusedOn)
newInputs = setValue(newInputs, focusedOn, current)
}
// set "skipSync" to avoid cyclic component value change
updateInputs(newInputs, true)
}, [componentValue, ...deps])
if (!hasDiff(value, newMergedValue, 2) || isFocused) return

useEffect(() => {
setIsValid(validate(input))
}, [input, ...deps])
setMergeValue(newMergedValue)
},
[entities, component, fromComponentValueToInput, value, isFocused]
)

const getProps = useCallback(
// Input props getter
const getInputProps = useCallback(
(
path: NestedKey<InputType>,
getter?: (event: React.ChangeEvent<HTMLInputElement>) => any
): Pick<InputHTMLAttributes<HTMLElement>, 'value' | 'onChange' | 'onFocus' | 'onBlur'> => {
const value = (getValue(asd, path) || '').toString()

return {
value,
onChange: handleUpdate(path, getter),
onFocus: handleFocus(path),
onBlur: handleBlur
}
},
[handleUpdate, handleFocus, handleBlur, input]
): Pick<InputHTMLAttributes<HTMLElement>, 'value' | 'onChange' | 'onFocus' | 'onBlur'> => ({
value: (getValue(value, path) || '').toString(),
onChange: handleUpdate(path, getter),
onFocus: () => setIsFocused(true),
onBlur: () => setIsFocused(false)
}),
[value, handleUpdate]
)

return { getInputProps: getProps, isValid }
return { getInputProps, isValid }
}

0 comments on commit 2bb97eb

Please sign in to comment.