diff --git a/.changeset/effector-model-7.md b/.changeset/effector-model-7.md new file mode 100644 index 0000000..a1ed451 --- /dev/null +++ b/.changeset/effector-model-7.md @@ -0,0 +1,10 @@ +--- +'@effector/model': patch +--- + +- Add `InputType` and `KeyvalWithState` type helpers +- Add `isKeyval` method +- Add recursive keyval support +- Implement lazy initialization for keyval body +- Add support for filling nested keyvals on `.edit.add` +- Fix `onMount` types diff --git a/.changeset/effector-model-react-7.md b/.changeset/effector-model-react-7.md new file mode 100644 index 0000000..8e5fadd --- /dev/null +++ b/.changeset/effector-model-react-7.md @@ -0,0 +1,8 @@ +--- +'@effector/model-react': patch +--- + +- Implement tree render support in `useEntityList` +- Add `useEditKeyval` public hook +- Fix `useEditItemField` types +- Fix peerDependencies diff --git a/apps/tree-todo-list/.babelrc b/apps/tree-todo-list/.babelrc new file mode 100644 index 0000000..9bf2459 --- /dev/null +++ b/apps/tree-todo-list/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": [], + "plugins": ["effector/babel-plugin"] +} diff --git a/apps/tree-todo-list/.gitignore b/apps/tree-todo-list/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/tree-todo-list/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/tree-todo-list/README.md b/apps/tree-todo-list/README.md new file mode 100644 index 0000000..f2c6df4 --- /dev/null +++ b/apps/tree-todo-list/README.md @@ -0,0 +1,3 @@ +# Tree todo list app + +Run `npx nx run tree-todo-list:serve` to start diff --git a/apps/tree-todo-list/index.html b/apps/tree-todo-list/index.html new file mode 100644 index 0000000..c56bfbc --- /dev/null +++ b/apps/tree-todo-list/index.html @@ -0,0 +1,19 @@ + + + + + + + Tree todo list effector model app + + + +
+ + + diff --git a/apps/tree-todo-list/package.json b/apps/tree-todo-list/package.json new file mode 100644 index 0000000..2c0c719 --- /dev/null +++ b/apps/tree-todo-list/package.json @@ -0,0 +1,13 @@ +{ + "name": "@effector/tree-todo-list-app", + "private": true, + "version": "0.0.0", + "type": "module", + "dependencies": { + "effector-action": "^1.1.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^3.1.0", + "vite": "^4.2.1" + } +} diff --git a/apps/tree-todo-list/project.json b/apps/tree-todo-list/project.json new file mode 100644 index 0000000..03f7825 --- /dev/null +++ b/apps/tree-todo-list/project.json @@ -0,0 +1,43 @@ +{ + "name": "tree-todo-list", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/tree-todo-list/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nrwl/vite:build", + "options": { + "outputPath": "dist/apps/tree-todo-list" + } + }, + "serve": { + "executor": "@nrwl/vite:dev-server", + "options": { + "buildTarget": "tree-todo-list:build" + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/tree-todo-list/**/*.{ts,js}"] + } + }, + "preview": { + "executor": "@nrwl/vite:preview-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "tree-todo-list:build" + }, + "configurations": { + "development": { + "buildTarget": "tree-todo-list:build:development" + }, + "production": { + "buildTarget": "tree-todo-list:build:production" + } + } + } + }, + "tags": [] +} diff --git a/apps/tree-todo-list/public/vite.svg b/apps/tree-todo-list/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/tree-todo-list/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/tree-todo-list/src/main.tsx b/apps/tree-todo-list/src/main.tsx new file mode 100644 index 0000000..6ee97dd --- /dev/null +++ b/apps/tree-todo-list/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { App } from './view/App'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/apps/tree-todo-list/src/model.ts b/apps/tree-todo-list/src/model.ts new file mode 100644 index 0000000..4ee5dd4 --- /dev/null +++ b/apps/tree-todo-list/src/model.ts @@ -0,0 +1,261 @@ +import { + combine, + createEvent, + createStore, + type EventCallable, + sample, +} from 'effector'; +import { KeyOrKeys, keyval, lazy, type Keyval } from '@effector/model'; +import { createAction } from 'effector-action'; + +type InputTodo = { + title: string; + subtasks?: InputTodo[]; +}; + +type TodoShape = { + id: string; + title: string; + completed: boolean; + editing: boolean; + titleDraft: string; + subtasks: TodoShape[]; + subtasksTotal: number; + subtasksVisible: number; + visible: boolean; +}; + +type TodoInputShape = { + id: string; + title: string; + completed?: boolean; + editing?: boolean; + titleDraft?: string; + subtasks: TodoInputShape[]; +}; + +export const $filterMode = createStore<'all' | 'completed' | 'active'>('all'); +export const changeFilterMode = createEvent<'all' | 'completed' | 'active'>(); +sample({ clock: changeFilterMode, target: $filterMode }); + +export const todoList = keyval(() => { + const $id = createStore(''); + const $title = createStore(''); + const $completed = createStore(false); + const $editing = createStore(false); + const $titleDraft = createStore(''); + const $visible = combine($completed, $filterMode, (completed, mode) => { + switch (mode) { + case 'all': + return true; + case 'active': + return !completed; + case 'completed': + return completed; + } + }); + + const childsList = keyval(todoList) as Keyval< + TodoInputShape, + TodoShape, + { removeCompleted: EventCallable; toggleAll: EventCallable }, + any + >; + + const $subtasksTotal = lazy(() => { + return combine(childsList.$items, (items) => { + return items.reduce( + (acc, { subtasksTotal }) => acc + subtasksTotal + 1, + 0, + ); + }); + }); + const $subtasksVisible = lazy(() => { + return combine(childsList.$items, $visible, (items, itemVisible) => { + if (!itemVisible) return 0; + return items.reduce( + (acc, { subtasksVisible, visible }) => + acc + subtasksVisible + (visible ? 1 : 0), + 0, + ); + }); + }); + + const saveDraft = createAction({ + source: $titleDraft, + target: { + editing: $editing, + title: $title, + }, + fn(targets, titleDraft) { + targets.editing(false); + targets.title(titleDraft.trim()); + }, + }); + + const editMode = createAction({ + source: $title, + target: { + editing: $editing, + titleDraft: $titleDraft, + }, + fn(targets, title, mode: 'on' | 'off') { + targets.editing(mode === 'on'); + targets.titleDraft(title); + }, + }); + + const toggleCompleted = createEvent(); + + sample({ + clock: toggleCompleted, + source: $completed, + fn: (completed) => !completed, + target: $completed, + }); + + const [addSubtask, removeCompleted, toggleAll] = lazy( + ['event', 'event', 'event'], + () => [ + createAddTodo(childsList), + createRemoveCompleted(childsList), + createToggleAll(childsList), + ], + ); + + return { + key: 'id', + state: { + id: $id, + title: $title, + completed: $completed, + editing: $editing, + titleDraft: $titleDraft, + subtasks: childsList, + subtasksTotal: $subtasksTotal, + subtasksVisible: $subtasksVisible, + visible: $visible, + }, + api: { + saveDraft, + editMode, + toggleCompleted, + addSubtask, + removeCompleted, + toggleAll, + }, + optional: ['completed', 'editing', 'titleDraft'], + }; +}); + +function createRemoveCompleted( + todoList: Keyval< + any, + TodoShape, + { removeCompleted: EventCallable }, + any + >, +) { + return createAction({ + source: todoList.$keys, + target: { + removeCompletedNestedChilds: todoList.api.removeCompleted, + /** effector-action messing with function payloads so we need to wrap data to pass thru it */ + removeItems: todoList.edit.remove.prepend<{ + fn: (entity: TodoShape) => boolean; + }>(({ fn }) => fn), + }, + fn(target, childKeys) { + target.removeCompletedNestedChilds({ + key: childKeys, + data: Array.from(childKeys, () => undefined), + }); + target.removeItems({ + fn: ({ completed }) => completed, + }); + }, + }); +} + +function createToggleAll( + todoList: Keyval }, any>, +) { + return createAction({ + source: todoList.$keys, + target: { + toggleAllNestedChilds: todoList.api.toggleAll, + mapItems: todoList.edit.map, + }, + fn(target, childKeys) { + target.toggleAllNestedChilds({ + key: childKeys, + data: Array.from(childKeys, () => undefined), + }); + target.mapItems({ + keys: childKeys, + map: ({ completed }) => ({ completed: !completed }), + }); + }, + }); +} + +function createAddTodo(todoList: Keyval) { + return createAction({ + source: $todoDraft, + target: { + add: todoList.edit.add, + todoDraft: $todoDraft, + }, + fn(target, todoDraft) { + if (!todoDraft.trim()) return; + target.add({ + id: createID(), + title: todoDraft.trim(), + subtasks: [], + }); + target.todoDraft.reinit(); + }, + }); +} + +export const removeCompleted = createRemoveCompleted(todoList); +export const toggleAll = createToggleAll(todoList); + +export const $totalSize = combine(todoList.$items, (items) => { + return items.reduce((acc, { subtasksTotal }) => acc + 1 + subtasksTotal, 0); +}); + +export const $todoDraft = createStore(''); +export const editDraft = createEvent(); +sample({ clock: editDraft, target: $todoDraft }); + +function createID() { + return `id-${Math.random().toString(36).slice(2, 10)}`; +} + +export const addTodo = createAddTodo(todoList); + +function addIds(inputs: InputTodo[]): TodoInputShape[] { + return inputs.map(({ title, subtasks = [] }) => ({ + id: createID(), + title, + subtasks: addIds(subtasks), + })); +} + +todoList.edit.add( + addIds([ + { title: '🖱 Double-click to edit' }, + { title: 'Effector models' }, + { + title: 'Example task', + subtasks: [ + { + title: 'subtask #1', + subtasks: [{ title: 'Foo' }, { title: 'Bar' }], + }, + { title: 'subtask #2' }, + ], + }, + ]), +); diff --git a/apps/tree-todo-list/src/view/App.tsx b/apps/tree-todo-list/src/view/App.tsx new file mode 100644 index 0000000..f876c49 --- /dev/null +++ b/apps/tree-todo-list/src/view/App.tsx @@ -0,0 +1,69 @@ +import type { KeyboardEvent, ChangeEvent } from 'react'; +import { useUnit } from 'effector-react'; +import { useEntityList } from '@effector/model-react'; + +import { + todoList, + $todoDraft, + editDraft, + addTodo, + removeCompleted, + toggleAll, +} from '../model'; +import { TodoCount } from './TodoCount'; +import { TodoFilters } from './TodoFilters'; +import { TodoItem } from './TodoItem'; + +import './main.css'; + +export const App = () => { + const [todoDraft, onDraftChange, saveDraft, onRemoveCompleted, onToggleAll] = + useUnit([$todoDraft, editDraft, addTodo, removeCompleted, toggleAll]); + const onChangeDraft = (e: ChangeEvent) => { + onDraftChange(e.target.value); + }; + const onAddTodo = (e: KeyboardEvent) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + saveDraft(); + }; + return ( +
+
+
+

TodoApp

+ +
+
+ +
+
+ + + +
+
+
+ ); +}; diff --git a/apps/tree-todo-list/src/view/TodoCount.tsx b/apps/tree-todo-list/src/view/TodoCount.tsx new file mode 100644 index 0000000..d67f7d7 --- /dev/null +++ b/apps/tree-todo-list/src/view/TodoCount.tsx @@ -0,0 +1,12 @@ +import { useUnit } from 'effector-react'; +import { $totalSize } from '../model'; + +export const TodoCount = () => { + const count = useUnit($totalSize); + return ( + + {count} +  {count === 1 ? 'item' : 'items'} + + ); +}; diff --git a/apps/tree-todo-list/src/view/TodoFilters.tsx b/apps/tree-todo-list/src/view/TodoFilters.tsx new file mode 100644 index 0000000..bfa397e --- /dev/null +++ b/apps/tree-todo-list/src/view/TodoFilters.tsx @@ -0,0 +1,26 @@ +import { useUnit } from 'effector-react'; +import { $filterMode, changeFilterMode } from '../model'; + +export const TodoFilters = () => { + const [mode, onChange] = useUnit([$filterMode, changeFilterMode]); + return ( + + ); +}; diff --git a/apps/tree-todo-list/src/view/TodoItem.tsx b/apps/tree-todo-list/src/view/TodoItem.tsx new file mode 100644 index 0000000..0bc8154 --- /dev/null +++ b/apps/tree-todo-list/src/view/TodoItem.tsx @@ -0,0 +1,80 @@ +import type { KeyboardEvent, ChangeEvent } from 'react'; +import { + useEntityItem, + useItemApi, + useEntityList, + useEditItemField, + useEditKeyval, +} from '@effector/model-react'; + +import { todoList } from '../model'; + +export const TodoItem = ({ nesting }: { nesting: number }) => { + const { + id, + title, + completed, + editing, + titleDraft, + subtasksTotal, + subtasksVisible, + visible, + } = useEntityItem(todoList); + const { remove } = useEditKeyval(todoList); + const api = useItemApi(todoList); + const fieldApi = useEditItemField(todoList); + const onToggle = () => api.toggleCompleted(); + const onRemove = () => { + remove(id); + }; + const onAddChild = () => { + api.addSubtask(); + }; + const onEdit = () => api.editMode('on'); + const onSave = () => api.saveDraft(); + const onConfirm = (e: KeyboardEvent) => { + if (e.key === 'Enter') api.saveDraft(); + else if (e.key === 'Escape') api.editMode('off'); + }; + const onChange = (e: ChangeEvent) => { + fieldApi.titleDraft(e.target.value); + }; + const subtasksList = useEntityList({ + keyval: todoList, + field: 'subtasks', + fn: () => , + }); + if (!visible) return null; + return ( +
    +
  • +
    + + +
    + +
  • + {subtasksList} +
+ ); +}; diff --git a/apps/tree-todo-list/src/view/main.css b/apps/tree-todo-list/src/view/main.css new file mode 100644 index 0000000..3d575a8 --- /dev/null +++ b/apps/tree-todo-list/src/view/main.css @@ -0,0 +1,420 @@ +/* stylelint-disable */ + +/* Source: http://todomvc.com/ */ +body { + margin: 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html, +body { + margin: 0; + padding: 10px; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font: + 14px 'Helvetica Neue', + Helvetica, + Arial, + sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #4d4d4d; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-weight: 300; +} + +:focus { + outline: 0; +} + +.hidden { + display: none; +} + +.todoapp { + background: #fff; + margin: 34px 0 40px 0; + position: relative; + box-shadow: + 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +/*noinspection CssInvalidPseudoSelector*/ +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp h1 { + position: absolute; + top: -70px; + width: 100%; + font-size: 36px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + border: 0; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.new-todo { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +.toggle-all { + text-align: center; + border: none; /* Mobile Safari */ + opacity: 0; + position: absolute; +} + +.toggle-all + label { + width: 60px; + height: 34px; + font-size: 0; + position: absolute; + top: -52px; + left: -13px; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); +} + +.toggle-all + label:before { + content: '❯'; + font-size: 22px; + color: #e6e6e6; + padding: 10px 27px 10px 27px; +} + +.toggle-all:checked + label:before { + color: #737373; +} + +.todo-list { + margin: 0; + margin-left: calc(var(--nesting, 0) * 30px); + padding: 0; + list-style: none; +} +.todo-list:empty { + display: none; +} + +.todo-list li { + position: relative; + font-size: 24px; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li[data-editing] { + border-bottom: none; + padding: 0; +} + +.todo-list li[data-editing] > .edit { + display: block; + width: 492px; + padding: 12px 16px; + margin: 0 0 0 43px; +} + +.todo-list li[data-editing] > .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle { + opacity: 0; +} + +.todo-list li .toggle + label { + /* + Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 + IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ + */ + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); + background-repeat: no-repeat; + background-position: center left; +} + +.todo-list li .toggle:checked + label { + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); +} + +.todo-list li label { + word-break: break-all; + padding: 15px 15px 15px 60px; + display: block; + transition: color 0.4s; + line-height: 1.2; +} + +.todo-list li label > [data-item-title] { + font-size: 24px; +} + +.todo-list li label > [data-item-stats] { + font-size: 14px; + font-style: italic; + display: block; +} + +.todo-list li[data-completed] label { + color: #d9d9d9; + text-decoration: line-through; +} + +.todo-list li [data-item-button] { + display: block; + position: absolute; + top: 0; + bottom: 0; + width: 50px; + height: 50px; + margin: auto 0; + font-size: 30px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.4s ease-out; + border: 1px solid transparent; + border-radius: 10px; + cursor: pointer; +} + +.todo-list li [data-item-button='+'] { + right: 55px; +} +.todo-list li [data-item-button='×'] { + right: 10px; +} + +.todo-list li [data-item-button]:after { + content: attr(data-item-button); +} + +.todo-list li [data-item-button]:hover { + border-color: rgba(175, 47, 47, 0.1); + color: #af5b5e; +} + +.todo-list li > .edit { + display: none; +} + +.todo-list li[data-editing]:last-child { + margin-bottom: -1px; +} + +.footer { + color: #777; + padding: 10px 15px; + height: 20px; + text-align: center; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +[data-filter-mode] { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +[data-filter-mode] > li { + display: inline; +} + +[data-filter] { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; + cursor: pointer; +} + +[data-filter]:hover { + border-color: rgba(175, 47, 47, 0.1); +} + +[data-filter-mode='all'] [data-filter='all'], +[data-filter-mode='active'] [data-filter='active'], +[data-filter-mode='completed'] [data-filter='completed'] { + border-color: rgba(175, 47, 47, 0.2); +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 20px; + text-decoration: none; + cursor: pointer; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #bfbfbf; + font-size: 10px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; +} + +/* + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox +*/ +@media screen and (-webkit-min-device-pixel-ratio: 0) { + .toggle-all, + .todo-list li .toggle { + background: none; + } + + .todo-list li .toggle { + height: 40px; + } +} + +@media (max-width: 430px) { + .footer { + height: 50px; + } + + [data-filter-mode] { + bottom: 10px; + } +} + +@media (max-width: 588px) { + .todo-list li[data-editing] > .edit { + width: calc(100vw - 98px); + } +} diff --git a/apps/tree-todo-list/tsconfig.json b/apps/tree-todo-list/tsconfig.json new file mode 100644 index 0000000..dcbd456 --- /dev/null +++ b/apps/tree-todo-list/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": false, + "noEmit": true, + "jsx": "react-jsx", + "noErrorTruncation": true, + "paths": { + "@effector/model": ["../../packages/core/index.ts"], + "@effector/model-react": ["../../packages/react/index.ts"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/tree-todo-list/tsconfig.node.json b/apps/tree-todo-list/tsconfig.node.json new file mode 100644 index 0000000..176d21b --- /dev/null +++ b/apps/tree-todo-list/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "paths": { + "@effector/model": ["../../packages/core/index.ts"], + "@effector/model-react": ["../../packages/react/index.ts"] + } + }, + "include": ["vite.config.ts"] +} diff --git a/apps/tree-todo-list/vite.config.ts b/apps/tree-todo-list/vite.config.ts new file mode 100644 index 0000000..9007918 --- /dev/null +++ b/apps/tree-todo-list/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tsconfigPaths from 'vite-tsconfig-paths'; +// import { babel } from '@rollup/plugin-babel'; + +export default defineConfig({ + esbuild: { + loader: 'tsx', + }, + cacheDir: '../../../node_modules/.vite/tree-todo-list', + plugins: [ + tsconfigPaths(), + // babel({ extensions: ['.ts', '.tsx'], babelHelpers: 'bundled' }), + react(), + ], + build: { outDir: '../../../dist/apps/tree-todo-list' }, +}); diff --git a/nx.json b/nx.json index a8fcb32..e0dfd9a 100644 --- a/nx.json +++ b/nx.json @@ -4,6 +4,9 @@ "affected": { "defaultBase": "origin/main" }, + "cli": { + "defaultOutputStyle": "stream" + }, "tasksRunnerOptions": { "default": { "runner": "nx/tasks-runners/default", diff --git a/package.json b/package.json index f937745..a411e08 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "author": "zerobias", "license": "MIT", "scripts": { - "test": "nx run-many --target=test --all", + "test": "nx run-many --target=test --all --output-style=stream", "test:watch": "nx run-many --target=test_watch --all --args=\"--\"", "test:types": "nx run-many --target=typetest --all", "lint": "nx run-many --target=lint --all", diff --git a/packages/core/project.json b/packages/core/project.json index dee5a51..1e654f3 100644 --- a/packages/core/project.json +++ b/packages/core/project.json @@ -49,12 +49,18 @@ } }, "test": { - "executor": "@nrwl/vite:test" + "executor": "@nrwl/vite:test", + "options": { + "config": "packages/core/vite.config.mts", + "passWithNoTests": true + } }, "test_watch": { "executor": "@nrwl/vite:test", "options": { - "watch": true + "watch": true, + "config": "packages/core/vite.config.mts", + "passWithNoTests": true } }, "typetest": { diff --git a/packages/core/src/__tests__/keyval/edit.test.ts b/packages/core/src/__tests__/keyval/edit.test.ts index 4f9bd97..46399c5 100644 --- a/packages/core/src/__tests__/keyval/edit.test.ts +++ b/packages/core/src/__tests__/keyval/edit.test.ts @@ -66,6 +66,47 @@ describe('edit.add', () => { { id: 'bar', count: 0, tag: 'y' }, ]); }); + test('support nested keyvals', () => { + const entities = keyval(() => { + const $id = createStore(''); + const childs = keyval(() => { + const $id = createStore(''); + const childs = keyval(() => { + const $id = createStore(''); + return { + key: 'id', + state: { + id: $id, + }, + }; + }); + return { + key: 'id', + state: { + id: $id, + childs, + }, + }; + }); + return { + key: 'id', + state: { + id: $id, + childs, + }, + }; + }); + entities.edit.add({ + id: 'a', + childs: [{ id: 'b', childs: [{ id: 'c' }] }], + }); + expect(entities.$items.getState()).toEqual([ + { + id: 'a', + childs: [{ id: 'b', childs: [{ id: 'c' }] }], + }, + ]); + }); }); describe('edit.remove', () => { diff --git a/packages/core/src/__tests__/keyval/index.test.ts b/packages/core/src/__tests__/keyval/index.test.ts index 0ea5360..2d44aac 100644 --- a/packages/core/src/__tests__/keyval/index.test.ts +++ b/packages/core/src/__tests__/keyval/index.test.ts @@ -1,6 +1,6 @@ import { expect, test, describe, vi } from 'vitest'; import { keyval } from '@effector/model'; -import { createEvent, createStore } from 'effector'; +import { combine, createEvent, createStore } from 'effector'; import { readonly } from 'patronum'; describe('support nested keyval', () => { @@ -127,3 +127,21 @@ test('onMount support', () => { entities.edit.add({ id: 2 }); expect(fn).toBeCalledTimes(2); }); + +test('.defaultState', () => { + const entities = keyval(() => { + const $id = createStore(''); + const $size = combine($id, (str) => str.length); + return { + key: 'id', + state: { + id: $id, + size: $size, + }, + }; + }); + expect(entities.defaultState()).toEqual({ + id: '', + size: 0, + }); +}); diff --git a/packages/core/src/__tests__/keyval/tree.test.ts b/packages/core/src/__tests__/keyval/tree.test.ts new file mode 100644 index 0000000..8570e58 --- /dev/null +++ b/packages/core/src/__tests__/keyval/tree.test.ts @@ -0,0 +1,143 @@ +import { expect, test, describe, vi } from 'vitest'; +import { keyval, lazy, KeyvalWithState } from '@effector/model'; +import { combine, createStore } from 'effector'; + +test('support self as child field', () => { + type Entity = { + id: string; + childs: Entity[]; + }; + const entities = keyval(() => { + const $id = createStore(''); + const childs = keyval(entities) as KeyvalWithState; + return { + key: 'id', + state: { + id: $id, + childs, + }, + }; + }); + entities.edit.add([ + { id: 'a', childs: [] }, + { id: 'b', childs: [{ id: 'c', childs: [{ id: 'd', childs: [] }] }] }, + ]); + expect(entities.$items.getState()).toEqual([ + { id: 'a', childs: [] }, + { id: 'b', childs: [{ id: 'c', childs: [{ id: 'd', childs: [] }] }] }, + ]); +}); + +test('support duplicated ids in childs', () => { + type Entity = { + id: string; + childs: Entity[]; + }; + const entities = keyval(() => { + const $id = createStore(''); + const childs = keyval(entities) as KeyvalWithState; + return { + key: 'id', + state: { + id: $id, + childs, + }, + }; + }); + entities.edit.add([ + { id: 'a', childs: [] }, + { id: 'b', childs: [{ id: 'a', childs: [] }] }, + ]); + expect(entities.$items.getState()).toEqual([ + { id: 'a', childs: [] }, + { id: 'b', childs: [{ id: 'a', childs: [] }] }, + ]); +}); + +test('lazy support recursive computations with childs', () => { + type Entity = { + id: string; + childs: Entity[]; + childsAmount: number; + }; + type EntityInput = { + id: string; + childs: EntityInput[]; + }; + const entities = keyval(() => { + const $id = createStore(''); + const childs = keyval(entities) as KeyvalWithState; + const $childsAmount = lazy(() => { + return combine(childs.$items, (items) => items.length); + }); + return { + key: 'id', + state: { + id: $id, + childs, + childsAmount: $childsAmount, + }, + }; + }); + entities.edit.add([ + { id: 'a', childs: [] }, + { id: 'b', childs: [{ id: 'c', childs: [] }] }, + ]); + expect(entities.$items.getState()).toEqual([ + { id: 'a', childs: [], childsAmount: 0 }, + { + id: 'b', + childs: [{ id: 'c', childs: [], childsAmount: 0 }], + childsAmount: 1, + }, + ]); +}); + +test('lazy will not break combine callbacks', () => { + type Entity = { + id: string; + childs: Entity[]; + childsAmount: number; + amountStr: string; + }; + type EntityInput = { + id: string; + childs: EntityInput[]; + }; + const fn = vi.fn(); + const entities = keyval(() => { + const $id = createStore(''); + const childs = keyval(entities) as KeyvalWithState; + const $childsAmount = lazy(() => { + return combine(childs.$items, (items) => items.length); + }); + const $amountStr = combine($childsAmount, (amount) => { + fn(amount); + return String(amount); + }); + return { + key: 'id', + state: { + id: $id, + childs, + childsAmount: $childsAmount, + amountStr: $amountStr, + }, + }; + }); + entities.edit.add([ + { id: 'a', childs: [] }, + { id: 'b', childs: [{ id: 'c', childs: [] }] }, + ]); + + expect(entities.$items.getState()).toEqual([ + { id: 'a', childs: [], childsAmount: 0, amountStr: '0' }, + { + id: 'b', + childs: [{ id: 'c', childs: [], childsAmount: 0, amountStr: '0' }], + childsAmount: 1, + amountStr: '1', + }, + ]); + expect(fn.mock.calls.filter(([val]) => typeof val !== 'number')).toEqual([]); +}); diff --git a/packages/core/src/editApi.ts b/packages/core/src/editApi.ts new file mode 100644 index 0000000..a869f12 --- /dev/null +++ b/packages/core/src/editApi.ts @@ -0,0 +1,418 @@ +import { + sample, + Store, + withRegion, + launch, + EventCallable, + createEvent, + attach, + StoreWritable, + clearNode, +} from 'effector'; + +import type { Model, StoreDef, InstanceOf, ListState, Keyval } from './types'; +import { spawn } from './spawn'; + +function refreshOnce(state: { + items: T[]; + instances: I[]; + keys: Array; +}) { + let needToUpdate = true; + return () => { + if (needToUpdate) { + needToUpdate = false; + state = { + items: [...state.items], + instances: [...state.instances], + keys: [...state.keys], + }; + } + return state; + }; +} + +function runUpdatesForInstance( + freshState: ListState, + idx: number, + // may be partial + inputUpdate: Input, +) { + const oldItem = freshState.items[idx]; + const newItem: Enriched = { + ...oldItem, + ...inputUpdate, + }; + freshState.items[idx] = newItem; + const instance = freshState.instances[idx]; + const storesToUpdate = [] as any[]; + const updates = [] as any[]; + for (const key in inputUpdate) { + //@ts-expect-error type mismatch + const store = instance.props[key]; + storesToUpdate.push(store); + updates.push(inputUpdate[key]); + } + launch({ + target: storesToUpdate, + params: updates, + defer: true, + }); +} + +function runNewItemInstance( + freshState: ListState, + key: string | number, + inputItem: Input, + kvModel: + | Model< + { + [K in keyof Input]?: Store | StoreDef; + }, + Output, + Api, + Shape + > + | undefined, + updateEnrichedItem: EventCallable<{ + key: string | number; + /** actually it is an enriched part only */ + partial: Partial; + }>, + api: Record>, +) { + freshState.keys.push(key); + if (kvModel) { + // typecast, ts cannot infer that types are indeed compatible + const item1: { + [K in keyof Input]: Store | StoreDef | Input[K]; + } = inputItem; + // @ts-expect-error some issues with types + const instance = spawn(kvModel, item1); + withRegion(instance.region, () => { + // obviosly dirty hack, wont make it way to release + const enriching = instance.output.getState(); + freshState.items.push(enriching as Enriched); + sample({ + source: instance.output, + fn: (partial) => ({ + key, + partial: partial as Partial, + }), + target: updateEnrichedItem, + }); + for (const key in instance.api) { + sample({ + clock: api[key] as EventCallable< + | { key: string | number; data: any } + | { + key: Array; + data: any[]; + } + >, + filter(upd) { + if (Array.isArray(upd.key)) { + return upd.key.includes(key); + } + return upd.key === key; + }, + fn: (upd) => + Array.isArray(upd.key) ? upd.data[upd.key.indexOf(key)] : upd.data, + target: instance.api[key] as EventCallable, + }); + } + }); + // @ts-expect-error some issues with types + freshState.instances.push(instance); + if (instance.onMount) { + launch({ + target: instance.onMount, + params: undefined, + defer: true, + }); + } + } else { + // typecast, there is no kvModel so Input === Enriched + freshState.items.push(inputItem as unknown as Enriched); + // dont use instances if there is no kvModel + freshState.instances.push(null as InstanceOf>); + } +} + +export function createEditApi( + $entities: StoreWritable>, + getKey: (entity: Input) => string | number, + keyField: keyof Input | null, + api: Record>, + kvModel: + | Model< + { + [K in keyof Input]?: Store | StoreDef; + }, + Output, + Api, + Shape + > + | undefined, +): Keyval['edit'] { + const add = createEvent(); + + const replaceAll = createEvent(); + const set = createEvent(); + + const removeMany = createEvent< + string | number | Array | ((entity: Enriched) => boolean) + >(); + + const updateSome = createEvent | Partial[]>(); + + const map = createEvent<{ + keys: string | number | Array; + map: (entity: Enriched) => Partial; + upsert?: boolean; + }>(); + + const updateEnrichedItem = createEvent<{ + key: string | number; + /** actually it is an enriched part only */ + partial: Partial; + }>(); + + $entities.on(updateEnrichedItem, (state, { key, partial }) => { + const refresh = refreshOnce(state); + const idx = state.keys.indexOf(key); + if (idx !== -1) { + state = refresh(); + state.items[idx] = { + ...state.items[idx], + ...partial, + }; + } + return state; + }); + + const addFx = attach({ + source: $entities, + effect(state, newItems: Input | Input[]) { + if (!Array.isArray(newItems)) newItems = [newItems]; + const refresh = refreshOnce(state); + for (const item of newItems) { + const key = getKey(item); + if (!state.keys.includes(key)) { + state = refresh(); + runNewItemInstance( + state, + key, + item, + kvModel, + updateEnrichedItem, + api, + ); + } + } + return state; + }, + }); + + const setFx = attach({ + source: $entities, + effect(state, updates: Input | Input[]) { + if (!Array.isArray(updates)) updates = [updates]; + const refresh = refreshOnce(state); + for (const item of updates) { + const key = getKey(item); + state = refresh(); + const idx = state.keys.indexOf(key); + if (idx !== -1) { + runUpdatesForInstance(state, idx, item); + } else { + runNewItemInstance( + state, + key, + item, + kvModel, + updateEnrichedItem, + api, + ); + } + } + return state; + }, + }); + + const replaceAllFx = attach({ + source: $entities, + effect(oldState, newItems: Input[]) { + if (kvModel) { + for (const instance of oldState.instances) { + clearNode(instance.region); + } + } + const state: ListState = { + items: [], + instances: [], + keys: [], + }; + for (const item of newItems) { + const key = getKey(item); + runNewItemInstance(state, key, item, kvModel, updateEnrichedItem, api); + if (kvModel) { + /** new instance is always last */ + const instance = state.instances[state.instances.length - 1]; + for (const field of kvModel.keyvalFields) { + // @ts-expect-error type mismatch, item is iterable + if (field in item) { + launch({ + target: instance.keyvalShape[field].edit.replaceAll, + params: (item as any)[field], + defer: true, + }); + } + } + } + } + return state; + }, + }); + + sample({ + clock: add, + target: addFx, + batch: false, + }); + + sample({ + clock: addFx.doneData, + target: $entities, + batch: false, + }); + + sample({ + clock: set, + target: setFx, + batch: false, + }); + + sample({ + clock: setFx.doneData, + target: $entities, + batch: false, + }); + + sample({ + clock: replaceAll, + target: replaceAllFx, + batch: false, + }); + + sample({ + clock: replaceAllFx.doneData, + target: $entities, + batch: false, + }); + + $entities.on(removeMany, (state, payload) => { + const refresh = refreshOnce(state); + const indexesToRemove: number[] = []; + if (typeof payload === 'function') { + for (let i = 0; i < state.items.length; i++) { + if (payload(state.items[i])) { + indexesToRemove.push(i); + } + } + } else { + payload = Array.isArray(payload) ? payload : [payload]; + for (const key of payload) { + const idx = state.keys.indexOf(key); + if (idx !== -1) { + indexesToRemove.push(idx); + } + } + } + /** delete in reverse order to prevent drift of following indexes after splice */ + for (let i = indexesToRemove.length - 1; i >= 0; i--) { + const idx = indexesToRemove[i]; + state = refresh(); + state.items.splice(idx, 1); + state.keys.splice(idx, 1); + const [instance] = state.instances.splice(idx, 1); + if (instance) { + clearNode(instance.region); + } + } + return state; + }); + $entities.on(updateSome, (state, updates) => { + if (!Array.isArray(updates)) updates = [updates]; + const refresh = refreshOnce(state); + for (const inputUpdate of updates) { + const key = getKey(inputUpdate as Input); + const idx = state.keys.indexOf(key); + if (idx !== -1) { + state = refresh(); + runUpdatesForInstance(state, idx, inputUpdate); + } + } + return state; + }); + const mapItemsFx = attach({ + source: $entities, + effect( + state, + { + keys, + map, + upsert = false, + }: { + keys: string | number | Array; + map: (entity: Enriched) => Partial; + upsert?: boolean; + }, + ) { + keys = Array.isArray(keys) ? keys : [keys]; + if (upsert && keyField === null) { + console.error( + 'map upsert is not supported with `key: function`, use `key: "fieldName"` instead', + ); + upsert = false; + } + const refresh = refreshOnce(state); + for (const key of keys) { + let idx = state.keys.indexOf(key); + if (upsert && idx === -1) { + state = refresh(); + const idObject = { [keyField!]: key } as Input; + runNewItemInstance( + state, + key, + idObject, + kvModel, + updateEnrichedItem, + api, + ); + idx = state.keys.indexOf(key); + } + if (idx !== -1) { + const originalItem = state.items[idx]; + const updatedItem = map(originalItem); + if (originalItem !== updatedItem) { + state = refresh(); + runUpdatesForInstance(state, idx, updatedItem); + } + } + } + return state; + }, + }); + sample({ clock: map, target: mapItemsFx }); + sample({ clock: mapItemsFx.doneData, target: $entities }); + + return { + add, + set, + update: updateSome, + replaceAll, + remove: removeMany, + map, + }; +} diff --git a/packages/core/src/editFieldApi.ts b/packages/core/src/editFieldApi.ts new file mode 100644 index 0000000..b055087 --- /dev/null +++ b/packages/core/src/editFieldApi.ts @@ -0,0 +1,59 @@ +import { type Store } from 'effector'; + +import type { Keyval, KeyOrKeys, Model, StoreDef } from './types'; + +export function createEditFieldApi( + keyField: keyof Input | null, + kvModel: + | Model< + { + [K in keyof Input]?: Store | StoreDef; + }, + Output, + Api, + Shape + > + | undefined, + editApiUpdate: Keyval['edit']['update'], +): Keyval['editField'] { + const editField = {} as any; + + //TODO add support for generated keys + if (kvModel && keyField) { + const structShape = kvModel.__struct!.shape; + for (const field in structShape) { + const fieldStruct = structShape[field]; + if (fieldStruct.type === 'structUnit') { + // derived stores are not supported + if (fieldStruct.unit === 'store' && fieldStruct.derived) { + continue; + } + const fieldEditor = editApiUpdate.prepend( + (upd: { key: KeyOrKeys; data: any }) => { + const keySet = Array.isArray(upd.key) ? upd.key : [upd.key]; + const dataSet: Array = Array.isArray(upd.key) + ? upd.data + : [upd.data]; + const results = [] as Partial[]; + for (let i = 0; i < keySet.length; i++) { + const keyValue = keySet[i]; + const dataValue = dataSet[i]; + const item = {} as Partial; + //@ts-expect-error + item[keyField] = keyValue; + //@ts-expect-error + item[field] = dataValue; + results.push(item); + } + return results; + }, + ); + editField[field] = fieldEditor; + } else { + // TODO keyval support + } + } + } + + return editField; +} diff --git a/packages/core/src/example.ts b/packages/core/src/example.ts index 31ee4f3..ec69c86 100644 --- a/packages/core/src/example.ts +++ b/packages/core/src/example.ts @@ -11,39 +11,6 @@ const $email = createStore(''); const triggerValidation = createEvent(); const validateDefaultFx = createEffect((age: number): boolean => true); -// const profile = model({ -// props: { -// age: 0, -// email: $email, -// triggerValidation, -// validateDefaultFx, -// validateFx: (email: string): boolean => true, -// click: define.event(), -// }, -// create({ age, email, triggerValidation, validateDefaultFx, validateFx }) { -// return { -// foo: createStore(0), -// }; -// }, -// }); - -// const aliceProfile = spawn(profile, { -// age: createStore(18), -// email: '', -// validateDefaultFx: (age) => age > 18, -// validateFx: createEffect((email: string) => email.length > 0), -// // triggerValidation: createEvent(), -// click: createEvent(), -// }); - -// const bobProfile = spawn(profile, { -// age: 20, -// email: createStore(''), -// validateDefaultFx: createEffect((age: number) => age > 18), -// validateFx: async (email) => email.length > 0, -// click: createEvent(), -// }); - type Field = { name: string; value: string; diff --git a/packages/core/src/factoryStatePaths.ts b/packages/core/src/factoryStatePaths.ts new file mode 100644 index 0000000..071632c --- /dev/null +++ b/packages/core/src/factoryStatePaths.ts @@ -0,0 +1,105 @@ +import { type Node, is, createNode } from 'effector'; + +import type { FactoryPathMap } from './types'; + +/** Monkey patching for effector 23 for proper initial state in stores */ +export function installStateHooks( + initState: Record, + node: Node, + currentFactoryPathToStateKey: FactoryPathMap, +) { + wrapPush(node.family.links, (item, idx) => { + if (!currentFactoryPathToStateKey.has(idx)) return; + const currentPath = currentFactoryPathToStateKey.get(idx)!; + if (typeof currentPath === 'string') { + if (item.scope.state && currentPath in initState) { + item.scope.state.initial = initState[currentPath]; + item.scope.state.current = initState[currentPath]; + } + } else { + installStateHooks(initState, item, currentPath); + } + }); +} + +function wrapPush(arr: T[], cb: (item: T, realIdx: number) => void) { + const push = arr.push.bind(arr); + arr.push = (...args: T[]) => { + const idx = arr.length; + for (let i = 0; i < args.length; i++) { + const child = args[i]; + const realIdx = idx + i; + cb(child, realIdx); + } + return push(...args); + }; +} + +/** Collect factory paths for further patching */ +export function collectFactoryPaths( + state: Record, + initRegion: Node, +) { + const factoryPathToStateKey: FactoryPathMap = new Map(); + for (const key in state) { + const value = state[key]; + if (is.store(value) && is.targetable(value)) { + const path = findNodeInTree((value as any).graphite, initRegion); + if (path) { + let nestedFactoryPathMap = factoryPathToStateKey; + for (let i = 0; i < path.length; i++) { + const step = path[i]; + const isLastStep = i === path.length - 1; + if (isLastStep) { + nestedFactoryPathMap.set(step, key); + } else { + let childFactoryPathMap = nestedFactoryPathMap.get(step); + if (!childFactoryPathMap) { + childFactoryPathMap = new Map(); + nestedFactoryPathMap.set(step, childFactoryPathMap); + } + nestedFactoryPathMap = childFactoryPathMap as FactoryPathMap; + } + } + } + } + } + return factoryPathToStateKey; +} + +function findNodeInTree( + searchNode: Node, + currentNode: Node, + path: number[] = [], +): number[] | void { + const idx = currentNode.family.links.findIndex((e) => e === searchNode); + if (idx !== -1) { + return [...path, idx]; + } else { + for (let i = 0; i < currentNode.family.links.length; i++) { + const linkNode = currentNode.family.links[i]; + if (linkNode.meta.isRegion) { + const result = findNodeInTree(searchNode, linkNode, [...path, i]); + if (result) return result; + } + } + } +} + +export function createRegionalNode(muteCallbacks: boolean) { + const node = createNode({ regional: true }); + let template = + /** fallback for forest templates, not sure that it works */ + node.family.owners[0]?.meta.template; + if (muteCallbacks && !template) { + /** empty template for muting combine/store.map callbacks */ + template = { + handlers: { + /** skip store watch immediate call */ + storeWatch: () => true, + }, + }; + } + node.meta.template = template; + return node; +} diff --git a/packages/core/src/getEntityItem.ts b/packages/core/src/getEntityItem.ts deleted file mode 100644 index c9f1173..0000000 --- a/packages/core/src/getEntityItem.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - EventCallable, - Store, - combine, - createEvent, - createStore, - is, - sample, -} from 'effector'; - -import type { Keyval } from './types'; - -export function getEntityItem< - T, - Api, - Key extends string | number, - Shape, ->(config: { - source: Keyval; - key: Store | string | number; - defaultValue: (key: Key) => T; -}): [Store, Api]; -export function getEntityItem(config: { - source: Keyval; - key: Store | string | number; -}): [Store, Api]; -export function getEntityItem({ - source, - key, - defaultValue, -}: { - source: Keyval; - key: Store | string | number; - defaultValue?: (key: string | number) => T; -}) { - const $key = is.store(key) ? key : createStore(key); - const $defaultValue = combine($key, (key) => - defaultValue ? defaultValue(key) : null, - ); - const $item = combine( - source.$keys, - source.$items, - $key, - $defaultValue, - (keys, items, key, defValue) => { - const idx = keys.findIndex((e) => e === key); - if (idx === -1) return defValue; - return items[idx]; - }, - ); - const api = {} as any; - for (const key in source.api) { - const trigger = createEvent(); - const target = source.api[key] as EventCallable<{ - key: string | number; - data: any; - }>; - sample({ - clock: trigger, - source: $key, - fn: (key, data) => ({ key, data }), - target, - }); - api[key] = trigger; - } - return [$item, api]; -} - -type IsNever = [T] extends [never] ? true : false; - -// @link https://ghaiklor.github.io/type-challenges-solutions/en/medium-isunion.html -type InternalIsUnion = ( - IsNever extends true - ? false - : T extends any - ? [U] extends [T] - ? false - : true - : never -) extends infer Result - ? // In some cases `Result` will return `false | true` which is `boolean`, - // that means `T` has at least two types and it's a union type, - // so we will return `true` instead of `boolean`. - boolean extends Result - ? true - : Result - : never; // Should never happen - -type IsUnion = InternalIsUnion; - -type SingleKeyObject = - IsUnion extends true ? never : ObjectType; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c5178f6..20bec4d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,9 +1,8 @@ // WARN: This is not a final implementation! Only for development purposes -export { define } from './define'; -export { model } from './model'; +export { define, isKeyval } from './define'; export { keyval } from './keyval'; -export { getEntityItem } from './getEntityItem'; export { lens } from './lens'; export { lazy } from './lazy'; export { createContext, readContext, setContext } from './context'; export { spawn } from './spawn'; +export type { InputType, KeyvalWithState } from './types'; diff --git a/packages/core/src/instanceApi.ts b/packages/core/src/instanceApi.ts new file mode 100644 index 0000000..36c78fe --- /dev/null +++ b/packages/core/src/instanceApi.ts @@ -0,0 +1,55 @@ +import { type Store, type StoreWritable, createEvent, launch } from 'effector'; +import type { Keyval, Model, StoreDef, ListState } from './types'; + +export function createInstanceApi( + $entities: StoreWritable>, + kvModel: + | Model< + { + [K in keyof Input]?: Store | StoreDef; + }, + Output, + Api, + Shape + > + | undefined, +): Keyval['api'] { + const api = {} as Record; + if (kvModel) { + for (const prop of kvModel.apiFields) { + const evt = createEvent< + | { + key: string | number; + data: any; + } + | { + key: Array; + data: any[]; + } + >(); + api[prop] = evt; + $entities.on(evt, (state, payload) => { + const [key, data] = Array.isArray(payload.key) + ? [payload.key, payload.data] + : [[payload.key], [payload.data]]; + const targets = [] as any[]; + const params = [] as any[]; + for (let i = 0; i < key.length; i++) { + const idx = state.keys.indexOf(key[i]); + if (idx !== -1) { + const instance = state.instances[idx]; + targets.push(instance.api[prop]); + params.push(data[i]); + } + } + launch({ + target: targets, + params, + defer: true, + }); + return state; + }); + } + } + return api; +} diff --git a/packages/core/src/keyval.ts b/packages/core/src/keyval.ts index 371dc83..92c87cc 100644 --- a/packages/core/src/keyval.ts +++ b/packages/core/src/keyval.ts @@ -1,69 +1,26 @@ import { createStore, - createEvent, - attach, - sample, - Store, - StoreWritable, - withRegion, - clearNode, - launch, - EventCallable, - Event, + type Store, + type StoreWritable, + type Event, } from 'effector'; import type { Keyval, Model, StoreDef, - InstanceOf, Show, ConvertToLensShape, StructKeyval, - StructShape, - OneOfShapeDef, - EntityShapeDef, - KeyOrKeys, + ListState, } from './types'; -import { spawn } from './spawn'; import { model } from './model'; import type { SetOptional } from './setOptional'; -import { isDefine, isKeyval } from './define'; - -type ToPlainShape = { - [K in { - [P in keyof Shape]: Shape[P] extends Store - ? P - : Shape[P] extends StoreDef - ? P - : never; - }[keyof Shape]]: Shape[K] extends Store - ? V - : Shape[K] extends StoreDef - ? V - : never; -}; - -// export function keyval(options: { -// key: (entity: ToPlainShape) => string | number; -// model: Model; -// }): Keyval< -// Show>, -// Show & ToPlainShape> -// >; -// export function keyval(options: { -// key: (entity: T) => string | number; -// }): Keyval; -// export function keyval({ -// key: getKeyRaw, -// model, -// }: { -// key: (entity: ToPlainShape) => string | number; -// model?: Model; -// }): Keyval< -// ToPlainShape, -// ToPlainShape & ToPlainShape -// > +import { lazyInit } from './lazyInit'; +import { createEditApi } from './editApi'; +import { createEditFieldApi } from './editFieldApi'; +import { createInstanceApi } from './instanceApi'; +import { isKeyval } from './define'; export function keyval< ReactiveState, @@ -105,11 +62,12 @@ export function keyval< Api = {}, OptionalFields extends keyof WritableState = never, >( - create: (config: { onMount: Event }) => { + create: () => { state: ReactiveState; api?: Api; key: keyof ReactiveState; optional?: ReadonlyArray; + onMount?: Event; }, ): Keyval< SetOptional, @@ -117,19 +75,6 @@ export function keyval< Api, Show> >; -// export function keyval(options: { -// key: ((entity: Input) => string | number) | keyof Input; -// model: Model< -// { -// [K in keyof Input]?: Store | StoreDef; -// }, -// { -// [K in keyof ModelEnhance]: Store; -// }, -// Api, -// Shape -// >; -// }): Keyval, Api, Shape>; export function keyval(options: { key: ((entity: T) => string | number) | keyof T; shape: Shape; @@ -137,6 +82,9 @@ export function keyval(options: { export function keyval(options: { key: ((entity: T) => string | number) | keyof T; }): Keyval; +export function keyval( + keyval: Keyval, +): Keyval; export function keyval( options: | { @@ -145,532 +93,169 @@ export function keyval( props?: any; create?: any; } - | Function, + | Function + | Keyval, ): Keyval { - let create: - | void - | ((config: { onMount: Event }) => { - state: unknown; - api?: unknown; - key: string; - optional?: string[]; - }); - // @ts-expect-error bad implementation - let getKeyRaw; - let shape: Shape; - if (typeof options === 'function') { - create = options as any; - } else { - ({ key: getKeyRaw, shape = {} as Shape, create } = options); + if (isKeyval(options)) { + return options.clone(true, options.cloneOf || options); } + type Enriched = Input & ModelEnhance; - let kvModel: - | Model< - { - [K in keyof Input]?: Store | StoreDef; - }, - { - [K in keyof ModelEnhance]: - | Store - | Keyval; - }, - Api, - Shape - > - | undefined; - if (create) { - // @ts-expect-error typecast - kvModel = model({ create }); - } - type ListState = { - items: Enriched[]; - // @ts-expect-error type mismatch - instances: Array>>; - keys: Array; + type Output = { + [K in keyof ModelEnhance]: + | Store + | Keyval; }; - const getKey = !kvModel - ? typeof getKeyRaw === 'function' - ? getKeyRaw - : // @ts-expect-error bad implementation - (entity: Input) => entity[getKeyRaw] as string | number - : (entity: Input) => entity[kvModel.keyField] as string | number; - const keyField = !kvModel - ? typeof getKeyRaw === 'function' || getKeyRaw === undefined - ? null - : getKeyRaw - : kvModel.keyField; - const $entities = createStore({ - items: [], - instances: [], - keys: [], - }); - - const add = createEvent(); - - const replaceAll = createEvent(); - const set = createEvent(); + type KeyvalListState = ListState; - const removeMany = createEvent< - string | number | Array | ((entity: Enriched) => boolean) - >(); - - const updateSome = createEvent | Partial[]>(); - - const map = createEvent<{ - keys: string | number | Array; - map: (entity: Enriched) => Partial; - upsert?: boolean; - }>(); - - const updateEnrichedItem = createEvent<{ - key: string | number; - /** actually it is an enriched part only */ - partial: Partial; - }>(); - - $entities.on(updateEnrichedItem, (state, { key, partial }) => { - const refresh = refreshOnce(state); - const idx = state.keys.indexOf(key); - if (idx !== -1) { - state = refresh(); - state.items[idx] = { - ...state.items[idx], - ...partial, - }; - } - return state; - }); - - function runNewItemInstance( - freshState: ListState, - key: string | number, - inputItem: Input, - ) { - freshState.keys.push(key); - if (kvModel) { - // typecast, ts cannot infer that types are indeed compatible - const item1: { - [K in keyof Input]: Store | StoreDef | Input[K]; - } = inputItem; - // @ts-expect-error some issues with types - const instance = spawn(kvModel, item1); - withRegion(instance.region, () => { - // obviosly dirty hack, wont make it way to release - const enriching = instance.output.getState(); - freshState.items.push(enriching as Enriched); - sample({ - source: instance.output, - fn: (partial) => ({ - key, - partial: partial as Partial, - }), - target: updateEnrichedItem, - }); - for (const key in instance.api) { - sample({ - clock: api[key] as EventCallable< - | { key: string | number; data: any } - | { - key: Array; - data: any[]; - } - >, - filter(upd) { - if (Array.isArray(upd.key)) { - return upd.key.includes(key); - } - return upd.key === key; - }, - fn: (upd) => - Array.isArray(upd.key) - ? upd.data[upd.key.indexOf(key)] - : upd.data, - target: instance.api[key] as EventCallable, - }); - } - }); - // @ts-expect-error some issues with types - freshState.instances.push(instance); - if (instance.onMount) { - launch({ - target: instance.onMount, - params: undefined, - defer: true, + const init = ( + isClone: boolean, + cloneOf: Keyval | null, + ) => { + const $entities = createStore({ + items: [], + instances: [], + keys: [], + }); + const $items = $entities.map(({ items }) => items); + const $keys = $entities.map(({ keys }) => keys); + let getKeyRaw: + | keyof Input + | ((entity: Input) => string | number) + | undefined; + let create: + | void + | ((config: { onMount: Event }) => { + state: unknown; + api?: unknown; + key: string; + optional?: string[]; }); - } + let shape: Shape; + if (typeof options === 'function') { + create = options as any; } else { - // typecast, there is no kvModel so Input === Enriched - freshState.items.push(inputItem as Enriched); - // dont use instances if there is no kvModel - freshState.instances.push( - null as InstanceOf>, - ); - } - } - - function runUpdatesForInstance( - freshState: ListState, - idx: number, - inputUpdate: Partial, - ) { - const oldItem = freshState.items[idx]; - const newItem: Enriched = { - ...oldItem, - ...inputUpdate, - }; - freshState.items[idx] = newItem; - const instance = freshState.instances[idx]; - const storesToUpdate = [] as any[]; - const updates = [] as any[]; - for (const key in inputUpdate) { - const store = instance.props[key as any as keyof ModelEnhance]; - storesToUpdate.push(store); - updates.push(inputUpdate[key]); + ({ + key: getKeyRaw, + shape = {} as Shape, + create, + } = options as Exclude>); } - launch({ - target: storesToUpdate, - params: updates, - defer: true, - }); - } - - const addFx = attach({ - source: $entities, - effect(state, newItems: Input | Input[]) { - if (!Array.isArray(newItems)) newItems = [newItems]; - const refresh = refreshOnce(state); - for (const item of newItems) { - const key = getKey(item); - if (!state.keys.includes(key)) { - state = refresh(); - runNewItemInstance(state, key, item); - } - } - return state; - }, - }); - - const setFx = attach({ - source: $entities, - effect(state, updates: Input | Input[]) { - if (!Array.isArray(updates)) updates = [updates]; - const refresh = refreshOnce(state); - for (const item of updates) { - const key = getKey(item); - state = refresh(); - const idx = state.keys.indexOf(key); - if (idx !== -1) { - runUpdatesForInstance(state, idx, item); - } else { - runNewItemInstance(state, key, item); - } - } - return state; - }, - }); + const { + getKey: getKeyClone, + keyField: keyFieldClone, + structShape: structShapeClone, + defaultState: defaultStateClone, + } = cloneOf?.getCloneData() ?? {}; + return lazyInit( + { + type: 'keyval', + api: 0, + __lens: 0, + __struct: structShapeClone, + $items, + $keys, + __$listState: $entities, + defaultState: defaultStateClone, + edit: 0, + editField: 0, + clone: init, + isClone, + cloneOf, + getCloneData: cloneOf?.getCloneData ?? (() => null as any), + } as any as Keyval, + () => { + let kvModel: + | Model< + { + [K in keyof Input]?: Store | StoreDef; + }, + Output, + Api, + Shape + > + | undefined; - const replaceAllFx = attach({ - source: $entities, - effect(oldState, newItems: Input[]) { - if (kvModel) { - for (const instance of oldState.instances) { - clearNode(instance.region); - } - } - const state: ListState = { - items: [], - instances: [], - keys: [], - }; - for (const item of newItems) { - const key = getKey(item); - runNewItemInstance(state, key, item); - if (kvModel) { - /** new instance is always last */ - const instance = state.instances[state.instances.length - 1]; - for (const field of kvModel.keyvalFields) { - // @ts-expect-error type mismatch, item is iterable - if (field in item) { - launch({ - target: instance.keyvalShape[field].edit.replaceAll, - params: (item as any)[field], - defer: true, - }); - } - } + if (create) { + // @ts-expect-error typecast + kvModel = model({ create, isClone }); } - } - return state; - }, - }); + const getKey = + getKeyClone ?? + (!kvModel + ? typeof getKeyRaw === 'function' + ? getKeyRaw + : // @ts-expect-error bad implementation + (entity: Input) => entity[getKeyRaw] as string | number + : (entity: Input) => entity[kvModel.keyField] as string | number); + const keyField = + keyFieldClone ?? + (!kvModel + ? typeof getKeyRaw === 'function' || getKeyRaw === undefined + ? null + : getKeyRaw + : kvModel.keyField); + const structShape = + structShapeClone ?? + (kvModel + ? ({ + type: 'structKeyval', + getKey, + shape: kvModel.__struct!.shape, + defaultItem: kvModel.defaultState, + } as StructKeyval) + : shape! + ? ({ + type: 'structKeyval', + getKey, + shape: {}, + // TODO add support for .itemStore + defaultItem: () => null, + } as StructKeyval) + : (null as any as StructKeyval)); - sample({ - clock: add, - target: addFx, - batch: false, - }); + const defaultState = + defaultStateClone ?? (() => kvModel?.defaultState() ?? (null as any)); - sample({ - clock: addFx.doneData, - target: $entities, - batch: false, - }); - - sample({ - clock: set, - target: setFx, - batch: false, - }); - - sample({ - clock: setFx.doneData, - target: $entities, - batch: false, - }); - - sample({ - clock: replaceAll, - target: replaceAllFx, - batch: false, - }); - - sample({ - clock: replaceAllFx.doneData, - target: $entities, - batch: false, - }); + const getCloneData = + cloneOf?.getCloneData ?? + (() => { + return { + defaultState, + structShape, + keyField, + getKey, + }; + }); - $entities.on(removeMany, (state, payload) => { - const refresh = refreshOnce(state); - const indexesToRemove: number[] = []; - if (typeof payload === 'function') { - for (let i = 0; i < state.items.length; i++) { - if (payload(state.items[i])) { - indexesToRemove.push(i); - } - } - } else { - payload = Array.isArray(payload) ? payload : [payload]; - for (const key of payload) { - const idx = state.keys.indexOf(key); - if (idx !== -1) { - indexesToRemove.push(idx); - } - } - } - /** delete in reverse order to prevent drift of following indexes after splice */ - for (let i = indexesToRemove.length - 1; i >= 0; i--) { - const idx = indexesToRemove[i]; - state = refresh(); - state.items.splice(idx, 1); - state.keys.splice(idx, 1); - const [instance] = state.instances.splice(idx, 1); - if (instance) { - clearNode(instance.region); - } - } - return state; - }); - $entities.on(updateSome, (state, updates) => { - if (!Array.isArray(updates)) updates = [updates]; - const refresh = refreshOnce(state); - for (const inputUpdate of updates) { - const key = getKey(inputUpdate as Input); - const idx = state.keys.indexOf(key); - if (idx !== -1) { - state = refresh(); - runUpdatesForInstance(state, idx, inputUpdate); - } - } - return state; - }); - const mapItemsFx = attach({ - source: $entities, - effect( - state, - { - keys, - map, - upsert = false, - }: { - keys: string | number | Array; - map: (entity: Enriched) => Partial; - upsert?: boolean; - }, - ) { - keys = Array.isArray(keys) ? keys : [keys]; - if (upsert && keyField === null) { - console.error( - 'map upsert is not supported with `key: function`, use `key: "fieldName"` instead', + const api = createInstanceApi($entities, kvModel); + const editApi = createEditApi( + $entities, + getKey, + keyField, + api, + kvModel, ); - upsert = false; - } - const refresh = refreshOnce(state); - for (const key of keys) { - let idx = state.keys.indexOf(key); - if (upsert && idx === -1) { - state = refresh(); - const idObject = { [keyField!]: key } as Input; - runNewItemInstance(state, key, idObject); - idx = state.keys.indexOf(key); - } - if (idx !== -1) { - const originalItem = state.items[idx]; - const updatedItem = map(originalItem); - if (originalItem !== updatedItem) { - state = refresh(); - runUpdatesForInstance(state, idx, updatedItem); - } - } - } - return state; - }, - }); - sample({ clock: map, target: mapItemsFx }); - sample({ clock: mapItemsFx.doneData, target: $entities }); + const editField = createEditFieldApi(keyField, kvModel, editApi.update); - const api = {} as Record>; - - let structShape: any = null; - const editField = {} as any; - if (kvModel) { - // @ts-expect-error type issues - const instance = spawn(kvModel, {}); - clearNode(instance.region); - structShape = { - type: 'structKeyval', - getKey, - shape: kvModel.__struct!.shape, - defaultItem: kvModel.defaultState ?? null, - } as StructKeyval; - for (const prop in instance.api) { - const evt = createEvent< - | { - key: string | number; - data: any; - } - | { - key: Array; - data: any[]; - } - >(); - api[prop] = evt; - $entities.on(evt, (state, payload) => { - const [key, data] = Array.isArray(payload.key) - ? [payload.key, payload.data] - : [[payload.key], [payload.data]]; - const targets = [] as any[]; - const params = [] as any[]; - for (let i = 0; i < key.length; i++) { - const idx = state.keys.indexOf(key[i]); - if (idx !== -1) { - const instance = state.instances[idx]; - targets.push(instance.api[prop]); - params.push(data[i]); - } - } - launch({ - target: targets, - params, - defer: true, - }); - return state; - }); - } - //TODO add support for generated keys - if (keyField) { - const structShape = kvModel.__struct!.shape; - for (const field in structShape) { - const fieldStruct = structShape[field]; - if (fieldStruct.type === 'structUnit') { - // derived stores are not supported - if (fieldStruct.unit === 'store' && fieldStruct.derived) { - continue; - } - const fieldEditor = updateSome.prepend( - (upd: { key: KeyOrKeys; data: any }) => { - const keySet = Array.isArray(upd.key) ? upd.key : [upd.key]; - const dataSet: Array = Array.isArray(upd.key) - ? upd.data - : [upd.data]; - const results = [] as Partial[]; - for (let i = 0; i < keySet.length; i++) { - const keyValue = keySet[i]; - const dataValue = dataSet[i]; - const item = {} as Partial; - //@ts-expect-error - item[keyField] = keyValue; - //@ts-expect-error - item[field] = dataValue; - results.push(item); - } - return results; - }, - ); - editField[field] = fieldEditor; - } else { - // TODO keyval support - } - } - } - } else if (shape!) { - const itemStructShape: StructKeyval['shape'] = {}; - structShape = { - type: 'structKeyval', - getKey, - shape: itemStructShape, - // TODO add support for .itemStore - defaultItem: null, - } as StructKeyval; - // for (const key in shape) { - // const def = shape[key] as OneOfShapeDef; - // itemStructShape[key] = def.type === 'entityShapeDefinition' - // isKeyval(def) - // ? def.__struct - // : { - // type: 'structUnit', - // unit: 'store', - // }; - // } - } - // function convertShapeDefToStructShape(def: EntityShapeDef, ) - - return { - type: 'keyval', - api: api as any, - // @ts-expect-error bad implementation - __lens: shape, - __struct: structShape, - $items: $entities.map(({ items }) => items), - $keys: $entities.map(({ keys }) => keys), - defaultState: (kvModel?.defaultState ?? null) as any, - edit: { - add, - set, - update: updateSome, - replaceAll, - remove: removeMany, - map, - }, - editField, - }; -} - -function refreshOnce(state: { - items: T[]; - instances: I[]; - keys: Array; -}) { - let needToUpdate = true; - return () => { - if (needToUpdate) { - needToUpdate = false; - state = { - items: [...state.items], - instances: [...state.instances], - keys: [...state.keys], - }; - } - return state; + return { + type: 'keyval' as const, + api: api as any, + __lens: shape, + __struct: structShape, + $items, + $keys, + __$listState: $entities as any, + defaultState, + edit: editApi, + editField, + clone: init, + isClone, + cloneOf, + getCloneData, + } as Keyval; + }, + ); }; + return init(false, null); } diff --git a/packages/core/src/lazy.ts b/packages/core/src/lazy.ts index bbbdab4..918c54c 100644 --- a/packages/core/src/lazy.ts +++ b/packages/core/src/lazy.ts @@ -1,7 +1,75 @@ -import type { Model } from './types'; +import { + Store, + Event, + Effect, + createEffect, + createEvent, + createStore, +} from 'effector'; -export function lazy>( - cb: () => M, -): M { - return cb(); +type Descriptor = 'store' | 'event' | 'effect'; + +type TypeMap = { + store: Store; + event: Event; + effect: Effect; +}; + +export let currentSkipLazyCb = true; +export let isRoot = true; +export let isInitClone = false; + +export function callInLazyStack any>( + fn: T, + skipLazyCb: boolean, + isClone: boolean, +): ReturnType { + const prevLazyCb = currentSkipLazyCb; + const prevIsRoot = isRoot; + const prevIsInitClone = isInitClone; + currentSkipLazyCb = skipLazyCb; + isRoot = false; + isInitClone = isClone; + const result = fn(); + currentSkipLazyCb = prevLazyCb; + isRoot = prevIsRoot; + isInitClone = prevIsInitClone; + return result; +} + +export function lazy(creator: () => Store): Store; +export function lazy< + S extends { [key: string]: Descriptor }, + R extends { [K in keyof S]: TypeMap[S[K]] }, +>(shape: S, creator: () => R): R; +export function lazy< + S extends readonly Descriptor[], + R extends { [K in keyof S]: TypeMap[S[K]] }, +>(shape: S, creator: () => R): R; + +export function lazy(shapeRaw: any, creatorRaw?: () => any): any { + const isSingle = typeof shapeRaw === 'function'; + const shape = isSingle ? { single: 'store' } : shapeRaw; + const creator: () => any = isSingle ? shapeRaw : creatorRaw; + if (currentSkipLazyCb) { + const result = Array.isArray(shape) ? [] : ({} as any); + for (const key in shape) { + switch (shape[key]) { + case 'store': + result[key] = createStore(null, { serialize: 'ignore' }).map( + (x: any) => x, + ); + break; + case 'event': + result[key] = createEvent(); + break; + case 'effect': + result[key] = createEffect(() => {}); + break; + } + } + return isSingle ? result.single : result; + } else { + return creator(); + } } diff --git a/packages/core/src/lazyInit.ts b/packages/core/src/lazyInit.ts new file mode 100644 index 0000000..17f94c7 --- /dev/null +++ b/packages/core/src/lazyInit.ts @@ -0,0 +1,72 @@ +import { currentSkipLazyCb, isRoot, isInitClone } from './lazy'; + +const queue: InitTask[] = []; +let scheduled = false; + +type InitTask = { + target: T; + init: () => T; + initialized: boolean; +}; + +const ignore = [ + 'type', + 'clone', + 'isClone', + 'cloneOf', + '__$listState', + '$items', + '$keys', + 'getCloneData', +]; + +function runQueue() { + for (const task of queue.splice(0)) { + if (!task.initialized) { + const value = task.init(); + for (const key of Object.keys(value) as (keyof typeof value)[]) { + if (!ignore.includes(key as any)) { + Object.defineProperty(task.target, key, { + value: value[key], + writable: true, + enumerable: true, + configurable: true, + }); + } + } + task.initialized = true; + } + } +} + +export function lazyInit(target: T, init: () => T): T { + if (currentSkipLazyCb && !isRoot && isInitClone) { + return target; + } + const task: InitTask = { target, init, initialized: false }; + queue.push(task); + if (!scheduled) { + scheduled = true; + setTimeout(() => { + scheduled = false; + runQueue(); + }, 0); + } + + for (const key of Object.keys(target) as (keyof T)[]) { + if (!ignore.includes(key as any)) { + Object.defineProperty(target, key, { + get() { + if (!task.initialized) { + runQueue(); + } + return target[key]; + }, + enumerable: true, + configurable: true, + }); + } + } + + return target; +} diff --git a/packages/core/src/lens.ts b/packages/core/src/lens.ts index 02ea7d6..5b40f1a 100644 --- a/packages/core/src/lens.ts +++ b/packages/core/src/lens.ts @@ -123,7 +123,7 @@ function createLensStruct( ], [...path, childKey], $items, - item.defaultItem, + item.defaultItem(), ); shape[key].has = (childKey: KeyStore | string | number) => createPathReaderStore( @@ -165,7 +165,7 @@ export function lens( [{ type: 'index', pathIndex: 0 }], [key], keyval.$items, - keyval.defaultState, + keyval.defaultState(), ); }, itemStore(key: KeyStore) { @@ -174,7 +174,7 @@ export function lens( [{ type: 'index', pathIndex: 0 }], [key], keyval.$items, - keyval.defaultState, + keyval.defaultState(), ); }, has(key: KeyStore) { @@ -193,7 +193,7 @@ export function lens( [{ type: 'index', pathIndex: 0 }], [key], keyval.$items, - keyval.defaultState, + keyval.defaultState(), ); } } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index b95e61e..89f782c 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -2,12 +2,9 @@ import { Store, Event, Effect, - createNode, withRegion, - createEvent, clearNode, is, - Node, EventCallable, } from 'effector'; @@ -19,10 +16,11 @@ import type { Keyval, Show, ConvertToLensShape, - FactoryPathMap, StructShape, } from './types'; import { define, isKeyval } from './define'; +import { collectFactoryPaths, createRegionalNode } from './factoryStatePaths'; +import { callInLazyStack } from './lazy'; export function model< Input extends { @@ -46,6 +44,7 @@ export function model< } = {}, >({ create, + isClone, }: { create: () => { state: Output; @@ -54,6 +53,7 @@ export function model< optional?: string[]; onMount?: EventCallable; }; + isClone: boolean; }): Model< Input, Show<{ @@ -71,7 +71,7 @@ export function model< // }[keyof Output]]: Output[K]; // } > { - const region = createNode({ regional: true }); + const region = createRegionalNode(true); const { state = {} as Output, key, @@ -80,7 +80,7 @@ export function model< onMount, ...rest } = withRegion(region, () => { - return create(); + return callInLazyStack(() => create(), true, isClone); }); if (Object.keys(rest).length > 0) { @@ -118,17 +118,17 @@ export function model< type: 'structShape', shape: {}, }; - const defaultState = {} as any; for (const key in state) { shape[key] = define.store(); - structShape.shape[key] = isKeyval(state[key]) - ? state[key].__struct - : { - type: 'structUnit', - unit: 'store', - derived: !is.targetable(state[key] as any), - }; - defaultState[key] = is.store(state[key]) ? state[key].getState() : []; + structShape.shape[key] = + // TODO cloned keyvals are omitted because of infinite recursion + isKeyval(state[key]) && !state[key].isClone + ? state[key].__struct + : { + type: 'structUnit', + unit: 'store', + derived: !is.targetable(state[key] as any), + }; } for (const key in api) { const value = api[key]; @@ -142,63 +142,33 @@ export function model< } clearNode(region); + let defaultState: any; return { type: 'model', create, keyField: key, requiredStateFields, keyvalFields, + apiFields: Object.keys(api), factoryStatePaths, shape, __lens: {} as any, __struct: structShape, - defaultState, - }; -} - -function collectFactoryPaths(state: Record, initRegion: Node) { - const factoryPathToStateKey: FactoryPathMap = new Map(); - for (const key in state) { - const value = state[key]; - if (is.store(value) && is.targetable(value)) { - const path = findNodeInTree((value as any).graphite, initRegion); - if (path) { - let nestedFactoryPathMap = factoryPathToStateKey; - for (let i = 0; i < path.length; i++) { - const step = path[i]; - const isLastStep = i === path.length - 1; - if (isLastStep) { - nestedFactoryPathMap.set(step, key); - } else { - let childFactoryPathMap = nestedFactoryPathMap.get(step); - if (!childFactoryPathMap) { - childFactoryPathMap = new Map(); - nestedFactoryPathMap.set(step, childFactoryPathMap); - } - nestedFactoryPathMap = childFactoryPathMap as FactoryPathMap; - } + defaultState() { + if (!defaultState) { + const region = createRegionalNode(false); + const { state = {} } = withRegion(region, () => + callInLazyStack(() => create(), false, false), + ); + defaultState = {}; + for (const key in state) { + defaultState[key] = is.store((state as any)[key]) + ? (state as any)[key].getState() + : []; } + clearNode(region); } - } - } - return factoryPathToStateKey; -} - -function findNodeInTree( - searchNode: Node, - currentNode: Node, - path: number[] = [], -): number[] | void { - const idx = currentNode.family.links.findIndex((e) => e === searchNode); - if (idx !== -1) { - return [...path, idx]; - } else { - for (let i = 0; i < currentNode.family.links.length; i++) { - const linkNode = currentNode.family.links[i]; - if (linkNode.meta.isRegion) { - const result = findNodeInTree(searchNode, linkNode, [...path, i]); - if (result) return result; - } - } - } + return defaultState; + }, + }; } diff --git a/packages/core/src/spawn.ts b/packages/core/src/spawn.ts index 10be2c2..b4eae40 100644 --- a/packages/core/src/spawn.ts +++ b/packages/core/src/spawn.ts @@ -2,14 +2,7 @@ import { Store, Event, Effect, - createStore, - createEffect, - is, - createNode, withRegion, - createEvent, - launch, - Node, combine, EventCallable, } from 'effector'; @@ -21,42 +14,10 @@ import type { EventDef, EffectDef, AnyDef, - FactoryPathMap, - Keyval, } from './types'; import { isKeyval } from './define'; - -type ParamsNormalize< - T extends { - [key: string]: - | Store - | Event - | Effect - | StoreDef - | EventDef - | EffectDef - | unknown; - }, -> = { - [K in keyof T]: T[K] extends Store - ? T[K] | V - : T[K] extends Event - ? T[K] - : T[K] extends Effect - ? T[K] | ((params: V) => Res | Promise) - : T[K] extends StoreDef - ? Store | V - : T[K] extends EventDef - ? Event - : T[K] extends EffectDef - ? Effect | ((params: V) => Res | Promise) - : T[K] extends (params: infer V) => infer Res - ? - | Effect, unknown> - | T[K] - | ((params: V) => Awaited | Promise>) - : Store | T[K]; -}; +import { createRegionalNode, installStateHooks } from './factoryStatePaths'; +import { callInLazyStack } from './lazy'; let childInstancesTracking: Instance[] | null = null; @@ -111,41 +72,46 @@ export function spawn< : never; }, ): Instance { - const region = createNode({ regional: true }); + const region = createRegionalNode(false); installStateHooks(params as any, region, model.factoryStatePaths); const parentTracking = childInstancesTracking; childInstancesTracking = []; - const outputs = withRegion(region, () => model.create()); - childInstancesTracking = parentTracking; - const storeOutputs = outputs.state ?? {}; - const apiOutputs = outputs.api ?? {}; - const onMount: EventCallable | void = outputs.onMount; - function forEachKeyvalField( - cb: (kv: Keyval, field: keyof Output) => void, - ) { - for (const field of model.keyvalFields) { - if (isKeyval(storeOutputs[field])) { - cb(storeOutputs[field], field); + + const [$output, keyvalShape, onMount, storeOutputs, apiOutputs] = withRegion( + region, + () => { + const { + state: storeOutputs = {}, + api: apiOutputs = {}, + onMount, + } = callInLazyStack(() => model.create(), false, false); + const resultShape = { + ...storeOutputs, + } as Output; + const keyvalShape = {} as Instance['keyvalShape']; + for (const field of model.keyvalFields) { + if (isKeyval(storeOutputs[field])) { + const kv = storeOutputs[field]; + if (field in params) { + // TODO implement without additional retrigger + kv.edit.add((params as any)[field]); + } + // @ts-expect-error generic mismatch + resultShape[field] = kv.$items; + keyvalShape[field] = kv; + } } - } - } - const $output = withRegion(region, () => { - const resultShape = { - ...storeOutputs, - } as Output; - forEachKeyvalField(({ $items }, field) => { - // @ts-expect-error generic mismatch - resultShape[field] = $items; - }); - return combine(resultShape) as unknown as Store; - }); - const keyvalShape = {} as Instance['keyvalShape']; + const $output = combine(resultShape) as unknown as Store; + return [ + $output, + keyvalShape, + onMount as EventCallable | void, + storeOutputs, + apiOutputs, + ] as const; + }, + ); - withRegion(region, () => { - forEachKeyvalField((kv, field) => { - keyvalShape[field] = kv; - }); - }); const result: Instance = { type: 'instance', output: $output, @@ -155,54 +121,10 @@ export function spawn< region, onMount, }; - if (childInstancesTracking) { - childInstancesTracking.push(result); - } - return result; -} - -function installStateHooks( - initState: Record, - node: Node, - currentFactoryPathToStateKey: FactoryPathMap, -) { - wrapPush(node.family.links, (item, idx) => { - if (!currentFactoryPathToStateKey.has(idx)) return; - const currentPath = currentFactoryPathToStateKey.get(idx)!; - if (typeof currentPath === 'string') { - if (item.scope.state && currentPath in initState) { - item.scope.state.initial = initState[currentPath]; - item.scope.state.current = initState[currentPath]; - } - } else { - installStateHooks(initState, item, currentPath); - } - }); -} -function wrapPush(arr: T[], cb: (item: T, realIdx: number) => void) { - const push = arr.push.bind(arr); - arr.push = (...args: T[]) => { - const idx = arr.length; - for (let i = 0; i < args.length; i++) { - const child = args[i]; - const realIdx = idx + i; - cb(child, realIdx); - } - return push(...args); - }; -} - -function getStoreParams(propParams: any) { - return is.store(propParams) ? propParams : createStore(propParams); -} - -function getEffectParams(key: string, propParams: any) { - if (is.effect(propParams)) { - return propParams; - } else if (!is.unit(propParams) && typeof propParams === 'function') { - return createEffect(propParams); - } else { - throw Error(`spawn field "${key}" expect effect or function`); + if (parentTracking) { + parentTracking.push(result); } + childInstancesTracking = parentTracking; + return result; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 04f8839..e153065 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -25,6 +25,8 @@ export type Model = { readonly __lens: Shape; // private // readonly api: Api; + // private + readonly apiFields: Array; shape: Show< { [K in keyof Props]: Props[K] extends Store @@ -56,7 +58,7 @@ export type Model = { >; // private __struct: StructShape; - defaultState: Output; + defaultState(): Output; }; export type Instance = { @@ -159,7 +161,7 @@ export type StructKeyval = { type: 'structKeyval'; getKey: (input: any) => string | number; shape: Record; - defaultItem: any; + defaultItem(): any; }; export type KeyStore = Store; @@ -234,7 +236,32 @@ export type Keyval = { __lens: Shape; // private __struct: StructKeyval; - defaultState: Enriched; + defaultState(): Enriched; + //private + clone( + isClone: boolean, + cloneOf: Keyval | null, + ): Keyval; + isClone: boolean; + // private + __$listState: Store< + ListState< + Enriched, + { + [K in keyof Enriched]: + | Store + | Keyval; + }, + Api + > + >; + cloneOf: Keyval | null; + getCloneData(): { + defaultState(): Enriched; + structShape: StructKeyval; + keyField: keyof Input | null; + getKey(entity: Input): string | number; + }; }; export type StoreContext = { @@ -276,3 +303,66 @@ export type Show = A extends BuiltInObject : { [K in keyof A]: A[K]; }; // & {} + +export type InputType> = + T extends Keyval ? Input : never; + +/** Internal state of keyval */ +export type ListState = { + items: Enriched[]; + instances: Array>; + keys: Array; +}; + +type ToPlainShape = { + [K in { + [P in keyof Shape]: Shape[P] extends Store + ? P + : Shape[P] extends StoreDef + ? P + : never; + }[keyof Shape]]: Shape[K] extends Store + ? V + : Shape[K] extends StoreDef + ? V + : never; +}; + +type ParamsNormalize< + T extends { + [key: string]: + | Store + | Event + | Effect + | StoreDef + | EventDef + | EffectDef + | unknown; + }, +> = { + [K in keyof T]: T[K] extends Store + ? T[K] | V + : T[K] extends Event + ? T[K] + : T[K] extends Effect + ? T[K] | ((params: V) => Res | Promise) + : T[K] extends StoreDef + ? Store | V + : T[K] extends EventDef + ? Event + : T[K] extends EffectDef + ? Effect | ((params: V) => Res | Promise) + : T[K] extends (params: infer V) => infer Res + ? + | Effect, unknown> + | T[K] + | ((params: V) => Awaited | Promise>) + : Store | T[K]; +}; + +export type KeyvalWithState = Keyval< + Input, + Output, + unknown, + unknown +>; diff --git a/packages/core/vite.config.mts b/packages/core/vite.config.mts index 70aabe0..4778c68 100644 --- a/packages/core/vite.config.mts +++ b/packages/core/vite.config.mts @@ -1,3 +1,5 @@ +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; import { defineConfig } from 'vitest/config'; import tsconfigPaths from 'vite-tsconfig-paths'; @@ -5,6 +7,11 @@ export default defineConfig({ test: { reporters: 'default', typecheck: { ignoreSourceErrors: true }, + include: [relativePath('./src/__tests__/**/*.test.ts')], }, plugins: [tsconfigPaths()], }); + +function relativePath(path: string) { + return resolve(dirname(fileURLToPath(import.meta.url)), path); +} diff --git a/packages/react/package.json b/packages/react/package.json index 78b2e18..c4905d8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -3,6 +3,11 @@ "version": "0.0.6", "type": "commonjs", "peerDependencies": { - "effector": "^23.2.2" + "effector": "^23.3.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.0", + "@testing-library/jest-dom": "^6.6.3", + "happy-dom": "^17.4.4" } } diff --git a/packages/react/project.json b/packages/react/project.json index f16f65d..90b2e0b 100644 --- a/packages/react/project.json +++ b/packages/react/project.json @@ -52,11 +52,17 @@ } }, "test": { - "executor": "@nrwl/vite:test" + "executor": "@nrwl/vite:test", + "options": { + "config": "packages/react/vite.config.mts", + "passWithNoTests": true + } }, "test_watch": { "executor": "@nrwl/vite:test", "options": { + "config": "packages/react/vite.config.mts", + "passWithNoTests": true, "watch": true } }, diff --git a/packages/react/src/__tests__/index.test.tsx b/packages/react/src/__tests__/index.test.tsx deleted file mode 100644 index 56d1e19..0000000 --- a/packages/react/src/__tests__/index.test.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { expect, test } from 'vitest'; - -test('smoke test react', () => { - expect(0).toBe(0); -}); diff --git a/packages/react/src/__tests__/testsSetup.ts b/packages/react/src/__tests__/testsSetup.ts new file mode 100644 index 0000000..0cdbb33 --- /dev/null +++ b/packages/react/src/__tests__/testsSetup.ts @@ -0,0 +1,7 @@ +import { afterEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; + +afterEach(() => { + cleanup(); +}); diff --git a/packages/react/src/__tests__/useEditItemField.test.tsx b/packages/react/src/__tests__/useEditItemField.test.tsx new file mode 100644 index 0000000..02e3534 --- /dev/null +++ b/packages/react/src/__tests__/useEditItemField.test.tsx @@ -0,0 +1,68 @@ +import { expect, test } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { createStore } from 'effector'; +import { keyval } from '@effector/model'; +import { + useEntityItem, + useEditItemField, + EntityProvider, +} from '@effector/model-react'; + +test('with key from provider', () => { + const entities = keyval(() => { + const $id = createStore(''); + const $text = createStore(''); + return { + key: 'id', + state: { + id: $id, + text: $text, + }, + }; + }); + entities.edit.add({ id: 'a', text: 'A' }); + const Entity = () => { + const { id, text } = useEntityItem(entities); + const editField = useEditItemField(entities); + return ( + + ); + }; + render( + + + , + ); + const result = screen.getByTestId('entity'); + fireEvent.click(result); + expect(result.innerText).toMatchInlineSnapshot(`"a: B"`); +}); +test('with key from argument', () => { + const entities = keyval(() => { + const $id = createStore(''); + const $text = createStore(''); + return { + key: 'id', + state: { + id: $id, + text: $text, + }, + }; + }); + entities.edit.add({ id: 'a', text: 'A' }); + const Entity = ({ id }: { id: string }) => { + const { text } = useEntityItem(entities, id); + const editField = useEditItemField(entities, id); + return ( + + ); + }; + render(); + const result = screen.getByTestId('entity'); + fireEvent.click(result); + expect(result.innerText).toMatchInlineSnapshot(`"a: B"`); +}); diff --git a/packages/react/src/__tests__/useEditKeyval.test.tsx b/packages/react/src/__tests__/useEditKeyval.test.tsx new file mode 100644 index 0000000..c32793b --- /dev/null +++ b/packages/react/src/__tests__/useEditKeyval.test.tsx @@ -0,0 +1,128 @@ +import { expect, test } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { createStore } from 'effector'; +import { keyval, type KeyvalWithState } from '@effector/model'; +import { + useEntityList, + useEntityItem, + useEditKeyval, +} from '@effector/model-react'; + +test('provide api for root keyval.edit in basic case', () => { + const entities = keyval(() => { + const $id = createStore(''); + const $value = createStore(''); + return { + key: 'id', + state: { + id: $id, + value: $value, + }, + }; + }); + entities.edit.add([ + { id: 'a', value: 'A' }, + { id: 'b', value: 'B' }, + ]); + const Entity = () => { + const { id, value } = useEntityItem(entities); + const { remove } = useEditKeyval(entities); + return ( + + ); + }; + const App = () => { + return ( +
+ {useEntityList(entities, () => ( + + ))} +
+ ); + }; + render(); + const result = screen.getByTestId('list'); + const firstButton = screen.getByTestId('entity-a'); + fireEvent.click(firstButton); + expect(result.innerHTML).toBe( + renderToString( + <> + + , + ), + ); + expect(entities.$items.getState()).toEqual([{ id: 'b', value: 'B' }]); +}); +test('allow to edit current (child) keyval in tree case', () => { + type Entity = { + id: string; + childs: Entity[]; + }; + const entities = keyval(() => { + const $id = createStore(''); + const childs = keyval(entities) as KeyvalWithState; + return { + key: 'id', + state: { + id: $id, + childs, + }, + }; + }); + entities.edit.add([ + { id: 'a', childs: [{ id: 'b', childs: [{ id: 'c', childs: [] }] }] }, + { id: 'c', childs: [] }, + ]); + const Entity = () => { + const { id } = useEntityItem(entities); + const { remove } = useEditKeyval(entities); + return ( +
+ + {useEntityList({ + keyval: entities, + field: 'childs', + fn: () => , + })} +
+ ); + }; + const App = () => { + return ( +
+ {useEntityList(entities, () => ( + + ))} +
+ ); + }; + render(); + const result = screen.getByTestId('list'); + /** select first (deep child) entity with that id */ + const targetButton = screen.getAllByTestId('entity-c')[0]; + fireEvent.click(targetButton); + expect(result.innerHTML).toBe( + renderToString( + <> +
+ +
+ +
+
+
+ +
+ , + ), + ); + expect(entities.$items.getState()).toEqual([ + { id: 'a', childs: [{ id: 'b', childs: [] }] }, + { id: 'c', childs: [] }, + ]); +}); diff --git a/packages/react/src/__tests__/useEntityItem.test.tsx b/packages/react/src/__tests__/useEntityItem.test.tsx new file mode 100644 index 0000000..ae80acf --- /dev/null +++ b/packages/react/src/__tests__/useEntityItem.test.tsx @@ -0,0 +1,111 @@ +import { expect, test } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { createStore } from 'effector'; +import { keyval } from '@effector/model'; +import { useEntityItem, EntityProvider } from '@effector/model-react'; + +test('with key from provider', () => { + const entities = keyval(() => { + const $id = createStore(''); + const $value = createStore(''); + return { + key: 'id', + state: { + id: $id, + value: $value, + }, + }; + }); + entities.edit.add({ id: 'a', value: 'A' }); + const Entity = () => { + const { value } = useEntityItem(entities); + return
{value}
; + }; + render( + + + , + ); + const result = screen.getByTestId('entity'); + expect(result.innerText).toBe('A'); +}); +test('with key from argument', () => { + const entities = keyval(() => { + const $id = createStore(''); + const $value = createStore(''); + return { + key: 'id', + state: { + id: $id, + value: $value, + }, + }; + }); + entities.edit.add({ id: 'a', value: 'A' }); + const Entity = ({ id }: { id: string }) => { + const { value } = useEntityItem(entities, id); + return
{value}
; + }; + render(); + const result = screen.getByTestId('entity'); + expect(result.innerText).toBe('A'); +}); +test('render default item when key is not found', () => { + const entities = keyval(() => { + const $id = createStore(''); + const $value = createStore('default'); + return { + key: 'id', + state: { + id: $id, + value: $value, + }, + }; + }); + const Entity = ({ id }: { id: string }) => { + const { value } = useEntityItem(entities, id); + return
{value}
; + }; + render(); + const result = screen.getByTestId('entity'); + expect(result.innerText).toBe('default'); +}); +test('nested components use nearest provider', () => { + const entities = keyval(() => { + const $id = createStore(''); + const $value = createStore(''); + return { + key: 'id', + state: { + id: $id, + value: $value, + }, + }; + }); + entities.edit.add([ + { id: 'a', value: 'A' }, + { id: 'b', value: 'B' }, + ]); + const EntityA = () => { + const { value } = useEntityItem(entities); + return ( +
+ {value} + + + +
+ ); + }; + const EntityB = () => { + const { value } = useEntityItem(entities); + return
{value}
; + }; + render( + + + , + ); + const result = screen.getByTestId('entity'); + expect(result.innerHTML).toMatchInlineSnapshot(`"A
B
"`); +}); diff --git a/packages/react/src/__tests__/useEntityList.test.tsx b/packages/react/src/__tests__/useEntityList.test.tsx new file mode 100644 index 0000000..7569a76 --- /dev/null +++ b/packages/react/src/__tests__/useEntityList.test.tsx @@ -0,0 +1,97 @@ +import { expect, test } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { render, screen } from '@testing-library/react'; +import { createStore } from 'effector'; +import { keyval, type KeyvalWithState } from '@effector/model'; +import { useEntityList, useEntityItem } from '@effector/model-react'; + +test('useEntityList', () => { + const entities = keyval(() => { + const $id = createStore(''); + const $value = createStore(''); + return { + key: 'id', + state: { + id: $id, + value: $value, + }, + }; + }); + entities.edit.add([ + { id: 'a', value: 'A' }, + { id: 'b', value: 'B' }, + ]); + const Entity = () => { + const { value } = useEntityItem(entities); + return
{value}
; + }; + const App = () => ( +
+ {useEntityList(entities, () => ( + + ))} +
+ ); + render(); + const result = screen.getByTestId('list'); + expect(result.innerHTML).toMatchInlineSnapshot(`"
A
B
"`); +}); + +test('tree support', () => { + type Entity = { + id: string; + childs: Entity[]; + }; + const entities = keyval(() => { + const $id = createStore(''); + const childs = keyval(entities) as KeyvalWithState; + return { + key: 'id', + state: { + id: $id, + childs, + }, + }; + }); + entities.edit.add([ + { id: 'a', childs: [{ id: 'e', childs: [] }] }, + { id: 'b', childs: [{ id: 'c', childs: [{ id: 'd', childs: [] }] }] }, + ]); + const Entity = () => { + const { id } = useEntityItem(entities); + return ( +
+ {useEntityList({ + keyval: entities, + field: 'childs', + fn: () => , + })} +
+ ); + }; + const App = () => { + return ( +
+ {useEntityList(entities, () => ( + + ))} +
+ ); + }; + render(); + const result = screen.getByTestId('list'); + expect(result.innerHTML).toBe( + renderToString( + <> +
+
+
+
+
+
+
+
+ , + ), + ); +}); diff --git a/packages/react/src/__tests__/useItemApi.test.tsx b/packages/react/src/__tests__/useItemApi.test.tsx new file mode 100644 index 0000000..ab73089 --- /dev/null +++ b/packages/react/src/__tests__/useItemApi.test.tsx @@ -0,0 +1,76 @@ +import { expect, test } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { createEvent, createStore, sample } from 'effector'; +import { keyval } from '@effector/model'; +import { + useEntityItem, + useItemApi, + EntityProvider, +} from '@effector/model-react'; + +test('with key from provider', () => { + const entities = keyval(() => { + const $id = createStore(''); + const $value = createStore(0); + const inc = createEvent(); + sample({ clock: inc, source: $value, target: $value, fn: (x) => x + 1 }); + return { + key: 'id', + state: { + id: $id, + value: $value, + }, + api: { inc }, + optional: ['value'], + }; + }); + entities.edit.add({ id: 'a' }); + const Entity = () => { + const { id, value } = useEntityItem(entities); + const { inc } = useItemApi(entities); + return ( + + ); + }; + render( + + + , + ); + const result = screen.getByTestId('entity'); + fireEvent.click(result); + expect(result.innerText).toMatchInlineSnapshot(`"a: 1"`); +}); +test('with key from argument', () => { + const entities = keyval(() => { + const $id = createStore(''); + const $value = createStore(0); + const inc = createEvent(); + sample({ clock: inc, source: $value, target: $value, fn: (x) => x + 1 }); + return { + key: 'id', + state: { + id: $id, + value: $value, + }, + api: { inc }, + optional: ['value'], + }; + }); + entities.edit.add({ id: 'a' }); + const Entity = ({ id }: { id: string }) => { + const { value } = useEntityItem(entities, id); + const { inc } = useItemApi(entities, id); + return ( + + ); + }; + render(); + const result = screen.getByTestId('entity'); + fireEvent.click(result); + expect(result.innerText).toMatchInlineSnapshot(`"a: 1"`); +}); diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx index 4b3296a..6be6fca 100644 --- a/packages/react/src/index.tsx +++ b/packages/react/src/index.tsx @@ -14,15 +14,15 @@ import { } from 'effector'; import { useList, useStoreMap, useUnit } from 'effector-react'; -import type { - Model, - Instance, - Keyval, - StoreDef, - EventDef, - EffectDef, - AnyDef, - Show, +import { + type Model, + type Instance, + type Keyval, + type StoreDef, + type EventDef, + type EffectDef, + type AnyDef, + isKeyval, } from '@effector/model'; import { spawn } from '@effector/model'; @@ -30,6 +30,7 @@ type ModelStack = | { type: 'entity'; model: Keyval; + clone: Keyval | null; value: string | number; parent: ModelStack | null; } @@ -47,14 +48,15 @@ export function EntityProvider({ value, children, }: { - model: Keyval; + model: Keyval; value: string | number; children: ReactNode; }) { const currentStack = useContext(ModelStackContext); const nextStack = { type: 'entity' as const, - model, + model: model.cloneOf || model, + clone: model.isClone ? model : null, value, parent: currentStack, }; @@ -135,73 +137,42 @@ export function ModelProvider< ); } -export function useModel( - model: Model, -): [ - state: Show< - { - [K in keyof Input]: Input[K] extends Store - ? V - : Input[K] extends StoreDef - ? V - : Input[K] extends Event - ? (params: V) => V - : Input[K] extends EventDef - ? (params: V) => V - : Input[K] extends Effect - ? (params: V) => Promise - : Input[K] extends EffectDef - ? (params: V) => Promise - : Input[K] extends (params: infer V) => infer D - ? (params: V) => Promise> - : Input[K]; - } & { - [K in keyof T]: T[K] extends Store ? V : never; - } - >, - api: { - [K in keyof Api]: Api[K] extends Event - ? (params: V) => V - : Api[K] extends Effect - ? (params: V) => Promise - : never; - }, -] { - const stack = useContext(ModelStackContext); - let currentStack = stack; - let instance: Instance | undefined; - while (instance === undefined && currentStack) { - if (currentStack.model === model) { - instance = currentStack.value as Instance; - } - currentStack = currentStack.parent; - } - if (instance === undefined) - throw Error('model not found, add ModelProvider first'); - const state = useUnit(instance.props as any); - const api = useUnit(instance.api as any); - return [state as any, api as any]; -} - function useGetKeyvalKey( args: | [keyval: Keyval] | [keyval: Keyval, key: string | number], + allowUndefinedKey?: false, +): [keyval: Keyval, key: string | number]; +function useGetKeyvalKey( + args: + | [keyval: Keyval] + | [keyval: Keyval, key: string | number], + allowUndefinedKey: true, +): [keyval: Keyval, key: string | number | void]; +function useGetKeyvalKey( + args: + | [keyval: Keyval] + | [keyval: Keyval, key: string | number], + allowUndefinedKey: boolean = false, ): [keyval: Keyval, key: string | number] { if (args.length === 1) { - const [keyval] = args; + let [keyval] = args; const stack = useContext(ModelStackContext); let currentStack = stack; let key: string | number | undefined; while (key === undefined && currentStack) { if (currentStack.model === keyval) { key = currentStack.value as string | number; + if (currentStack.type === 'entity' && currentStack.clone) { + // @ts-expect-error typecast + keyval = currentStack.clone; + } } currentStack = currentStack.parent; } - if (key === undefined) + if (key === undefined && !allowUndefinedKey) throw Error('model not found, add EntityProvider first'); - return [keyval, key]; + return [keyval, key!]; } else { return args; } @@ -230,7 +201,7 @@ export function useEntityItem( }); if (idx === -1) { // NOTE probably need to throw error here - return keyval.defaultState; + return keyval.defaultState(); } return result as T; } @@ -238,31 +209,61 @@ export function useEntityItem( export function useEntityList( keyval: Keyval, View: () => ReactNode, +): ReactNode; +export function useEntityList(config: { + keyval: Keyval; + field: keyof T; + fn: () => ReactNode; +}): ReactNode; +export function useEntityList( + ...[keyvalOrConfig, viewFn]: + | [keyval: Keyval, View: () => ReactNode] + | [ + config: { + keyval: Keyval; + field: keyof T; + fn: () => ReactNode; + }, + ] ) { - return useList(keyval.$keys, (key) => ( - - - - )); -} + let View: () => ReactNode; + let keyvalToIterate: Keyval; + if (isKeyval(keyvalOrConfig)) { + [keyvalToIterate, View] = [keyvalOrConfig, viewFn!]; + } else { + const { + keyval: keyvalRaw, + field, + fn, + } = keyvalOrConfig as Exclude< + typeof keyvalOrConfig, + Keyval + >; + View = fn; + /** + * keyvalRaw is always a root keyval, used as a tag + * keyval is current instance in which computation will happens + * instanceKeyval is child instance from a field + */ + const [keyval, currentKey] = useGetKeyvalKey([keyvalRaw]); + const instanceKeyval = useStoreMap({ + store: keyval.__$listState, + keys: [currentKey, field], + fn({ instances, keys }, [key, field]) { + const idx = keys.findIndex((e) => e === key); + return instances[idx].keyvalShape[field]; + }, + }); + keyvalToIterate = instanceKeyval; + } -export function useEntityByKey( - keyval: Keyval, - key: string | number, - View: (params: { value: T }) => ReactNode, -) { - const idx = useStoreMap({ - store: keyval.$keys, - keys: [key], - fn: (keys, [value]) => keys.indexOf(value), + return useList(keyvalToIterate.$keys, (key) => { + return ( + + + + ); }); - const result = useStoreMap({ - store: keyval.$items, - keys: [idx, key], - fn: (values, [idx]) => (idx === -1 ? null : values[idx]), - }); - if (idx === -1) return null; - return ; } export function useItemApi( @@ -296,7 +297,7 @@ export function useEditItemField( | [keyval: Keyval] | [keyval: Keyval, key: string | number] ): { - [K in keyof Input]: (params: Input[K]) => void; + [K in keyof Input]-?: (params: Input[K]) => void; } { const [keyval, key] = useGetKeyvalKey(args); const commonApi = useUnit(keyval.editField); @@ -314,3 +315,10 @@ export function useEditItemField( return result; }, [keyval, key, commonApi]); } + +export function useEditKeyval( + keyval: Keyval, +) { + const [currentKeyval] = useGetKeyvalKey([keyval], true); + return useUnit(currentKeyval.edit); +} diff --git a/packages/react/vite.config.mts b/packages/react/vite.config.mts index d9b365e..68f7728 100644 --- a/packages/react/vite.config.mts +++ b/packages/react/vite.config.mts @@ -1,7 +1,18 @@ +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; import { defineConfig } from 'vitest/config'; import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ - test: { typecheck: { ignoreSourceErrors: true } }, + test: { + typecheck: { ignoreSourceErrors: true }, + setupFiles: [relativePath('./src/__tests__/testsSetup.ts')], + environment: 'happy-dom', + include: [relativePath('./src/__tests__/**/*.test.tsx')], + }, plugins: [tsconfigPaths()], }); + +function relativePath(path: string) { + return resolve(dirname(fileURLToPath(import.meta.url)), path); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 985015b..9265cea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,7 +62,7 @@ importers: version: 19.5.7(@babel/core@7.25.2)(@babel/traverse@7.25.3)(@types/babel__core@7.20.5)(@types/node@20.14.15)(nx@19.5.7)(ts-node@10.9.1(@types/node@20.14.15)(typescript@5.5.4))(typescript@5.5.4) '@nrwl/vite': specifier: 19.5.7 - version: 19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)(sugarss@2.0.0))(vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(sugarss@2.0.0)) + version: 19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)(sugarss@2.0.0))(vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(happy-dom@17.4.4)(sugarss@2.0.0)) '@nrwl/web': specifier: 19.5.7 version: 19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4) @@ -215,7 +215,7 @@ importers: version: 5.0.1(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)(sugarss@2.0.0)) vitest: specifier: ^2.0.5 - version: 2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(sugarss@2.0.0) + version: 2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(happy-dom@17.4.4)(sugarss@2.0.0) apps/food-order: devDependencies: @@ -251,6 +251,19 @@ importers: specifier: ^4.2.1 version: 4.5.10(@types/node@20.14.15)(sugarss@2.0.0) + apps/tree-todo-list: + dependencies: + effector-action: + specifier: ^1.1.0 + version: 1.1.0(effector@23.3.0)(patronum@2.3.0(effector@23.3.0)) + devDependencies: + '@vitejs/plugin-react': + specifier: ^3.1.0 + version: 3.1.0(vite@4.5.10(@types/node@20.14.15)(sugarss@2.0.0)) + vite: + specifier: ^4.2.1 + version: 4.5.10(@types/node@20.14.15)(sugarss@2.0.0) + packages/core: dependencies: effector: @@ -260,11 +273,24 @@ importers: packages/react: dependencies: effector: - specifier: ^23.2.2 - version: 23.2.2 + specifier: ^23.3.0 + version: 23.3.0 + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.6.3 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + happy-dom: + specifier: ^17.4.4 + version: 17.4.4 packages: + '@adobe/css-tools@4.4.2': + resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} + '@ampproject/remapping@2.2.0': resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} engines: {node: '>=6.0.0'} @@ -1938,6 +1964,29 @@ packages: '@swc/helpers@0.5.12': resolution: {integrity: sha512-KMZNXiGibsW9kvZAO1Pam2JPTDBm+KSHMMHWdsyI/1DbIZjT2A6Gy3hblVXUMEDvUAKq+e0vL0X0o54owWji7g==} + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.6.3': + resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -1957,6 +2006,9 @@ packages: '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2278,6 +2330,9 @@ packages: aria-query@5.1.3: resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + arr-diff@4.0.0: resolution: {integrity: sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==} engines: {node: '>=0.10.0'} @@ -2591,6 +2646,10 @@ packages: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2791,6 +2850,9 @@ packages: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2941,6 +3003,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -2973,6 +3039,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@0.2.2: resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} @@ -3021,6 +3093,12 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + effector-action@1.1.0: + resolution: {integrity: sha512-CHzv5LB6dW81GRiQKoVIxVEDeRPVTKipy2LWH8AjcSAV7Dmv7x9fvU0SQt6FmsmMV8LHw3v2jomL3kpVp3yNdA==} + peerDependencies: + effector: '>=23' + patronum: '>=2.1.0' + effector-react@23.3.0: resolution: {integrity: sha512-QR0+x1EnbiWhO80Yc0GVF+I9xCYoxBm3t+QLB5Wg+1uY1Q1BrSWDmKvJaJJZ/+9BU4RAr25yS5J2EkdWnicu8g==} engines: {node: '>=11.0.0'} @@ -3028,10 +3106,6 @@ packages: effector: ^23.0.0 react: '>=16.8.0 <20.0.0' - effector@23.2.2: - resolution: {integrity: sha512-gzwATi9pgZQx0TNhM2LESmoUpEO+vhibLZPCvVzi7spMvKFwKnfJV2PFj4xqNFFSC35TXaznx30ne62dCQ6ZRQ==} - engines: {node: '>=11.0.0'} - effector@23.3.0: resolution: {integrity: sha512-ZnQ3POaNARlxT9+kxrK58PO/xmStBdxfPq0rceglENg8Ryxx/yx+1RsV/ziznrFPhLkZYc7NdDA1OKxnMW98/g==} engines: {node: '>=11.0.0'} @@ -3763,6 +3837,10 @@ packages: gud@1.0.0: resolution: {integrity: sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==} + happy-dom@17.4.4: + resolution: {integrity: sha512-/Pb0ctk3HTZ5xEL3BZ0hK1AqDSAUuRQitOmROPHhfUYEWpmTImwfD8vFDGADmMAX0JYgbcgxWoLFKtsWhcpuVA==} + engines: {node: '>=18.0.0'} + has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -4515,6 +4593,10 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.27.0: resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} engines: {node: '>=12'} @@ -5383,6 +5465,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5430,6 +5516,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} @@ -5524,6 +5613,10 @@ packages: resolution: {integrity: sha512-XNwrTx77JQCEMXTeb8movBKuK75MgH0RZkujNuDKCezemx/voapl9i2gCSi8WWm8+ox5ycJi1gxF22fR7c0Ciw==} engines: {node: '>=4'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.6: resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} engines: {node: '>= 0.4'} @@ -6643,10 +6736,18 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + whatwg-encoding@2.0.0: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} @@ -6754,6 +6855,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.2': {} + '@ampproject/remapping@2.2.0': dependencies: '@jridgewell/gen-mapping': 0.1.1 @@ -8356,9 +8459,9 @@ snapshots: - '@swc/core' - debug - '@nrwl/vite@19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)(sugarss@2.0.0))(vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(sugarss@2.0.0))': + '@nrwl/vite@19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)(sugarss@2.0.0))(vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(happy-dom@17.4.4)(sugarss@2.0.0))': dependencies: - '@nx/vite': 19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)(sugarss@2.0.0))(vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(sugarss@2.0.0)) + '@nx/vite': 19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)(sugarss@2.0.0))(vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(happy-dom@17.4.4)(sugarss@2.0.0)) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -8624,9 +8727,9 @@ snapshots: - typescript - verdaccio - '@nx/vite@19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)(sugarss@2.0.0))(vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(sugarss@2.0.0))': + '@nx/vite@19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)(sugarss@2.0.0))(vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(happy-dom@17.4.4)(sugarss@2.0.0))': dependencies: - '@nrwl/vite': 19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)(sugarss@2.0.0))(vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(sugarss@2.0.0)) + '@nrwl/vite': 19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)(sugarss@2.0.0))(vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(happy-dom@17.4.4)(sugarss@2.0.0)) '@nx/devkit': 19.5.7(nx@19.5.7) '@nx/js': 19.5.7(@babel/traverse@7.25.3)(@types/node@20.14.15)(nx@19.5.7)(typescript@5.5.4) '@phenomnomnominal/tsquery': 5.0.1(typescript@5.5.4) @@ -8634,7 +8737,7 @@ snapshots: enquirer: 2.3.6 tsconfig-paths: 4.2.0 vite: 5.4.0(@types/node@20.14.15)(sugarss@2.0.0) - vitest: 2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(sugarss@2.0.0) + vitest: 2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(happy-dom@17.4.4)(sugarss@2.0.0) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -8893,6 +8996,37 @@ snapshots: dependencies: tslib: 2.5.0 + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/runtime': 7.25.0 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.6.3': + dependencies: + '@adobe/css-tools': 4.4.2 + aria-query: 5.1.3 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.0 + '@testing-library/dom': 10.4.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@trysound/sax@0.2.0': {} '@tsconfig/node10@1.0.11': {} @@ -8907,6 +9041,8 @@ snapshots: dependencies: tslib: 2.5.0 + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.21.4 @@ -9206,7 +9342,7 @@ snapshots: pathe: 1.1.2 sirv: 2.0.4 tinyrainbow: 1.2.0 - vitest: 2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(sugarss@2.0.0) + vitest: 2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(happy-dom@17.4.4)(sugarss@2.0.0) '@vitest/utils@2.0.5': dependencies: @@ -9300,6 +9436,10 @@ snapshots: dependencies: deep-equal: 2.2.0 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + arr-diff@4.0.0: {} arr-flatten@1.1.0: {} @@ -9682,6 +9822,11 @@ snapshots: escape-string-regexp: 1.0.5 supports-color: 5.5.0 + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -9883,6 +10028,8 @@ snapshots: css-what@6.1.0: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} cssnano-preset-default@5.2.14(postcss@8.4.41): @@ -10059,6 +10206,8 @@ snapshots: delayed-stream@1.0.0: {} + dequal@2.0.3: {} + detect-indent@6.1.0: {} detect-node-es@1.1.0: {} @@ -10086,6 +10235,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@0.2.2: dependencies: domelementtype: 2.3.0 @@ -10136,14 +10289,17 @@ snapshots: duplexer@0.1.2: {} + effector-action@1.1.0(effector@23.3.0)(patronum@2.3.0(effector@23.3.0)): + dependencies: + effector: 23.3.0 + patronum: 2.3.0(effector@23.3.0) + effector-react@23.3.0(effector@23.3.0)(react@18.3.1): dependencies: effector: 23.3.0 react: 18.3.1 use-sync-external-store: 1.2.0(react@18.3.1) - effector@23.2.2: {} - effector@23.3.0: {} ejs@3.1.9: @@ -11152,6 +11308,11 @@ snapshots: gud@1.0.0: {} + happy-dom@17.4.4: + dependencies: + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + has-bigints@1.0.2: {} has-flag@3.0.0: {} @@ -11848,6 +12009,8 @@ snapshots: dependencies: yallist: 4.0.0 + lz-string@1.5.0: {} + magic-string@0.27.0: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -12750,6 +12913,12 @@ snapshots: prettier@3.3.3: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -12790,6 +12959,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.2.0: {} react-number-format@5.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -12900,6 +13071,11 @@ snapshots: indent-string: 3.2.0 strip-indent: 2.0.0 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.6: dependencies: call-bind: 1.0.7 @@ -14196,7 +14372,7 @@ snapshots: fsevents: 2.3.3 sugarss: 2.0.0 - vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(sugarss@2.0.0): + vitest@2.0.5(@types/node@20.14.15)(@vitest/ui@2.0.5)(happy-dom@17.4.4)(sugarss@2.0.0): dependencies: '@ampproject/remapping': 2.3.0 '@vitest/expect': 2.0.5 @@ -14220,6 +14396,7 @@ snapshots: optionalDependencies: '@types/node': 20.14.15 '@vitest/ui': 2.0.5(vitest@2.0.5) + happy-dom: 17.4.4 transitivePeerDependencies: - less - lightningcss @@ -14238,10 +14415,14 @@ snapshots: dependencies: defaults: 1.0.4 + webidl-conversions@7.0.0: {} + whatwg-encoding@2.0.0: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + which-boxed-primitive@1.0.2: dependencies: is-bigint: 1.0.4 diff --git a/tsconfig.base.json b/tsconfig.base.json index d549625..1115365 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,8 +13,8 @@ "skipDefaultLibCheck": true, "baseUrl": ".", "paths": { - "@effector/model": ["packages/core/index.ts"], - "@effector/model-react": ["packages/react/index.ts"] + "@effector/model": ["./packages/core/index.ts"], + "@effector/model-react": ["./packages/react/index.ts"] } }, "exclude": ["node_modules", "tmp"]