Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(useData): enhance flexibility #3248

Merged
merged 1 commit into from
Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import Context, { ContextState } from '../Context'
*/
import structuredClone from '@ungap/structured-clone'

// SSR warning fix: https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85
const useLayoutEffect =
typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect

export type Path = string
export type UpdateDataValue = (path: Path, data: unknown) => void

Expand Down Expand Up @@ -118,12 +122,28 @@ export default function Provider<Data extends JsonObject>({
// - Validator
const ajvSchemaValidatorRef = useRef<ValidateFunction>()
// - Shared state
const sharedState = useSharedState(id, initialData)
const sharedState = useSharedState<Data>(id)
useMemo(() => {
if (sharedState?.data && !initialData) {
// Update the internal data set, if the shared state changes
if (id && sharedState?.data && !initialData) {
internalDataRef.current = sharedState.data
}
}, [initialData, sharedState.data])
}, [id, initialData, sharedState.data])
useLayoutEffect(() => {
// Update the shared state, if initialData is given
if (id && !sharedState?.data && initialData) {
sharedState.set?.(initialData)
}

// If the shared state changes, update the internal data set
if (
id &&
sharedState?.data &&
sharedState?.data !== internalDataRef.current
) {
internalDataRef.current = sharedState?.data
}
}, [id, initialData, sharedState, sharedState?.data])

const validateData = useCallback(() => {
if (!ajvSchemaValidatorRef.current) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -922,37 +922,153 @@ describe('DataContext.Provider', () => {
it('should rerender provider and its contents', async () => {
const existingData = { count: 1 }

let countRender = 0

const MockComponent = () => {
const { data, update } = Form.useData('update-id', existingData)
const id = React.useId()
const { data, update } = Form.useData(id, existingData)

const increment = React.useCallback(() => {
update('/count', (count) => {
return count + 1
})
}, [update])

countRender++

return (
<Form.Handler id="update-id">
<Field.Number path="/count" showStepControls />
<Form.SubmitButton
onClick={increment}
text={'Increment ' + data.count}
/>
</Form.Handler>
<DataContext.Provider id={id}>
<Field.Number path="/count" />
<Form.SubmitButton onClick={increment} text={data.count} />
</DataContext.Provider>
)
}

render(<MockComponent />)

const inputElement = document.querySelector('input')
const buttonElement = document.querySelector('button')

expect(inputElement).toHaveValue('1')
expect(buttonElement).toHaveTextContent('1')
expect(countRender).toBe(1)

await userEvent.click(
document.querySelector('.dnb-forms-submit-button')
)

expect(inputElement).toHaveValue('2')
expect(buttonElement).toHaveTextContent('2')
expect(countRender).toBe(2)
})

it('should return data given in the context provider after a rerender', async () => {
const existingData = { count: 1 }

let countRender = 0

const MockComponent = () => {
const id = React.useId()
const { data, update } = Form.useData<{ count: number }>(id)

console.log('data', data)

const increment = React.useCallback(() => {
update('/count', (count) => {
return count + 1
})
}, [update])

countRender++

return (
<DataContext.Provider id={id} data={existingData}>
<Field.Number path="/count" />
<Form.SubmitButton onClick={increment} text={data?.count} />
</DataContext.Provider>
)
}

render(<MockComponent />)

const inputElement = document.querySelector('input')
const buttonElement = document.querySelector('button')

expect(inputElement).toHaveValue('1')
expect(buttonElement).toHaveTextContent('1')
expect(countRender).toBe(2)

await userEvent.click(
document.querySelector('.dnb-forms-submit-button')
)

expect(inputElement).toHaveValue('2')
expect(buttonElement).toHaveTextContent('2')
expect(countRender).toBe(3)
})

it('should update data via useEffect when data is given in useData', async () => {
const existingData = { count: 1 }

let countRender = 0

const MockComponent = () => {
const id = React.useId()
const { data, update } = Form.useData(id, existingData)

React.useEffect(() => {
update('/count', (count) => count + 1)
}, [update])

countRender++

return (
<DataContext.Provider id={id}>
<Field.Number path="/count" label={data?.count} />
</DataContext.Provider>
)
}

render(<MockComponent />)

const inputElement = document.querySelector('input')
const labelElement = document.querySelector('label')

expect(inputElement).toHaveValue('2')
expect(labelElement).toHaveTextContent('2')
expect(countRender).toBe(2)
})

it('should update data via useEffect when data is given in the context provider', async () => {
const existingData = { count: 1 }

let countRender = 0

const MockComponent = () => {
const id = React.useId()
const { data, update } = Form.useData<{ count: number }>(id)

React.useEffect(() => {
update('/count', () => 123)
}, [update])

countRender++

return (
<DataContext.Provider id={id} data={existingData}>
<Field.Number path="/count" label={data?.count} />
</DataContext.Provider>
)
}

render(<MockComponent />)

const inputElement = document.querySelector('input')
const labelElement = document.querySelector('label')

expect(inputElement).toHaveValue('123')
expect(labelElement).toHaveTextContent('123')
expect(countRender).toBe(3)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ describe('Form.useData', () => {
expect(B.current.data).toEqual({ key: 'changed value' })
})

it('should rerender when shared state calls "set"', () => {
const { result } = renderHook(() => useData('onSet'))

const { result: sharedState } = renderHook(() =>
useSharedState('onSet')
)

act(() => {
sharedState.current.set({ foo: 'bar' })
})

expect(result.current.data).toEqual({ foo: 'bar' })
})

describe('with mock', () => {
it('should call "set" with initialData on mount if data is not present', () => {
const update = jest.fn()
Expand Down
39 changes: 19 additions & 20 deletions packages/dnb-eufemia/src/extensions/forms/Form/hooks/useData.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useReducer, useRef } from 'react'
import pointer from 'json-pointer'
import { useSharedState } from '../../../../shared/helpers/useSharedState'
import type { Path } from '../../DataContext/Provider'
Expand Down Expand Up @@ -30,34 +30,33 @@ export default function useData<Data>(
data: Data = undefined
): UseDataReturn<Data> {
const initialDataRef = useRef(data)
const sharedState = useSharedState<Data>(id, data)
const sharedStateRef = useRef(null)
const [, forceUpdate] = useReducer(() => ({}), {})
sharedStateRef.current = useSharedState<Data>(id, data, forceUpdate)

const updatePath = useCallback<UseDataReturnUpdate<Data>>(
(path, fn) => {
const existingData = sharedState.data || ({} as Data)
const existingValue = pointer.has(existingData, path)
? pointer.get(existingData, path)
: undefined
const updatePath = useCallback<UseDataReturnUpdate<Data>>((path, fn) => {
const existingData = sharedStateRef.current.data || ({} as Data)
const existingValue = pointer.has(existingData, path)
? pointer.get(existingData, path)
: undefined

// get new value
const newValue = fn(existingValue)
// get new value
const newValue = fn(existingValue)

// update existing data
pointer.set(existingData, path, newValue)
// update existing data
pointer.set(existingData, path, newValue)

// update provider
sharedState.update?.(existingData)
},
[sharedState]
)
// update provider
sharedStateRef.current?.update?.(existingData)
}, [])

// when initial data changes, update the shared state
useEffect(() => {
if (data && data !== initialDataRef.current) {
initialDataRef.current = data
sharedState.update?.(data)
sharedStateRef.current?.update?.(data)
}
}, [data, sharedState])
}, [data])

return { data: sharedState.data, update: updatePath }
return { data: sharedStateRef.current?.data, update: updatePath }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react'
import { useSharedState, getOrCreateSharedState } from '../useSharedState'
import { useSharedState, createSharedState } from '../useSharedState'

describe('useSharedState', () => {
it('should create a new shared state if one does not exist', () => {
Expand All @@ -10,7 +10,7 @@ describe('useSharedState', () => {
})

it('should use an existing shared state if one exists', () => {
getOrCreateSharedState('existingId', { test: 'existing' })
createSharedState('existingId', { test: 'existing' })
const { result } = renderHook(() =>
useSharedState('existingId', { test: 'initial' })
)
Expand All @@ -31,11 +31,11 @@ describe('useSharedState', () => {
const { result } = renderHook(() =>
useSharedState('changeId', { test: 'initial' })
)
const sharedState = getOrCreateSharedState('changeId', {
const sharedState = createSharedState('changeId', {
test: 'initial',
})
act(() => {
sharedState.updateSharedState({ test: 'changed' })
sharedState.update({ test: 'changed' })
})
expect(result.current.data).toEqual({ test: 'changed' })
})
Expand All @@ -44,12 +44,12 @@ describe('useSharedState', () => {
const { result, unmount } = renderHook(() =>
useSharedState('unmountId', { test: 'initial' })
)
const sharedState = getOrCreateSharedState('unmountId', {
const sharedState = createSharedState('unmountId', {
test: 'initial',
})
unmount()
act(() => {
sharedState.updateSharedState({ test: 'unmounted' })
sharedState.update({ test: 'unmounted' })
})
expect(result.current.data).toEqual({ test: 'initial' })
})
Expand All @@ -65,6 +65,7 @@ describe('useSharedState', () => {
const { result } = renderHook(() =>
useSharedState(null, { test: 'initial' })
)
expect(result.current.data).toBeUndefined()
act(() => {
result.current.update({ test: 'updated' })
})
Expand All @@ -75,10 +76,52 @@ describe('useSharedState', () => {
const { result, unmount } = renderHook(() =>
useSharedState(null, { test: 'initial' })
)
expect(result.current.data).toBeUndefined()
unmount()
act(() => {
result.current.update({ test: 'unmounted' })
})
expect(result.current.data).toBeUndefined()
})

it('should call onSet when set is called from another hook', () => {
const onSet = jest.fn()

const { result: resultA } = renderHook(() => useSharedState('onSet'))
const { result: resultB } = renderHook(() =>
useSharedState('onSet', undefined, onSet)
)
const { result: resultC } = renderHook(() => useSharedState('onSet'))

resultA.current.set({ foo: 'bar' })

expect(onSet).toHaveBeenCalledTimes(1)
expect(onSet).toHaveBeenCalledWith({ foo: 'bar' })

expect(resultA.current.data).toEqual(undefined)
expect(resultB.current.data).toEqual(undefined)
expect(resultC.current.data).toEqual(undefined)
})

it('should sync all hooks', () => {
const { result: resultA } = renderHook(() => useSharedState('in-sync'))
const { result: resultB } = renderHook(() => useSharedState('in-sync'))

expect(resultA.current.data).toEqual(undefined)
expect(resultB.current.data).toEqual(undefined)

act(() => {
resultA.current.update({ foo: 'bar' })
})

expect(resultA.current.data).toEqual({ foo: 'bar' })
expect(resultB.current.data).toEqual({ foo: 'bar' })

act(() => {
resultB.current.update({ foo: 'baz' })
})

expect(resultA.current.data).toEqual({ foo: 'baz' })
expect(resultB.current.data).toEqual({ foo: 'baz' })
})
})
Loading
Loading