@idem.agency/form-builder 0.0.12 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/CHANGELOG.md +0 -16
- package/eslint.config.js +0 -23
- package/public/index.html +0 -13
- package/public/main.tsx +0 -97
- package/src/app/index.test.tsx +0 -137
- package/src/app/index.tsx +0 -34
- package/src/app/test.css +0 -1
- package/src/entity/dynamicBuilder/element.tsx +0 -71
- package/src/entity/dynamicBuilder/index.tsx +0 -21
- package/src/entity/dynamicBuilder/model/createBuilderContext.tsx +0 -52
- package/src/entity/dynamicBuilder/model/index.ts +0 -7
- package/src/entity/inputs/index.ts +0 -2
- package/src/entity/inputs/ui/group/index.test.tsx +0 -46
- package/src/entity/inputs/ui/group/index.tsx +0 -26
- package/src/entity/inputs/ui/input/index.test.tsx +0 -73
- package/src/entity/inputs/ui/input/index.tsx +0 -31
- package/src/plugins/validation/index.ts +0 -39
- package/src/plugins/validation/rules/confirm.test.ts +0 -23
- package/src/plugins/validation/rules/confirm.ts +0 -11
- package/src/plugins/validation/rules/email.test.ts +0 -20
- package/src/plugins/validation/rules/email.ts +0 -10
- package/src/plugins/validation/rules/index.ts +0 -5
- package/src/plugins/validation/rules/required.test.ts +0 -24
- package/src/plugins/validation/rules/required.ts +0 -9
- package/src/plugins/validation/types/index.ts +0 -7
- package/src/plugins/validation/validation.test.ts +0 -69
- package/src/plugins/validation/validation.ts +0 -57
- package/src/plugins/visibility/core.ts +0 -56
- package/src/plugins/visibility/index.ts +0 -25
- package/src/plugins/visibility/types.ts +0 -20
- package/src/shared/hook/useUpdateEffect.tsx +0 -23
- package/src/shared/model/index.ts +0 -12
- package/src/shared/model/plugins/EventBus.ts +0 -15
- package/src/shared/model/plugins/FieldRegistry.ts +0 -15
- package/src/shared/model/plugins/HookRegistry.ts +0 -21
- package/src/shared/model/plugins/MiddlewareRegistry.ts +0 -17
- package/src/shared/model/plugins/PipelineRegistry.ts +0 -73
- package/src/shared/model/plugins/PluginManager.ts +0 -84
- package/src/shared/model/plugins/context.tsx +0 -10
- package/src/shared/model/plugins/hooks.ts +0 -22
- package/src/shared/model/plugins/index.ts +0 -23
- package/src/shared/model/plugins/types.ts +0 -99
- package/src/shared/model/store/createStoreContext.tsx +0 -70
- package/src/shared/model/store/index.test.tsx +0 -113
- package/src/shared/model/store/index.tsx +0 -96
- package/src/shared/model/store/store.test.ts +0 -64
- package/src/shared/model/store/store.ts +0 -38
- package/src/shared/test-utils.tsx +0 -33
- package/src/shared/types/common.ts +0 -45
- package/src/shared/utils.test.ts +0 -55
- package/src/shared/utils.ts +0 -36
- package/src/widgets/form/form.tsx +0 -59
- package/tsconfig.json +0 -24
- package/tsdown.config.ts +0 -10
- package/vite.config.ts +0 -12
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import type { IPlugin, IPluginContext } from './types';
|
|
2
|
-
import { HookRegistry } from './HookRegistry';
|
|
3
|
-
import { PipelineRegistry } from './PipelineRegistry';
|
|
4
|
-
import { EventBus } from './EventBus';
|
|
5
|
-
import { MiddlewareRegistry } from './MiddlewareRegistry';
|
|
6
|
-
import { FieldRegistry } from './FieldRegistry';
|
|
7
|
-
|
|
8
|
-
export class PluginManager {
|
|
9
|
-
private readonly eventRegistry = new HookRegistry();
|
|
10
|
-
private readonly pipelineRegistry = new PipelineRegistry();
|
|
11
|
-
private readonly eventBus = new EventBus();
|
|
12
|
-
private readonly middlewareRegistry = new MiddlewareRegistry();
|
|
13
|
-
private readonly fieldRegistry = new FieldRegistry();
|
|
14
|
-
private unsubscribers: Array<() => void> = [];
|
|
15
|
-
private installed = false;
|
|
16
|
-
|
|
17
|
-
constructor(private readonly plugins: IPlugin[]) {}
|
|
18
|
-
|
|
19
|
-
install(): void {
|
|
20
|
-
if (this.installed) return;
|
|
21
|
-
const context: IPluginContext = {
|
|
22
|
-
events: {
|
|
23
|
-
tap: (hook, handler) => {
|
|
24
|
-
const unsub = this.eventRegistry.tap(hook, handler);
|
|
25
|
-
this.unsubscribers.push(unsub);
|
|
26
|
-
return unsub;
|
|
27
|
-
},
|
|
28
|
-
},
|
|
29
|
-
pipeline: {
|
|
30
|
-
use: (pipeline, stage, priority) => {
|
|
31
|
-
const unsub = this.pipelineRegistry.use(pipeline, stage, priority);
|
|
32
|
-
this.unsubscribers.push(unsub);
|
|
33
|
-
return unsub;
|
|
34
|
-
},
|
|
35
|
-
useSync: (pipeline, stage, priority) => {
|
|
36
|
-
const unsub = this.pipelineRegistry.useSync(pipeline, stage, priority);
|
|
37
|
-
this.unsubscribers.push(unsub);
|
|
38
|
-
return unsub;
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
eventBus: {
|
|
42
|
-
on: (event, handler) => {
|
|
43
|
-
const unsub = this.eventBus.on(event, handler);
|
|
44
|
-
this.unsubscribers.push(unsub);
|
|
45
|
-
return unsub;
|
|
46
|
-
},
|
|
47
|
-
emit: (event, data) => this.eventBus.emit(event, data),
|
|
48
|
-
},
|
|
49
|
-
fields: {
|
|
50
|
-
register: (type, component) => {
|
|
51
|
-
const unsub = this.fieldRegistry.register(type, component);
|
|
52
|
-
this.unsubscribers.push(unsub);
|
|
53
|
-
return unsub;
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
middleware: {
|
|
57
|
-
use: (middleware) => {
|
|
58
|
-
const unsub = this.middlewareRegistry.use(middleware);
|
|
59
|
-
this.unsubscribers.push(unsub);
|
|
60
|
-
return unsub;
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
this.installed = true;
|
|
66
|
-
for (const plugin of this.plugins) {
|
|
67
|
-
plugin.install(context);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
uninstall(): void {
|
|
72
|
-
if (!this.installed) return;
|
|
73
|
-
this.installed = false;
|
|
74
|
-
[...this.unsubscribers].reverse().forEach((fn) => fn());
|
|
75
|
-
this.unsubscribers = [];
|
|
76
|
-
[...this.plugins].reverse().forEach((plugin) => plugin.uninstall?.());
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
callEvents = this.eventRegistry.call.bind(this.eventRegistry);
|
|
80
|
-
runPipeline = this.pipelineRegistry.run.bind(this.pipelineRegistry);
|
|
81
|
-
runPipelineSync = this.pipelineRegistry.runSync.bind(this.pipelineRegistry);
|
|
82
|
-
getMiddleware = this.middlewareRegistry.getAll.bind(this.middlewareRegistry);
|
|
83
|
-
getFields = this.fieldRegistry.getAll.bind(this.fieldRegistry);
|
|
84
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { createContext, useContext } from 'react';
|
|
2
|
-
import type { PluginManager } from './PluginManager';
|
|
3
|
-
|
|
4
|
-
export const PluginSystemContext = createContext<PluginManager | null>(null);
|
|
5
|
-
|
|
6
|
-
export function usePluginManager(): PluginManager {
|
|
7
|
-
const ctx = useContext(PluginSystemContext);
|
|
8
|
-
if (!ctx) throw new Error('PluginSystemContext not found');
|
|
9
|
-
return ctx;
|
|
10
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { usePluginManager } from './context';
|
|
2
|
-
import type { StoreMiddleware } from './types';
|
|
3
|
-
|
|
4
|
-
export function useCallEvents() {
|
|
5
|
-
const pm = usePluginManager();
|
|
6
|
-
return pm.callEvents;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function useRunPipeline() {
|
|
10
|
-
const pm = usePluginManager();
|
|
11
|
-
return pm.runPipeline;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function useRunPipelineSync() {
|
|
15
|
-
const pm = usePluginManager();
|
|
16
|
-
return pm.runPipelineSync;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function useMiddleware(): StoreMiddleware[] {
|
|
20
|
-
const pm = usePluginManager();
|
|
21
|
-
return pm.getMiddleware();
|
|
22
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
export type {
|
|
2
|
-
IPlugin,
|
|
3
|
-
IPluginContext,
|
|
4
|
-
IEventRegistry,
|
|
5
|
-
IPipelineRegistry,
|
|
6
|
-
IEventBus,
|
|
7
|
-
IFieldRegistry,
|
|
8
|
-
IMiddlewareRegistry,
|
|
9
|
-
FormHookName,
|
|
10
|
-
HookHandler,
|
|
11
|
-
AsyncPipelineName,
|
|
12
|
-
SyncPipelineName,
|
|
13
|
-
PipelineStage,
|
|
14
|
-
SyncPipelineStage,
|
|
15
|
-
AsyncPipelineDataMap,
|
|
16
|
-
SyncPipelineDataMap,
|
|
17
|
-
StoreMiddleware,
|
|
18
|
-
FormAction,
|
|
19
|
-
FormState,
|
|
20
|
-
} from './types';
|
|
21
|
-
export { PluginManager } from './PluginManager';
|
|
22
|
-
export { PluginSystemContext, usePluginManager } from './context';
|
|
23
|
-
export { useCallEvents, useRunPipeline, useRunPipelineSync, useMiddleware } from './hooks';
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import type { FormFieldConfig, FormData } from '@/shared/types/common';
|
|
2
|
-
import type { FormElementComponent } from '@/shared/types/common';
|
|
3
|
-
|
|
4
|
-
// ── Event registry types ─────────────────────────────────────────────────────
|
|
5
|
-
export type FormHookName =
|
|
6
|
-
| 'form:init'
|
|
7
|
-
| 'form:destroy'
|
|
8
|
-
| 'form:reset'
|
|
9
|
-
| 'form:submit:before'
|
|
10
|
-
| 'form:submit:after'
|
|
11
|
-
| 'field:register'
|
|
12
|
-
| 'field:change'
|
|
13
|
-
| 'field:validate';
|
|
14
|
-
|
|
15
|
-
export type HookHandler<T = unknown> = (data: T) => void | Promise<void>;
|
|
16
|
-
|
|
17
|
-
export interface IEventRegistry {
|
|
18
|
-
tap<T>(hook: FormHookName, handler: HookHandler<T>): () => void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// ── Pipeline types ───────────────────────────────────────────────────────────
|
|
22
|
-
export type AsyncPipelineName =
|
|
23
|
-
| 'field:change'
|
|
24
|
-
| 'form:submit'
|
|
25
|
-
| 'form:init'
|
|
26
|
-
| 'form:validate';
|
|
27
|
-
|
|
28
|
-
export type SyncPipelineName = 'field:visible';
|
|
29
|
-
|
|
30
|
-
export type PipelineName = AsyncPipelineName | SyncPipelineName;
|
|
31
|
-
|
|
32
|
-
export type AsyncPipelineDataMap = {
|
|
33
|
-
'field:change': { value: any; field: FormFieldConfig; path: string; formData: FormData; errors: string[] };
|
|
34
|
-
'form:submit': { formData: FormData };
|
|
35
|
-
'form:init': { formData: FormData };
|
|
36
|
-
'form:validate': { formData: FormData; errors: Record<string, string[]> };
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export type SyncPipelineDataMap = {
|
|
40
|
-
'field:visible': { visible: boolean; field: FormFieldConfig; path: string; formData: FormData };
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export type PipelineStage<T> = (data: T, next: (data: T) => Promise<T>) => Promise<T>;
|
|
44
|
-
export type SyncPipelineStage<T> = (data: T, next: (data: T) => T) => T;
|
|
45
|
-
|
|
46
|
-
export interface IPipelineRegistry {
|
|
47
|
-
use<K extends AsyncPipelineName>(
|
|
48
|
-
pipeline: K,
|
|
49
|
-
stage: PipelineStage<AsyncPipelineDataMap[K]>,
|
|
50
|
-
priority?: number
|
|
51
|
-
): () => void;
|
|
52
|
-
useSync<K extends SyncPipelineName>(
|
|
53
|
-
pipeline: K,
|
|
54
|
-
stage: SyncPipelineStage<SyncPipelineDataMap[K]>,
|
|
55
|
-
priority?: number
|
|
56
|
-
): () => void;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ── EventBus types ───────────────────────────────────────────────────────────
|
|
60
|
-
export interface IEventBus {
|
|
61
|
-
on<T = unknown>(event: string, handler: (data: T) => void): () => void;
|
|
62
|
-
emit<T = unknown>(event: string, data: T): void;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ── Middleware types ─────────────────────────────────────────────────────────
|
|
66
|
-
export type FormValues = Record<string, unknown>;
|
|
67
|
-
export type FormAction = { type: string; payload?: unknown };
|
|
68
|
-
export type FormState = { formData: FormValues; errors: Record<string, string[]> };
|
|
69
|
-
|
|
70
|
-
export type StoreMiddleware = (
|
|
71
|
-
action: FormAction,
|
|
72
|
-
next: (action: FormAction) => void,
|
|
73
|
-
getState: () => FormState
|
|
74
|
-
) => void;
|
|
75
|
-
|
|
76
|
-
export interface IMiddlewareRegistry {
|
|
77
|
-
use(middleware: StoreMiddleware): () => void;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── FieldRegistry types ──────────────────────────────────────────────────────
|
|
81
|
-
export interface IFieldRegistry {
|
|
82
|
-
register(type: string, component: FormElementComponent<any>): () => void;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// ── Plugin types ─────────────────────────────────────────────────────────────
|
|
86
|
-
export interface IPluginContext {
|
|
87
|
-
events: IEventRegistry;
|
|
88
|
-
pipeline: IPipelineRegistry;
|
|
89
|
-
eventBus: IEventBus;
|
|
90
|
-
fields: IFieldRegistry;
|
|
91
|
-
middleware: IMiddlewareRegistry;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export interface IPlugin {
|
|
95
|
-
name: string;
|
|
96
|
-
version?: string;
|
|
97
|
-
install(context: IPluginContext): void;
|
|
98
|
-
uninstall?(): void;
|
|
99
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createContext,
|
|
3
|
-
useContext,
|
|
4
|
-
useRef,
|
|
5
|
-
useSyncExternalStore
|
|
6
|
-
} from "react";
|
|
7
|
-
|
|
8
|
-
import { createStore, type Reducer, type Store } from "./store";
|
|
9
|
-
|
|
10
|
-
export function createStoreContext<S, A>(
|
|
11
|
-
reducer: Reducer<S, A>,
|
|
12
|
-
defaultState: S
|
|
13
|
-
) {
|
|
14
|
-
const StoreContext = createContext<Store<S, A> | null>(null);
|
|
15
|
-
const Provider: React.FC<{ children: React.ReactNode, initialState?: S }> = ({
|
|
16
|
-
children,
|
|
17
|
-
initialState
|
|
18
|
-
}) => {
|
|
19
|
-
const storeRef = useRef<Store<S, A>>(createStore(reducer, initialState ?? defaultState));
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<StoreContext.Provider value={storeRef.current}>
|
|
23
|
-
{children}
|
|
24
|
-
</StoreContext.Provider>
|
|
25
|
-
);
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
function useStore<T>(
|
|
29
|
-
selector: (state: S) => T
|
|
30
|
-
): T {
|
|
31
|
-
const store = useContext(StoreContext);
|
|
32
|
-
|
|
33
|
-
if (!store) {
|
|
34
|
-
throw new Error("StoreProvider missing");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return useSyncExternalStore(
|
|
38
|
-
store.subscribe,
|
|
39
|
-
() => selector(store.getState()),
|
|
40
|
-
() => selector(store.getState())
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function useDispatch() {
|
|
45
|
-
const store = useContext(StoreContext);
|
|
46
|
-
|
|
47
|
-
if (!store) {
|
|
48
|
-
throw new Error("StoreProvider missing");
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return store.dispatch;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function useStoreInstance(): Store<S, A> {
|
|
55
|
-
const store = useContext(StoreContext);
|
|
56
|
-
|
|
57
|
-
if (!store) {
|
|
58
|
-
throw new Error("StoreProvider missing");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return store;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
Provider,
|
|
66
|
-
useStore,
|
|
67
|
-
useDispatch,
|
|
68
|
-
useStoreInstance,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { render, act } from '@testing-library/react'
|
|
3
|
-
import React from 'react'
|
|
4
|
-
import { FormStoreProvider, useFormStore, useFormDispatch } from './index'
|
|
5
|
-
|
|
6
|
-
function wrapper(children: React.ReactNode, formData?: Record<string, any>) {
|
|
7
|
-
return render(
|
|
8
|
-
<FormStoreProvider formData={formData}>{children}</FormStoreProvider>
|
|
9
|
-
)
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe('FormStore reducer', () => {
|
|
13
|
-
it('setValue updates formData', () => {
|
|
14
|
-
let dispatch: ReturnType<typeof useFormDispatch>
|
|
15
|
-
const Comp = () => {
|
|
16
|
-
const value = useFormStore(s => s.formData.name)
|
|
17
|
-
dispatch = useFormDispatch()
|
|
18
|
-
return <span data-testid="val">{value}</span>
|
|
19
|
-
}
|
|
20
|
-
const { getByTestId } = wrapper(<Comp />)
|
|
21
|
-
act(() => dispatch({ type: 'setValue', path: 'name', value: 'John' }))
|
|
22
|
-
expect(getByTestId('val').textContent).toBe('John')
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('setFieldValue updates formData and errors', () => {
|
|
26
|
-
let dispatch: ReturnType<typeof useFormDispatch>
|
|
27
|
-
const Comp = () => {
|
|
28
|
-
const value = useFormStore(s => s.formData.email)
|
|
29
|
-
const errors = useFormStore(s => s.errors.email)
|
|
30
|
-
dispatch = useFormDispatch()
|
|
31
|
-
return (
|
|
32
|
-
<>
|
|
33
|
-
<span data-testid="val">{value}</span>
|
|
34
|
-
<span data-testid="err">{JSON.stringify(errors)}</span>
|
|
35
|
-
</>
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
const { getByTestId } = wrapper(<Comp />)
|
|
39
|
-
act(() => dispatch({ type: 'setFieldValue', path: 'email', value: 'bad', errors: ['Invalid'] }))
|
|
40
|
-
expect(getByTestId('val').textContent).toBe('bad')
|
|
41
|
-
expect(getByTestId('err').textContent).toBe('["Invalid"]')
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('setFieldValue clears errors when errors array is empty', () => {
|
|
45
|
-
let dispatch: ReturnType<typeof useFormDispatch>
|
|
46
|
-
const Comp = () => {
|
|
47
|
-
const errors = useFormStore(s => s.errors.email)
|
|
48
|
-
dispatch = useFormDispatch()
|
|
49
|
-
return <span data-testid="err">{JSON.stringify(errors)}</span>
|
|
50
|
-
}
|
|
51
|
-
const { getByTestId } = wrapper(<Comp />)
|
|
52
|
-
act(() => dispatch({ type: 'setFieldValue', path: 'email', value: 'ok@ok.com', errors: [] }))
|
|
53
|
-
expect(getByTestId('err').textContent).toBe('null')
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('setError updates specific error', () => {
|
|
57
|
-
let dispatch: ReturnType<typeof useFormDispatch>
|
|
58
|
-
const Comp = () => {
|
|
59
|
-
const error = useFormStore(s => s.errors.name)
|
|
60
|
-
dispatch = useFormDispatch()
|
|
61
|
-
return <span data-testid="err">{error}</span>
|
|
62
|
-
}
|
|
63
|
-
const { getByTestId } = wrapper(<Comp />)
|
|
64
|
-
act(() => dispatch({ type: 'setError', path: 'name', value: 'Required' }))
|
|
65
|
-
expect(getByTestId('err').textContent).toBe('Required')
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('reset clears formData and errors', () => {
|
|
69
|
-
let dispatch: ReturnType<typeof useFormDispatch>
|
|
70
|
-
const Comp = () => {
|
|
71
|
-
const value = useFormStore(s => s.formData.name)
|
|
72
|
-
const error = useFormStore(s => s.errors.name)
|
|
73
|
-
dispatch = useFormDispatch()
|
|
74
|
-
return (
|
|
75
|
-
<>
|
|
76
|
-
<span data-testid="val">{value}</span>
|
|
77
|
-
<span data-testid="err">{error}</span>
|
|
78
|
-
</>
|
|
79
|
-
)
|
|
80
|
-
}
|
|
81
|
-
const { getByTestId } = wrapper(<Comp />)
|
|
82
|
-
act(() => {
|
|
83
|
-
dispatch({ type: 'setValue', path: 'name', value: 'John' })
|
|
84
|
-
dispatch({ type: 'setError', path: 'name', value: 'Error' })
|
|
85
|
-
})
|
|
86
|
-
act(() => dispatch({ type: 'reset' }))
|
|
87
|
-
expect(getByTestId('val').textContent).toBe('')
|
|
88
|
-
expect(getByTestId('err').textContent).toBe('')
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('setErrors replaces all errors', () => {
|
|
92
|
-
let dispatch: ReturnType<typeof useFormDispatch>
|
|
93
|
-
const Comp = () => {
|
|
94
|
-
const errors = useFormStore(s => s.errors)
|
|
95
|
-
dispatch = useFormDispatch()
|
|
96
|
-
return <span data-testid="err">{JSON.stringify(errors)}</span>
|
|
97
|
-
}
|
|
98
|
-
const { getByTestId } = wrapper(<Comp />)
|
|
99
|
-
act(() => dispatch({ type: 'setErrors', errors: { name: ['Required'], email: ['Invalid'] } }))
|
|
100
|
-
const errors = JSON.parse(getByTestId('err').textContent!)
|
|
101
|
-
expect(errors.name).toEqual(['Required'])
|
|
102
|
-
expect(errors.email).toEqual(['Invalid'])
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('FormStoreProvider accepts initial formData', () => {
|
|
106
|
-
const Comp = () => {
|
|
107
|
-
const value = useFormStore(s => s.formData.name)
|
|
108
|
-
return <span data-testid="val">{value}</span>
|
|
109
|
-
}
|
|
110
|
-
const { getByTestId } = wrapper(<Comp />, { name: 'InitialName' })
|
|
111
|
-
expect(getByTestId('val').textContent).toBe('InitialName')
|
|
112
|
-
})
|
|
113
|
-
})
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { createStoreContext } from "./createStoreContext";
|
|
2
|
-
import type { FormData } from "@/shared/types/common";
|
|
3
|
-
import { updateNestedValue } from "@/shared/utils";
|
|
4
|
-
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
5
|
-
import { PluginSystemContext } from "@/shared/model/plugins/context";
|
|
6
|
-
import type { StoreMiddleware, FormAction as PluginFormAction, FormState as PluginFormState } from "@/shared/model/plugins/types";
|
|
7
|
-
|
|
8
|
-
type State = {
|
|
9
|
-
formData: FormData
|
|
10
|
-
errors: Record<string, any>
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
type Action =
|
|
14
|
-
| { type: "setValue"; path: string; value: unknown }
|
|
15
|
-
| { type: "setFieldValue"; path: string; value: unknown, errors?: string[] }
|
|
16
|
-
| { type: "setError"; path: string; value?: string }
|
|
17
|
-
| { type: "reset"; }
|
|
18
|
-
| { type: "setErrors"; errors?: object };
|
|
19
|
-
|
|
20
|
-
function reducer(state: State, action: Action): State {
|
|
21
|
-
const newData = { ...state };
|
|
22
|
-
switch (action.type) {
|
|
23
|
-
case 'setValue':
|
|
24
|
-
newData.formData = updateNestedValue(newData.formData, action.path, action.value);
|
|
25
|
-
break;
|
|
26
|
-
case 'setFieldValue':
|
|
27
|
-
newData.formData = updateNestedValue(newData.formData, action.path, action.value);
|
|
28
|
-
newData.errors = updateNestedValue(newData.errors, action.path, action?.errors?.length ? action.errors : null);
|
|
29
|
-
break;
|
|
30
|
-
case 'setError':
|
|
31
|
-
newData.errors = updateNestedValue(newData.errors, action.path, action.value);
|
|
32
|
-
break;
|
|
33
|
-
case 'reset':
|
|
34
|
-
newData.formData = {};
|
|
35
|
-
newData.errors = {};
|
|
36
|
-
break;
|
|
37
|
-
case 'setErrors':
|
|
38
|
-
newData.errors = { ...action.errors };
|
|
39
|
-
break;
|
|
40
|
-
}
|
|
41
|
-
return newData;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function composeMiddleware(
|
|
45
|
-
middlewares: StoreMiddleware[],
|
|
46
|
-
baseDispatch: (action: Action) => void,
|
|
47
|
-
getState: () => State
|
|
48
|
-
): (action: Action) => void {
|
|
49
|
-
if (middlewares.length === 0) return baseDispatch;
|
|
50
|
-
return middlewares.reduceRight(
|
|
51
|
-
(next, m) => (action: Action) =>
|
|
52
|
-
m(
|
|
53
|
-
action as PluginFormAction,
|
|
54
|
-
next as (a: PluginFormAction) => void,
|
|
55
|
-
getState as () => PluginFormState
|
|
56
|
-
),
|
|
57
|
-
baseDispatch as (action: Action) => void
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const {
|
|
62
|
-
Provider,
|
|
63
|
-
useStore: useFormStore,
|
|
64
|
-
useDispatch: _useRawDispatch,
|
|
65
|
-
useStoreInstance: useFormStoreInstance,
|
|
66
|
-
} = createStoreContext(reducer, { formData: {}, errors: {} });
|
|
67
|
-
|
|
68
|
-
const DispatchContext = createContext<((action: Action) => void) | null>(null);
|
|
69
|
-
|
|
70
|
-
const DispatchEnhancer: React.FC<{ children: ReactNode }> = ({ children }) => {
|
|
71
|
-
const pluginManager = useContext(PluginSystemContext);
|
|
72
|
-
const store = useFormStoreInstance();
|
|
73
|
-
const rawDispatch = _useRawDispatch();
|
|
74
|
-
const middlewares = pluginManager ? pluginManager.getMiddleware() : [];
|
|
75
|
-
|
|
76
|
-
const dispatch = useMemo(
|
|
77
|
-
() => composeMiddleware(middlewares, rawDispatch, store.getState as () => State),
|
|
78
|
-
[] // middleware is fixed at mount time
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
return <DispatchContext.Provider value={dispatch}>{children}</DispatchContext.Provider>;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const FormStoreProvider: React.FC<{ children: ReactNode; formData?: FormData }> = ({ children, formData }) => (
|
|
85
|
-
<Provider initialState={{ formData: formData ?? {}, errors: {} }}>
|
|
86
|
-
<DispatchEnhancer>{children}</DispatchEnhancer>
|
|
87
|
-
</Provider>
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
function useFormDispatch(): (action: Action) => void {
|
|
91
|
-
const ctx = useContext(DispatchContext);
|
|
92
|
-
if (!ctx) throw new Error("FormStoreProvider missing");
|
|
93
|
-
return ctx;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export { FormStoreProvider, useFormStore, useFormDispatch, useFormStoreInstance };
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
-
import { createStore } from './store'
|
|
3
|
-
|
|
4
|
-
type State = { count: number; name: string }
|
|
5
|
-
type Action = { type: 'increment' } | { type: 'setName'; name: string }
|
|
6
|
-
|
|
7
|
-
function reducer(state: State, action: Action): State {
|
|
8
|
-
switch (action.type) {
|
|
9
|
-
case 'increment':
|
|
10
|
-
return { ...state, count: state.count + 1 }
|
|
11
|
-
case 'setName':
|
|
12
|
-
return { ...state, name: action.name }
|
|
13
|
-
default:
|
|
14
|
-
return state
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
describe('createStore', () => {
|
|
19
|
-
it('getState returns initial state', () => {
|
|
20
|
-
const store = createStore(reducer, { count: 0, name: 'test' })
|
|
21
|
-
expect(store.getState()).toEqual({ count: 0, name: 'test' })
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('dispatch updates state via reducer', () => {
|
|
25
|
-
const store = createStore(reducer, { count: 0, name: '' })
|
|
26
|
-
store.dispatch({ type: 'increment' })
|
|
27
|
-
expect(store.getState().count).toBe(1)
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('subscribe notifies listener on dispatch', () => {
|
|
31
|
-
const store = createStore(reducer, { count: 0, name: '' })
|
|
32
|
-
const listener = vi.fn()
|
|
33
|
-
store.subscribe(listener)
|
|
34
|
-
store.dispatch({ type: 'increment' })
|
|
35
|
-
expect(listener).toHaveBeenCalledTimes(1)
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('unsubscribe removes listener', () => {
|
|
39
|
-
const store = createStore(reducer, { count: 0, name: '' })
|
|
40
|
-
const listener = vi.fn()
|
|
41
|
-
const unsub = store.subscribe(listener)
|
|
42
|
-
unsub()
|
|
43
|
-
store.dispatch({ type: 'increment' })
|
|
44
|
-
expect(listener).not.toHaveBeenCalled()
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('subscribeSelector calls listener only when selected value changes', () => {
|
|
48
|
-
const store = createStore(reducer, { count: 0, name: '' })
|
|
49
|
-
const listener = vi.fn()
|
|
50
|
-
store.subscribeSelector(s => s.count, listener)
|
|
51
|
-
store.dispatch({ type: 'setName', name: 'hello' })
|
|
52
|
-
expect(listener).not.toHaveBeenCalled()
|
|
53
|
-
store.dispatch({ type: 'increment' })
|
|
54
|
-
expect(listener).toHaveBeenCalledWith(1)
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('subscribeSelector does not call listener on unrelated changes', () => {
|
|
58
|
-
const store = createStore(reducer, { count: 0, name: '' })
|
|
59
|
-
const listener = vi.fn()
|
|
60
|
-
store.subscribeSelector(s => s.name, listener)
|
|
61
|
-
store.dispatch({ type: 'increment' })
|
|
62
|
-
expect(listener).not.toHaveBeenCalled()
|
|
63
|
-
})
|
|
64
|
-
})
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
export type Reducer<S, A> = (state: S, action: A) => S;
|
|
2
|
-
|
|
3
|
-
export function createStore<S, A>(
|
|
4
|
-
reducer: Reducer<S, A>,
|
|
5
|
-
initialState: S
|
|
6
|
-
) {
|
|
7
|
-
let state = initialState;
|
|
8
|
-
const listeners = new Set<() => void>();
|
|
9
|
-
|
|
10
|
-
return {
|
|
11
|
-
getState(): S {
|
|
12
|
-
return state;
|
|
13
|
-
},
|
|
14
|
-
|
|
15
|
-
dispatch(action: A) {
|
|
16
|
-
state = reducer(state, action);
|
|
17
|
-
listeners.forEach(l => l());
|
|
18
|
-
},
|
|
19
|
-
|
|
20
|
-
subscribe(listener: () => void) {
|
|
21
|
-
listeners.add(listener);
|
|
22
|
-
return () => { listeners.delete(listener) };
|
|
23
|
-
},
|
|
24
|
-
|
|
25
|
-
subscribeSelector<T>(selector: (state: S) => T, listener: (value: T) => void) {
|
|
26
|
-
let prev = selector(state);
|
|
27
|
-
return this.subscribe(() => {
|
|
28
|
-
const next = selector(state);
|
|
29
|
-
if (next !== prev) {
|
|
30
|
-
prev = next;
|
|
31
|
-
listener(next);
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export type Store<S, A> = ReturnType<typeof createStore<S, A>>;
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import React, { useMemo } from 'react'
|
|
2
|
-
import { render } from '@testing-library/react'
|
|
3
|
-
import { FormStoreProvider } from '@/shared/model/store'
|
|
4
|
-
import { PluginManager } from '@/shared/model/plugins/PluginManager'
|
|
5
|
-
import { PluginSystemContext } from '@/shared/model/plugins/context'
|
|
6
|
-
import { createValidationPlugin } from '@/plugins/validation'
|
|
7
|
-
import { BuilderProvider } from '@/entity/dynamicBuilder'
|
|
8
|
-
import type { TValidator } from '@/plugins/validation'
|
|
9
|
-
import type { FormData, FormElementRegistry } from '@/shared/types/common'
|
|
10
|
-
|
|
11
|
-
type RenderOptions = {
|
|
12
|
-
formData?: FormData
|
|
13
|
-
validator?: TValidator
|
|
14
|
-
fields?: FormElementRegistry
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function renderWithProviders(
|
|
18
|
-
ui: React.ReactElement,
|
|
19
|
-
{ formData, validator, fields }: RenderOptions = {}
|
|
20
|
-
) {
|
|
21
|
-
const pm = new PluginManager([createValidationPlugin(validator)]);
|
|
22
|
-
pm.install();
|
|
23
|
-
|
|
24
|
-
return render(
|
|
25
|
-
<PluginSystemContext.Provider value={pm}>
|
|
26
|
-
<FormStoreProvider formData={formData}>
|
|
27
|
-
<BuilderProvider fields={fields ?? {}}>
|
|
28
|
-
{ui}
|
|
29
|
-
</BuilderProvider>
|
|
30
|
-
</FormStoreProvider>
|
|
31
|
-
</PluginSystemContext.Provider>
|
|
32
|
-
)
|
|
33
|
-
}
|