diff --git a/packages/@dcl/inspector/src/components/EntityInspector/EntityInspector.tsx b/packages/@dcl/inspector/src/components/EntityInspector/EntityInspector.tsx index 3e5ecd6d9..528cfbfa8 100644 --- a/packages/@dcl/inspector/src/components/EntityInspector/EntityInspector.tsx +++ b/packages/@dcl/inspector/src/components/EntityInspector/EntityInspector.tsx @@ -63,7 +63,7 @@ const MultiEntityInspector = withSdk<{ entities: Entity[] }>(({ sdk, entities }) {inspectors.map( ({ name, component: Inspector }, index) => - !hiddenComponents[name] && + !hiddenComponents[name] && )} ) @@ -165,14 +165,14 @@ const SingleEntityInspector = withSdk<{ entity: Entity | null }>(({ sdk, entity {inspectors.map( ({ name, component: Inspector }, index) => - !hiddenComponents[name] && + !hiddenComponents[name] && )} {isBasicViewEnabled ? ( ) : ( advancedInspectorComponents.map( ({ name, component: Inspector }, index) => - !hiddenComponents[name] && + !hiddenComponents[name] && ) )} diff --git a/packages/@dcl/inspector/src/components/EntityInspector/TransformInspector/TransformInspector.tsx b/packages/@dcl/inspector/src/components/EntityInspector/TransformInspector/TransformInspector.tsx index 2ca684c10..ca02c2fe4 100644 --- a/packages/@dcl/inspector/src/components/EntityInspector/TransformInspector/TransformInspector.tsx +++ b/packages/@dcl/inspector/src/components/EntityInspector/TransformInspector/TransformInspector.tsx @@ -1,6 +1,7 @@ +import { Entity } from '@dcl/ecs' 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' @@ -20,7 +21,7 @@ export default withSdk(({ 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, diff --git a/packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts b/packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts index e709d993b..52fc7918a 100644 --- a/packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts +++ b/packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts @@ -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 @@ -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)) } @@ -114,6 +120,28 @@ export const useComponentInput = { + // Base case - if any value is not an object, compare directly + if (!values.every(val => val && typeof val === 'object')) { + return values.every(val => val === values[0]) ? values[0] : '--' // symbol? + } + + // 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]) + result[key] = mergeValues(valuesForKey) + } + + return result +} + const mergeComponentValues = ( values: ComponentValueType[], fromComponentValueToInput: (componentValue: ComponentValueType) => InputType @@ -129,124 +157,128 @@ const mergeComponentValues = { - 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]) + result[key] = mergeValues(valuesForKey) } return result } -export const useComponentInput2 = ( +const getEntityAndComponentValue = (entities: Entity[], component: Component): [Entity, ComponentValueType][] => { + return entities.map((entity) => [entity, getComponentValue(entity, component) as ComponentValueType]) +} + +export const useMultiComponentInput = ( entities: Entity[], component: Component, fromComponentValueToInput: (componentValue: ComponentValueType) => InputType, fromInputToComponentValue: (input: InputType) => ComponentValueType, validateInput: (input: InputType) => boolean = () => true, - deps: unknown[] = [] ) => { - const componentValues = entities.map((entity) => useComponentValue(entity, component)) - const [componentValue, _, isEqual] = componentValues[0] - const asd = mergeComponentValues( - componentValues.map(([value]) => value), - fromComponentValueToInput - ) - const [input, setInput] = useState( - 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(() => + mergeComponentValues( + initialEntityValues.map(([_, component]) => component), + fromComponentValueToInput + ), + [] // only compute on mount ) - const [focusedOn, setFocusedOn] = useState(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, getter: (event: React.ChangeEvent) => any = (e) => e.target.value) => (event: React.ChangeEvent) => { - if (input === null) return - const newInputs = setValue(input, path, getter(event)) - updateInputs(newInputs) - } + if (!value) return - const handleFocus = useCallback( - (path: NestedKey) => () => { - 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, getter?: (event: React.ChangeEvent) => any - ): Pick, '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, '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 } }