diff --git a/packages/core/src/form/index.ts b/packages/core/src/form/index.ts index ab60b2d..9627dd1 100644 --- a/packages/core/src/form/index.ts +++ b/packages/core/src/form/index.ts @@ -1,6 +1,11 @@ import { useRef } from 'react'; import { SugarInner } from '../sugar'; -import { Sugar, SugarValue, SugarGetResult } from '../sugar/types'; +import { + Sugar, + SugarValue, + SugarGetResult, + SugarTemplateState, +} from '../sugar/types'; export interface UseFormResult { sugar: Sugar; @@ -10,7 +15,7 @@ export interface UseFormResult { export const useForm = ({ template, }: { - template?: T; + template?: SugarTemplateState; } = {}): UseFormResult => { const sugar = useRef>(undefined); if (!sugar.current) { diff --git a/packages/core/src/lib.ts b/packages/core/src/lib.ts index a1ff34a..07b9fc6 100644 --- a/packages/core/src/lib.ts +++ b/packages/core/src/lib.ts @@ -2,4 +2,4 @@ export { useForm } from './form'; export type { UseFormResult } from './form'; export { TextInput } from './components/textInput'; export { NumberInput } from './components/numberInput'; -export type { Sugar } from './sugar/types'; +export type { Sugar, SugarTemplateState } from './sugar/types'; diff --git a/packages/core/src/sugar/index.ts b/packages/core/src/sugar/index.ts index 8b6ed3c..7937fb2 100644 --- a/packages/core/src/sugar/index.ts +++ b/packages/core/src/sugar/index.ts @@ -7,6 +7,7 @@ import { SugarSetResult, SugarSetter, SugarTemplateSetter, + SugarTemplateState, SugarValue, SugarValueObject, } from './types'; @@ -17,6 +18,7 @@ import { ValidationStage, FailFn, } from './useValidation'; +import { useIsPending, SugarUseIsPending } from './useIsPending'; export class SugarInner { // Sugarは、get/setができるようになるまでに、Reactのレンダリングを待つ必要があります。 @@ -63,12 +65,12 @@ export class SugarInner { status: 'unavailable'; }; - template: T | undefined; + template: SugarTemplateState; private validators: Set< (stage: ValidationStage, value: T) => Promise > = new Set(); - constructor(template?: T) { + constructor(template?: SugarTemplateState) { const { promise: getPromise, resolve: resolveGetPromise } = Promise.withResolvers>(); const { promise: setPromise, resolve: resolveSetPromise } = @@ -155,7 +157,7 @@ export class SugarInner { } setTemplate(value: T, executeSet = true): Promise> { - this.template = value; + this.template = { status: 'resolved', value }; switch (this.status.status) { case 'unavailable': @@ -181,6 +183,20 @@ export class SugarInner { } } + setPendingTemplate(): void { + this.template = { status: 'pending' }; + if (this.status.status === 'ready' && this.status.templateSetter) { + this.status.templateSetter(undefined as T, false); + } + } + + private getTemplateValue(): T | undefined { + if (this.template?.status === 'resolved') { + return this.template.value; + } + return undefined; + } + private eventTarget: EventTarget = new EventTarget(); addEventListener( @@ -214,7 +230,7 @@ export class SugarInner { const status = this.status; status.lock = true; - const initial = status.recentValue ?? this.template; + const initial = status.recentValue ?? this.getTemplateValue(); if (initial !== undefined) { status.resolveSetPromise(await setter(initial)); } @@ -273,4 +289,7 @@ export class SugarInner { deps?: React.DependencyList ) => useValidation(this as Sugar, validator, deps)) as SugarUseValidation; + + useIsPending: SugarUseIsPending = (() => + useIsPending(this as Sugar)) as SugarUseIsPending; } diff --git a/packages/core/src/sugar/types.ts b/packages/core/src/sugar/types.ts index 9b8c3b7..8b9be81 100644 --- a/packages/core/src/sugar/types.ts +++ b/packages/core/src/sugar/types.ts @@ -1,6 +1,11 @@ export type SugarValue = unknown; export type SugarValueObject = SugarValue & Record; +export type SugarTemplateState = + | { status: 'pending' } + | { status: 'resolved'; value: T } + | undefined; + export type SugarGetResult = | { result: 'success'; @@ -34,6 +39,7 @@ export type SugarTemplateSetter = ( import type { SugarUseObject } from './useObject'; import type { SugarUseValidation } from './useValidation'; +import type { SugarUseIsPending } from './useIsPending'; type SugarType = { get: SugarGetter; @@ -47,6 +53,7 @@ type SugarType = { destroy: () => void; useObject: SugarUseObject; useValidation: SugarUseValidation; + useIsPending: SugarUseIsPending; addEventListener: ( type: K, listener: CustomEventListener diff --git a/packages/core/src/sugar/useIsPending.ts b/packages/core/src/sugar/useIsPending.ts new file mode 100644 index 0000000..57583b4 --- /dev/null +++ b/packages/core/src/sugar/useIsPending.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; +import { SugarInner } from '.'; +import { Sugar, SugarValue } from './types'; + +export type SugarUseIsPending = () => boolean; + +export function useIsPending(sugar: Sugar): boolean { + const [isPending, setIsPending] = useState(() => { + const sugarInner = sugar as unknown as SugarInner; + return sugarInner.template?.status === 'pending'; + }); + + useEffect(() => { + const checkPendingState = () => { + const sugarInner = sugar as unknown as SugarInner; + const newIsPending = sugarInner.template?.status === 'pending'; + setIsPending(newIsPending); + }; + + checkPendingState(); + + const originalSetTemplate = sugar.setTemplate.bind(sugar); + sugar.setTemplate = async (value: T, executeSet = true) => { + const result = await originalSetTemplate(value, executeSet); + checkPendingState(); + return result; + }; + + const sugarInner = sugar as unknown as SugarInner; + const originalSetPendingTemplate = + sugarInner.setPendingTemplate?.bind(sugarInner); + if (originalSetPendingTemplate) { + sugarInner.setPendingTemplate = () => { + originalSetPendingTemplate(); + checkPendingState(); + }; + } + + return () => { + sugar.setTemplate = originalSetTemplate; + if (originalSetPendingTemplate) { + sugarInner.setPendingTemplate = originalSetPendingTemplate; + } + }; + }, [sugar]); + + return isPending; +} diff --git a/packages/core/src/sugar/useObject.ts b/packages/core/src/sugar/useObject.ts index fd0696b..358da5d 100644 --- a/packages/core/src/sugar/useObject.ts +++ b/packages/core/src/sugar/useObject.ts @@ -3,6 +3,7 @@ import { Sugar, SugarGetResult, SugarSetResult, + SugarTemplateState, SugarValue, SugarValueObject, } from './types'; @@ -36,7 +37,33 @@ export function useObject( { get: (target: Record>, prop: string, _) => { if (!(prop in target)) { - const s = new SugarInner((sugar as SugarInner).template?.[prop]); + const parentTemplate = (sugar as SugarInner).template; + let childTemplate: SugarTemplateState; + + if (parentTemplate?.status === 'pending') { + childTemplate = { status: 'pending' }; + } else if (parentTemplate?.status === 'resolved') { + const parentValue = parentTemplate.value as Record< + string, + unknown + >; + if ( + parentValue && + typeof parentValue === 'object' && + prop in parentValue + ) { + childTemplate = { + status: 'resolved', + value: parentValue[prop], + }; + } else { + childTemplate = undefined; + } + } else { + childTemplate = undefined; + } + + const s = new SugarInner(childTemplate); sugarInitializer.current.forEach((initializer) => { s.addEventListener('change', initializer.dispatchChange); s.addEventListener('blur', initializer.dispatchBlur); @@ -63,6 +90,7 @@ export function useObject( sugar.addEventListener('change', dispatchChange); sugar.addEventListener('blur', dispatchBlur); }); + sugarInitializer.current.push(initializer); sugar.ready( @@ -157,30 +185,74 @@ export function useObject( // return { result: 'unavailable' }; // } - const results: [string, SugarSetResult][] = await Promise.all( - Object.entries(fields.current!).map(async ([key, s]) => { - if (key in value) { - const nestedValue = (value as Record)[key]; - const result = await s.setTemplate(nestedValue, executeSet); - return [key, result]; - } - return [key, { result: 'success' as const }]; - }) - ); + const parentTemplate = (sugar as SugarInner).template; - const unavailables = results.filter( - ([_, value]) => value.result === 'unavailable' - ); - if (unavailables.length > 0) { - console.error( - `Setting template for useObject sugar: ${unavailables - .map(([key, _]) => key) - .join(', ')} is unavailable.` + if (parentTemplate?.status === 'pending') { + await Promise.all( + Object.entries(fields.current!).map(async ([_, s]) => { + (s as SugarInner).setPendingTemplate(); + }) ); - return { result: 'unavailable' }; - } + return { result: 'success' }; + } else if (parentTemplate?.status === 'resolved') { + const parentValue = parentTemplate.value as Record; + const results: [string, SugarSetResult][] = + await Promise.all( + Object.entries(fields.current!).map(async ([key, s]) => { + if ( + parentValue && + typeof parentValue === 'object' && + key in parentValue + ) { + const result = await s.setTemplate( + parentValue[key], + executeSet + ); + return [key, result]; + } + return [key, { result: 'success' as const }]; + }) + ); - return { result: 'success' }; + const unavailables = results.filter( + ([_, value]) => value.result === 'unavailable' + ); + if (unavailables.length > 0) { + console.error( + `Setting template for useObject sugar: ${unavailables + .map(([key, _]) => key) + .join(', ')} is unavailable.` + ); + return { result: 'unavailable' }; + } + + return { result: 'success' }; + } else { + const results: [string, SugarSetResult][] = + await Promise.all( + Object.entries(fields.current!).map(async ([key, s]) => { + const result = await s.setTemplate( + undefined as unknown, + executeSet + ); + return [key, result]; + }) + ); + + const unavailables = results.filter( + ([_, value]) => value.result === 'unavailable' + ); + if (unavailables.length > 0) { + console.error( + `Setting template for useObject sugar: ${unavailables + .map(([key, _]) => key) + .join(', ')} is unavailable.` + ); + return { result: 'unavailable' }; + } + + return { result: 'success' }; + } } ); @@ -189,7 +261,7 @@ export function useObject( sugar.destroy(); if (fields.current) { Object.values(fields.current).forEach((sugar) => { - sugar.removeEventListener('change', dispatchEvent); + sugar.removeEventListener('change', dispatchChange); sugar.removeEventListener('blur', dispatchBlur); }); sugarInitializer.current = sugarInitializer.current.filter( diff --git a/tests/core-unittest/src/collect.spec.tsx b/tests/core-unittest/src/collect.spec.tsx index 40fd27c..62f88dc 100644 --- a/tests/core-unittest/src/collect.spec.tsx +++ b/tests/core-unittest/src/collect.spec.tsx @@ -6,7 +6,7 @@ import { describeWithStrict } from '../util/describeWithStrict'; describeWithStrict('useForm#collect', () => { test('collect method should be equivalent to sugar.get(true)', async () => { const { result } = renderHook(() => - useForm({ template: 'initial' }) + useForm({ template: { status: 'resolved', value: 'initial' } }) ); render(); @@ -23,7 +23,9 @@ describeWithStrict('useForm#collect', () => { }); test('collect method should trigger validation like sugar.get(true)', async () => { - const { result } = renderHook(() => useForm({ template: { a: '' } })); + const { result } = renderHook(() => + useForm({ template: { status: 'resolved', value: { a: '' } } }) + ); const { result: obj } = renderHook(() => result.current.sugar.useObject()); const validate = async ( @@ -48,8 +50,8 @@ describeWithStrict('useForm#collect', () => { test('collect method should return the same type as sugar.get(true)', async () => { const { result } = renderHook(() => - useForm<{ name: string; age: number }>({ - template: { name: 'John', age: 25 }, + useForm({ + template: { status: 'resolved', value: { name: 'John', age: 25 } }, }) ); const { result: obj } = renderHook(() => result.current.sugar.useObject()); diff --git a/tests/core-unittest/src/component.spec.tsx b/tests/core-unittest/src/component.spec.tsx index 07d0507..57484e4 100644 --- a/tests/core-unittest/src/component.spec.tsx +++ b/tests/core-unittest/src/component.spec.tsx @@ -22,7 +22,9 @@ type Component = { describeWithStrict('Component requirements', () => { describe.each(Components)('$name', (c) => { test('Component should be ready after render', async () => { - const { result } = renderHook(() => useForm({ template: c.template })); + const { result } = renderHook(() => + useForm({ template: { status: 'resolved', value: c.template } }) + ); const get = result.current.sugar.get(); expect(await checkPending(get)).toStrictEqual({ resolved: false }); @@ -39,7 +41,9 @@ describeWithStrict('Component requirements', () => { }); test('Sugar should be destroyed after unmount', async () => { - const { result } = renderHook(() => useForm({ template: c.template })); + const { result } = renderHook(() => + useForm({ template: { status: 'resolved', value: c.template } }) + ); const { unmount } = render(); await expect(result.current.sugar.get()).resolves.toStrictEqual({ result: 'success', diff --git a/tests/core-unittest/src/destroyBeforeReady.spec.tsx b/tests/core-unittest/src/destroyBeforeReady.spec.tsx index c01e079..b9c17d2 100644 --- a/tests/core-unittest/src/destroyBeforeReady.spec.tsx +++ b/tests/core-unittest/src/destroyBeforeReady.spec.tsx @@ -6,7 +6,9 @@ import { checkPending } from '../util/checkPending'; describeWithStrict('Sugar#destroy before ready', () => { test('destroy resolves pending promises', async () => { - const { result } = renderHook(() => useForm({ template: '' })); + const { result } = renderHook(() => + useForm({ template: { status: 'resolved', value: '' } }) + ); const getPromise = result.current.sugar.get(); const setPromise = result.current.sugar.set('test'); diff --git a/tests/core-unittest/src/setTemplate.spec.tsx b/tests/core-unittest/src/setTemplate.spec.tsx index a1a3f7e..4e59c9e 100644 --- a/tests/core-unittest/src/setTemplate.spec.tsx +++ b/tests/core-unittest/src/setTemplate.spec.tsx @@ -7,7 +7,7 @@ import { SugarInner } from '../../../packages/core/src/sugar/index'; describeWithStrict('Sugar#setTemplate', () => { test('setTemplate(value, true) updates template and executes set (default behavior)', async () => { const { result } = renderHook(() => - useForm({ template: 'original' }) + useForm({ template: { status: 'resolved', value: 'original' } }) ); render(); @@ -21,14 +21,17 @@ describeWithStrict('Sugar#setTemplate', () => { value: 'new template', }); - expect((result.current.sugar as SugarInner).template).toBe( - 'new template' + expect((result.current.sugar as SugarInner).template).toStrictEqual( + { + status: 'resolved', + value: 'new template', + } ); }); test('setTemplate(value, false) updates template only without executing set', async () => { const { result } = renderHook(() => - useForm({ template: 'original' }) + useForm({ template: { status: 'resolved', value: 'original' } }) ); render(); @@ -43,14 +46,17 @@ describeWithStrict('Sugar#setTemplate', () => { value: 'current value', }); - expect((result.current.sugar as SugarInner).template).toBe( - 'new template' + expect((result.current.sugar as SugarInner).template).toStrictEqual( + { + status: 'resolved', + value: 'new template', + } ); }); test('setTemplate without executeSet parameter defaults to true', async () => { const { result } = renderHook(() => - useForm({ template: 'original' }) + useForm({ template: { status: 'resolved', value: 'original' } }) ); render(); @@ -64,14 +70,19 @@ describeWithStrict('Sugar#setTemplate', () => { value: 'new template', }); - expect((result.current.sugar as SugarInner).template).toBe( - 'new template' + expect((result.current.sugar as SugarInner).template).toStrictEqual( + { + status: 'resolved', + value: 'new template', + } ); }); test('setTemplate works with nested objects', async () => { const { result } = renderHook(() => - useForm({ template: { a: 'initial', b: 'initial' } }) + useForm({ + template: { status: 'resolved', value: { a: 'initial', b: 'initial' } }, + }) ); const { result: obj } = renderHook(() => result.current.sugar.useObject()); @@ -93,9 +104,19 @@ describeWithStrict('Sugar#setTemplate', () => { expect( (result.current.sugar as SugarInner<{ a: string; b: string }>).template - ).toStrictEqual({ a: 'new-a', b: 'new-b' }); + ).toStrictEqual({ status: 'resolved', value: { a: 'new-a', b: 'new-b' } }); - expect((obj.current.fields.a as SugarInner).template).toBe('new-a'); - expect((obj.current.fields.b as SugarInner).template).toBe('new-b'); + expect((obj.current.fields.a as SugarInner).template).toStrictEqual( + { + status: 'resolved', + value: 'new-a', + } + ); + expect((obj.current.fields.b as SugarInner).template).toStrictEqual( + { + status: 'resolved', + value: 'new-b', + } + ); }); }); diff --git a/tests/core-unittest/src/useIsPending.spec.tsx b/tests/core-unittest/src/useIsPending.spec.tsx new file mode 100644 index 0000000..a6ee0b5 --- /dev/null +++ b/tests/core-unittest/src/useIsPending.spec.tsx @@ -0,0 +1,106 @@ +import { TextInput, useForm } from '@sugarform/core'; +import { renderHook, render, act } from '@testing-library/react'; +import { expect, test } from 'vitest'; +import { describeWithStrict } from '../util/describeWithStrict'; + +describeWithStrict('Sugar#useIsPending', () => { + test('returns false when template is resolved', async () => { + const { result } = renderHook(() => + useForm({ template: { status: 'resolved', value: 'test' } }) + ); + + const { result: isPendingResult } = renderHook(() => + result.current.sugar.useIsPending() + ); + + expect(isPendingResult.current).toBe(false); + }); + + test('returns true when template is pending', async () => { + const { result } = renderHook(() => + useForm({ template: { status: 'pending' } }) + ); + + const { result: isPendingResult } = renderHook(() => + result.current.sugar.useIsPending() + ); + + expect(isPendingResult.current).toBe(true); + }); + + test('returns false when template is undefined', async () => { + const { result } = renderHook(() => useForm({ template: undefined })); + + const { result: isPendingResult } = renderHook(() => + result.current.sugar.useIsPending() + ); + + expect(isPendingResult.current).toBe(false); + }); + + test('updates when template changes from pending to resolved', async () => { + const { result } = renderHook(() => + useForm({ template: { status: 'pending' } }) + ); + + const { result: isPendingResult } = renderHook(() => + result.current.sugar.useIsPending() + ); + + render(); + await act(async () => {}); + + expect(isPendingResult.current).toBe(true); + + await act(async () => { + await result.current.sugar.setTemplate('resolved value'); + }); + + expect(isPendingResult.current).toBe(false); + }); + + test('propagates pending state to nested objects', async () => { + const { result } = renderHook(() => + useForm<{ a: string }>({ template: { status: 'pending' } }) + ); + const { result: obj } = renderHook(() => result.current.sugar.useObject()); + + const { result: childIsPendingResult } = renderHook(() => + obj.current.fields.a.useIsPending() + ); + + expect(childIsPendingResult.current).toBe(true); + }); + + test('updates nested objects when parent template changes', async () => { + const { result } = renderHook(() => + useForm<{ a: string; b: string }>({ template: { status: 'pending' } }) + ); + const { result: obj } = renderHook(() => result.current.sugar.useObject()); + + render( + <> + + + + ); + await act(async () => {}); + + const { result: childAIsPendingResult } = renderHook(() => + obj.current.fields.a.useIsPending() + ); + const { result: childBIsPendingResult } = renderHook(() => + obj.current.fields.b.useIsPending() + ); + + expect(childAIsPendingResult.current).toBe(true); + expect(childBIsPendingResult.current).toBe(true); + + await act(async () => { + await result.current.sugar.setTemplate({ a: 'value-a', b: 'value-b' }); + }); + + expect(childAIsPendingResult.current).toBe(false); + expect(childBIsPendingResult.current).toBe(false); + }); +}); diff --git a/tests/sandbox/src/App.tsx b/tests/sandbox/src/App.tsx index ea36655..d89980e 100644 --- a/tests/sandbox/src/App.tsx +++ b/tests/sandbox/src/App.tsx @@ -21,20 +21,7 @@ type FormType = { }; function App() { - const { sugar, collect } = useForm({ - template: { - person_a: { - firstName: 'Alice', - lastName: 'Smith', - birthday: { year: 2000, month: 1, day: 1 }, - }, - person_b: { - firstName: 'Bob', - lastName: 'Johnson', - birthday: { year: 2000, month: 1, day: 1 }, - }, - }, - }); + const { sugar, collect } = useForm(); const { fields } = sugar.useObject(); @@ -60,6 +47,11 @@ function App() { function PersonInput({ sugar }: { sugar: Sugar }) { const { fields } = sugar.useObject(); + const isPending = sugar.useIsPending(); + + if (isPending) { + return
Loading person data...
; + } return (
@@ -78,6 +70,7 @@ function PersonInput({ sugar }: { sugar: Sugar }) { function BirthdayInput({ sugar }: { sugar: Sugar }) { const { fields } = sugar.useObject(); + const isPending = sugar.useIsPending(); const errors = sugar.useValidation( useCallback(async (value, fail) => { @@ -107,6 +100,10 @@ function BirthdayInput({ sugar }: { sugar: Sugar }) { }, []) ); + if (isPending) { + return
Loading birthday data...
; + } + return (