diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 862c8df..b1b5f6d 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,7 +1,24 @@ import type { Preview } from '@storybook/react-vite'; +import { useState } from 'react'; +import { Provider } from 'react-redux'; import '../src/app/index.css'; +import { setupStore } from '../src/app/store'; const preview: Preview = { + // Every story runs inside a fresh, isolated Redux store, so connected + // components (those using useAppSelector/useAppDispatch) work and stories + // don't share state. A story can preload slice state via + // `parameters.preloadedState` to show a specific situation. + decorators: [ + (Story, context) => { + const [store] = useState(() => setupStore(context.parameters.preloadedState)); + return ( + + + + ); + }, + ], parameters: { controls: { matchers: { diff --git a/eslint.config.js b/eslint.config.js index 470a15c..e32c004 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -87,6 +87,16 @@ export default defineConfig([ message: 'Import a slice through its public API (index), not its internal files: {{ dependency.source }}', }, + // 1c. Documented exception: the Redux store and its typed hooks live + // in `app/` because the store composes every slice and must sit at + // the top layer. They are cross-cutting infrastructure, so any layer + // may import `@/app/store` and `@/app/hooks`. This is the single + // sanctioned upward import. (A stricter alternative — hooks in + // shared with reducer injection — is noted in wiki/redux-setup.md.) + { + from: { type: '*' }, + allow: [{ to: { type: 'app', internalPath: '{store,hooks}.{ts,tsx}' } }], + }, ], }, ], diff --git a/package-lock.json b/package-lock.json index 1a95d58..efb0e6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "edu-react-app", "version": "0.0.0", "dependencies": { + "@reduxjs/toolkit": "^2.12.0", "@tailwindcss/vite": "^4.3.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-redux": "^9.3.0", "tailwindcss": "^4.3.0" }, "devDependencies": { @@ -1822,6 +1824,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", @@ -2115,7 +2143,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@storybook/addon-a11y": { @@ -2932,7 +2965,7 @@ "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2955,6 +2988,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.4", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", @@ -4239,7 +4278,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -5131,6 +5170,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6459,6 +6508,29 @@ "license": "MIT", "peer": true }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -6503,6 +6575,27 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -7192,7 +7285,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "dev": true, "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index e92cfcd..219528c 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,11 @@ "build-storybook": "storybook build" }, "dependencies": { + "@reduxjs/toolkit": "^2.12.0", "@tailwindcss/vite": "^4.3.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-redux": "^9.3.0", "tailwindcss": "^4.3.0" }, "devDependencies": { diff --git a/src/app/hooks.ts b/src/app/hooks.ts new file mode 100644 index 0000000..a3b7db1 --- /dev/null +++ b/src/app/hooks.ts @@ -0,0 +1,8 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from './store'; + +// Typed wrappers around the React-Redux hooks. Use these throughout the app +// instead of the plain useDispatch / useSelector so dispatch and selectors are +// fully typed against our store. +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/src/app/main.tsx b/src/app/main.tsx index 80399d0..b135856 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -1,10 +1,14 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { Provider } from 'react-redux'; import './index.css'; +import { store } from './store'; import { HomePage } from '@/pages/home'; createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/src/app/store.ts b/src/app/store.ts new file mode 100644 index 0000000..bd3d3f2 --- /dev/null +++ b/src/app/store.ts @@ -0,0 +1,22 @@ +import { todoReducer } from '@/entities/todo'; +import { filterReducer } from '@/features/filter-todos'; +import { combineReducers, configureStore } from '@reduxjs/toolkit'; + +const rootReducer = combineReducers({ + todos: todoReducer, + filter: filterReducer, +}); + +// Factory so tests and Storybook can spin up isolated stores (optionally with +// preloaded state) instead of sharing the app singleton. +export const setupStore = (preloadedState?: Partial) => { + return configureStore({ reducer: rootReducer, preloadedState }); +}; + +// The one store the running app uses. +export const store = setupStore(); + +// Types are derived from the root reducer / store, not written by hand. +export type RootState = ReturnType; +export type AppStore = ReturnType; +export type AppDispatch = AppStore['dispatch']; diff --git a/src/entities/todo/index.ts b/src/entities/todo/index.ts index c3230c5..b2ff67e 100644 --- a/src/entities/todo/index.ts +++ b/src/entities/todo/index.ts @@ -1,2 +1,4 @@ -export { TodoItem } from './ui/TodoItem'; +export { selectTodos } from './model/selectors'; +export { todoAdded, todoDeleted, todoReducer, todoToggled } from './model/todoSlice'; export type { Todo } from './model/types'; +export { TodoItem } from './ui/TodoItem'; diff --git a/src/entities/todo/model/selectors.ts b/src/entities/todo/model/selectors.ts new file mode 100644 index 0000000..0fab324 --- /dev/null +++ b/src/entities/todo/model/selectors.ts @@ -0,0 +1,4 @@ +import type { RootState } from '@/app/store'; + +// Basic selector: reads the todos slice's items out of the whole state. +export const selectTodos = (state: RootState) => state.todos.items; diff --git a/src/entities/todo/model/todoSlice.ts b/src/entities/todo/model/todoSlice.ts new file mode 100644 index 0000000..2d119dc --- /dev/null +++ b/src/entities/todo/model/todoSlice.ts @@ -0,0 +1,42 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import type { Todo } from './types'; + +interface TodosState { + items: Todo[]; +} + +const initialState: TodosState = { + items: [], +}; + +const todoSlice = createSlice({ + name: 'todos', + initialState, + reducers: { + // `prepare` lets the caller pass just the text; the id is generated here, + // so the action stays a description of "what happened" and the component + // does not need to know how ids are made. + todoAdded: { + reducer(state, action: PayloadAction) { + state.items.push(action.payload); + }, + prepare(text: string) { + return { + payload: { id: crypto.randomUUID(), text, completed: false } satisfies Todo, + }; + }, + }, + todoToggled(state, action: PayloadAction) { + const todo = state.items.find((item) => item.id === action.payload); + if (todo) { + todo.completed = !todo.completed; + } + }, + todoDeleted(state, action: PayloadAction) { + state.items = state.items.filter((item) => item.id !== action.payload); + }, + }, +}); + +export const { todoAdded, todoToggled, todoDeleted } = todoSlice.actions; +export const todoReducer = todoSlice.reducer; diff --git a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx index 0d3f017..84ee90d 100644 --- a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx +++ b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.stories.tsx @@ -1,6 +1,5 @@ -import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn, userEvent, within } from 'storybook/test'; +import { expect, fn, userEvent, within } from 'storybook/test'; import { AddTodoForm } from './AddTodoForm'; const meta: Meta = { @@ -10,42 +9,22 @@ const meta: Meta = { layout: 'padded', }, tags: ['autodocs'], - args: { - onAdd: fn(), - }, + args: { onAdd: fn() }, }; export default meta; type Story = StoryObj; -export const Interactive: Story = { - render: (args) => { - const [todos, setTodos] = useState([]); - - const handleAdd = (text: string) => { - args.onAdd(text); // вызовем из args — попадёт в Actions panel - setTodos((prev) => [...prev, text]); - }; - - return ( -
- -
    - {todos.map((t, i) => ( -
  • • {t}
  • - ))} -
-
- ); - }, -}; - -export const Empty: Story = {}; +// Presentational again — onAdd is a prop, the widget owns the dispatch. +export const Default: Story = {}; -export const Filled: Story = { - play: async ({ canvasElement }) => { +export const AddsTodo: Story = { + play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); - const input = canvas.getByPlaceholderText('Add a new todo'); + const input = canvas.getByPlaceholderText('Add a new todo task'); await userEvent.type(input, 'Buy milk'); + await userEvent.click(canvas.getByRole('button', { name: 'Add' })); + await expect(input).toHaveValue(''); // input cleared after submit + await expect(args.onAdd).toHaveBeenCalledWith('Buy milk'); }, }; diff --git a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx index 670509f..4a6494a 100644 --- a/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx +++ b/src/features/add-todo/ui/AddTodoForm/AddTodoForm.tsx @@ -8,6 +8,7 @@ interface AddTodoFormProps { export const AddTodoForm = ({ onAdd }: AddTodoFormProps) => { const [text, setText] = useState(''); const trimmedText = text.trim(); + const handleSubmit = (e: SubmitEvent) => { e.preventDefault(); if (trimmedText === '') return; diff --git a/src/features/filter-todos/index.ts b/src/features/filter-todos/index.ts index 2e248d3..0480035 100644 --- a/src/features/filter-todos/index.ts +++ b/src/features/filter-todos/index.ts @@ -1,2 +1,4 @@ -export { TodoFilter } from './ui/TodoFilter'; +export { filterChanged, filterReducer } from './model/filterSlice'; +export { selectFilteredTodos, selectFilterStatus } from './model/selectors'; export type { FilterStatus } from './model/types'; +export { TodoFilter } from './ui/TodoFilter'; diff --git a/src/features/filter-todos/model/filterSlice.ts b/src/features/filter-todos/model/filterSlice.ts new file mode 100644 index 0000000..5a0bf8c --- /dev/null +++ b/src/features/filter-todos/model/filterSlice.ts @@ -0,0 +1,19 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import type { FilterStatus } from './types'; + +const initialState = { + status: 'all' as FilterStatus, +}; + +const filterSlice = createSlice({ + name: 'filter', + initialState, + reducers: { + filterChanged(state, action: PayloadAction) { + state.status = action.payload; + }, + }, +}); + +export const { filterChanged } = filterSlice.actions; +export const filterReducer = filterSlice.reducer; diff --git a/src/features/filter-todos/model/selectors.ts b/src/features/filter-todos/model/selectors.ts new file mode 100644 index 0000000..8895a66 --- /dev/null +++ b/src/features/filter-todos/model/selectors.ts @@ -0,0 +1,20 @@ +import type { RootState } from '@/app/store'; +import { selectTodos } from '@/entities/todo'; +import { createSelector } from '@reduxjs/toolkit'; + +// Basic selector: reads the filter slice's value out of the whole state. +export const selectFilterStatus = (state: RootState) => state.filter.status; + +export const selectFilteredTodos = createSelector( + [selectFilterStatus, selectTodos], + (filterStatus, todos) => { + switch (filterStatus) { + case 'completed': + return todos.filter((todo) => todo.completed); + case 'active': + return todos.filter((todo) => !todo.completed); + default: + return todos; + } + }, +); diff --git a/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx b/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx index a4bac94..e29ab73 100644 --- a/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx +++ b/src/features/filter-todos/ui/TodoFilter/TodoFilter.stories.tsx @@ -1,54 +1,33 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useArgs } from 'storybook/preview-api'; -import { fn } from 'storybook/test'; -import type { TodoFilterProps } from './TodoFilter'; +import { expect, fn, userEvent, within } from 'storybook/test'; import { TodoFilter } from './TodoFilter'; const meta = { title: 'Todos/TodoFilter', component: TodoFilter, tags: ['autodocs'], - args: { - onChange: fn(), - }, - argTypes: { - value: { - control: 'radio', - options: ['all', 'active', 'completed'], - }, - }, + args: { status: 'all', onChange: fn() }, } satisfies Meta; export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - value: 'all', - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - return ( - { - updateArgs({ value: next }); - args.onChange?.(next); // чтобы Actions panel тоже видел вызов - }} - /> - ); - }, -}; +export const Default: Story = {}; export const ActiveSelected: Story = { - args: { - value: 'active', - }, + args: { status: 'active' }, }; export const CompletedSelected: Story = { - args: { - value: 'completed', + args: { status: 'completed' }, +}; + +// Controlled component: it reports the click, the widget decides the next status. +export const SelectsFilter: Story = { + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const activeBtn = canvas.getByRole('button', { name: 'Active' }); + await userEvent.click(activeBtn); + await expect(args.onChange).toHaveBeenCalledWith('active'); }, }; diff --git a/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx b/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx index ab7694c..d9e62a6 100644 --- a/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx +++ b/src/features/filter-todos/ui/TodoFilter/TodoFilter.tsx @@ -1,21 +1,21 @@ import type { FilterStatus } from '@/features/filter-todos/model/types'; -export interface TodoFilterProps { - value: FilterStatus; - onChange: (filter: FilterStatus) => void; -} - const OPTIONS: { value: FilterStatus; label: string }[] = [ { value: 'all', label: 'All' }, { value: 'active', label: 'Active' }, { value: 'completed', label: 'Completed' }, ]; -export const TodoFilter = ({ value, onChange }: TodoFilterProps) => { +interface TodoFilterProps { + status: FilterStatus; + onChange: (status: FilterStatus) => void; +} + +export const TodoFilter = ({ status, onChange }: TodoFilterProps) => { return (
{OPTIONS.map((option) => { - const isActive = value === option.value; + const isActive = status === option.value; return (