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