palistor 0.0.25 → 0.0.27
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/README.md +609 -542
- package/README.ru.md +1015 -0
- package/dist/index.d.ts +16 -27
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -28
- package/dist/index.js.map +1 -1
- package/dist/react/createTrackingProxy.d.ts +27 -27
- package/dist/react/createTrackingProxy.d.ts.map +1 -1
- package/dist/react/createTrackingProxy.js +54 -53
- package/dist/react/createTrackingProxy.js.map +1 -1
- package/dist/react/useForm.d.ts +42 -43
- package/dist/react/useForm.d.ts.map +1 -1
- package/dist/react/useForm.js +38 -38
- package/dist/react/useForm.js.map +1 -1
- package/dist/react/usePersist.d.ts +14 -14
- package/dist/react/usePersist.js +18 -18
- package/dist/react/usePersist.js.map +1 -1
- package/dist/react/useStoreContext.d.ts +11 -11
- package/dist/react/useStoreContext.js +11 -11
- package/dist/react/useTranslator.d.ts +12 -12
- package/dist/react/useTranslator.js +12 -12
- package/dist/store/applyPatch/applyPatch.d.ts +8 -8
- package/dist/store/applyPatch/applyPatch.js +12 -12
- package/dist/store/applyPatch/applyPatch.js.map +1 -1
- package/dist/store/buildProxy/buildEntityProjectionProxy.js +6 -6
- package/dist/store/buildProxy/buildEntityProjectionProxy.js.map +1 -1
- package/dist/store/buildProxy/buildListProxy.d.ts +8 -8
- package/dist/store/buildProxy/buildListProxy.js +30 -30
- package/dist/store/buildProxy/buildListProxy.js.map +1 -1
- package/dist/store/buildProxy/buildProxy.d.ts +8 -8
- package/dist/store/buildProxy/buildProxy.js +39 -39
- package/dist/store/buildProxy/buildProxy.js.map +1 -1
- package/dist/store/buildProxy/computeProxyKeys.d.ts +8 -8
- package/dist/store/buildProxy/computeProxyKeys.js +9 -9
- package/dist/store/buildProxy/computeProxyKeys.js.map +1 -1
- package/dist/store/buildProxy/handleLazyResolve.d.ts +3 -3
- package/dist/store/buildProxy/handleLazyResolve.js +3 -3
- package/dist/store/buildProxy/initProxyCaches.d.ts +5 -5
- package/dist/store/buildProxy/initProxyCaches.js +5 -5
- package/dist/store/compute/computeFieldState.d.ts +3 -3
- package/dist/store/compute/computeFieldState.js +8 -8
- package/dist/store/compute/computeFieldState.js.map +1 -1
- package/dist/store/compute/fieldStateChanged.d.ts +1 -1
- package/dist/store/compute/fieldStateChanged.js +1 -1
- package/dist/store/compute/recompute/collectGroupComputeNodes.d.ts +4 -4
- package/dist/store/compute/recompute/collectGroupComputeNodes.js +7 -7
- package/dist/store/compute/recompute/collectGroupComputeNodes.js.map +1 -1
- package/dist/store/compute/recompute/recomputeAndNotify.d.ts +3 -3
- package/dist/store/compute/recompute/recomputeAndNotify.js +3 -3
- package/dist/store/compute/recompute/recomputeLeaves.d.ts +11 -10
- package/dist/store/compute/recompute/recomputeLeaves.d.ts.map +1 -1
- package/dist/store/compute/recompute/recomputeLeaves.js +26 -27
- package/dist/store/compute/recompute/recomputeLeaves.js.map +1 -1
- package/dist/store/compute/recompute/recomputeTargeted.d.ts +7 -7
- package/dist/store/compute/recompute/recomputeTargeted.js +12 -12
- package/dist/store/compute/recompute/recomputeTargeted.js.map +1 -1
- package/dist/store/compute/recompute/topologicalSortComputed.d.ts +4 -4
- package/dist/store/compute/recompute/topologicalSortComputed.d.ts.map +1 -1
- package/dist/store/compute/recompute/topologicalSortComputed.js +11 -13
- package/dist/store/compute/recompute/topologicalSortComputed.js.map +1 -1
- package/dist/store/compute/recompute/types.d.ts +4 -4
- package/dist/store/compute/resolveFlag.d.ts +2 -2
- package/dist/store/compute/resolveFlag.js +2 -2
- package/dist/store/compute/resolveString.d.ts +5 -5
- package/dist/store/compute/resolveString.js +6 -6
- package/dist/store/compute/resolveString.js.map +1 -1
- package/dist/store/compute/types.d.ts +3 -3
- package/dist/store/constants.d.ts +48 -48
- package/dist/store/constants.d.ts.map +1 -1
- package/dist/store/constants.js +45 -45
- package/dist/store/defineFieldMapping.d.ts +21 -19
- package/dist/store/defineFieldMapping.d.ts.map +1 -1
- package/dist/store/defineFieldMapping.js +21 -19
- package/dist/store/defineFieldMapping.js.map +1 -1
- package/dist/store/dirtyTracking/collectInitialSnapshot.js +1 -1
- package/dist/store/dirtyTracking/collectInitialSnapshot.js.map +1 -1
- package/dist/store/dirtyTracking/recomputeDirtyTargeted.d.ts +3 -3
- package/dist/store/dirtyTracking/recomputeDirtyTargeted.js +11 -11
- package/dist/store/dirtyTracking/recomputeDirtyTargeted.js.map +1 -1
- package/dist/store/dirtyTracking/setGroupRevalidate.d.ts +6 -6
- package/dist/store/dirtyTracking/setGroupRevalidate.js +7 -7
- package/dist/store/dirtyTracking/setGroupRevalidate.js.map +1 -1
- package/dist/store/entityRegistry/entityRegistry.d.ts +72 -72
- package/dist/store/entityRegistry/entityRegistry.d.ts.map +1 -1
- package/dist/store/entityRegistry/entityRegistry.js +91 -91
- package/dist/store/entityRegistry/entityRegistry.js.map +1 -1
- package/dist/store/entityRegistry/generateId.d.ts +3 -3
- package/dist/store/entityRegistry/generateId.js +3 -3
- package/dist/store/entityRegistry/types.d.ts +17 -17
- package/dist/store/entityRegistry/types.d.ts.map +1 -1
- package/dist/store/flow/buildFlowStepsProxy.d.ts +9 -9
- package/dist/store/flow/buildFlowStepsProxy.js +13 -13
- package/dist/store/flow/buildFlowStepsProxy.js.map +1 -1
- package/dist/store/flow/defineFlow.d.ts +27 -25
- package/dist/store/flow/defineFlow.d.ts.map +1 -1
- package/dist/store/flow/defineFlow.js +15 -13
- package/dist/store/flow/defineFlow.js.map +1 -1
- package/dist/store/flow/flowApi.d.ts +3 -2
- package/dist/store/flow/flowApi.d.ts.map +1 -1
- package/dist/store/flow/flowApi.js +1 -1
- package/dist/store/flow/flowApi.js.map +1 -1
- package/dist/store/flow/flowNavigation.d.ts +65 -63
- package/dist/store/flow/flowNavigation.d.ts.map +1 -1
- package/dist/store/flow/flowNavigation.js +74 -72
- package/dist/store/flow/flowNavigation.js.map +1 -1
- package/dist/store/flow/flowState.d.ts +16 -16
- package/dist/store/flow/flowState.d.ts.map +1 -1
- package/dist/store/flow/flowState.js +5 -5
- package/dist/store/flow/flowState.js.map +1 -1
- package/dist/store/groupDeps/createGroupDeps.d.ts +3 -3
- package/dist/store/groupDeps/createGroupDeps.js +6 -6
- package/dist/store/groupDeps/createGroupDeps.js.map +1 -1
- package/dist/store/groupDeps/createTrackingValues.d.ts +9 -9
- package/dist/store/groupDeps/createTrackingValues.js +11 -11
- package/dist/store/groupDeps/createTrackingValues.js.map +1 -1
- package/dist/store/groupDeps/getNodeGroupPath.d.ts +3 -3
- package/dist/store/groupDeps/getNodeGroupPath.js +5 -5
- package/dist/store/groupDeps/getNodeGroupPath.js.map +1 -1
- package/dist/store/groupDeps/getRecipientGroups.d.ts +1 -1
- package/dist/store/groupDeps/getRecipientGroups.js +1 -1
- package/dist/store/groupDeps/groupDeps.d.ts +7 -7
- package/dist/store/groupDeps/groupDeps.js +7 -7
- package/dist/store/groupDeps/pairKey.d.ts +3 -3
- package/dist/store/groupDeps/pairKey.d.ts.map +1 -1
- package/dist/store/groupDeps/pairKey.js +4 -4
- package/dist/store/groupDeps/pairKey.js.map +1 -1
- package/dist/store/groupDeps/resolveGroupByPath.d.ts +3 -3
- package/dist/store/groupDeps/resolveGroupByPath.js +3 -3
- package/dist/store/init/createNotificationHub.d.ts +31 -31
- package/dist/store/init/createNotificationHub.d.ts.map +1 -1
- package/dist/store/init/createNotificationHub.js +36 -37
- package/dist/store/init/createNotificationHub.js.map +1 -1
- package/dist/store/init/createResolveManager.d.ts +72 -69
- package/dist/store/init/createResolveManager.d.ts.map +1 -1
- package/dist/store/init/createResolveManager.js +101 -98
- package/dist/store/init/createResolveManager.js.map +1 -1
- package/dist/store/init/initGroupSubmitting.d.ts +2 -2
- package/dist/store/init/initGroupSubmitting.js +4 -4
- package/dist/store/init/initGroupSubmitting.js.map +1 -1
- package/dist/store/normalizeConfig.d.ts +20 -18
- package/dist/store/normalizeConfig.d.ts.map +1 -1
- package/dist/store/normalizeConfig.js +31 -29
- package/dist/store/normalizeConfig.js.map +1 -1
- package/dist/store/onChangePipeline/computeFieldKey.d.ts +2 -2
- package/dist/store/onChangePipeline/computeFieldKey.js +2 -2
- package/dist/store/onChangePipeline/findOnChangeAncestors.d.ts +2 -2
- package/dist/store/onChangePipeline/findOnChangeAncestors.js +4 -4
- package/dist/store/onChangePipeline/findOnChangeAncestors.js.map +1 -1
- package/dist/store/onChangePipeline/onChangePipeline.d.ts +13 -12
- package/dist/store/onChangePipeline/onChangePipeline.d.ts.map +1 -1
- package/dist/store/onChangePipeline/onChangePipeline.js +14 -13
- package/dist/store/onChangePipeline/onChangePipeline.js.map +1 -1
- package/dist/store/persist/drivers.d.ts +7 -7
- package/dist/store/persist/drivers.js +8 -8
- package/dist/store/persist/drivers.js.map +1 -1
- package/dist/store/persist/index.d.ts +1 -1
- package/dist/store/persist/persistManager.d.ts +23 -23
- package/dist/store/persist/persistManager.d.ts.map +1 -1
- package/dist/store/persist/persistManager.js +51 -51
- package/dist/store/persist/persistManager.js.map +1 -1
- package/dist/store/persist/types.d.ts +23 -23
- package/dist/store/persist/types.d.ts.map +1 -1
- package/dist/store/persist/types.js +4 -4
- package/dist/store/resetPipeline/buildResetPatch.d.ts +5 -5
- package/dist/store/resetPipeline/buildResetPatch.js +5 -5
- package/dist/store/resetPipeline/collectDefaults.d.ts +5 -5
- package/dist/store/resetPipeline/collectDefaults.js +6 -6
- package/dist/store/resetPipeline/collectDefaults.js.map +1 -1
- package/dist/store/resetPipeline/resetPipeline.d.ts +7 -7
- package/dist/store/resetPipeline/resetPipeline.js +15 -15
- package/dist/store/resetPipeline/resetPipeline.js.map +1 -1
- package/dist/store/resolvePipeline/applyPendingWrites.d.ts +10 -10
- package/dist/store/resolvePipeline/applyPendingWrites.d.ts.map +1 -1
- package/dist/store/resolvePipeline/applyPendingWrites.js +35 -38
- package/dist/store/resolvePipeline/applyPendingWrites.js.map +1 -1
- package/dist/store/resolvePipeline/createContextTrackingProxy.d.ts +8 -7
- package/dist/store/resolvePipeline/createContextTrackingProxy.d.ts.map +1 -1
- package/dist/store/resolvePipeline/createContextTrackingProxy.js +6 -5
- package/dist/store/resolvePipeline/createContextTrackingProxy.js.map +1 -1
- package/dist/store/resolvePipeline/createValuesTrackingProxy.d.ts +43 -43
- package/dist/store/resolvePipeline/createValuesTrackingProxy.d.ts.map +1 -1
- package/dist/store/resolvePipeline/createValuesTrackingProxy.js +70 -70
- package/dist/store/resolvePipeline/createValuesTrackingProxy.js.map +1 -1
- package/dist/store/resolvePipeline/executeListResolve.d.ts +14 -14
- package/dist/store/resolvePipeline/executeListResolve.js +27 -27
- package/dist/store/resolvePipeline/executeListResolve.js.map +1 -1
- package/dist/store/resolvePipeline/executeResolve.d.ts +15 -15
- package/dist/store/resolvePipeline/executeResolve.js +46 -46
- package/dist/store/resolvePipeline/executeResolve.js.map +1 -1
- package/dist/store/store/NodeRegistry/nodeRegistry.d.ts +61 -61
- package/dist/store/store/NodeRegistry/nodeRegistry.d.ts.map +1 -1
- package/dist/store/store/NodeRegistry/nodeRegistry.js +85 -86
- package/dist/store/store/NodeRegistry/nodeRegistry.js.map +1 -1
- package/dist/store/store/NodeRegistry/nodeUtils.d.ts +5 -5
- package/dist/store/store/NodeRegistry/nodeUtils.d.ts.map +1 -1
- package/dist/store/store/NodeRegistry/nodeUtils.js +5 -5
- package/dist/store/store/NodeRegistry/nodeUtils.js.map +1 -1
- package/dist/store/store/groupDepsMap.d.ts +23 -23
- package/dist/store/store/groupDepsMap.d.ts.map +1 -1
- package/dist/store/store/groupDepsMap.js +35 -34
- package/dist/store/store/groupDepsMap.js.map +1 -1
- package/dist/store/store/hasComputedProps.d.ts +2 -2
- package/dist/store/store/hasComputedProps.js +2 -2
- package/dist/store/store/nodeMap.d.ts +5 -5
- package/dist/store/store/nodeMap.js +7 -7
- package/dist/store/store/nodeMap.js.map +1 -1
- package/dist/store/store/palistor.d.ts +80 -80
- package/dist/store/store/palistor.d.ts.map +1 -1
- package/dist/store/store/palistor.js +149 -169
- package/dist/store/store/palistor.js.map +1 -1
- package/dist/store/store/registerNodes.d.ts +10 -10
- package/dist/store/store/registerNodes.js +15 -15
- package/dist/store/store/registerNodes.js.map +1 -1
- package/dist/store/store/serviceRegistry.d.ts +8 -8
- package/dist/store/store/serviceRegistry.d.ts.map +1 -1
- package/dist/store/store/serviceRegistry.js +8 -8
- package/dist/store/store/serviceRegistry.js.map +1 -1
- package/dist/store/store/types.d.ts +236 -234
- package/dist/store/store/types.d.ts.map +1 -1
- package/dist/store/submitPipeline/applyLeafBeforeSubmit.d.ts +2 -2
- package/dist/store/submitPipeline/applyLeafBeforeSubmit.js +2 -2
- package/dist/store/submitPipeline/collectLeafStates.d.ts +2 -2
- package/dist/store/submitPipeline/collectLeafStates.js +2 -2
- package/dist/store/submitPipeline/submitPipeline.d.ts +8 -8
- package/dist/store/submitPipeline/submitPipeline.js +12 -12
- package/dist/store/submitPipeline/submitPipeline.js.map +1 -1
- package/dist/store/submitPipeline/types.d.ts +1 -1
- package/dist/store/submitPipeline/types.d.ts.map +1 -1
- package/dist/store/traversal/nodeClassifier.d.ts +9 -9
- package/dist/store/traversal/nodeClassifier.d.ts.map +1 -1
- package/dist/store/traversal/nodeClassifier.js +11 -11
- package/dist/store/traversal/nodeClassifier.js.map +1 -1
- package/dist/store/traversal/walkFull.d.ts +14 -14
- package/dist/store/traversal/walkFull.js +2 -2
- package/dist/store/valuesCache/valuesCache.d.ts +9 -9
- package/dist/store/valuesCache/valuesCache.d.ts.map +1 -1
- package/dist/store/valuesCache/valuesCache.js +6 -6
- package/dist/store/writePipeline/formatPatch.d.ts +5 -5
- package/dist/store/writePipeline/formatPatch.js +7 -7
- package/dist/store/writePipeline/formatPatch.js.map +1 -1
- package/dist/store/writePipeline/formatValue.d.ts +5 -5
- package/dist/store/writePipeline/formatValue.js +5 -5
- package/dist/store/writePipeline/mergeChanged.d.ts +4 -4
- package/dist/store/writePipeline/mergeChanged.js +4 -4
- package/dist/store/writePipeline/runSetter.d.ts +6 -6
- package/dist/store/writePipeline/runSetter.js +8 -8
- package/dist/store/writePipeline/storeValue.d.ts +3 -3
- package/dist/store/writePipeline/storeValue.js +3 -3
- package/dist/store/writePipeline/types.d.ts +4 -4
- package/dist/store/writePipeline/types.d.ts.map +1 -1
- package/dist/store/writePipeline/writePipeline.d.ts +7 -7
- package/dist/store/writePipeline/writePipeline.d.ts.map +1 -1
- package/dist/store/writePipeline/writePipeline.js +15 -15
- package/dist/store/writePipeline/writePipeline.js.map +1 -1
- package/package.json +11 -4
package/README.md
CHANGED
|
@@ -1,39 +1,132 @@
|
|
|
1
1
|
# Palistor
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> A declarative framework for data-driven React interfaces — behavior, data and view as three separate layers
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**English** | [Русский](./README.ru.md)
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/palistor)
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+
[](https://react.dev)
|
|
10
|
+
|
|
11
|
+
**Palistor is a declarative framework for stateful, data-driven React interfaces.** It treats a screen as three independent layers — **configuration** (how it behaves), **data** (where its values come from) and **view** (how it renders) — and keeps them from leaking into each other. A two-layer proxy is the seam that binds them: reads become fine-grained subscriptions, writes run the behavior you declared. A component re-renders **only** for the exact fields it read.
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
const store = new Palistor({
|
|
15
|
+
config: {
|
|
16
|
+
email: { value: "", isRequired: true },
|
|
17
|
+
phone: { value: "", isVisible: (v) => v.email !== "" },
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function Form() {
|
|
22
|
+
const form = useForm(store);
|
|
23
|
+
return (
|
|
24
|
+
<input
|
|
25
|
+
value={form.email.value}
|
|
26
|
+
onChange={(e) => (form.email.value = e.target.value)}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## The idea — three layers, not one store
|
|
35
|
+
|
|
36
|
+
Most React screens tangle three unrelated concerns inside components: how the screen **behaves** (validation, conditional fields, cross-field rules), where its **data** comes from (loading, caching, mutation) and how it **looks** (JSX). As the screen grows, the three braid together until every change touches everything.
|
|
37
|
+
|
|
38
|
+
Palistor pulls them apart:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
┌── Configuration — behavior ──────────────┐ declarative · framework-agnostic
|
|
42
|
+
│ fields · validation · visibility · │ isVisible / isRequired / validate
|
|
43
|
+
│ cross-field rules · dependencies · │ formatter / setter / onSubmit
|
|
44
|
+
│ lifecycle │ fully testable without React
|
|
45
|
+
└─────────────────────┬────────────────────┘
|
|
46
|
+
│ proxy — the seam:
|
|
47
|
+
│ read → subscribe to a field
|
|
48
|
+
│ write → run the declared pipeline
|
|
49
|
+
┌── Data — values & entities ──────────────┐ normalized entity registry
|
|
50
|
+
│ values cache · normalized registry · │ resolvers with auto-tracked deps
|
|
51
|
+
│ async resolvers │ retry · optimistic · Suspense
|
|
52
|
+
└─────────────────────┬────────────────────┘
|
|
53
|
+
│
|
|
54
|
+
┌── View — rendering ──────────────────────┐ useForm(store) → tracking proxy
|
|
55
|
+
│ read state · assign values · no logic │ granular, per-field re-renders
|
|
56
|
+
└──────────────────────────────────────────┘
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- **Configuration — behavior.** One declarative tree describes fields, validation, visibility, cross-field rules, dependencies and lifecycle (`onSubmit`, `resolve`). Pure and framework-agnostic — fully testable without React.
|
|
60
|
+
- **Data — values & entities.** Values flow through a normalized entity registry and async resolvers with automatically tracked dependencies, retry, optimistic updates and Suspense.
|
|
61
|
+
- **View — rendering.** Components only read reactive state and assign values. They carry no logic and re-render only for the fields they actually read.
|
|
62
|
+
|
|
63
|
+
The proxy is the seam between the layers: a **read** (`form.email.value`) subscribes the component to that field; a **write** (`form.email.value = x`) runs the pipeline you declared in the config — formatter → setter → recompute → notify. Nothing is wired by hand.
|
|
64
|
+
|
|
65
|
+
The payoff is that complexity grows **per layer**, not per component. A form, a wizard, a data table and an admin panel are the same three layers at different scale — which is why async loading, normalized lists, multi-step flows, persistence and i18n are part of the framework, not add-ons you bolt on later.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Table of contents
|
|
70
|
+
|
|
71
|
+
- [Features](#features)
|
|
72
|
+
- [Installation](#installation)
|
|
73
|
+
- [Quick start](#quick-start)
|
|
74
|
+
- [Concepts](#concepts)
|
|
75
|
+
- [API reference](#api-reference)
|
|
76
|
+
- [Async resolvers](#async-resolvers)
|
|
77
|
+
- [Lists & entities](#lists--entities)
|
|
78
|
+
- [Flows (step wizards)](#flows-step-wizards)
|
|
79
|
+
- [Field mapping](#field-mapping)
|
|
80
|
+
- [Persist](#persist)
|
|
81
|
+
- [i18n](#i18n)
|
|
82
|
+
- [Notifications](#notifications)
|
|
83
|
+
- [Store context](#store-context)
|
|
84
|
+
- [TypeScript](#typescript)
|
|
85
|
+
- [License](#license)
|
|
6
86
|
|
|
7
87
|
---
|
|
8
88
|
|
|
9
|
-
##
|
|
89
|
+
## Features
|
|
10
90
|
|
|
11
|
-
|
|
91
|
+
| | |
|
|
92
|
+
|---|---|
|
|
93
|
+
| **Granular re-renders** | A component subscribes only to the fields it read — nothing else triggers a re-render |
|
|
94
|
+
| **Computed field state** | `isVisible`, `isRequired`, `label`, validation errors are recomputed automatically from the config |
|
|
95
|
+
| **Proxy API** | Native syntax: `form.email.value = x` instead of dispatching actions |
|
|
96
|
+
| **Submit pipeline** | `beforeSubmit → validate → onSubmit → afterSubmit`, errors surface after the first failed submit |
|
|
97
|
+
| **Dirty tracking** | Per-field and per-group change flags; the baseline updates after resolve and reset |
|
|
98
|
+
| **Async resolvers** | Data loading with auto-tracked dependencies, retry, optimistic updates and React Suspense |
|
|
99
|
+
| **Lists & entities** | Normalized entity registry, list proxy with `add / remove / setItems`, per-entity templates |
|
|
100
|
+
| **Flows** | Step wizards via `defineFlow` / `defineStep`: navigation, branching, per-step validation |
|
|
101
|
+
| **Field mapping** | Rename field-state props to your UI kit's convention (`isRequired` → `required`, …) |
|
|
102
|
+
| **Persist** | Autosave to `localStorage`, `sessionStorage` or any custom driver — flow navigation included |
|
|
103
|
+
| **i18n** | Register a translator once — `label`, `placeholder`, `description` are translated everywhere |
|
|
104
|
+
| **Testability** | The core is framework-agnostic and fully testable without React |
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Installation
|
|
109
|
+
|
|
110
|
+
The package is published to the **public npm registry** as `palistor`.
|
|
12
111
|
|
|
13
112
|
```bash
|
|
14
113
|
npm install palistor
|
|
15
|
-
#
|
|
114
|
+
# or
|
|
16
115
|
yarn add palistor
|
|
17
|
-
#
|
|
116
|
+
# or
|
|
18
117
|
pnpm add palistor
|
|
19
118
|
```
|
|
20
119
|
|
|
21
|
-
**Peer
|
|
120
|
+
**Peer dependency:** `react ^19`
|
|
22
121
|
|
|
23
|
-
>
|
|
24
|
-
> `@projectint/palistor`.
|
|
122
|
+
> **Alternative — GitHub Packages.** The same package is available under the scoped
|
|
123
|
+
> name `@projectint/palistor`. Add to `.npmrc`:
|
|
25
124
|
> ```
|
|
26
125
|
> @projectint:registry=https://npm.pkg.github.com
|
|
27
126
|
> ```
|
|
28
|
-
>
|
|
29
|
-
> ```
|
|
30
|
-
> //npm.pkg.github.com/:_authToken=YOUR_GITHUB_PAT
|
|
31
|
-
> ```
|
|
32
|
-
> затем `npm install @projectint/palistor`. Каноническим считается имя `palistor`.
|
|
127
|
+
> then `npm install @projectint/palistor`. The canonical name is `palistor`.
|
|
33
128
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
Все публичные symbol'ы доступны из корневого модуля:
|
|
129
|
+
All public symbols are available from the root module:
|
|
37
130
|
|
|
38
131
|
```typescript
|
|
39
132
|
import {
|
|
@@ -42,7 +135,11 @@ import {
|
|
|
42
135
|
usePersist,
|
|
43
136
|
useTranslator,
|
|
44
137
|
useNotifier,
|
|
138
|
+
useStoreContext,
|
|
45
139
|
defineList,
|
|
140
|
+
defineFlow,
|
|
141
|
+
defineStep,
|
|
142
|
+
defineFieldMapping,
|
|
46
143
|
localStorageDriver,
|
|
47
144
|
sessionStorageDriver,
|
|
48
145
|
} from "palistor";
|
|
@@ -50,85 +147,54 @@ import {
|
|
|
50
147
|
|
|
51
148
|
---
|
|
52
149
|
|
|
53
|
-
##
|
|
54
|
-
|
|
55
|
-
- [Возможности](#возможности)
|
|
56
|
-
- [Быстрый старт](#быстрый-старт)
|
|
57
|
-
- [Концепции](#концепции)
|
|
58
|
-
- [API Reference](#api-reference)
|
|
59
|
-
- [Типизация](#типизация)
|
|
60
|
-
- [Рецепты](#рецепты)
|
|
61
|
-
- [Async Resolver](#async-resolver)
|
|
62
|
-
- [Списки и сущности](#списки-и-сущности)
|
|
63
|
-
- [Persist](#persist)
|
|
64
|
-
- [i18n](#i18n)
|
|
65
|
-
- [Уведомления](#уведомления)
|
|
66
|
-
- [Структура модуля](#структура-модуля)
|
|
67
|
-
|
|
68
|
-
---
|
|
69
|
-
|
|
70
|
-
## Возможности
|
|
71
|
-
|
|
72
|
-
| | |
|
|
73
|
-
|---|---|
|
|
74
|
-
| **Гранулярные ре-рендеры** | Компонент подписывается только на те поля, которые он читал — больше ничего не триггерит ре-рендер |
|
|
75
|
-
| **Computed Field State** | `isVisible`, `isRequired`, `error`, `dirty` пересчитываются автоматически по конфигу |
|
|
76
|
-
| **Proxy API** | Нативный синтаксис: `field.value = x` вместо `dispatch({ type: "SET", field: "...", value: x })` |
|
|
77
|
-
| **Submit pipeline** | `beforeSubmit → validate → onSubmit → afterSubmit` с revalidate-семантикой |
|
|
78
|
-
| **Dirty tracking** | Per-field и per-group флаг изменений; baseline обновляется после resolve и reset |
|
|
79
|
-
| **Async resolvers** | Загрузка данных с auto-deps, retry, optimistic updates и React Suspense |
|
|
80
|
-
| **Списки / Entities** | Нормализованный реестр сущностей, list proxy с `add / remove / setItems` |
|
|
81
|
-
| **Persist** | Автосохранение в `localStorage`, `sessionStorage` или любом кастомном драйвере |
|
|
82
|
-
| **i18n** | Одна строка в layout — `label`, `placeholder`, `description` переводятся везде |
|
|
83
|
-
| **Тестируемость** | Framework-agnostic ядро — тестируется без React |
|
|
84
|
-
|
|
85
|
-
---
|
|
150
|
+
## Quick start
|
|
86
151
|
|
|
87
|
-
|
|
152
|
+
### 1. Describe the form
|
|
88
153
|
|
|
89
|
-
|
|
154
|
+
The config is declarative: field values, validation, visibility and lifecycle callbacks live in one tree. Create the store at module level.
|
|
90
155
|
|
|
91
156
|
```typescript
|
|
92
|
-
import {
|
|
157
|
+
import { Palistor } from "palistor";
|
|
93
158
|
|
|
94
|
-
export const paymentStore =
|
|
159
|
+
export const paymentStore = new Palistor({
|
|
95
160
|
config: {
|
|
96
161
|
paymentType: {
|
|
97
162
|
value: "card",
|
|
98
|
-
label: "
|
|
163
|
+
label: "Payment method",
|
|
99
164
|
},
|
|
100
165
|
cardNumber: {
|
|
101
166
|
value: "",
|
|
102
|
-
label: "
|
|
167
|
+
label: "Card number",
|
|
103
168
|
placeholder: "0000 0000 0000 0000",
|
|
104
169
|
isVisible: (v) => v.paymentType === "card",
|
|
105
170
|
isRequired: (v) => v.paymentType === "card",
|
|
106
171
|
validate: (value, v) =>
|
|
107
172
|
v.paymentType === "card" && value.length < 16
|
|
108
|
-
? "
|
|
173
|
+
? "Enter 16 digits"
|
|
109
174
|
: undefined,
|
|
110
175
|
},
|
|
111
176
|
passport: {
|
|
112
177
|
isVisible: (v) => v.paymentType === "bank",
|
|
113
|
-
number: { value: "", label: "
|
|
114
|
-
issueDate: { value: "", label: "
|
|
178
|
+
number: { value: "", label: "Passport number", isRequired: true },
|
|
179
|
+
issueDate: { value: "", label: "Issue date" },
|
|
115
180
|
},
|
|
116
|
-
amount: { value: 0, label: "
|
|
181
|
+
amount: { value: 0, label: "Amount", isRequired: true },
|
|
117
182
|
},
|
|
118
183
|
initialValues: { paymentType: "card" },
|
|
119
184
|
});
|
|
120
185
|
```
|
|
121
186
|
|
|
122
|
-
### 2.
|
|
187
|
+
### 2. Connect a component
|
|
123
188
|
|
|
124
189
|
```tsx
|
|
125
190
|
import { useForm } from "palistor";
|
|
191
|
+
import { paymentStore } from "./paymentStore";
|
|
126
192
|
|
|
127
193
|
function PaymentForm() {
|
|
128
194
|
const form = useForm(paymentStore);
|
|
129
195
|
|
|
130
196
|
return (
|
|
131
|
-
<form onSubmit={() => paymentStore.submit()}>
|
|
197
|
+
<form onSubmit={(e) => { e.preventDefault(); paymentStore.submit(); }}>
|
|
132
198
|
<Select
|
|
133
199
|
value={form.paymentType.value}
|
|
134
200
|
onChange={(e) => (form.paymentType.value = e.target.value)}
|
|
@@ -148,120 +214,128 @@ function PaymentForm() {
|
|
|
148
214
|
|
|
149
215
|
{form.passport.isVisible && <PassportSection passport={form.passport} />}
|
|
150
216
|
|
|
151
|
-
<Button type="submit" isLoading={form.submitting}>
|
|
152
|
-
Оплатить
|
|
153
|
-
</Button>
|
|
217
|
+
<Button type="submit" isLoading={form.submitting}>Pay</Button>
|
|
154
218
|
</form>
|
|
155
219
|
);
|
|
156
220
|
}
|
|
157
221
|
```
|
|
158
222
|
|
|
159
|
-
### 3.
|
|
223
|
+
### 3. Independent re-render for a child component
|
|
160
224
|
|
|
161
|
-
|
|
225
|
+
If a child needs **independent** tracking, pass the subtree down as a prop and call `useForm` on it:
|
|
162
226
|
|
|
163
227
|
```tsx
|
|
164
228
|
function PassportSection({ passport }) {
|
|
165
|
-
//
|
|
229
|
+
// Own tracking proxy — re-renders only when passport fields change
|
|
166
230
|
const p = useForm(passport);
|
|
167
231
|
|
|
168
232
|
return (
|
|
169
233
|
<>
|
|
170
|
-
<Input value={p.number.value}
|
|
234
|
+
<Input value={p.number.value} onChange={(e) => (p.number.value = e.target.value)} label={p.number.label} />
|
|
171
235
|
<Input value={p.issueDate.value} onChange={(e) => (p.issueDate.value = e.target.value)} label={p.issueDate.label} />
|
|
172
236
|
</>
|
|
173
237
|
);
|
|
174
238
|
}
|
|
175
239
|
```
|
|
176
240
|
|
|
177
|
-
>
|
|
241
|
+
> **Without `useForm` in the child** the component re-renders in cascade with its parent. That's fine for simple leaf components.
|
|
178
242
|
|
|
179
243
|
---
|
|
180
244
|
|
|
181
|
-
##
|
|
245
|
+
## Concepts
|
|
182
246
|
|
|
183
|
-
###
|
|
247
|
+
### Two node kinds: leaf and group
|
|
184
248
|
|
|
185
|
-
|
|
249
|
+
The node kind is determined by the presence of a `value` property:
|
|
186
250
|
|
|
187
251
|
```
|
|
188
|
-
|
|
189
|
-
|
|
252
|
+
Has "value" → leaf node (a form field)
|
|
253
|
+
No "value" → group node (a container / section)
|
|
190
254
|
```
|
|
191
255
|
|
|
192
256
|
```typescript
|
|
193
257
|
const config = {
|
|
194
|
-
//
|
|
258
|
+
// Leaf node — owns a value
|
|
195
259
|
email: { value: "", isRequired: true },
|
|
196
260
|
|
|
197
|
-
//
|
|
261
|
+
// Group node — a container with computed properties
|
|
198
262
|
address: {
|
|
199
263
|
isVisible: (v) => v.showAddress,
|
|
200
264
|
city: { value: "" },
|
|
201
|
-
country: { value: "
|
|
265
|
+
country: { value: "US" },
|
|
202
266
|
},
|
|
203
267
|
};
|
|
204
268
|
```
|
|
205
269
|
|
|
206
|
-
###
|
|
270
|
+
### How tracking works
|
|
207
271
|
|
|
208
272
|
```
|
|
209
|
-
|
|
273
|
+
Render: the component reads form.email.value and form.phone.value
|
|
210
274
|
→ accessed = { emailNode, phoneNode }
|
|
211
275
|
|
|
212
|
-
SET form.city.value → cityNode++ → snapshot
|
|
213
|
-
SET form.email.value → emailNode++ → snapshot
|
|
276
|
+
SET form.city.value → cityNode version++ → snapshot unchanged → no re-render ✓
|
|
277
|
+
SET form.email.value → emailNode version++ → snapshot changed → re-render ✓
|
|
214
278
|
```
|
|
215
279
|
|
|
216
|
-
|
|
280
|
+
A parent that only navigates (`form.passport`) without reading field state does **not** re-render when fields inside `passport` change.
|
|
217
281
|
|
|
218
|
-
###
|
|
282
|
+
### Write data flow
|
|
219
283
|
|
|
220
284
|
```
|
|
221
285
|
form.email.value = "user@example.com"
|
|
222
286
|
│
|
|
223
|
-
├─ 1. formatter(rawValue, allValues) →
|
|
224
|
-
├─ 2.
|
|
225
|
-
├─ 3. setter(value, allValues)?
|
|
226
|
-
├─ 4. recompute(changedNodes) →
|
|
227
|
-
├─ 5.
|
|
228
|
-
├─ 6.
|
|
229
|
-
└─ 7.
|
|
287
|
+
├─ 1. formatter(rawValue, allValues) → normalized value
|
|
288
|
+
├─ 2. store value → nodeState updated, valuesCache O(1)
|
|
289
|
+
├─ 3. setter(value, allValues, prev)? → patch → applied to sibling fields
|
|
290
|
+
├─ 4. recompute(changedNodes) → targeted FieldState recompute
|
|
291
|
+
├─ 5. dirty flags → propagated up the tree
|
|
292
|
+
├─ 6. notify → version++ → useSyncExternalStore → re-render
|
|
293
|
+
└─ 7. onChange → group onChange callback (fire-and-forget)
|
|
230
294
|
```
|
|
231
295
|
|
|
296
|
+
### Validation timing (`revalidate`)
|
|
297
|
+
|
|
298
|
+
Validation errors are hidden until the first failed `submit()` of the enclosing group. After that the group's `revalidate` flag turns `true` and `isInvalid` / `errorMessage` update live on every keystroke.
|
|
299
|
+
|
|
232
300
|
---
|
|
233
301
|
|
|
234
|
-
## API
|
|
302
|
+
## API reference
|
|
235
303
|
|
|
236
|
-
### `
|
|
304
|
+
### `new Palistor(options)`
|
|
237
305
|
|
|
238
306
|
```typescript
|
|
239
|
-
import {
|
|
307
|
+
import { Palistor } from "palistor";
|
|
240
308
|
|
|
241
|
-
const store =
|
|
242
|
-
config
|
|
243
|
-
initialValues
|
|
309
|
+
const store = new Palistor({
|
|
310
|
+
config, // ConfigNode tree — required
|
|
311
|
+
initialValues, // deep-partial values that override config defaults
|
|
312
|
+
context, // initial non-reactive context (see Store context)
|
|
313
|
+
fieldMapping, // prop renaming map (see Field mapping)
|
|
244
314
|
});
|
|
245
315
|
```
|
|
246
316
|
|
|
247
|
-
|
|
317
|
+
**Store — public API:**
|
|
248
318
|
|
|
249
|
-
|
|
|
319
|
+
| Property / method | Returns | Description |
|
|
250
320
|
|---|---|---|
|
|
251
|
-
| `store.proxy` |
|
|
252
|
-
| `store.getValues()` |
|
|
253
|
-
| `store.submit()` | `Promise<SubmitResult>` | Submit root
|
|
254
|
-
| `store.reset(values?)` | `void` |
|
|
255
|
-
| `store.
|
|
256
|
-
| `store.
|
|
257
|
-
| `store.
|
|
258
|
-
| `store.
|
|
259
|
-
| `store.
|
|
260
|
-
| `store.
|
|
261
|
-
| `store.
|
|
262
|
-
| `store.
|
|
263
|
-
| `store.
|
|
264
|
-
| `store.
|
|
321
|
+
| `store.proxy` | proxy | Reactive proxy mirroring the config. **Do not** pass it (or its subtrees) to `useForm` — pass the store itself |
|
|
322
|
+
| `store.getValues()` | values | Deep **clone** of all current values as a nested object |
|
|
323
|
+
| `store.submit()` | `Promise<SubmitResult>` | Submit the root group |
|
|
324
|
+
| `store.reset(values?)` | `void` | Reset to config defaults (or to the provided values) |
|
|
325
|
+
| `store.setValues(patch)` | `void` | Bulk patch: one recompute + notify; skips setters and formatters |
|
|
326
|
+
| `store.set(data)` | `void` | Upsert an entity or an array of entities in the registry |
|
|
327
|
+
| `store.delete(id)` | `void` | Remove an entity (cascades to child entities it owns) |
|
|
328
|
+
| `store.rekey(oldId, newId)` | `void` | Rename an entity in the registry and in every list |
|
|
329
|
+
| `store.invalidate(id, template?)` | `void` | Clear an entity's resolved cache so its resolve re-runs |
|
|
330
|
+
| `store.subscribe(node, fn)` | unsubscribe | Subscribe to one node's changes |
|
|
331
|
+
| `store.subscribeGlobal(fn)` | unsubscribe | Subscribe to all changes |
|
|
332
|
+
| `store.getVersion()` | `number` | Global version (incremented on every change) |
|
|
333
|
+
| `store.getNodeVersion(node)` | `number` | Version of a specific node |
|
|
334
|
+
| `store.setTranslator(fn \| null)` | `void` | Register an i18n function |
|
|
335
|
+
| `store.setNotifier(fn \| null)` | `void` | Register a notification function |
|
|
336
|
+
| `store.setContext(ctx)` | `void` | Merge non-reactive context (see Store context) |
|
|
337
|
+
| `store.context` | object | Current non-reactive context |
|
|
338
|
+
| `store.persist` | `PersistManager` | Persistence manager (`enable / disable / flush`) |
|
|
265
339
|
|
|
266
340
|
---
|
|
267
341
|
|
|
@@ -270,26 +344,27 @@ const store = createProxyStore({
|
|
|
270
344
|
```typescript
|
|
271
345
|
import { useForm } from "palistor";
|
|
272
346
|
|
|
273
|
-
const form
|
|
274
|
-
const section = useForm(form.address);
|
|
347
|
+
const form = useForm(store); // tracking proxy over the whole store
|
|
348
|
+
const section = useForm(form.address); // independent tracking for a subtree (from a prop)
|
|
349
|
+
const entity = useForm(item, (s) => s.editForm); // bind an entity to a template
|
|
275
350
|
```
|
|
276
351
|
|
|
277
|
-
|
|
352
|
+
Returns a typed tracking proxy. The component re-renders only when nodes it accessed change.
|
|
278
353
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
| Паттерн | Когда использовать |
|
|
354
|
+
| Overload | When to use |
|
|
282
355
|
|---|---|
|
|
283
|
-
| `useForm(store)`
|
|
284
|
-
| `useForm(
|
|
285
|
-
|
|
|
356
|
+
| `useForm(store)` | Root of a form; pass subtrees down as props |
|
|
357
|
+
| `useForm(subtreeProp)` | A large section with independent re-renders |
|
|
358
|
+
| `useForm(entityProxy, templateSelector)` | Render/edit a list entity through a template (binds on mount, unbinds on unmount) |
|
|
359
|
+
|
|
360
|
+
> ⚠️ Passing a raw `store.proxy` subtree (e.g. `useForm(store.proxy.address)`) is a **compile-time and runtime error**. Always go through `useForm(store)` first and drill into the returned proxy.
|
|
286
361
|
|
|
287
362
|
---
|
|
288
363
|
|
|
289
|
-
###
|
|
364
|
+
### Leaf node — proxy properties
|
|
290
365
|
|
|
291
366
|
```typescript
|
|
292
|
-
//
|
|
367
|
+
// Reads are reactive — they register the node in the tracking set
|
|
293
368
|
form.email.value // → TValue
|
|
294
369
|
form.email.label // → string | undefined
|
|
295
370
|
form.email.placeholder // → string | undefined
|
|
@@ -301,13 +376,18 @@ form.email.isVisible // → boolean
|
|
|
301
376
|
form.email.isInvalid // → boolean | undefined
|
|
302
377
|
form.email.errorMessage // → string | undefined
|
|
303
378
|
form.email.dirty // → boolean
|
|
379
|
+
form.email.loading // → boolean (per-field resolver)
|
|
304
380
|
|
|
305
|
-
//
|
|
381
|
+
// Writes trigger formatter → setter → recompute → notify
|
|
306
382
|
form.email.value = "new@example.com";
|
|
307
|
-
form.email.onValueChange("new@example.com"); //
|
|
383
|
+
form.email.onValueChange("new@example.com"); // equivalent, handy as a callback prop
|
|
384
|
+
|
|
385
|
+
// A leaf can also be submitted on its own (runs the same pipeline)
|
|
386
|
+
form.email.submitting // → boolean
|
|
387
|
+
await form.email.submit(); // → SubmitResult
|
|
308
388
|
```
|
|
309
389
|
|
|
310
|
-
###
|
|
390
|
+
### Group node — proxy properties
|
|
311
391
|
|
|
312
392
|
```typescript
|
|
313
393
|
form.passport.isVisible // → boolean
|
|
@@ -317,71 +397,62 @@ form.passport.isDisabled // → boolean | undefined
|
|
|
317
397
|
form.passport.isInvalid // → boolean | undefined
|
|
318
398
|
form.passport.errorMessage // → string | undefined
|
|
319
399
|
form.passport.submitting // → boolean
|
|
320
|
-
form.passport.loading // → boolean (async resolver)
|
|
321
|
-
form.passport.dirty // → boolean (
|
|
322
|
-
form.passport.revalidate // → boolean (true
|
|
323
|
-
form.passport.values // →
|
|
400
|
+
form.passport.loading // → boolean (async resolver in progress)
|
|
401
|
+
form.passport.dirty // → boolean (at least one field changed)
|
|
402
|
+
form.passport.revalidate // → boolean (true after the first failed submit)
|
|
403
|
+
form.passport.values // → live snapshot of the group's values (stable reference)
|
|
324
404
|
|
|
325
|
-
await form.passport.submit();
|
|
326
|
-
form.passport.reset({ number: "" });
|
|
405
|
+
await form.passport.submit(); // → SubmitResult
|
|
406
|
+
form.passport.reset({ number: "" }); // reset the subtree
|
|
407
|
+
form.passport.setValues({ number: "AB1234" }); // bulk patch, no setters/formatters
|
|
327
408
|
```
|
|
328
409
|
|
|
329
|
-
`values`
|
|
330
|
-
|
|
331
|
-
```typescript
|
|
332
|
-
const vals = form.passport.values;
|
|
333
|
-
// → { number: "AB1234", issueDate: "2020-01-01" }
|
|
334
|
-
|
|
335
|
-
await api.submit(vals); // стабильная ссылка, всегда актуальна
|
|
336
|
-
```
|
|
410
|
+
`values` is a live reference into the values cache: it is updated in place on every write, the reference itself stays stable — safe to hand to an API call. For a detached deep clone use `store.getValues()`.
|
|
337
411
|
|
|
338
412
|
---
|
|
339
413
|
|
|
340
|
-
### `ConfigNode` —
|
|
341
|
-
|
|
342
|
-
```typescript
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
value?: TValue | ((values: TValues) => TValue)
|
|
346
|
-
validate?:
|
|
347
|
-
formatter?:
|
|
348
|
-
setter?:
|
|
349
|
-
beforeSubmit?: (value
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
414
|
+
### `ConfigNode` — field schema
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
// Leaf node (has "value")
|
|
418
|
+
{
|
|
419
|
+
value?: TValue | ((values: TValues) => TValue), // static or computed
|
|
420
|
+
validate?: (value, values, t) => string | undefined | false,
|
|
421
|
+
formatter?: (raw, values) => TValue, // normalize on write
|
|
422
|
+
setter?: (value, values, previousValue) => DeepPartialValues<TValues>, // patch siblings
|
|
423
|
+
beforeSubmit?: (value, groupValues) => TValue, // transform before submit (no mutation)
|
|
424
|
+
resolve?: Resolve<TValue>, // per-field resolver (inside list templates)
|
|
425
|
+
dependencies?: string[], // topological order for computed chains
|
|
426
|
+
componentProps?: Record<string, unknown>,
|
|
427
|
+
|
|
428
|
+
label?: string | ((t, values) => string),
|
|
429
|
+
placeholder?: string | ((t, values) => string),
|
|
430
|
+
description?: string | ((t, values) => string),
|
|
431
|
+
isVisible?: boolean | ((values) => boolean), // default: true
|
|
432
|
+
isRequired?: boolean | ((values) => boolean), // default: false
|
|
433
|
+
isDisabled?: boolean | ((values) => boolean), // default: false
|
|
434
|
+
isReadOnly?: boolean | ((values) => boolean), // default: false
|
|
360
435
|
}
|
|
361
436
|
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
beforeSubmit?: (values
|
|
365
|
-
onSubmit?: (values
|
|
366
|
-
afterSubmit?: (result
|
|
367
|
-
reset?: (defaults
|
|
368
|
-
onChange?: (
|
|
369
|
-
resolve?:
|
|
370
|
-
|
|
371
|
-
isVisible
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
isReadOnly?: boolean | ((values: TValues) => boolean);
|
|
375
|
-
|
|
376
|
-
[key: string]: LeafNode | GroupNode | ListNode | any;
|
|
437
|
+
// Group node (no "value")
|
|
438
|
+
{
|
|
439
|
+
beforeSubmit?: (values) => values,
|
|
440
|
+
onSubmit?: (values, store, parentProxy) => Promise<unknown> | unknown,
|
|
441
|
+
afterSubmit?: (result, { reset }) => void | Promise<void>,
|
|
442
|
+
reset?: (defaults) => values, // transform on reset
|
|
443
|
+
onChange?: ({ fieldKey, newValue, previousValue, allValues }) => patch | void,
|
|
444
|
+
resolve?: Resolve, // async resolver (see below)
|
|
445
|
+
|
|
446
|
+
isVisible?, isRequired?, isDisabled?, isReadOnly?, // same as leaf
|
|
447
|
+
|
|
448
|
+
[childKey]: LeafNode | GroupNode | ListNode, // children
|
|
377
449
|
}
|
|
378
450
|
|
|
379
|
-
//
|
|
380
|
-
|
|
451
|
+
// List node — array of length 1 or 2 (or use defineList)
|
|
452
|
+
[templateGroupNode]
|
|
453
|
+
[templateGroupNode, { resolve: { resolver, deps?, onError? } }]
|
|
381
454
|
```
|
|
382
455
|
|
|
383
|
-
---
|
|
384
|
-
|
|
385
456
|
### `SubmitResult`
|
|
386
457
|
|
|
387
458
|
```typescript
|
|
@@ -390,85 +461,107 @@ type SubmitResult =
|
|
|
390
461
|
| { success: false; errors: Array<{ path: string; message: string }> };
|
|
391
462
|
```
|
|
392
463
|
|
|
393
|
-
|
|
464
|
+
### Recipes
|
|
394
465
|
|
|
395
|
-
|
|
466
|
+
```typescript
|
|
467
|
+
// Computed value
|
|
468
|
+
total: { value: (v) => v.price * v.quantity, isReadOnly: true },
|
|
396
469
|
|
|
397
|
-
|
|
470
|
+
// Computed chain — declare dependencies for topological ordering
|
|
471
|
+
tax: { value: (v) => v.price * 0.2, dependencies: ["price"] },
|
|
472
|
+
total: { value: (v) => v.price + v.tax, dependencies: ["price", "tax"] },
|
|
398
473
|
|
|
399
|
-
|
|
474
|
+
// formatter — normalize on write
|
|
475
|
+
email: { value: "", formatter: (v) => String(v).trim().toLowerCase() },
|
|
400
476
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
477
|
+
// setter — cascade changes to other fields
|
|
478
|
+
country: { value: "US", setter: (value) => ({ city: "" }) },
|
|
479
|
+
|
|
480
|
+
// group onChange — react to any field change inside the group
|
|
481
|
+
passport: {
|
|
482
|
+
onChange: ({ fieldKey }) => {
|
|
483
|
+
if (fieldKey === "number") return { issueDate: "" };
|
|
408
484
|
},
|
|
409
|
-
}
|
|
485
|
+
number: { value: "" },
|
|
486
|
+
issueDate: { value: "" },
|
|
487
|
+
},
|
|
410
488
|
|
|
411
|
-
|
|
412
|
-
|
|
489
|
+
// group submit with validation
|
|
490
|
+
company: {
|
|
491
|
+
onSubmit: async (values) => api.saveCompany(values),
|
|
492
|
+
afterSubmit: (_result, { reset }) => { showSuccessToast(); reset(); },
|
|
493
|
+
name: { value: "", isRequired: true },
|
|
494
|
+
taxId: { value: "" },
|
|
495
|
+
},
|
|
413
496
|
```
|
|
414
497
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
```typescript
|
|
418
|
-
const initial: DeepPartialValues<FormValues> = { address: { city: "Москва" } };
|
|
419
|
-
```
|
|
498
|
+
---
|
|
420
499
|
|
|
421
|
-
|
|
500
|
+
## Async resolvers
|
|
422
501
|
|
|
423
|
-
|
|
502
|
+
A resolver is configured on a group node. It loads data asynchronously with auto-tracked dependencies, retry and React Suspense support.
|
|
424
503
|
|
|
425
504
|
```typescript
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
505
|
+
const store = new Palistor({
|
|
506
|
+
config: {
|
|
507
|
+
userId: { value: "" },
|
|
429
508
|
|
|
430
|
-
|
|
509
|
+
userInfo: {
|
|
510
|
+
resolve: {
|
|
511
|
+
// `values` is a tracking proxy: every GET becomes a dependency.
|
|
512
|
+
// When userId changes, the resolver re-runs automatically.
|
|
513
|
+
// `store` gives access to the rest of the store (and store.context).
|
|
514
|
+
resolver: async (values, store) => {
|
|
515
|
+
const data = await api.getUser(values.userId);
|
|
516
|
+
return { name: data.name, email: data.email };
|
|
517
|
+
},
|
|
431
518
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
return <input value={u.name.value} onChange={e => { u.name.value = e.target.value }} />;
|
|
435
|
-
}
|
|
436
|
-
// u.name → FieldProxyNode<string>
|
|
437
|
-
// u.address → Palistor<{ city: string }> (GroupProxyNode + поля)
|
|
438
|
-
```
|
|
519
|
+
// Instant placeholder while the resolver is running
|
|
520
|
+
optimisticResolver: (values) => ({ name: "Loading…" }),
|
|
439
521
|
|
|
440
|
-
|
|
522
|
+
onError: (error, ctx) => {
|
|
523
|
+
ctx.notify("Failed to load user", "USER_LOAD_ERROR");
|
|
524
|
+
},
|
|
441
525
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// form.users → ListProxyNode<Palistor<{ name: string; email: string }>>
|
|
445
|
-
```
|
|
526
|
+
deps: ["userId"], // explicit deps (used for the first run)
|
|
527
|
+
contextDeps: ["accountId"], // wait until store.context.accountId != null
|
|
446
528
|
|
|
447
|
-
|
|
529
|
+
options: {
|
|
530
|
+
lazy: true, // wait for the first access to the node (default: true)
|
|
531
|
+
suspense: false, // throw a promise for React Suspense (default: false)
|
|
532
|
+
retry: { attempts: 3, delay: 1000 },
|
|
533
|
+
},
|
|
534
|
+
},
|
|
448
535
|
|
|
449
|
-
|
|
536
|
+
name: { value: "" },
|
|
537
|
+
email: { value: "" },
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
```
|
|
450
542
|
|
|
451
|
-
```
|
|
452
|
-
|
|
543
|
+
```tsx
|
|
544
|
+
const form = useForm(store);
|
|
453
545
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
return <span>{u.name.value}</span>;
|
|
457
|
-
}
|
|
546
|
+
// Without Suspense — check loading manually
|
|
547
|
+
if (form.userInfo.loading) return <Spinner />;
|
|
458
548
|
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
549
|
+
// With Suspense — automatic
|
|
550
|
+
<Suspense fallback={<Spinner />}>
|
|
551
|
+
<UserInfoSection />
|
|
552
|
+
</Suspense>
|
|
462
553
|
```
|
|
463
554
|
|
|
464
|
-
`
|
|
555
|
+
When a dependency changes, the resolve state resets, `optimisticResolver` applies instantly and the resolver re-runs. After success the dirty baseline is updated, so resolved data is not marked dirty.
|
|
465
556
|
|
|
466
|
-
|
|
557
|
+
---
|
|
467
558
|
|
|
468
|
-
|
|
559
|
+
## Lists & entities
|
|
560
|
+
|
|
561
|
+
Lists are declared with `defineList` (preferred — fully typed) or as a raw array of length 1–2 where `[0]` is the item template.
|
|
469
562
|
|
|
470
563
|
```typescript
|
|
471
|
-
import { defineList } from "palistor";
|
|
564
|
+
import { defineList, Palistor } from "palistor";
|
|
472
565
|
|
|
473
566
|
interface User { id: string; name: string; email: string }
|
|
474
567
|
|
|
@@ -479,360 +572,274 @@ const users = defineList<User>({
|
|
|
479
572
|
email: { value: "" },
|
|
480
573
|
},
|
|
481
574
|
resolve: {
|
|
482
|
-
resolver: async (values) => api.getUsers(values.filter), // → Promise<User[]>
|
|
575
|
+
resolver: async (values, store) => api.getUsers(values.filter), // → Promise<User[]>
|
|
483
576
|
deps: ["filter"],
|
|
484
577
|
},
|
|
485
578
|
});
|
|
486
579
|
|
|
487
|
-
const
|
|
488
|
-
|
|
580
|
+
const store = new Palistor({
|
|
581
|
+
config: { filter: { value: "" }, users },
|
|
582
|
+
});
|
|
489
583
|
```
|
|
490
584
|
|
|
491
|
-
|
|
585
|
+
Items are stored in a **normalized entity registry**: the same entity can appear in multiple lists and be rendered through different templates without duplication.
|
|
492
586
|
|
|
493
|
-
|
|
494
|
-
|-----|-----------|
|
|
495
|
-
| `ExtractValues<TConfig>` | Плоский тип значений из конфига |
|
|
496
|
-
| `ConfigProxy<TConfig>` | Полный прокси — тип, возвращаемый `useForm(store)` |
|
|
497
|
-
| `Palistor<T>` | Proxy на основе интерфейса значений — для пропсов дочерних компонентов |
|
|
498
|
-
| `PalistorRef<TEntity>` | Непрозрачная ссылка на entity — для пропсов одного элемента |
|
|
499
|
-
| `PalistorList<TEntity>` | Типизированный список: `ListProxyNode<PalistorRef<TEntity>>` |
|
|
500
|
-
| `InferEntity<T>` | Извлечь тип entity из `PalistorRef<TEntity>` |
|
|
501
|
-
| `MaybeComputed<TResult, TValues>` | `isVisible`, `isRequired`, `value` — константа или `(values) => T` |
|
|
502
|
-
| `MaybeTranslatable<TResult, TValues>` | `label`, `placeholder` — константа или `(t, values) => string` |
|
|
503
|
-
| `DeepPartialValues<T>` | Глубокая partial-версия: `initialValues`, патчи `setter`, `setValues` |
|
|
504
|
-
| `TranslateFn` | Совместим с next-intl `t`, i18next `t` и любым `(...args) => string` |
|
|
505
|
-
| `TemplateConfig<TEntity>` | Типизированный шаблон — каждый ключ entity → `ConfigNode<TEntity[K]>` |
|
|
506
|
-
| `ListResolver<TEntity>` | Типизированный resolver — `(values) => Promise<TEntity[]>` |
|
|
507
|
-
|
|
508
|
-
### Полный паттерн типизации
|
|
587
|
+
### List proxy API
|
|
509
588
|
|
|
510
589
|
```typescript
|
|
511
|
-
|
|
512
|
-
import type { ExtractValues, PalistorRef, DeepPartialValues } from "palistor";
|
|
513
|
-
|
|
514
|
-
// 1. Вывести тип значений из конфига
|
|
515
|
-
const config = {
|
|
516
|
-
filter: { value: "" },
|
|
517
|
-
users: defineList<{ id: string; name: string }>({
|
|
518
|
-
template: { id: { value: "" }, name: { value: "" } },
|
|
519
|
-
}),
|
|
520
|
-
};
|
|
521
|
-
|
|
522
|
-
type Values = ExtractValues<typeof config>;
|
|
523
|
-
// → { filter: string; users: Array<{ id: string; name: string }> }
|
|
590
|
+
const form = useForm(store);
|
|
524
591
|
|
|
525
|
-
//
|
|
526
|
-
|
|
592
|
+
// Read
|
|
593
|
+
form.users.items // ReadonlyArray<entity proxy>
|
|
594
|
+
form.users.length // number
|
|
595
|
+
form.users.loading // boolean
|
|
596
|
+
form.users.dirty // boolean — list membership changed vs. baseline
|
|
597
|
+
form.users.getValues() // Array<plain object>
|
|
527
598
|
|
|
528
|
-
//
|
|
529
|
-
|
|
599
|
+
// Iterate
|
|
600
|
+
form.users.map((item, index, id) => <Row key={id} item={item} />)
|
|
601
|
+
for (const item of form.users) { /* … */ }
|
|
530
602
|
|
|
531
|
-
//
|
|
532
|
-
|
|
603
|
+
// Mutate
|
|
604
|
+
form.users.add({ name: "Alice", email: "alice@ex.com" }); // object → upsert + append, returns the item proxy
|
|
605
|
+
form.users.add("existing-id"); // string → append an existing entity
|
|
606
|
+
form.users.remove("user-id");
|
|
607
|
+
form.users.setItems(["id1", "id2", "id3"]); // bulk replace
|
|
608
|
+
form.users.getById("user-id"); // → item proxy | undefined
|
|
533
609
|
```
|
|
534
610
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
## Рецепты
|
|
611
|
+
### List item — proxy properties
|
|
538
612
|
|
|
539
|
-
|
|
613
|
+
Every element of `form.users.items` is an entity projection through the template:
|
|
540
614
|
|
|
541
615
|
```typescript
|
|
542
|
-
|
|
543
|
-
config: {
|
|
544
|
-
price: { value: 100 },
|
|
545
|
-
quantity: { value: 2 },
|
|
546
|
-
total: { value: (v) => v.price * v.quantity, isReadOnly: true },
|
|
547
|
-
},
|
|
548
|
-
});
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
### Цепочка computed (топологическая сортировка)
|
|
552
|
-
|
|
553
|
-
```typescript
|
|
554
|
-
config: {
|
|
555
|
-
price: { value: 100 },
|
|
556
|
-
tax: { value: (v) => v.price * 0.2, dependencies: ["price"] },
|
|
557
|
-
total: { value: (v) => v.price + v.tax, dependencies: ["price", "tax"] },
|
|
558
|
-
}
|
|
559
|
-
```
|
|
616
|
+
const item = form.users.items[0];
|
|
560
617
|
|
|
561
|
-
|
|
618
|
+
item.id // string — entity id
|
|
619
|
+
item.name.value // field value through the template (formatter/validate/isRequired apply)
|
|
620
|
+
item.name.label // computed label from the template
|
|
621
|
+
// …all leaf props: value, label, placeholder, isRequired, isReadOnly, isDisabled,
|
|
622
|
+
// isVisible, isInvalid, errorMessage, dirty, loading, onValueChange
|
|
562
623
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
},
|
|
624
|
+
item.loading // boolean — a resolve is running for this entity
|
|
625
|
+
item.submitting // boolean — a submit is running for this entity
|
|
626
|
+
item.values // plain object of the entity's current values
|
|
627
|
+
await item.submit(); // → SubmitResult — validates + onSubmit from the template
|
|
568
628
|
```
|
|
569
629
|
|
|
570
|
-
###
|
|
630
|
+
### Rendering an entity through another template
|
|
571
631
|
|
|
572
|
-
|
|
573
|
-
country: {
|
|
574
|
-
value: "RU",
|
|
575
|
-
setter: (value) => ({ city: "" }), // сбрасываем city при смене country
|
|
576
|
-
},
|
|
577
|
-
```
|
|
632
|
+
Bind an entity to any template group (e.g. an edit form) with the two-argument `useForm`:
|
|
578
633
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
if (fieldKey === "number") return { issueDate: "" };
|
|
585
|
-
},
|
|
586
|
-
number: { value: "" },
|
|
587
|
-
issueDate: { value: "" },
|
|
588
|
-
},
|
|
634
|
+
```tsx
|
|
635
|
+
function UserRow({ user }: { user: PalistorRef<User> }) {
|
|
636
|
+
const u = useForm(user, (s) => s.editUserForm);
|
|
637
|
+
return <span>{u.name.value}</span>;
|
|
638
|
+
}
|
|
589
639
|
```
|
|
590
640
|
|
|
591
|
-
|
|
641
|
+
On mount the entity is bound to the template and the template's resolver runs once per entity+template pair (cached). Call `store.invalidate(id, template?)` to force it to run again.
|
|
592
642
|
|
|
593
|
-
|
|
594
|
-
phone: {
|
|
595
|
-
value: "",
|
|
596
|
-
beforeSubmit: (value) => value.replace(/\D/g, ""), // не мутирует store
|
|
597
|
-
},
|
|
598
|
-
```
|
|
599
|
-
|
|
600
|
-
### Групповой submit с валидацией
|
|
643
|
+
### Working with entities directly
|
|
601
644
|
|
|
602
645
|
```typescript
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
onSubmit: async (values) => api.saveCompany(values.company),
|
|
607
|
-
afterSubmit: (_result, { reset }) => {
|
|
608
|
-
showSuccessToast();
|
|
609
|
-
reset();
|
|
610
|
-
},
|
|
611
|
-
name: { value: "", isRequired: true },
|
|
612
|
-
taxId: { value: "" },
|
|
613
|
-
},
|
|
614
|
-
},
|
|
615
|
-
});
|
|
646
|
+
// Create / update entities (single or batch — one recompute + notify)
|
|
647
|
+
store.set({ id: "u1", name: "Bob" });
|
|
648
|
+
store.set([{ id: "u1" }, { id: "u2" }]);
|
|
616
649
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
// [{ path: "company.name", message: "Обязательное поле" }]
|
|
621
|
-
}
|
|
622
|
-
```
|
|
623
|
-
|
|
624
|
-
### Получение значений для отправки
|
|
650
|
+
// If no id is provided, a temporary one is generated (_tmp_…).
|
|
651
|
+
// After the server assigns a real id:
|
|
652
|
+
store.rekey(tmpId, serverAssignedId);
|
|
625
653
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
// → { paymentType: "bank", passport: { number: "AB123", issueDate: "2020-01-01" } }
|
|
629
|
-
await api.submit(values);
|
|
654
|
+
// Remove an entity (children owned by it are removed in cascade)
|
|
655
|
+
store.delete("u1");
|
|
630
656
|
```
|
|
631
657
|
|
|
632
658
|
---
|
|
633
659
|
|
|
634
|
-
##
|
|
660
|
+
## Flows (step wizards)
|
|
635
661
|
|
|
636
|
-
|
|
662
|
+
`defineFlow` / `defineStep` build a step wizard on top of regular group nodes: navigation state, step statuses, branching and per-step validation.
|
|
637
663
|
|
|
638
664
|
```typescript
|
|
639
|
-
|
|
640
|
-
config: {
|
|
641
|
-
userId: { value: "" },
|
|
665
|
+
import { defineFlow, defineStep, Palistor } from "palistor";
|
|
642
666
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
667
|
+
const onboarding = defineFlow({
|
|
668
|
+
steps: [
|
|
669
|
+
defineStep("account", {
|
|
670
|
+
fullName: { value: "", isRequired: true },
|
|
671
|
+
email: { value: "", isRequired: true },
|
|
672
|
+
// Third onSubmit argument is the flow proxy — navigation methods are bound,
|
|
673
|
+
// so destructuring works:
|
|
674
|
+
onSubmit: (_values, _store, { nextStep }) => nextStep(),
|
|
675
|
+
}),
|
|
651
676
|
|
|
652
|
-
|
|
653
|
-
|
|
677
|
+
defineStep("plan", {
|
|
678
|
+
plan: { value: "", isRequired: true },
|
|
679
|
+
onSubmit: (_values, _store, { nextStep }) => nextStep(),
|
|
680
|
+
}),
|
|
654
681
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
682
|
+
// Branching: a hidden step is skipped by nextStep() and excluded from
|
|
683
|
+
// final validation.
|
|
684
|
+
defineStep("company", {
|
|
685
|
+
isVisible: (values) => values.plan.plan === "enterprise",
|
|
686
|
+
companyName: { value: "", isRequired: true },
|
|
687
|
+
onSubmit: (_values, _store, { nextStep }) => nextStep(),
|
|
688
|
+
}),
|
|
658
689
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
suspense: false, // throw promise для React Suspense (default: false)
|
|
662
|
-
retry: { attempts: 3, delay: 1000 },
|
|
663
|
-
},
|
|
664
|
-
},
|
|
690
|
+
defineStep("summary", {}), // read-only summary step
|
|
691
|
+
],
|
|
665
692
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
},
|
|
669
|
-
},
|
|
693
|
+
// Flow-level finalization — runs through the standard submit pipeline
|
|
694
|
+
onSubmit: async (allValues, store) => api.completeOnboarding(allValues),
|
|
670
695
|
});
|
|
696
|
+
|
|
697
|
+
const store = new Palistor({ config: { onboarding } });
|
|
671
698
|
```
|
|
672
699
|
|
|
700
|
+
### Flow proxy API
|
|
701
|
+
|
|
673
702
|
```tsx
|
|
674
703
|
const form = useForm(store);
|
|
704
|
+
const flow = form.onboarding;
|
|
675
705
|
|
|
676
|
-
//
|
|
677
|
-
|
|
706
|
+
// Navigation state (reactive)
|
|
707
|
+
flow.currentStepKey // "account" | "plan" | …
|
|
708
|
+
flow.currentStepIndex // number
|
|
709
|
+
flow.canGoBack // boolean — visit stack is non-empty
|
|
710
|
+
flow.history // readonly string[] — [...visitStack, currentStepKey]
|
|
711
|
+
flow.errors // FlowError[] — from the last validate() / finalization
|
|
678
712
|
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
713
|
+
// Steps collection
|
|
714
|
+
flow.steps.current // proxy of the active step
|
|
715
|
+
flow.steps.account // by key
|
|
716
|
+
flow.steps[0] // by index
|
|
717
|
+
flow.steps.length // number of steps
|
|
718
|
+
[...flow.steps] // iterable
|
|
684
719
|
|
|
685
|
-
|
|
720
|
+
// Step proxy = regular group proxy + status
|
|
721
|
+
flow.steps.account.status // "active" | "completed" | null (not visited yet)
|
|
722
|
+
flow.steps.account.email // field proxy — regular leaf
|
|
686
723
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
→ resolver запускается заново
|
|
693
|
-
```
|
|
724
|
+
// Navigation
|
|
725
|
+
flow.nextStep(); // next VISIBLE step; if none ahead → finalize via flow.submit()
|
|
726
|
+
flow.back(); // pop the visit stack; no-op when empty
|
|
727
|
+
flow.goTo("plan"); // jump by key or index; throws on unknown key
|
|
728
|
+
flow.validate(); // validate visited visible steps → flow.errors
|
|
694
729
|
|
|
695
|
-
|
|
730
|
+
// Values & finalization
|
|
731
|
+
flow.values // accumulated values of all steps, keyed by step
|
|
732
|
+
await flow.submit(); // standard pipeline; hidden steps' errors are filtered out
|
|
733
|
+
```
|
|
696
734
|
|
|
697
|
-
|
|
735
|
+
### Step lifecycle
|
|
698
736
|
|
|
699
|
-
|
|
737
|
+
A step config accepts two flow-specific callbacks in addition to everything a group supports:
|
|
700
738
|
|
|
701
739
|
```typescript
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
name: { value: "", isRequired: true },
|
|
709
|
-
email: { value: "" },
|
|
710
|
-
},
|
|
711
|
-
// Опциональный конфиг списка
|
|
712
|
-
{
|
|
713
|
-
resolve: {
|
|
714
|
-
resolver: async () => {
|
|
715
|
-
const data = await api.getUsers();
|
|
716
|
-
return data; // Array<{ id, name, email }>
|
|
717
|
-
},
|
|
718
|
-
},
|
|
719
|
-
},
|
|
720
|
-
],
|
|
721
|
-
},
|
|
722
|
-
});
|
|
740
|
+
defineStep("details", {
|
|
741
|
+
onEnter: (flowValues, store) => { /* fired on entering the step */ },
|
|
742
|
+
onReady: (flowValues, store) => { /* fired after the step's resolve completes */ },
|
|
743
|
+
resolve: { resolver: async (values, store) => api.getDetails(), onError: () => {} },
|
|
744
|
+
// …fields
|
|
745
|
+
})
|
|
723
746
|
```
|
|
724
747
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
```typescript
|
|
728
|
-
const form = useForm(store);
|
|
729
|
-
|
|
730
|
-
// Чтение
|
|
731
|
-
form.users.items // ReadonlyArray<EntityProjectionProxy>
|
|
732
|
-
form.users.length // number
|
|
733
|
-
form.users.loading // boolean
|
|
734
|
-
form.users.dirty // boolean — состав списка изменился vs. baseline
|
|
748
|
+
On entering a step: `onEnter → resolve (triggered eagerly) → onReady`. The first step of every flow is entered when the store is created. A step's resolve result is cached — re-entering does not re-run it. `reset()` of the flow (or any ancestor) resets navigation to the first step and re-runs the entry lifecycle.
|
|
735
749
|
|
|
736
|
-
|
|
737
|
-
form.users.map((item, index, id) => <Row key={id} item={item} />)
|
|
738
|
-
for (const item of form.users) { ... }
|
|
750
|
+
Flow navigation (current step, history) is included in [persist](#persist) snapshots and restored on hydration.
|
|
739
751
|
|
|
740
|
-
|
|
741
|
-
form.users.add({ name: "Alice", email: "alice@example.com" }); // объект → upsert + добавить
|
|
742
|
-
form.users.add("existing-id"); // строка → добавить существующую
|
|
743
|
-
form.users.remove("user-id");
|
|
744
|
-
form.users.setItems(["id1", "id2", "id3"]); // bulk замена
|
|
745
|
-
form.users.getById("user-id"); // → EntityProjectionProxy | undefined
|
|
746
|
-
```
|
|
752
|
+
---
|
|
747
753
|
|
|
748
|
-
|
|
754
|
+
## Field mapping
|
|
749
755
|
|
|
750
|
-
|
|
756
|
+
`fieldMapping` renames field-state props **at the proxy boundary** — GET, SET, tracking and spread — so `{...form.email}` can be spread directly into a component from MUI, Ant Design or plain HTML without adapters. The internal engine is unchanged.
|
|
751
757
|
|
|
752
758
|
```typescript
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
item.id // string — идентификатор entity
|
|
756
|
-
item.name.value // значение поля через template (с formatter/validate/isRequired)
|
|
757
|
-
item.name.label // computed label из template
|
|
758
|
-
item.name.isRequired // computed isRequired из template
|
|
759
|
-
// ... все leaf props: value, label, placeholder, isRequired, isReadOnly, isDisabled,
|
|
760
|
-
// isVisible, isInvalid, errorMessage, dirty, onValueChange
|
|
761
|
-
|
|
762
|
-
item.loading // boolean — идёт ли resolve для этого entity
|
|
763
|
-
item.submitting // boolean — идёт ли submit для этого entity
|
|
764
|
-
item.values // Record<string, unknown> — plain объект значений entity
|
|
765
|
-
await item.submit(); // → SubmitResult — submit этого entity через template
|
|
766
|
-
```
|
|
767
|
-
|
|
768
|
-
`item.values` — plain объект с текущими значениями полей entity, пригодный для передачи в API:
|
|
759
|
+
import { Palistor, defineFieldMapping, useForm } from "palistor";
|
|
769
760
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
761
|
+
// defineFieldMapping preserves the literal types so renamed props are
|
|
762
|
+
// statically typed on store.proxy / useForm(store).
|
|
763
|
+
const uiFieldMapping = defineFieldMapping({
|
|
764
|
+
isRequired: "required",
|
|
765
|
+
isDisabled: "disabled",
|
|
766
|
+
isReadOnly: "readOnly",
|
|
767
|
+
isInvalid: "error",
|
|
768
|
+
errorMessage: "helperText",
|
|
769
|
+
description: "helpText",
|
|
773
770
|
});
|
|
774
|
-
```
|
|
775
771
|
|
|
776
|
-
|
|
772
|
+
const store = new Palistor({
|
|
773
|
+
// The config is written in the SAME public vocabulary (external names).
|
|
774
|
+
// Writing an internal name (isRequired) of a remapped key is a type error.
|
|
775
|
+
config: {
|
|
776
|
+
email: {
|
|
777
|
+
value: "",
|
|
778
|
+
label: "Email",
|
|
779
|
+
required: true,
|
|
780
|
+
helpText: "We never share your email",
|
|
781
|
+
validate: (v: string) => (!v.includes("@") ? "Invalid email" : undefined),
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
fieldMapping: uiFieldMapping,
|
|
785
|
+
});
|
|
777
786
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
787
|
+
function EmailField() {
|
|
788
|
+
const form = useForm(store);
|
|
789
|
+
return <TextField {...form.email} />; // required / error / helperText — as MUI expects
|
|
790
|
+
}
|
|
791
|
+
```
|
|
781
792
|
|
|
782
|
-
|
|
783
|
-
// После сохранения на сервере переименуйте:
|
|
784
|
-
store.rekey(tmpId, serverAssignedId);
|
|
793
|
+
Mappable keys: `value`, `label`, `placeholder`, `description`, `isRequired`, `isReadOnly`, `isDisabled`, `isVisible`, `isInvalid`, `errorMessage`, `dirty`, `loading`, `onValueChange`. Without `fieldMapping` there is zero overhead and no behavior change.
|
|
785
794
|
|
|
786
|
-
|
|
787
|
-
store.delete(id);
|
|
788
|
-
```
|
|
795
|
+
> Use `defineFieldMapping` (or `as const`) for a reusable map — a plain `: FieldMapping` annotation or `satisfies FieldMapping` widens the literals to `string` and the static renaming is lost.
|
|
789
796
|
|
|
790
797
|
---
|
|
791
798
|
|
|
792
799
|
## Persist
|
|
793
800
|
|
|
794
|
-
|
|
801
|
+
Autosave form state to any storage.
|
|
795
802
|
|
|
796
|
-
### React
|
|
803
|
+
### React hook (recommended)
|
|
797
804
|
|
|
798
805
|
```tsx
|
|
799
|
-
import { usePersist } from "palistor
|
|
806
|
+
import { usePersist, localStorageDriver } from "palistor";
|
|
800
807
|
|
|
801
808
|
function PaymentPage({ orderId }: { orderId: string }) {
|
|
802
809
|
usePersist(paymentStore, {
|
|
803
|
-
key: `payment-${orderId}`, //
|
|
810
|
+
key: `payment-${orderId}`, // key may depend on props / router
|
|
804
811
|
driver: localStorageDriver,
|
|
805
812
|
debounce: 500, // ms, default: 100
|
|
806
|
-
pick: ["cardNumber"], //
|
|
807
|
-
// omit: ["cvv"], //
|
|
813
|
+
pick: ["cardNumber"], // persist only these top-level fields
|
|
814
|
+
// omit: ["cvv"], // …or exclude sensitive ones
|
|
808
815
|
});
|
|
809
816
|
|
|
810
817
|
const form = useForm(paymentStore);
|
|
811
|
-
//
|
|
818
|
+
// …
|
|
812
819
|
}
|
|
813
820
|
```
|
|
814
821
|
|
|
815
|
-
|
|
822
|
+
The hook hydrates on mount, autosaves on change, and flushes + disables on unmount.
|
|
816
823
|
|
|
817
|
-
|
|
818
|
-
import { localStorageDriver, sessionStorageDriver } from "palistor/store/persist";
|
|
824
|
+
### Outside React
|
|
819
825
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
driver: localStorageDriver,
|
|
823
|
-
});
|
|
826
|
+
```typescript
|
|
827
|
+
import { localStorageDriver } from "palistor";
|
|
824
828
|
|
|
825
|
-
|
|
829
|
+
paymentStore.persist.enable({ key: "payment", driver: localStorageDriver });
|
|
830
|
+
await paymentStore.persist.flush(); // force save
|
|
826
831
|
paymentStore.persist.disable();
|
|
827
832
|
```
|
|
828
833
|
|
|
829
|
-
###
|
|
834
|
+
### Custom driver
|
|
835
|
+
|
|
836
|
+
Sync or async — both are supported (localStorage, IndexedDB, AsyncStorage, …):
|
|
830
837
|
|
|
831
838
|
```typescript
|
|
832
|
-
import type { PersistDriver } from "palistor
|
|
839
|
+
import type { PersistDriver } from "palistor";
|
|
833
840
|
|
|
834
841
|
const myDriver: PersistDriver = {
|
|
835
|
-
getItem: (key) => myStorage.get(key),
|
|
842
|
+
getItem: (key) => myStorage.get(key), // string | null | Promise<…>
|
|
836
843
|
setItem: (key, value) => myStorage.set(key, value),
|
|
837
844
|
removeItem: (key) => myStorage.delete(key),
|
|
838
845
|
};
|
|
@@ -840,25 +847,27 @@ const myDriver: PersistDriver = {
|
|
|
840
847
|
|
|
841
848
|
**`PersistOptions`:**
|
|
842
849
|
|
|
843
|
-
|
|
|
850
|
+
| Option | Type | Default | Description |
|
|
844
851
|
|---|---|---|---|
|
|
845
|
-
| `key` | `string` | — |
|
|
846
|
-
| `driver` | `PersistDriver` | — |
|
|
847
|
-
| `debounce` | `number` | `100` |
|
|
848
|
-
| `serialize` | `fn` | `JSON.stringify` |
|
|
849
|
-
| `deserialize` | `fn` | `JSON.parse` |
|
|
850
|
-
| `pick` | `string[]` | — |
|
|
851
|
-
| `omit` | `string[]` | — |
|
|
852
|
+
| `key` | `string` | — | Storage key |
|
|
853
|
+
| `driver` | `PersistDriver` | — | Storage implementation |
|
|
854
|
+
| `debounce` | `number` | `100` | Write delay, ms (`0` = immediate) |
|
|
855
|
+
| `serialize` | `fn` | `JSON.stringify` | Custom serializer |
|
|
856
|
+
| `deserialize` | `fn` | `JSON.parse` | Custom deserializer |
|
|
857
|
+
| `pick` | `string[]` | — | Persist only these top-level keys |
|
|
858
|
+
| `omit` | `string[]` | — | Exclude these keys (ignored when `pick` is set) |
|
|
859
|
+
|
|
860
|
+
Persisted snapshots include list membership and flow navigation; both are restored on hydration.
|
|
852
861
|
|
|
853
862
|
---
|
|
854
863
|
|
|
855
864
|
## i18n
|
|
856
865
|
|
|
857
|
-
|
|
866
|
+
Register a translation function once — in a layout or provider. Every component using `useForm` gets translated `label` / `placeholder` / `description` automatically; changing the locale re-renders subscribed components.
|
|
858
867
|
|
|
859
868
|
```tsx
|
|
860
869
|
import { useTranslations } from "next-intl";
|
|
861
|
-
import { useTranslator } from "palistor
|
|
870
|
+
import { useTranslator } from "palistor";
|
|
862
871
|
|
|
863
872
|
function RootLayout({ children }: { children: React.ReactNode }) {
|
|
864
873
|
const t = useTranslations();
|
|
@@ -867,7 +876,7 @@ function RootLayout({ children }: { children: React.ReactNode }) {
|
|
|
867
876
|
}
|
|
868
877
|
```
|
|
869
878
|
|
|
870
|
-
|
|
879
|
+
In the config, translatable strings are functions of `(t, values)`:
|
|
871
880
|
|
|
872
881
|
```typescript
|
|
873
882
|
cardNumber: {
|
|
@@ -877,20 +886,21 @@ cardNumber: {
|
|
|
877
886
|
},
|
|
878
887
|
```
|
|
879
888
|
|
|
880
|
-
|
|
889
|
+
The `validate` callback also receives `t` as its third argument. Without a registered translator the functions receive an identity `t` — convenient for tests and SSR without an i18n environment.
|
|
881
890
|
|
|
882
891
|
---
|
|
883
892
|
|
|
884
|
-
##
|
|
893
|
+
## Notifications
|
|
885
894
|
|
|
886
|
-
|
|
895
|
+
Register a toast/alert function once; resolvers receive it in `onError` via `ctx.notify`:
|
|
887
896
|
|
|
888
897
|
```tsx
|
|
889
|
-
import {
|
|
898
|
+
import { useCallback } from "react";
|
|
899
|
+
import { useNotifier } from "palistor";
|
|
890
900
|
|
|
891
901
|
function RootLayout({ children }: { children: React.ReactNode }) {
|
|
892
902
|
const notifyError = useCallback((error: unknown, code?: string) => {
|
|
893
|
-
addToast({ title: code ?? "
|
|
903
|
+
addToast({ title: code ?? "Error", color: "danger" });
|
|
894
904
|
}, []);
|
|
895
905
|
|
|
896
906
|
useNotifier(paymentStore, notifyError);
|
|
@@ -900,48 +910,105 @@ function RootLayout({ children }: { children: React.ReactNode }) {
|
|
|
900
910
|
|
|
901
911
|
---
|
|
902
912
|
|
|
903
|
-
##
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
│ ├── onChangePipeline/ # onChange callback
|
|
923
|
-
│ ├── resolvePipeline/ # Async resolver: deps, retry, optimistic, suspense
|
|
924
|
-
│ ├── dirtyTracking/ # captureInitialValues, recomputeDirtyTargeted
|
|
925
|
-
│ ├── entityRegistry/ # Нормализованный реестр сущностей
|
|
926
|
-
│ ├── groupDeps/ # Карта зависимостей между группами
|
|
927
|
-
│ ├── traversal/ # isLeaf / isGroup / isListNode / walkFull
|
|
928
|
-
│ ├── valuesCache/ # buildValuesCache + O(1) updateValuesCacheEntry
|
|
929
|
-
│ ├── init/ # NotificationHub, ResolveManager, initGroupSubmitting
|
|
930
|
-
│ └── persist/
|
|
931
|
-
│ ├── persistManager.ts # enable / disable / flush
|
|
932
|
-
│ ├── drivers.ts # localStorageDriver, sessionStorageDriver
|
|
933
|
-
│ └── types.ts # PersistDriver, PersistOptions
|
|
934
|
-
└── react/
|
|
935
|
-
├── useForm.ts # useSyncExternalStore + tracking proxy
|
|
936
|
-
├── createTrackingProxy.ts # Proxy слой 2: запись accessed нод
|
|
937
|
-
├── useTranslator.ts # Регистрация i18n-функции
|
|
938
|
-
├── useNotifier.ts # Регистрация функции уведомлений
|
|
939
|
-
└── usePersist.ts # React-интеграция persist
|
|
913
|
+
## Store context
|
|
914
|
+
|
|
915
|
+
Non-reactive data (account id, tenant, tokens…) available to every callback via `store.context`. It is not part of the form — it does not appear in `getValues()`, submit payloads or persisted state.
|
|
916
|
+
|
|
917
|
+
```tsx
|
|
918
|
+
import { useStoreContext } from "palistor";
|
|
919
|
+
|
|
920
|
+
function Layout({ children }) {
|
|
921
|
+
const accountId = useAccountId();
|
|
922
|
+
useStoreContext(store, { accountId }); // merges into store.context
|
|
923
|
+
return <>{children}</>;
|
|
924
|
+
}
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
```typescript
|
|
928
|
+
resolve: {
|
|
929
|
+
resolver: async (values, store) => api.fetchUsers(store.context.accountId),
|
|
930
|
+
contextDeps: ["accountId"], // wait until context.accountId != null before the first run
|
|
931
|
+
},
|
|
940
932
|
```
|
|
941
933
|
|
|
934
|
+
Changing a context key re-triggers resolvers that depend on it (via `contextDeps` or a tracked `$context.…` path). You can also pass an initial context to the constructor: `new Palistor({ config, context: { accountId } })`.
|
|
935
|
+
|
|
942
936
|
---
|
|
943
937
|
|
|
944
|
-
##
|
|
938
|
+
## TypeScript
|
|
939
|
+
|
|
940
|
+
Palistor is fully typed — values, proxies, entities and even `fieldMapping` renames are inferred statically.
|
|
941
|
+
|
|
942
|
+
### Infer values from a config
|
|
943
|
+
|
|
944
|
+
```typescript
|
|
945
|
+
import type { ExtractValues, DeepPartialValues } from "palistor";
|
|
946
|
+
|
|
947
|
+
const config = {
|
|
948
|
+
name: { value: "" },
|
|
949
|
+
age: { value: 0 },
|
|
950
|
+
address: { city: { value: "" }, country: { value: "US" } },
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
type FormValues = ExtractValues<typeof config>;
|
|
954
|
+
// → { name: string; age: number; address: { city: string; country: string } }
|
|
955
|
+
|
|
956
|
+
const initial: DeepPartialValues<FormValues> = { address: { city: "Berlin" } };
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
### Type child-component props without importing the config
|
|
960
|
+
|
|
961
|
+
`PalistorProxy<T>` maps a plain values interface onto the proxy tree (the type is named `PalistorProxy` because the value `Palistor` is the store class):
|
|
962
|
+
|
|
963
|
+
```typescript
|
|
964
|
+
import type { PalistorProxy } from "palistor";
|
|
965
|
+
|
|
966
|
+
interface UserData { name: string; email: string; address: { city: string } }
|
|
967
|
+
|
|
968
|
+
function UserForm({ user }: { user: PalistorProxy<UserData> }) {
|
|
969
|
+
const u = useForm(user);
|
|
970
|
+
return <input value={u.name.value} onChange={(e) => (u.name.value = e.target.value)} />;
|
|
971
|
+
}
|
|
972
|
+
```
|
|
973
|
+
|
|
974
|
+
### Typed entity references
|
|
975
|
+
|
|
976
|
+
```typescript
|
|
977
|
+
import type { PalistorRef, PalistorList, InferEntity } from "palistor";
|
|
978
|
+
|
|
979
|
+
interface User { id: string; name: string }
|
|
980
|
+
|
|
981
|
+
function UserRow({ user }: { user: PalistorRef<User> }) {
|
|
982
|
+
const u = useForm(user, (s) => s.editUserForm);
|
|
983
|
+
return <span>{u.name.value}</span>;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
type UserEntity = InferEntity<PalistorRef<User>>; // → User
|
|
987
|
+
type UsersList = PalistorList<User>; // typed list proxy
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
### Type reference
|
|
991
|
+
|
|
992
|
+
| Type | Purpose |
|
|
993
|
+
|-----|-----|
|
|
994
|
+
| `ExtractValues<TConfig>` | Values type inferred from a config |
|
|
995
|
+
| `ConfigProxy<TConfig>` | Full proxy type — what `useForm(store)` returns |
|
|
996
|
+
| `PalistorProxy<T>` | Proxy built from a plain values interface — for child-component props |
|
|
997
|
+
| `PalistorRef<TEntity>` | Opaque entity reference — for single-item props |
|
|
998
|
+
| `PalistorList<TEntity>` | Typed list proxy |
|
|
999
|
+
| `InferEntity<T>` | Extract the entity type from a `PalistorRef` |
|
|
1000
|
+
| `FieldMapping` / `defineFieldMapping` | Prop renaming map (preserves literals) |
|
|
1001
|
+
| `FlowProxyNode<S>` / `FlowStepProxy<C>` / `StepStatus` | Flow proxy types |
|
|
1002
|
+
| `MaybeComputed<TResult, TValues>` | Constant or `(values) => T` — `isVisible`, `isRequired`, `value` |
|
|
1003
|
+
| `MaybeTranslatable<TResult, TValues>` | Constant or `(t, values) => string` — `label`, `placeholder` |
|
|
1004
|
+
| `DeepPartialValues<T>` | Deep-partial values: `initialValues`, `setter` patches, `setValues` |
|
|
1005
|
+
| `TranslateFn` | Compatible with next-intl `t`, i18next `t`, any `(...args) => string` |
|
|
1006
|
+
| `TemplateConfig<TEntity>` / `ListResolver<TEntity>` | Typed list template / resolver |
|
|
1007
|
+
| `PersistDriver` / `PersistOptions` | Persistence contracts |
|
|
1008
|
+
| `Resolve<T>` / `NotifyFn` / `ResolveErrorContext` | Resolver contracts |
|
|
1009
|
+
|
|
1010
|
+
---
|
|
945
1011
|
|
|
946
|
-
|
|
1012
|
+
## License
|
|
947
1013
|
|
|
1014
|
+
[MIT](./LICENSE) © Yuri Palienko
|