From ac41982f088eaca06625a9c2869573e393d8b0c6 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 31 Dec 2025 02:02:25 +0300 Subject: [PATCH 01/48] feat: initializing the Redux store --- hwproj.front/package-lock.json | 177 +++++++++++++++++++++++--------- hwproj.front/package.json | 2 + hwproj.front/src/store/index.ts | 0 3 files changed, 130 insertions(+), 49 deletions(-) create mode 100644 hwproj.front/src/store/index.ts diff --git a/hwproj.front/package-lock.json b/hwproj.front/package-lock.json index a0882868d..fe2e3c324 100644 --- a/hwproj.front/package-lock.json +++ b/hwproj.front/package-lock.json @@ -20,6 +20,7 @@ "@mui/lab": "^5.0.0-alpha.99", "@mui/material": "^5.16.11", "@mui/x-charts": "^8.2.0", + "@reduxjs/toolkit": "^2.11.2", "@storybook/addon-knobs": "^6.3.0", "@types/bluebird": "^3.5.36", "@types/classnames": "^2.3.1", @@ -52,6 +53,7 @@ "react-drag-drop-files": "^3.1.0", "react-markdown": "^5.0.0", "react-query": "^3.21.1", + "react-redux": "^9.2.0", "react-router-dom": "^6.5.0", "react-social-login-buttons": "^3.5.1", "react-syntax-highlighter": "^15.5.0", @@ -4564,6 +4566,32 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "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/@remix-run/router": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", @@ -4874,6 +4902,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "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-knobs": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@storybook/addon-knobs/-/addon-knobs-6.4.0.tgz", @@ -4977,6 +5017,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.5.16.tgz", "integrity": "sha512-p3DqQi+8QRL5k7jXhXmJZLsE/GqHqyY6PcoA1oNTJr0try48uhTGUOYkgzmqtDaa/qPFO5LP+xCPzZXckGtquQ==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/api": "6.5.16", @@ -5004,12 +5045,14 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/api": { "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/api/-/api-6.5.16.tgz", "integrity": "sha512-HOsuT8iomqeTMQJrRx5U8nsC7lJTwRr1DhdD0SzlqL4c80S/7uuCy4IZvOt4sYQjOzW5fOo/kamcoBXyLproTA==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/channels": "6.5.16", @@ -5043,6 +5086,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/builder-webpack4": { @@ -5441,6 +5485,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-6.5.16.tgz", "integrity": "sha512-VylzaWQZaMozEwZPJdyJoz+0jpDa8GRyaqu9TGG6QGv+KU5POoZaGLDkRE7TzWkyyP0KQLo80K99MssZCpgSeg==", + "dev": true, "license": "MIT", "dependencies": { "core-js": "^3.8.2", @@ -5500,6 +5545,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.5.16.tgz", "integrity": "sha512-pxcNaCj3ItDdicPTXTtmYJE3YC1SjxFrBmHcyrN+nffeNyiMuViJdOOZzzzucTUG0wcOOX8jaSyak+nnHg5H1Q==", + "dev": true, "license": "MIT", "dependencies": { "core-js": "^3.8.2", @@ -5514,6 +5560,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/components/-/components-6.5.16.tgz", "integrity": "sha512-LzBOFJKITLtDcbW9jXl0/PaG+4xAz25PK8JxPZpIALbmOpYWOAPcO6V9C2heX6e6NgWFMUxjplkULEk9RCQMNA==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/client-logger": "6.5.16", @@ -5538,6 +5585,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/core": { @@ -5704,6 +5752,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.5.16.tgz", "integrity": "sha512-qMZQwmvzpH5F2uwNUllTPg6eZXr2OaYZQRRN8VZJiuorZzDNdAFmiVWMWdkThwmyLEJuQKXxqCL8lMj/7PPM+g==", + "dev": true, "license": "MIT", "dependencies": { "core-js": "^3.8.2" @@ -5804,6 +5853,7 @@ "version": "0.0.2--canary.4566f4d.1", "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.0.2--canary.4566f4d.1.tgz", "integrity": "sha512-9OVvMVh3t9znYZwb0Svf/YQoxX2gVOeQTGe2bses2yj+a3+OJnCrUF3/hGv6Em7KujtOdL2LL+JnG49oMVGFgQ==", + "dev": true, "license": "MIT", "dependencies": { "lodash": "^4.17.15" @@ -6276,6 +6326,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/router/-/router-6.5.16.tgz", "integrity": "sha512-ZgeP8a5YV/iuKbv31V8DjPxlV4AzorRiR8OuSt/KqaiYXNXlOoQDz/qMmiNcrshrfLpmkzoq7fSo4T8lWo2UwQ==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/client-logger": "6.5.16", @@ -6297,12 +6348,14 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/semver": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz", "integrity": "sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg==", + "dev": true, "license": "ISC", "dependencies": { "core-js": "^3.6.5", @@ -6319,6 +6372,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -6332,6 +6386,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -6344,6 +6399,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -6359,6 +6415,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -6442,6 +6499,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-6.5.16.tgz", "integrity": "sha512-hNLctkjaYLRdk1+xYTkC1mg4dYz2wSv6SqbLpcKMbkPHTE0ElhddGPHQqB362md/w9emYXNkt1LSMD8Xk9JzVQ==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/client-logger": "6.5.16", @@ -6462,6 +6520,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/ui": { @@ -7088,6 +7147,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/is-function/-/is-function-1.0.3.tgz", "integrity": "sha512-/CLhCW79JUeLKznI6mbVieGbl4QU5Hfn+6udw1YHZoofASjbQ5zaP5LzAUZYDpRYEjS4/P+DhEgyJ/PQmGGTWw==", + "dev": true, "license": "MIT" }, "node_modules/@types/isomorphic-fetch": { @@ -7437,6 +7497,12 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "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/@types/webpack": { "version": "4.41.40", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.40.tgz", @@ -7456,6 +7522,7 @@ "version": "1.18.8", "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.8.tgz", "integrity": "sha512-G9eAoJRMLjcvN4I08wB5I7YofOb/kaJNd5uoCMX+LbKXTPCF+ZIHuqTnFaK9Jz1rgs035f9JUPUhNFtqgucy/A==", + "dev": true, "license": "MIT" }, "node_modules/@types/webpack-sources": { @@ -17528,6 +17595,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -18027,6 +18104,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", + "dev": true, "license": "MIT" }, "node_modules/is-generator-function": { @@ -18149,6 +18227,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -18226,6 +18305,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -18697,13 +18777,6 @@ "node": ">= 10.13.0" } }, - "node_modules/jquery": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", - "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", - "license": "MIT", - "peer": true - }, "node_modules/js-sha3": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", @@ -19343,6 +19416,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==", + "dev": true, "license": "MIT" }, "node_modules/map-visit": { @@ -20535,6 +20609,7 @@ "version": "1.11.3", "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==", + "dev": true, "license": "MIT", "dependencies": { "map-or-similar": "^1.5.0" @@ -20591,21 +20666,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -22516,6 +22576,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -22718,6 +22779,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -22870,18 +22932,6 @@ "node": ">=6" } }, - "node_modules/popper.js": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", - "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", - "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/portable-fetch": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/portable-fetch/-/portable-fetch-3.0.0.tgz", @@ -23769,6 +23819,29 @@ } } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+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/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -24076,6 +24149,21 @@ "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/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -27176,6 +27264,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -28052,6 +28141,7 @@ "version": "2.14.4", "resolved": "https://registry.npmjs.org/store2/-/store2-2.14.4.tgz", "integrity": "sha512-srTItn1GOvyvOycgxjAnPA63FZNwy0PTyUBFMHRM+hVFltAeoh0LmNBz9SZqUS9mMqGk8rfyWyXn3GH5ReJ8Zw==", + "dev": true, "license": "MIT" }, "node_modules/stream-browserify": { @@ -28685,6 +28775,7 @@ "version": "6.0.8", "resolved": "https://registry.npmjs.org/telejson/-/telejson-6.0.8.tgz", "integrity": "sha512-nerNXi+j8NK1QEfBHtZUN/aLdDcyupA//9kAboYLrtzZlPLpUfqbVGWb9zz91f/mIjRbAYhbgtnJHY8I1b5MBg==", + "dev": true, "license": "MIT", "dependencies": { "@types/is-function": "^1.0.0", @@ -28701,6 +28792,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -29222,6 +29314,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.10" @@ -30114,6 +30207,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/util.promisify": { @@ -31969,21 +32063,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/hwproj.front/package.json b/hwproj.front/package.json index 0ddb05582..9698a7bd2 100644 --- a/hwproj.front/package.json +++ b/hwproj.front/package.json @@ -16,6 +16,7 @@ "@mui/lab": "^5.0.0-alpha.99", "@mui/material": "^5.16.11", "@mui/x-charts": "^8.2.0", + "@reduxjs/toolkit": "^2.11.2", "@storybook/addon-knobs": "^6.3.0", "@types/bluebird": "^3.5.36", "@types/classnames": "^2.3.1", @@ -48,6 +49,7 @@ "react-drag-drop-files": "^3.1.0", "react-markdown": "^5.0.0", "react-query": "^3.21.1", + "react-redux": "^9.2.0", "react-router-dom": "^6.5.0", "react-social-login-buttons": "^3.5.1", "react-syntax-highlighter": "^15.5.0", diff --git a/hwproj.front/src/store/index.ts b/hwproj.front/src/store/index.ts new file mode 100644 index 000000000..e69de29bb From 75ab88fdf1ac85fbb558e8116a030bb4b2c5a452 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 31 Dec 2025 02:33:22 +0300 Subject: [PATCH 02/48] feat: add index.ts and hooks.ts --- hwproj.front/src/store/hooks.ts | 5 +++++ hwproj.front/src/store/index.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) create mode 100644 hwproj.front/src/store/hooks.ts diff --git a/hwproj.front/src/store/hooks.ts b/hwproj.front/src/store/hooks.ts new file mode 100644 index 000000000..753d94ac6 --- /dev/null +++ b/hwproj.front/src/store/hooks.ts @@ -0,0 +1,5 @@ +import {useDispatch, useSelector} from 'react-redux'; +import type {RootState, AppDispatch} from './index'; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); \ No newline at end of file diff --git a/hwproj.front/src/store/index.ts b/hwproj.front/src/store/index.ts index e69de29bb..1e6f81be3 100644 --- a/hwproj.front/src/store/index.ts +++ b/hwproj.front/src/store/index.ts @@ -0,0 +1,10 @@ +import {configureStore} from '@reduxjs/toolkit'; + +export const store = configureStore({ + reducer: { + + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; \ No newline at end of file From fcc37725f3c0664cbccca5b6d6877f56acc4b2e5 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 31 Dec 2025 07:29:53 +0300 Subject: [PATCH 03/48] feat: upd store --- hwproj.front/src/store/index.ts | 5 ++++- hwproj.front/src/store/slices/courseSlice.ts | 0 hwproj.front/src/store/slices/homeworkSlice.ts | 0 3 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 hwproj.front/src/store/slices/courseSlice.ts create mode 100644 hwproj.front/src/store/slices/homeworkSlice.ts diff --git a/hwproj.front/src/store/index.ts b/hwproj.front/src/store/index.ts index 1e6f81be3..6caf9112b 100644 --- a/hwproj.front/src/store/index.ts +++ b/hwproj.front/src/store/index.ts @@ -1,8 +1,11 @@ import {configureStore} from '@reduxjs/toolkit'; +import courseReducer from './slices/courseSlice'; +import homeworkReducer from './slices/homeworkSlice'; export const store = configureStore({ reducer: { - + course: courseReducer, + homework: homeworkReducer, }, }); diff --git a/hwproj.front/src/store/slices/courseSlice.ts b/hwproj.front/src/store/slices/courseSlice.ts new file mode 100644 index 000000000..e69de29bb diff --git a/hwproj.front/src/store/slices/homeworkSlice.ts b/hwproj.front/src/store/slices/homeworkSlice.ts new file mode 100644 index 000000000..e69de29bb From 37e0b7728963447cd8acfd77c413d461cffb8dd2 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Wed, 31 Dec 2025 09:07:22 +0300 Subject: [PATCH 04/48] feat: upd store --- hwproj.front/src/store/slices/courseSlice.ts | 69 ++++++++++++++++ .../src/store/slices/homeworkSlice.ts | 81 +++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/hwproj.front/src/store/slices/courseSlice.ts b/hwproj.front/src/store/slices/courseSlice.ts index e69de29bb..66533f841 100644 --- a/hwproj.front/src/store/slices/courseSlice.ts +++ b/hwproj.front/src/store/slices/courseSlice.ts @@ -0,0 +1,69 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import {AccountDataDto, CourseViewModel} from '@/api' + + +interface CourseState { + isFound: boolean; + isLoading: boolean; + course: CourseViewModel | null; + mentors: AccountDataDto[]; + acceptedStudents: AccountDataDto[]; + newStudents: AccountDataDto[]; +} + +const initialState: CourseState = { + isFound: false, + isLoading: false, + course: null, + mentors: [], + acceptedStudents: [], + newStudents: [], +}; + +const courseSlice = createSlice({ + name: 'course', + initialState, + reducers: { + setCourse(state, action: PayloadAction) { + state.course = action.payload; + state.isFound = true; + state.isLoading = false; + }, + + setMentors(state, action: PayloadAction) { + state.mentors = action.payload; + }, + + setAcceptedStudents(state, action: PayloadAction) { + state.acceptedStudents = action.payload + }, + + setNewStudents(state, action: PayloadAction) { + state.newStudents = action.payload + }, + + setLoading(state, action: PayloadAction) { + state.isLoading = action.payload; + }, + + resetCourse(state) { + state.course = null; + state.isFound = false; + state.isLoading = false; + state.mentors = []; + state.acceptedStudents = []; + state.newStudents = []; + }, + }, +}); + +export const { + setCourse, + setMentors, + setAcceptedStudents, + setNewStudents, + setLoading, + resetCourse +} = courseSlice.actions; + +export default courseSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/homeworkSlice.ts b/hwproj.front/src/store/slices/homeworkSlice.ts index e69de29bb..171ea855b 100644 --- a/hwproj.front/src/store/slices/homeworkSlice.ts +++ b/hwproj.front/src/store/slices/homeworkSlice.ts @@ -0,0 +1,81 @@ +import {createSlice, PayloadAction } from '@reduxjs/toolkit'; +import {HomeworkViewModel, HomeworkTaskViewModel } from '@/api'; + +interface HomeworkState { + homeworks: HomeworkViewModel[]; + isLoading: boolean +} + +const initialState: HomeworkState = { + homeworks: [], + isLoading: false, +} + +const homeworkSlice = createSlice({ + name: 'homework', + initialState, + reducers: { + setHomeworks(state, action: PayloadAction) { + state.homeworks = action.payload; + state.isLoading = false; + }, + + addHomework(state, action: PayloadAction) { + state.homeworks.push(action.payload); + }, + + updateHomework(state, action: PayloadAction) { + const index = state.homeworks.findIndex(hw => hw.id === action.payload.id); + if (index !== -1) { + state.homeworks[index] = action.payload; + } + }, + + deleteHomework(state, action: PayloadAction) { + state.homeworks = state.homeworks.filter(hw => hw.id !== action.payload); + }, + + updateTask(state, action: PayloadAction) { + const task = action.payload; + const homework = state.homeworks.find(hw => hw.id === task.homeworkId); + if (homework && homework.tasks) { + const taskIndex = homework.tasks.findIndex(t => t.id === task.id); + if (taskIndex !== -1) { + homework.tasks[taskIndex] = task; + } + else { + homework.tasks.push(task); + } + } + }, + + deleteTask(state, action: PayloadAction<{homeworkId: number, taskId: number}>) { + const homework = state.homeworks.find(hw => hw.id === action.payload.homeworkId); + if (homework && homework.tasks) { + homework.tasks = homework.tasks.filter(t => t.id !== action.payload.taskId); + } + }, + + setHomeworkLoading(state, action: PayloadAction) { + state.isLoading = action.payload; + }, + + resetHomeworks(state) { + state.homeworks = []; + state.isLoading = false; + }, + }, +}); + +export const { + setHomeworks, + addHomework, + updateHomework, + deleteHomework, + updateTask, + deleteTask, + setHomeworkLoading, + resetHomeworks, +} = homeworkSlice.actions; + +export default homeworkSlice.reducer; \ No newline at end of file From 21c7785eb7aebd870edd6d4b89cd98e338ed21da Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sun, 4 Jan 2026 02:16:12 +0300 Subject: [PATCH 05/48] feat: connect the Redux Provider --- hwproj.front/src/index.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/hwproj.front/src/index.tsx b/hwproj.front/src/index.tsx index f63d3e5db..3e813aefa 100644 --- a/hwproj.front/src/index.tsx +++ b/hwproj.front/src/index.tsx @@ -8,6 +8,8 @@ import {BrowserRouter} from "react-router-dom"; import ThemeProvider from "@material-ui/styles/ThemeProvider"; import {createTheme} from "@material-ui/core/styles"; import {SnackbarProvider} from "notistack"; +import { Provider } from "react-redux"; +import { store } from "./store"; const theme = createTheme({ typography: { @@ -22,13 +24,15 @@ const theme = createTheme({ }); ReactDOM.render( - - - - - - - , + + + + + + + + + , document.getElementById("root") ); From f4eb53af6aaaa6ff9f0fa0b31eb247bb4f7db9a3 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sun, 4 Jan 2026 05:19:35 +0300 Subject: [PATCH 06/48] refactor: replaced useState with useAppSelector/dispatch in Course.tsx --- .../src/components/Courses/Course.tsx | 92 ++++++------------- 1 file changed, 30 insertions(+), 62 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index c2309e0fe..220c0ce13 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -40,6 +40,9 @@ import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {CourseUnitType} from "../Files/CourseUnitType"; import {FileStatus} from "../Files/FileStatus"; +import { useAppDispatch, useAppSelector } from "@/store/hooks"; +import { setCourse, setMentors, setAcceptedStudents, setNewStudents } from "@/store/slices/courseSlice"; +import { setHomeworks } from "@/store/slices/homeworkSlice"; type TabValue = "homeworks" | "stats" | "applications" @@ -47,17 +50,6 @@ function isAcceptableTabValue(str: string): str is TabValue { return str === "homeworks" || str === "stats" || str === "applications"; } -interface ICourseState { - isFound: boolean; - course: CourseViewModel; - courseHomeworks: HomeworkViewModel[]; - mentors: AccountDataDto[]; - acceptedStudents: AccountDataDto[]; - newStudents: AccountDataDto[]; - studentSolutions: StatisticsCourseMatesModel[]; - showQrCode: boolean; -} - interface ICourseFilesState { processingFilesState: { [homeworkId: number]: { @@ -78,16 +70,15 @@ const Course: React.FC = () => { const navigate = useNavigate() const {enqueueSnackbar} = useSnackbar() - const [courseState, setCourseState] = useState({ - isFound: false, - course: {}, - courseHomeworks: [], - mentors: [], - acceptedStudents: [], - newStudents: [], - studentSolutions: [], - showQrCode: false - }) + const dispatch = useAppDispatch(); + const course = useAppSelector(state => state.course.course); + const isFound = useAppSelector(state => state.course.isFound); + const mentors = useAppSelector(state => state.course.mentors); + const acceptedStudents = useAppSelector(state => state.course.acceptedStudents); + const newStudents = useAppSelector(state => state.course.newStudents); + const courseHomeworks = useAppSelector(state => state.homework.homeworks); + const [showQrCode, setShowQrCode] = useState(false); + const [studentSolutions, setStudentSolutions] = useState(undefined) const [courseFilesState, setCourseFilesState] = useState({ processingFilesState: {}, @@ -223,15 +214,6 @@ const Course: React.FC = () => { tabValue: "homeworks" }) - const { - isFound, - course, - mentors, - newStudents, - acceptedStudents, - courseHomeworks, - } = courseState - const userId = ApiSingleton.authService.getUserId() const isLecturer = ApiSingleton.authService.isLecturer() @@ -272,16 +254,11 @@ const Course: React.FC = () => { return } - setCourseState(prevState => ({ - ...prevState, - isFound: true, - course: course, - courseHomeworks: course.homeworks!, - createHomework: false, - mentors: course.mentors!, - acceptedStudents: course.acceptedStudents!, - newStudents: course.newStudents!, - })) + dispatch(setCourse(course)); + dispatch(setMentors(course.mentors!)); + dispatch(setAcceptedStudents(course.acceptedStudents!)); + dispatch(setNewStudents(course.newStudents!)); + dispatch(setHomeworks(course.homeworks!)); } const getCourseFilesInfo = async () => { @@ -367,10 +344,7 @@ const Course: React.FC = () => { Управление } - setCourseState(prevState => ({ - ...prevState, - showQrCode: true - }))}> + setShowQrCode(true)}> @@ -391,8 +365,8 @@ const Course: React.FC = () => { return (
setCourseState(prevState => ({...prevState, showQrCode: false}))} + open={showQrCode} + onClose={() => setShowQrCode(false)} > Поделитесь ссылкой на курс с помощью QR-кода @@ -407,7 +381,7 @@ const Course: React.FC = () => { - {course.isCompleted && + {course?.isCompleted && Курс завершен! {isAcceptedStudent @@ -422,7 +396,7 @@ const Course: React.FC = () => { - {NameBuilder.getCourseFullName(course.name!, course.groupName)} + {NameBuilder.getCourseFullName(course?.name || "", course?.groupName || "")} @@ -499,21 +473,18 @@ const Course: React.FC = () => { processingFiles={courseFilesState.processingFilesState} onStartProcessing={getFilesByInterval} onHomeworkUpdate={({fileInfos, homework, isDeleted}) => { - const homeworkIndex = courseState.courseHomeworks.findIndex(x => x.id === homework.id) - const homeworks = courseState.courseHomeworks + const homeworkIndex = courseHomeworks.findIndex(x => x.id === homework.id) + const homeworks = [...courseHomeworks] if (isDeleted) homeworks.splice(homeworkIndex, 1) else if (homeworkIndex === -1) homeworks.push(homework) else homeworks[homeworkIndex] = homework - setCourseState(prevState => ({ - ...prevState, - courseHomeworks: homeworks - })) + dispatch(setHomeworks(homeworks)); }} onTaskUpdate={update => { const task = update.task - const homeworks = courseState.courseHomeworks + const homeworks = [...courseHomeworks] const homework = homeworks.find(x => x.id === task.homeworkId)! const tasks = [...homework.tasks!] const taskIndex = tasks.findIndex(x => x!.id === task.id) @@ -524,10 +495,7 @@ const Course: React.FC = () => { homework.tasks = tasks - setCourseState(prevState => ({ - ...prevState, - courseHomeworks: homeworks - })) + dispatch(setHomeworks(homeworks)); }} /> } @@ -538,7 +506,7 @@ const Course: React.FC = () => { homeworks={courseHomeworks} userId={userId as string} isMentor={isCourseMentor} - course={courseState.course} + course={course!} solutions={studentSolutions} /> @@ -546,8 +514,8 @@ const Course: React.FC = () => { {tabValue === "applications" && showApplicationsTab && setCurrentState()} - course={courseState.course} - students={courseState.newStudents} + course={course!} + students={newStudents} courseId={courseId!} /> } From 8b6225a75f1530e296d8f3601c18db3333c95af7 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sun, 4 Jan 2026 06:30:36 +0300 Subject: [PATCH 07/48] feat: integrate Redux for Course/CourseExperimental --- .../src/components/Courses/Course.tsx | 26 ---------- .../components/Courses/CourseExperimental.tsx | 51 +++++++++++++++---- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 220c0ce13..168b93c2a 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -463,7 +463,6 @@ const Course: React.FC = () => { {tabValue === "homeworks" && { userId={userId!} processingFiles={courseFilesState.processingFilesState} onStartProcessing={getFilesByInterval} - onHomeworkUpdate={({fileInfos, homework, isDeleted}) => { - const homeworkIndex = courseHomeworks.findIndex(x => x.id === homework.id) - const homeworks = [...courseHomeworks] - - if (isDeleted) homeworks.splice(homeworkIndex, 1) - else if (homeworkIndex === -1) homeworks.push(homework) - else homeworks[homeworkIndex] = homework - - dispatch(setHomeworks(homeworks)); - }} - onTaskUpdate={update => { - const task = update.task - const homeworks = [...courseHomeworks] - const homework = homeworks.find(x => x.id === task.homeworkId)! - const tasks = [...homework.tasks!] - const taskIndex = tasks.findIndex(x => x!.id === task.id) - - if (update.isDeleted) tasks.splice(taskIndex, 1) - else if (taskIndex !== -1) tasks![taskIndex] = task - else tasks.push(task) - - homework.tasks = tasks - - dispatch(setHomeworks(homeworks)); - }} /> } {tabValue === "stats" && diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index b5d1819fe..7b3a011fc 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -4,6 +4,8 @@ import { HomeworkTaskViewModel, HomeworkViewModel, Solution, StatisticsCourseMatesModel, } from "@/api"; +import { useAppDispatch, useAppSelector } from "@/store/hooks"; +import {setHomeworks} from "@/store/slices/homeworkSlice"; import { AlertTitle, Button, @@ -37,7 +39,6 @@ import SwitchAccessShortcutIcon from '@mui/icons-material/SwitchAccessShortcut'; import Lodash from "lodash"; interface ICourseExperimentalProps { - homeworks: HomeworkViewModel[] courseFilesInfo: FileInfoDTO[] studentSolutions: StatisticsCourseMatesModel[] courseId: number @@ -45,10 +46,6 @@ interface ICourseExperimentalProps { isStudentAccepted: boolean userId: string selectedHomeworkId: number | undefined - onHomeworkUpdate: (update: { homework: HomeworkViewModel, fileInfos: FileInfoDTO[] | undefined } & { - isDeleted?: boolean - }) => void - onTaskUpdate: (update: { task: HomeworkTaskViewModel, isDeleted?: boolean }) => void, processingFiles: { [homeworkId: number]: { isLoading: boolean; @@ -66,6 +63,8 @@ interface ICourseExperimentalState { } export const CourseExperimental: FC = (props) => { + const dispatch = useAppDispatch() + const allHomeworks = useAppSelector(state => state.homework.homeworks) const [hideDeferred, setHideDeferred] = useState(false) const [showOnlyGroupedTest, setShowOnlyGroupedTest] = useState(undefined) const filterAdded = hideDeferred || showOnlyGroupedTest !== undefined @@ -77,7 +76,7 @@ export const CourseExperimental: FC = (props) => { // Состояние для кнопки "Наверх" const [showScrollButton, setShowScrollButton] = useState(false); - const homeworks = props.homeworks.slice().reverse().filter(x => { + const homeworks = allHomeworks.slice().reverse().filter(x => { if (hideDeferred) return !x.isDeferred if (showOnlyGroupedTest !== undefined) return x.tags!.includes(TestTag) && x.tags!.includes(showOnlyGroupedTest) return true @@ -340,8 +339,40 @@ export const CourseExperimental: FC = (props) => { const [newTaskCounter, setNewTaskCounter] = useState(-1) + const handleHomeworkUpdate = (update: { homework: HomeworkViewModel, fileInfos?: FileInfoDTO[], isDeleted?: boolean }) => { + const { homework, isDeleted } = update; + const homeworkIndex = allHomeworks.findIndex(h => h.id === homework.id); + const newHomeworks = [...allHomeworks]; + + if (isDeleted) newHomeworks.splice(homeworkIndex, 1); + else if (homeworkIndex === -1) newHomeworks.push(homework); + else newHomeworks[homeworkIndex] = homework; + + dispatch(setHomeworks(newHomeworks)) + }; + + const handleTaskUpdate = (update: { task: HomeworkTaskViewModel, isDeleted?: boolean }) => { + const { task, isDeleted } = update; + const homeworkIndex = allHomeworks.findIndex(h => h.id === task.homeworkId); + + if (homeworkIndex === -1) return; + + const homework = allHomeworks[homeworkIndex]; + const tasks = [...(homework.tasks || [])]; + const taskIndex = tasks.findIndex(t => t?.id === task.id); + + if (isDeleted) tasks.splice(taskIndex, 1); + else if (taskIndex === -1) tasks.push(task); + else tasks[taskIndex] = task; + + const updatedHomework = { ...homework, tasks }; + const newHomeworks = [...allHomeworks]; + newHomeworks[homeworkIndex] = updatedHomework; + dispatch(setHomeworks(newHomeworks)) + } + const addNewHomework = () => { - props.onHomeworkUpdate({ + handleHomeworkUpdate({ homework: { courseId: props.courseId, title: "Новое задание", @@ -398,7 +429,7 @@ export const CourseExperimental: FC = (props) => { id } - props.onTaskUpdate({task}) + handleTaskUpdate({task}) setState((prevState) => ({ ...prevState, selectedItem: { @@ -425,7 +456,7 @@ export const CourseExperimental: FC = (props) => { onMount={onSelectedItemMount} onAddTask={addNewTask} onUpdate={update => { - props.onHomeworkUpdate(update) + handleHomeworkUpdate(update) setState((prevState) => ({ ...prevState, selectedItem: { @@ -454,7 +485,7 @@ export const CourseExperimental: FC = (props) => { initialEditMode={initialEditMode || taskEditMode} onMount={onSelectedItemMount} onUpdate={update => { - props.onTaskUpdate(update) + handleTaskUpdate(update) if (update.isDeleted) setState((prevState) => ({ ...prevState, From e8154586d687c0e58418589674fea507724f7fbb Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sun, 4 Jan 2026 14:52:14 +0300 Subject: [PATCH 08/48] feat: add courseFileSlice.ts and solutionSlice.ts with update index.ts --- hwproj.front/src/store/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hwproj.front/src/store/index.ts b/hwproj.front/src/store/index.ts index 6caf9112b..1ee9758cf 100644 --- a/hwproj.front/src/store/index.ts +++ b/hwproj.front/src/store/index.ts @@ -1,11 +1,15 @@ import {configureStore} from '@reduxjs/toolkit'; import courseReducer from './slices/courseSlice'; import homeworkReducer from './slices/homeworkSlice'; +import solutionsReducer from './slices/solutionSlice'; +import courseFilesReducer from './slices/courseFileSlice'; export const store = configureStore({ reducer: { course: courseReducer, homework: homeworkReducer, + solutions: solutionsReducer, + courseFiles: courseFilesReducer, }, }); From 8e6bc5403df28b4562ea9141c412b37f305d198e Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sun, 4 Jan 2026 15:01:54 +0300 Subject: [PATCH 09/48] feat: solutionSlice and courseFileSlice integrate into Course.tsx --- .../src/components/Courses/Course.tsx | 73 +++++-------------- 1 file changed, 20 insertions(+), 53 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 168b93c2a..338abbe84 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -43,6 +43,8 @@ import {FileStatus} from "../Files/FileStatus"; import { useAppDispatch, useAppSelector } from "@/store/hooks"; import { setCourse, setMentors, setAcceptedStudents, setNewStudents } from "@/store/slices/courseSlice"; import { setHomeworks } from "@/store/slices/homeworkSlice"; +import { setStudentSolutions } from "@/store/slices/solutionSlice"; +import { setCourseFiles, updateCourseFiles, setProcessingLoading } from "@/store/slices/courseFileSlice"; type TabValue = "homeworks" | "stats" | "applications" @@ -50,16 +52,6 @@ function isAcceptableTabValue(str: string): str is TabValue { return str === "homeworks" || str === "stats" || str === "applications"; } -interface ICourseFilesState { - processingFilesState: { - [homeworkId: number]: { - isLoading: boolean; - intervalId?: NodeJS.Timeout; - }; - }; - courseFiles: FileInfoDTO[]; -} - interface IPageState { tabValue: TabValue } @@ -77,45 +69,23 @@ const Course: React.FC = () => { const acceptedStudents = useAppSelector(state => state.course.acceptedStudents); const newStudents = useAppSelector(state => state.course.newStudents); const courseHomeworks = useAppSelector(state => state.homework.homeworks); + const studentSolutions = useAppSelector(state => state.solutions.studentSolutions); + const courseFiles = useAppSelector(state => state.courseFiles.courseFiles); + const processingFilesState = useAppSelector(state => state.courseFiles.processingFilesState); const [showQrCode, setShowQrCode] = useState(false); - const [studentSolutions, setStudentSolutions] = useState(undefined) - const [courseFilesState, setCourseFilesState] = useState({ - processingFilesState: {}, - courseFiles: [] - }) - const intervalsRef = React.useRef>({}); - const updateCourseFiles = (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { - setCourseFilesState(prev => ({ - ...prev, - courseFiles: [ - ...prev.courseFiles.filter( - f => !(f.courseUnitType === unitType && f.courseUnitId === unitId)), - ...files - ] - })); + const handleUpdateCourseFiles = (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { + dispatch(updateCourseFiles({ files, unitType, unitId })); }; const setCommonLoading = (homeworkId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [homeworkId]: {isLoading: true} - } - })); + dispatch(setProcessingLoading({ homeworkId, isLoading: true })); } const unsetCommonLoading = (homeworkId: number) => { - setCourseFilesState(prev => ({ - ...prev, - processingFilesState: { - ...prev.processingFilesState, - [homeworkId]: {isLoading: false} - } - })); + dispatch(setProcessingLoading({ homeworkId, isLoading: false })); } const stopProcessing = (homeworkId: number) => { @@ -160,14 +130,14 @@ const Course: React.FC = () => { // Первый вариант для явного отображения всех файлов if (waitingNewFilesCount === 0 && files.filter(f => f.status === FileStatus.ReadyToUse).length === previouslyExistingFilesCount - deletingFilesIds.length) { - updateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) + handleUpdateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) unsetCommonLoading(homeworkId) } // Второй вариант для явного отображения всех файлов if (waitingNewFilesCount > 0 && files.filter(f => !deletingFilesIds.some(dfi => dfi === f.id)).length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount) { - updateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) + handleUpdateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) unsetCommonLoading(homeworkId) } @@ -266,15 +236,12 @@ const Course: React.FC = () => { try { courseFilesInfo = isCourseMentor ? await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!) - : await ApiSingleton.filesApi.filesGetUploadedFilesInfo(+courseId!) + : await ApiSingleton.filesApi.filesGetUploadedFilesInfo(+courseId!); } catch (e) { const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); } - setCourseFilesState(prevState => ({ - ...prevState, - courseFiles: courseFilesInfo - })) + dispatch(setCourseFiles(courseFilesInfo)); } useEffect(() => { @@ -287,7 +254,7 @@ const Course: React.FC = () => { useEffect(() => { ApiSingleton.statisticsApi.statisticsGetCourseStatistics(+courseId!) - .then(res => setStudentSolutions(res)) + .then(res => dispatch(setStudentSolutions(res))) }, [courseId]) useEffect(() => changeTab(tab || "homeworks"), [tab, courseId, isFound]) @@ -300,7 +267,7 @@ const Course: React.FC = () => { const {tabValue} = pageState const searchedHomeworkId = searchParams.get("homeworkId") - const unratedSolutionsCount = (studentSolutions || []) + const unratedSolutionsCount = studentSolutions .flatMap(x => x.homeworks) .flatMap(x => x!.tasks) .filter(t => t!.solution!.slice(-1)[0]?.state === 0) //last solution @@ -463,13 +430,13 @@ const Course: React.FC = () => { {tabValue === "homeworks" && } @@ -481,7 +448,7 @@ const Course: React.FC = () => { userId={userId as string} isMentor={isCourseMentor} course={course!} - solutions={studentSolutions} + solutions={studentSolutions.length > 0 ? studentSolutions : undefined} /> } @@ -506,4 +473,4 @@ const Course: React.FC = () => {
} -export default Course +export default Course \ No newline at end of file From c895c10aad60a6196d07f91267ea9800b7fb3d60 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sun, 4 Jan 2026 17:49:37 +0300 Subject: [PATCH 10/48] feat: upd slices in store --- hwproj.front/src/store/index.ts | 2 + hwproj.front/src/store/slices/authSlice.ts | 34 ++++++++++++ .../src/store/slices/courseFileSlice.ts | 52 +++++++++++++++++++ .../src/store/slices/solutionSlice.ts | 31 +++++++++++ 4 files changed, 119 insertions(+) create mode 100644 hwproj.front/src/store/slices/authSlice.ts create mode 100644 hwproj.front/src/store/slices/courseFileSlice.ts create mode 100644 hwproj.front/src/store/slices/solutionSlice.ts diff --git a/hwproj.front/src/store/index.ts b/hwproj.front/src/store/index.ts index 1ee9758cf..9a70c695c 100644 --- a/hwproj.front/src/store/index.ts +++ b/hwproj.front/src/store/index.ts @@ -3,6 +3,7 @@ import courseReducer from './slices/courseSlice'; import homeworkReducer from './slices/homeworkSlice'; import solutionsReducer from './slices/solutionSlice'; import courseFilesReducer from './slices/courseFileSlice'; +import authReducer from './slices/authSlice'; export const store = configureStore({ reducer: { @@ -10,6 +11,7 @@ export const store = configureStore({ homework: homeworkReducer, solutions: solutionsReducer, courseFiles: courseFilesReducer, + auth: authReducer, }, }); diff --git a/hwproj.front/src/store/slices/authSlice.ts b/hwproj.front/src/store/slices/authSlice.ts new file mode 100644 index 000000000..448dba5d7 --- /dev/null +++ b/hwproj.front/src/store/slices/authSlice.ts @@ -0,0 +1,34 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface AuthState { + userId: string | null; + isLecturer: boolean; + isExpert: boolean; +} + +const initialState: AuthState = { + userId: null, + isLecturer: false, + isExpert: false, +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setAuth: (state, action: PayloadAction) => { + state.userId = action.payload.userId; + state.isLecturer = action.payload.isLecturer; + state.isExpert = action.payload.isExpert; + }, + + clearAuth: (state) => { + state.userId = null; + state.isLecturer = false; + state.isExpert = false; + }, + }, +}); + +export const { setAuth, clearAuth } = authSlice.actions; +export default authSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/courseFileSlice.ts b/hwproj.front/src/store/slices/courseFileSlice.ts new file mode 100644 index 000000000..c12e54fd0 --- /dev/null +++ b/hwproj.front/src/store/slices/courseFileSlice.ts @@ -0,0 +1,52 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { FileInfoDTO } from "@/api"; +import { CourseUnitType } from "@/components/Files/CourseUnitType"; + +interface ProcessingState { + isLoading: boolean; +} + +interface CourseFilesState { + courseFiles: FileInfoDTO[]; + processingFilesState: Record; +} + +const initialState: CourseFilesState = { + courseFiles: [], + processingFilesState: {}, +} + +const courseFilesSlice = createSlice({ + name: "courseFiles", + initialState, + reducers: { + setCourseFiles(state, action: PayloadAction) { + state.courseFiles = action.payload; + }, + + updateCourseFiles(state, action: PayloadAction<{ + files: FileInfoDTO[]; + unitType: CourseUnitType; + unitId: number; + }>) { + const { files, unitType, unitId } = action.payload; + state.courseFiles = [ + ...state.courseFiles.filter(f => !(f.courseUnitType === unitType && f.courseUnitId === unitId)), + ...files + ]; + }, + + setProcessingLoading(state, action: PayloadAction<{ homeworkId: number; isLoading: boolean }>) { + const { homeworkId, isLoading } = action.payload; + state.processingFilesState[homeworkId] = { isLoading }; + }, + + clearCourseFiles(state) { + state.courseFiles = []; + state.processingFilesState = {}; + }, + }, +}) + +export const { setCourseFiles, updateCourseFiles, setProcessingLoading, clearCourseFiles } = courseFilesSlice.actions; +export default courseFilesSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/solutionSlice.ts b/hwproj.front/src/store/slices/solutionSlice.ts new file mode 100644 index 000000000..8c95ba730 --- /dev/null +++ b/hwproj.front/src/store/slices/solutionSlice.ts @@ -0,0 +1,31 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { StatisticsCourseMatesModel } from '@/api'; + +interface SolutionState { + studentSolutions: StatisticsCourseMatesModel[]; + isLoaded: boolean; +} + +const initialState: SolutionState = { + studentSolutions: [], + isLoaded: false, +}; + +const solutionSlice = createSlice({ + name: "solution", + initialState, + reducers: { + setStudentSolutions(state, action: PayloadAction) { + state.studentSolutions = action.payload; + state.isLoaded = true; + }, + + clearStudentSolutions(state) { + state.studentSolutions = []; + state.isLoaded = false; + }, + }, +}); + +export const { setStudentSolutions, clearStudentSolutions } = solutionSlice.actions; +export default solutionSlice.reducer; \ No newline at end of file From 940ddc8474403daee3bbaa545de83c07f502d8af Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sun, 4 Jan 2026 17:51:47 +0300 Subject: [PATCH 11/48] refactor: add-on for Redux integration --- .../src/components/Courses/Course.tsx | 21 +++++----- .../components/Courses/CourseExperimental.tsx | 42 ++++++++++--------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 338abbe84..def40d355 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -45,6 +45,7 @@ import { setCourse, setMentors, setAcceptedStudents, setNewStudents } from "@/st import { setHomeworks } from "@/store/slices/homeworkSlice"; import { setStudentSolutions } from "@/store/slices/solutionSlice"; import { setCourseFiles, updateCourseFiles, setProcessingLoading } from "@/store/slices/courseFileSlice"; +import { setAuth } from "@/store/slices/authSlice"; type TabValue = "homeworks" | "stats" | "applications" @@ -184,10 +185,16 @@ const Course: React.FC = () => { tabValue: "homeworks" }) - const userId = ApiSingleton.authService.getUserId() + useEffect(() => { + const userId = ApiSingleton.authService.getUserId(); + const isLecturer = ApiSingleton.authService.isLecturer(); + const isExpert = ApiSingleton.authService.isExpert(); + dispatch(setAuth({ userId, isLecturer, isExpert })) + }, []) - const isLecturer = ApiSingleton.authService.isLecturer() - const isExpert = ApiSingleton.authService.isExpert() + const userId = useAppSelector(state => state.auth.userId); + const isLecturer = useAppSelector(state => state.auth.isLecturer); + const isExpert = useAppSelector(state => state.auth.isExpert); const isMentor = isLecturer || isExpert const isCourseMentor = mentors.some(t => t.userId === userId) const isSignedInCourse = newStudents!.some(cm => cm.userId === userId) @@ -429,14 +436,6 @@ const Course: React.FC = () => { }/>} {tabValue === "homeworks" && } diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index 7b3a011fc..ceac68bbd 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -25,7 +25,7 @@ import TimelineContent from '@mui/lab/TimelineContent'; import TimelineDot from '@mui/lab/TimelineDot'; import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent'; import {Alert, Card, CardActions, Chip, Paper, Stack, Tooltip} from "@mui/material"; -import {Link} from "react-router-dom"; +import {Link, useSearchParams} from "react-router-dom"; import StudentStatsUtils from "../../services/StudentStatsUtils"; import {BonusTag, DefaultTags, getTip, isBonusWork, isTestWork, TestTag} from "../Common/HomeworkTags"; import FileInfoConverter from "components/Utils/FileInfoConverter"; @@ -37,20 +37,9 @@ import ErrorIcon from '@mui/icons-material/Error'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import SwitchAccessShortcutIcon from '@mui/icons-material/SwitchAccessShortcut'; import Lodash from "lodash"; +import MentorsList from "../Common/MentorsList"; interface ICourseExperimentalProps { - courseFilesInfo: FileInfoDTO[] - studentSolutions: StatisticsCourseMatesModel[] - courseId: number - isMentor: boolean - isStudentAccepted: boolean - userId: string - selectedHomeworkId: number | undefined - processingFiles: { - [homeworkId: number]: { - isLoading: boolean; - }; - }; onStartProcessing: (homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; } @@ -65,10 +54,22 @@ interface ICourseExperimentalState { export const CourseExperimental: FC = (props) => { const dispatch = useAppDispatch() const allHomeworks = useAppSelector(state => state.homework.homeworks) + const studentSolutions = useAppSelector(state => state.solutions.studentSolutions) + const courseFilesInfo = useAppSelector(state => state.courseFiles.courseFiles) + const processingFiles = useAppSelector(state => state.courseFiles.processingFilesState) + const mentors = useAppSelector(state => state.course.mentors) + const course = useAppSelector(state => state.course.course) + const acceptedStudents = useAppSelector(state => state.course.acceptedStudents) + const userId = useAppSelector(state => state.auth.userId) + + const courseId = course?.id ?? 0 + const isAcceptedStudent = acceptedStudents.some(s => s.userId === userId) + const [hideDeferred, setHideDeferred] = useState(false) const [showOnlyGroupedTest, setShowOnlyGroupedTest] = useState(undefined) const filterAdded = hideDeferred || showOnlyGroupedTest !== undefined + const isMentor = mentors.some(m => m.userId === userId) // Определяем разрешение экрана пользователя const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); @@ -82,7 +83,8 @@ export const CourseExperimental: FC = (props) => { return true }) - const {isMentor, studentSolutions, isStudentAccepted, userId, selectedHomeworkId, courseFilesInfo} = props + const [ searchParams ]= useSearchParams(); + const selectedHomeworkId = searchParams.get("homeworkId") ? +searchParams.get("homeworkId")! : undefined const [state, setState] = useState({ initialEditMode: false, @@ -161,7 +163,7 @@ export const CourseExperimental: FC = (props) => { const taskSolutionsMap = new Map() - if (!isMentor && isStudentAccepted) { + if (!isMentor && isAcceptedStudent) { studentSolutions .filter(t => t.id === userId) .flatMap(t => t.homeworks!) @@ -374,7 +376,7 @@ export const CourseExperimental: FC = (props) => { const addNewHomework = () => { handleHomeworkUpdate({ homework: { - courseId: props.courseId, + courseId: courseId, title: "Новое задание", publicationDateNotSet: false, publicationDate: undefined, @@ -465,7 +467,7 @@ export const CourseExperimental: FC = (props) => { } })) }} - isProcessing={props.processingFiles[homework.id!]?.isLoading || false} + isProcessing={processingFiles[homework.id!]?.isLoading || false} onStartProcessing={(homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => props.onStartProcessing(homeworkId, previouslyExistingFilesCount, waitingNewFilesCount, deletingFilesIds)} /> @@ -496,7 +498,7 @@ export const CourseExperimental: FC = (props) => { })) }} toEditHomework={() => toEditHomework(homework!)} getAllHomeworks={() => homeworks}/> - {!props.isMentor && props.isStudentAccepted && < CardActions> + {!isMentor && isAcceptedStudent && < CardActions> @@ -547,7 +549,7 @@ export const CourseExperimental: FC = (props) => { borderRadius: 10 } }}> - {props.isMentor && filterAdded && + {isMentor && filterAdded && }>Часть добавления нового задания - return Новая задача будет добавлена после нажатия на 'Добавить задачу' - } - - if (entity.isDeferred) return ( - setHideDeferred(true)} - > - Скрыть неопубликованное - }> - {isHomework ? "Задание будет опубликовано " : "Задача будет опубликована "} - {renderDate(entity.publicationDate!) + " " + renderTime(entity.publicationDate!)} - - ) - } - const getGroupingAlert = (homework: HomeworkViewModel) => { - const result = validateTestGrouping(homework) - if (result === true) return null - const {hasErrors, groupingTag} = result - if (!hasErrors) return setShowOnlyGroupedTest(groupingTag)} - > - Задания - }> - Работа сгруппирована по ключу '{groupingTag}'. - - - return setShowOnlyGroupedTest(groupingTag)} - > - Задания - }> - Группировка контрольных работ - Создано несколько контрольных работ, сгруппированных по ключу '{groupingTag}', - однако работы отличаются между собой по количеству задач или их максимальным баллам. -
-
- Количество задач должно быть одинаковым, а баллы между соответствующими задачами равными. -
- } - - const selectedItemHomework = isHomework - ? homeworks.find(x => x.id === id)! - : homeworks.find(x => x.tasks!.some(t => t.id === id))! - - const selectedItem = isHomework - ? selectedItemHomework - : selectedItemHomework?.tasks!.find(x => x.id === id) as HomeworkTaskViewModel - - const [newTaskCounter, setNewTaskCounter] = useState(-1) - - const handleHomeworkUpdate = (update: { homework: HomeworkViewModel, fileInfos?: FileInfoDTO[], isDeleted?: boolean }) => { - const { homework, isDeleted } = update; - if (isDeleted) { - dispatch(deleteHomework(homework.id!)); - } else { - dispatch(updateOrInsertHomework(homework)); - } - }; - - const handleTaskUpdate = (update: { task: HomeworkTaskViewModel, isDeleted?: boolean }) => { - const { task, isDeleted } = update; - if (isDeleted) { - dispatch(deleteTask({homeworkId: task.homeworkId!, taskId: task.id!})); - } else { - dispatch(updateTask(task)); - } - } - - const addNewHomework = () => { - handleHomeworkUpdate({ - homework: { - courseId: courseId, - title: "Новое задание", - publicationDateNotSet: false, - publicationDate: undefined, - hasDeadline: false, - id: -1, - isGroupWork: false, - deadlineDateNotSet: false, - deadlineDate: undefined, - isDeadlineStrict: false, - description: "", - tasks: [], - tags: [] - }, - fileInfos: [] - }) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: true, - id: -1 - } - })) - } - - const addNewTask = (homework: HomeworkViewModel) => { - const id = newTaskCounter - const tags = homework.tags! - const isTest = tags.includes(TestTag) - const isBonus = tags.includes(BonusTag) - - const ratingCandidate = Lodash(homeworks - .map(h => h.tasks![0]) - .filter(x => { - if (x === undefined) return false - const xIsTest = isTestWork(x) - const xIsBonus = isBonusWork(x) - return x.id! > 0 && (isTest && xIsTest || isBonus && xIsBonus || !isTest && !isBonus && !xIsTest && !xIsBonus) - })) - .map(x => x.maxRating!) - .groupBy(x => [x]) - .entries() - .sortBy(x => x[1].length).last()?.[1][0] - - const task = { - homeworkId: homework.id, - maxRating: ratingCandidate || 10, - suggestedMaxRating: ratingCandidate, - title: `Новая задача`, - tags: homework.tags, - isDeferred: homework.isDeferred, - description: "", - id - } - - handleTaskUpdate({task}) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: false, - id: id - } - })) - setNewTaskCounter(id - 1) - } - - const renderHomework = (homework: HomeworkViewModel & { isModified?: boolean }) => { - const filesInfo = id ? FileInfoConverter.getHomeworkFilesInfo(courseFilesInfo, id) : [] - const homeworkEditMode = homework && (homework.id! < 0 || homework.isModified === true) - return homework && - - {isMentor && getGroupingAlert(homework)} - {isMentor && getDatesAlert(homework, true)} - { - handleHomeworkUpdate(update) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: true, - id: update.isDeleted ? undefined : update.homework.id! - } - })) - }} - onStartProcessing={(homeworkId: number, previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => - props.onStartProcessing(homeworkId, previouslyExistingFilesCount, waitingNewFilesCount, deletingFilesIds)} - /> - - - } - - const renderTask = (task: HomeworkTaskViewModel & { isModified?: boolean }, homework: HomeworkViewModel) => { - const taskEditMode = task && (task.id! < 0 || task.isModified === true) - return task && - {isMentor && getDatesAlert(task, false)} - { - handleTaskUpdate(update) - if (update.isDeleted) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: true, - id: homework!.id - } - })) - }} - toEditHomework={() => toEditHomework(homework!)}/> - {!isMentor && isAcceptedStudent && < CardActions> - - - - } - - } - - const renderGif = () => - - - const renderLecturerWelcomeScreen = () => - - - Спасибо за ещё один курс - Самое время добавить новое задание! - - - - return - - - {isMentor && filterAdded && - - {hideDeferred - ? "только опубликованные задания" - : showOnlyGroupedTest - ? `контрольные работы '${showOnlyGroupedTest}'` - : ""} - - } - {isMentor && !filterAdded && (homeworks[0]?.id || 1) > 0 && } - {isMentor && homeworks.length === 0 && renderLecturerWelcomeScreen()} - {homeworks.map((x: HomeworkViewModel & { isModified?: boolean, hasErrors?: boolean }) => { - return
- { - setState(prevState => ({ - ...prevState, - selectedItem: { - data: x, - isHomework: true, - id: x.id, - homeworkFilesInfo: FileInfoConverter.getHomeworkFilesInfo(courseFilesInfo, x.id!) - } - })) - }}> - - {isMentor && renderHomeworkStatus(x)} - {x.title}{getTip(x)} - - {x.isDeferred && !x.publicationDateNotSet && - - {"🕘 " + renderDate(x.publicationDate!) + " " + renderTime(x.publicationDate!)} - } - {x.tasks?.length === 0 && - - - - - } - - {x.tasks!.map(t => { - setState(prevState => ({ - ...prevState, - selectedItem: { - data: t, - isHomework: false, - id: t.id, - homeworkFilesInfo: [] - } - })) - }} - style={{...getStyle(false, t.id!), marginBottom: 2}} - sx={{":hover": hoveredItemStyle}}> - {!t.deadlineDateNotSet && - - {t.deadlineDate ? renderDate(t.deadlineDate) : ""} -
- {t.deadlineDate ? renderTime(t.deadlineDate) : ""} -
- } - - {renderTaskStatus(t)} - - - - - {t.title}{getTip(x)} - - -
)} - {x.id! < 0 && - } -
; - })} -
-
- - {isHomework - ? renderHomework(selectedItem as HomeworkViewModel) - : renderTask(selectedItem as HomeworkTaskViewModel, selectedItemHomework!)} - - {renderGif()} - - - - {renderGif()} - - - {/* Кнопка "Наверх" для мобильных устройств */} - - - - - -
-} -======= import * as React from "react"; import { FileInfoDTO, @@ -1098,9 +422,7 @@ export const CourseExperimental: FC = (props) => { {isMentor && getDatesAlert(homework, true)} homeworks} homeworkAndFilesInfo={{homework, filesInfo}} - isMentor={isMentor} initialEditMode={initialEditMode || homeworkEditMode} onMount={onSelectedItemMount} onAddTask={addNewTask} @@ -1129,7 +451,6 @@ export const CourseExperimental: FC = (props) => { key={task.id} task={task} homework={homework!} - isMentor={isMentor} initialEditMode={initialEditMode || taskEditMode} onMount={onSelectedItemMount} onUpdate={update => { @@ -1143,7 +464,7 @@ export const CourseExperimental: FC = (props) => { } })) }} - toEditHomework={() => toEditHomework(homework!)} getAllHomeworks={() => homeworks}/> + toEditHomework={() => toEditHomework(homework!)}/> {!props.isMentor && props.isStudentAccepted && < CardActions> = (props) => { -} ->>>>>>> upstream/master +} \ No newline at end of file From 01d7a7601c76b537c5afacace36265eb69e6cf42 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 02:28:40 +0300 Subject: [PATCH 25/48] refactor: changes in store and related to that --- .../src/components/Courses/Course.tsx | 38 +++++++------- .../components/Courses/NewCourseStudents.tsx | 8 +-- .../Homeworks/CourseHomeworkExperimental.tsx | 12 ++--- .../Tasks/CourseTaskExperimental.tsx | 6 +-- hwproj.front/src/index.tsx | 2 +- hwproj.front/src/store/hooks.ts | 6 +-- .../src/store/slices/courseFileSlice.ts | 12 ++--- hwproj.front/src/store/slices/courseSlice.ts | 8 +-- .../src/store/slices/homeworkSlice.ts | 22 ++++----- hwproj.front/src/store/slices/userSlice.ts | 49 +++++++++++++++++++ hwproj.front/src/store/{index.ts => store.ts} | 4 +- 11 files changed, 108 insertions(+), 59 deletions(-) create mode 100644 hwproj.front/src/store/slices/userSlice.ts rename hwproj.front/src/store/{index.ts => store.ts} (89%) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 8f56b7f84..09c444bfb 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -34,12 +34,12 @@ import QrCode2Icon from '@mui/icons-material/QrCode2'; import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {FileStatus} from "../Files/FileStatus"; -import {useAppDispatch, useAppSelector} from "@/store/hooks"; +import {useCourseDispatch, useCourseState} from "@/store/hooks"; import {setCourse, setMentors, setAcceptedStudents, setNewStudents} from "@/store/slices/courseSlice"; import {setHomeworks, updateOrInsertHomework, deleteHomework, updateTask, deleteTask} from "@/store/slices/homeworkSlice"; import {setStudentSolutions} from "@/store/slices/solutionSlice"; import {setCourseFiles, updateCourseFiles, setProcessingLoading} from "@/store/slices/courseFileSlice"; -import {setAuth} from "@/store/slices/authSlice"; +import {setUser, UserRole} from "@/store/slices/userSlice"; import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; import {CourseUnitType} from "@/components/Files/CourseUnitType"; import {enqueueSnackbar} from "notistack"; @@ -60,16 +60,16 @@ const Course: React.FC = () => { const [searchParams] = useSearchParams() const navigate = useNavigate() - const dispatch = useAppDispatch(); - const course = useAppSelector(state => state.course.course); - const isFound = useAppSelector(state => state.course.isFound); - const mentors = useAppSelector(state => state.course.mentors); - const acceptedStudents = useAppSelector(state => state.course.acceptedStudents); - const newStudents = useAppSelector(state => state.course.newStudents); - const courseHomeworks = useAppSelector(state => state.homeworks.homeworks); - const studentSolutions = useAppSelector(state => state.solutions.studentSolutions); - const courseFiles = useAppSelector(state => state.courseFiles.courseFiles); - const processingFilesState = useAppSelector(state => state.courseFiles.processingFilesState); + const dispatch = useCourseDispatch(); + const course = useCourseState(state => state.course.currentCourse); + const isFound = useCourseState(state => state.course.isFound); + const mentors = useCourseState(state => state.course.mentors); + const acceptedStudents = useCourseState(state => state.course.acceptedStudents); + const newStudents = useCourseState(state => state.course.newStudents); + const courseHomeworks = useCourseState(state => state.homeworks.items); + const studentSolutions = useCourseState(state => state.solutions.studentSolutions); + const courseFiles = useCourseState(state => state.courseFiles.items); + const processingFilesState = useCourseState(state => state.courseFiles.processingFilesState); const [showQrCode, setShowQrCode] = useState(false); const intervalsRef = React.useRef>({}); @@ -184,14 +184,13 @@ const Course: React.FC = () => { useEffect(() => { const userId = ApiSingleton.authService.getUserId(); - const isLecturer = ApiSingleton.authService.isLecturer(); - const isExpert = ApiSingleton.authService.isExpert(); - dispatch(setAuth({ userId, isLecturer, isExpert })) + const role = ApiSingleton.authService.getRole() as UserRole; + dispatch(setUser({ userId, role })) }, []) - const userId = useAppSelector(state => state.auth.userId); - const isLecturer = useAppSelector(state => state.auth.isLecturer); - const isExpert = useAppSelector(state => state.auth.isExpert); + const userId = useCourseState(state => state.user.userId); + const isLecturer = useCourseState(state => state.user.isLecturer); + const isExpert = useCourseState(state => state.user.isExpert); const isMentor = isLecturer || isExpert const isCourseMentor = mentors.some(t => t.userId === userId) const isSignedInCourse = newStudents!.some(cm => cm.userId === userId) @@ -384,10 +383,11 @@ const Course: React.FC = () => { - + {lecturerStatsState && setLecturerStatsState(false)} /> } diff --git a/hwproj.front/src/components/Courses/NewCourseStudents.tsx b/hwproj.front/src/components/Courses/NewCourseStudents.tsx index 9efa948ce..7a03e9425 100644 --- a/hwproj.front/src/components/Courses/NewCourseStudents.tsx +++ b/hwproj.front/src/components/Courses/NewCourseStudents.tsx @@ -2,13 +2,13 @@ import * as React from 'react'; import ApiSingleton from "../../api/ApiSingleton"; import {FC} from "react"; import {Card, CardContent, CardActions, Grid, Button, Typography, Alert, AlertTitle} from '@mui/material'; -import {useAppSelector, useAppDispatch} from "@/store/hooks"; +import {useCourseState, useCourseDispatch} from "@/store/hooks"; import {fetchCourseData} from '@/store/slices/courseSlice'; const NewCourseStudents: FC = () => { - const course = useAppSelector(state => state.course.course); - const students = useAppSelector(state => state.course.newStudents); - const dispatch = useAppDispatch(); + const course = useCourseState(state => state.course.currentCourse); + const students = useCourseState(state => state.course.newStudents); + const dispatch = useCourseDispatch(); const acceptStudent = async (studentId: string) => { await ApiSingleton.coursesApi.coursesAcceptStudent(course?.id!, studentId) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 7961b3944..253e922f0 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -35,7 +35,7 @@ import {BonusTag, DefaultTags, isBonusWork, isTestWork, TestTag} from "@/compone import Lodash from "lodash"; import {CourseUnitType} from "../Files/CourseUnitType" import ProcessFilesUtils from "../Utils/ProcessFilesUtils"; -import {useAppSelector} from "@/store/hooks"; +import {useCourseState} from "@/store/hooks"; import {FilesHandler} from "@/components/Files/FilesHandler"; export interface HomeworkAndFilesInfo { @@ -66,7 +66,7 @@ const CourseHomeworkEditor: FC<{ }> = (props) => { const homework = props.homeworkAndFilesInfo.homework const isNewHomework = homework.id! < 0 - const homeworks = useAppSelector(state => state.homeworks.homeworks); + const homeworks = useCourseState(state => state.homeworks.items); const [homeworkData, setHomeworkData] = useState<{ loadedHomework: HomeworkViewModel, @@ -394,11 +394,11 @@ const CourseHomeworkExperimental: FC<{ waitingNewFilesCount: number, deletingFilesIds: number[]) => void; }> = (props) => { - const mentors = useAppSelector(state => state.course.mentors); - const userId = useAppSelector(state => state.auth.userId); + const mentors = useCourseState(state => state.course.mentors); + const userId = useCourseState(state => state.user.userId); const isMentor = mentors.some(m => m.userId === userId); - const processingFilesState = useAppSelector(state => state.courseFiles.processingFilesState); - const homeworks = useAppSelector(state => state.homeworks.homeworks); + const processingFilesState = useCourseState(state => state.courseFiles.processingFilesState); + const homeworks = useCourseState(state => state.homeworks.items); const {homework, filesInfo} = props.homeworkAndFilesInfo const deferredTasks = homework.tasks!.filter(t => t.isDeferred!) diff --git a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx index 82a567ef9..7620c4878 100644 --- a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx +++ b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx @@ -25,7 +25,7 @@ import {LoadingButton} from "@mui/lab"; import TaskPublicationAndDeadlineDates from "../Common/TaskPublicationAndDeadlineDates"; import DeletionConfirmation from "../DeletionConfirmation"; import ActionOptionsUI from "../Common/ActionOptions"; -import {useAppSelector} from "@/store/hooks"; +import {useCourseState} from "@/store/hooks"; import {Stack} from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import Collapse from "@mui/material/Collapse"; @@ -498,8 +498,8 @@ const CourseTaskExperimental: FC<{ onUpdate: (x: { task: TaskEditData, isDeleted?: boolean }) => void toEditHomework: () => void, }> = (props) => { - const mentors = useAppSelector(state => state.course.mentors); - const userId = useAppSelector(state => state.auth.userId); + const mentors = useCourseState(state => state.course.mentors); + const userId = useCourseState(state => state.user.userId); const isMentor = mentors.some(m => m.userId === userId); const {task, homework} = props diff --git a/hwproj.front/src/index.tsx b/hwproj.front/src/index.tsx index 3e813aefa..fcbf374f9 100644 --- a/hwproj.front/src/index.tsx +++ b/hwproj.front/src/index.tsx @@ -9,7 +9,7 @@ import ThemeProvider from "@material-ui/styles/ThemeProvider"; import {createTheme} from "@material-ui/core/styles"; import {SnackbarProvider} from "notistack"; import { Provider } from "react-redux"; -import { store } from "./store"; +import { store } from "./store/store"; const theme = createTheme({ typography: { diff --git a/hwproj.front/src/store/hooks.ts b/hwproj.front/src/store/hooks.ts index 753d94ac6..a32e197cf 100644 --- a/hwproj.front/src/store/hooks.ts +++ b/hwproj.front/src/store/hooks.ts @@ -1,5 +1,5 @@ import {useDispatch, useSelector} from 'react-redux'; -import type {RootState, AppDispatch} from './index'; +import type {RootState, AppDispatch} from './store'; -export const useAppDispatch = useDispatch.withTypes(); -export const useAppSelector = useSelector.withTypes(); \ No newline at end of file +export const useCourseDispatch = useDispatch.withTypes(); +export const useCourseState = useSelector.withTypes(); \ No newline at end of file diff --git a/hwproj.front/src/store/slices/courseFileSlice.ts b/hwproj.front/src/store/slices/courseFileSlice.ts index e26bec0a8..301c01043 100644 --- a/hwproj.front/src/store/slices/courseFileSlice.ts +++ b/hwproj.front/src/store/slices/courseFileSlice.ts @@ -7,12 +7,12 @@ interface ProcessingState { } interface CourseFilesState { - courseFiles: FileInfoDTO[]; + items: FileInfoDTO[]; processingFilesState: Record; } const initialState: CourseFilesState = { - courseFiles: [], + items: [], processingFilesState: {}, } @@ -21,7 +21,7 @@ const courseFilesSlice = createSlice({ initialState, reducers: { setCourseFiles(state, action: PayloadAction) { - state.courseFiles = action.payload; + state.items = action.payload; }, updateCourseFiles(state, action: PayloadAction<{ @@ -30,8 +30,8 @@ const courseFilesSlice = createSlice({ unitId: number; }>) { const { files, unitType, unitId } = action.payload; - state.courseFiles = [ - ...state.courseFiles.filter(f => !(f.courseUnitType === unitType && f.courseUnitId === unitId)), + state.items = [ + ...state.items.filter(f => !(f.courseUnitType === unitType && f.courseUnitId === unitId)), ...files ]; }, @@ -42,7 +42,7 @@ const courseFilesSlice = createSlice({ }, clearCourseFiles(state) { - state.courseFiles = []; + state.items = []; state.processingFilesState = {}; }, }, diff --git a/hwproj.front/src/store/slices/courseSlice.ts b/hwproj.front/src/store/slices/courseSlice.ts index 4d7749e6f..7c42f7c93 100644 --- a/hwproj.front/src/store/slices/courseSlice.ts +++ b/hwproj.front/src/store/slices/courseSlice.ts @@ -20,7 +20,7 @@ export const fetchCourseData = createAsyncThunk( interface CourseState { isFound: boolean; isLoading: boolean; - course: CourseViewModel | null; + currentCourse: CourseViewModel | null; mentors: AccountDataDto[]; acceptedStudents: AccountDataDto[]; newStudents: AccountDataDto[]; @@ -29,7 +29,7 @@ interface CourseState { const initialState: CourseState = { isFound: false, isLoading: false, - course: null, + currentCourse: null, mentors: [], acceptedStudents: [], newStudents: [], @@ -40,7 +40,7 @@ const courseSlice = createSlice({ initialState, reducers: { setCourse(state, action: PayloadAction) { - state.course = action.payload; + state.currentCourse = action.payload; state.isFound = true; state.isLoading = false; }, @@ -62,7 +62,7 @@ const courseSlice = createSlice({ }, resetCourse(state) { - state.course = null; + state.currentCourse = null; state.isFound = false; state.isLoading = false; state.mentors = []; diff --git a/hwproj.front/src/store/slices/homeworkSlice.ts b/hwproj.front/src/store/slices/homeworkSlice.ts index 3e6481a3b..a5ba52edb 100644 --- a/hwproj.front/src/store/slices/homeworkSlice.ts +++ b/hwproj.front/src/store/slices/homeworkSlice.ts @@ -2,40 +2,40 @@ import {createSlice, PayloadAction} from '@reduxjs/toolkit'; import {HomeworkViewModel, HomeworkTaskViewModel} from '@/api'; interface HomeworkState { - homeworks: HomeworkViewModel[]; + items: HomeworkViewModel[]; isLoading: boolean } const initialState: HomeworkState = { - homeworks: [], + items: [], isLoading: false, } const homeworkSlice = createSlice({ - name: 'homework', + name: 'homeworks', initialState, reducers: { setHomeworks(state, action: PayloadAction) { - state.homeworks = action.payload; + state.items = action.payload; state.isLoading = false; }, deleteHomework(state, action: PayloadAction) { - state.homeworks = state.homeworks.filter(hw => hw.id !== action.payload); + state.items = state.items.filter(hw => hw.id !== action.payload); }, updateOrInsertHomework(state, action: PayloadAction) { - const index = state.homeworks.findIndex(hw => hw.id === action.payload.id); + const index = state.items.findIndex(hw => hw.id === action.payload.id); if (index !== -1) { - state.homeworks[index] = action.payload; + state.items[index] = action.payload; } else { - state.homeworks.push(action.payload); + state.items.push(action.payload); } }, updateTask(state, action: PayloadAction) { const task = action.payload; - const homework = state.homeworks.find(hw => hw.id === task.homeworkId); + const homework = state.items.find(hw => hw.id === task.homeworkId); if (homework && homework.tasks) { const taskIndex = homework.tasks.findIndex(t => t.id === task.id); if (taskIndex !== -1) { @@ -48,7 +48,7 @@ const homeworkSlice = createSlice({ }, deleteTask(state, action: PayloadAction<{homeworkId: number, taskId: number}>) { - const homework = state.homeworks.find(hw => hw.id === action.payload.homeworkId); + const homework = state.items.find(hw => hw.id === action.payload.homeworkId); if (homework && homework.tasks) { homework.tasks = homework.tasks.filter(t => t.id !== action.payload.taskId); } @@ -59,7 +59,7 @@ const homeworkSlice = createSlice({ }, resetHomeworks(state) { - state.homeworks = []; + state.items = []; state.isLoading = false; }, }, diff --git a/hwproj.front/src/store/slices/userSlice.ts b/hwproj.front/src/store/slices/userSlice.ts new file mode 100644 index 000000000..a3c37b383 --- /dev/null +++ b/hwproj.front/src/store/slices/userSlice.ts @@ -0,0 +1,49 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +export type UserRole = "Lecturer" | "Expert" | "Student" | null; + +interface UserState { + userId: string | null; + role : UserRole; + isLecturer: boolean; + isExpert: boolean; +} + +type SetUserPayload = { + userId: string | null; + role: UserRole; +} + +const initialState: UserState = { + userId: null, + role: null, + isLecturer: false, + isExpert: false, +}; + +const userSlice = createSlice({ + name: 'user', + initialState, + reducers: { + setUser: (state, action: PayloadAction) => { + const {userId, role} = action.payload; + state.userId = userId; + state.role = role; + state.isLecturer = role === "Lecturer"; + state.isExpert = role === "Expert"; + }, + + clearUser: (state) => { + state.userId = null; + state.role = null; + state.isLecturer = false; + state.isExpert = false; + }, + }, +}); + +export const { setUser, + clearUser + } = userSlice.actions; + +export default userSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/index.ts b/hwproj.front/src/store/store.ts similarity index 89% rename from hwproj.front/src/store/index.ts rename to hwproj.front/src/store/store.ts index 63ea8b163..64de082f9 100644 --- a/hwproj.front/src/store/index.ts +++ b/hwproj.front/src/store/store.ts @@ -3,7 +3,7 @@ import courseReducer from './slices/courseSlice'; import homeworkReducer from './slices/homeworkSlice'; import solutionsReducer from './slices/solutionSlice'; import courseFilesReducer from './slices/courseFileSlice'; -import authReducer from './slices/authSlice'; +import userReducer from './slices/userSlice'; export const store = configureStore({ reducer: { @@ -11,7 +11,7 @@ export const store = configureStore({ homeworks: homeworkReducer, solutions: solutionsReducer, courseFiles: courseFilesReducer, - auth: authReducer, + user: userReducer, }, }); From d4ae7eb775f8afe9e529158b1d31ec293ecc7d3c Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 02:35:45 +0300 Subject: [PATCH 26/48] rolling back changes --- .../src/components/Common/MentorsList.tsx | 14 +++---- .../src/components/Courses/Course.tsx | 8 +++- .../Courses/Statistics/LecturerStatistics.tsx | 6 +-- .../src/components/Courses/StudentStats.tsx | 42 +++++++++---------- 4 files changed, 34 insertions(+), 36 deletions(-) diff --git a/hwproj.front/src/components/Common/MentorsList.tsx b/hwproj.front/src/components/Common/MentorsList.tsx index de10792f1..68adeb04a 100644 --- a/hwproj.front/src/components/Common/MentorsList.tsx +++ b/hwproj.front/src/components/Common/MentorsList.tsx @@ -1,18 +1,14 @@ import {FC} from "react"; +import {AccountDataDto} from "@/api"; import {Typography} from "@material-ui/core"; import * as React from "react"; import {Stack, Tooltip} from "@mui/material"; -import { useAppSelector } from "@/store/hooks"; -import { AccountDataDto } from "@/api"; -interface MentorsListProps { - mentors?: AccountDataDto[]; -} - -const MentorsList: FC = ({ mentors: propMentors }) => { - const reduxMentors = useAppSelector(state => state.course.mentors); - const mentors = propMentors ?? reduxMentors; +const MentorsList: FC<{ + mentors: AccountDataDto[] +}> = (props) => { const count = 1 + const {mentors} = props const mentorsToShow = mentors.length > count ? mentors.slice(0, count) : mentors const mentorsToHide = mentors.length > count ? mentors.slice(count) : [] const fontSize = 18 diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 09c444bfb..fdc250396 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -458,7 +458,13 @@ const Course: React.FC = () => { {tabValue === "stats" && - + } {tabValue === "applications" && showApplicationsTab && diff --git a/hwproj.front/src/components/Courses/Statistics/LecturerStatistics.tsx b/hwproj.front/src/components/Courses/Statistics/LecturerStatistics.tsx index d5666bef7..349e130ea 100644 --- a/hwproj.front/src/components/Courses/Statistics/LecturerStatistics.tsx +++ b/hwproj.front/src/components/Courses/Statistics/LecturerStatistics.tsx @@ -4,20 +4,18 @@ import ApiSingleton from "../../../api/ApiSingleton"; import {Dialog, DialogContent, DialogTitle, Typography, Grid, Tooltip, Chip} from "@mui/material"; import * as React from "react"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; -import { useAppSelector } from "@/store/hooks"; const LecturerStatistics: FC<{ + courseId: number onClose: () => void }> = (props) => { - const courseId = useAppSelector(state => state.course.course?.id); - const [isLoading, setIsLoading] = useState(true); const [statistics, setStatistics] = useState([]) const getStatistics = async () => { const statistics: StatisticsLecturersModel[] = - await ApiSingleton.statisticsApi.statisticsGetLecturersStatistics(courseId!); + await ApiSingleton.statisticsApi.statisticsGetLecturersStatistics(props.courseId) setStatistics(statistics) setIsLoading(false); } diff --git a/hwproj.front/src/components/Courses/StudentStats.tsx b/hwproj.front/src/components/Courses/StudentStats.tsx index 4db349d80..6e6befebc 100644 --- a/hwproj.front/src/components/Courses/StudentStats.tsx +++ b/hwproj.front/src/components/Courses/StudentStats.tsx @@ -1,5 +1,5 @@ import React, {useEffect, useState, useRef} from "react"; -import {HomeworkViewModel} from "@/api"; +import {CourseViewModel, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; import {useNavigate, useParams} from 'react-router-dom'; import {LinearProgress, Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@material-ui/core"; import StudentStatsCell from "../Tasks/StudentStatsCell"; @@ -11,8 +11,14 @@ import {BonusTag, DefaultTags, TestTag} from "../Common/HomeworkTags"; import Lodash from "lodash" import FullscreenIcon from '@mui/icons-material/Fullscreen'; import FullscreenExitIcon from '@mui/icons-material/FullscreenExit'; -import {useAppSelector} from "@/store/hooks"; +interface IStudentStatsProps { + course: CourseViewModel; + homeworks: HomeworkViewModel[]; + isMentor: boolean; + userId: string; + solutions: StatisticsCourseMatesModel[] | undefined; +} interface IStudentStatsState { searched: string @@ -20,21 +26,12 @@ interface IStudentStatsState { const greyBorder = grey[300] -const StudentStats: React.FC = () => { +const StudentStats: React.FC = (props) => { const [state, setSearched] = useState({ searched: "" }); const {courseId} = useParams(); const navigate = useNavigate(); - - const course = useAppSelector(state => state.course.course); - const allHomeworks = useAppSelector(state => state.homeworks.homeworks); - const studentSolutions = useAppSelector(state => state.solutions.studentSolutions); - const userId = useAppSelector(state => state.auth.userId); - const isLecturer = useAppSelector(state => state.auth.isLecturer); - const isExpert = useAppSelector(state => state.auth.isExpert); - const isMentor = isLecturer || isExpert; - const handleClick = () => { navigate(`/statistics/${courseId}/charts`) } @@ -64,6 +61,7 @@ const StudentStats: React.FC = () => { }, []) const {searched} = state + const isMentor = props.isMentor useEffect(() => { const keyDownHandler = (event: KeyboardEvent) => { @@ -83,10 +81,10 @@ const StudentStats: React.FC = () => { return () => document.removeEventListener('keydown', keyDownHandler); }, [searched]); - const homeworks = allHomeworks.filter(h => h.tasks && h.tasks.length > 0) + const homeworks = props.homeworks.filter(h => h.tasks && h.tasks.length > 0) const solutions = searched - ? studentSolutions?.filter(cm => (cm.surname + " " + cm.name).toLowerCase().includes(searched.toLowerCase())) - : studentSolutions + ? props.solutions?.filter(cm => (cm.surname + " " + cm.name).toLowerCase().includes(searched.toLowerCase())) + : props.solutions const borderStyle = `1px solid ${greyBorder}` const testHomeworkStyle = { @@ -131,10 +129,10 @@ const StudentStats: React.FC = () => { const showBestSolutions = isMentor && (hasHomeworks || hasTests) const bestTaskSolutions = new Map() - if (studentSolutions && isMentor) { + if (props.solutions && isMentor) { Lodash(homeworks) .flatMap(h => h.tasks!) - .map(t => studentSolutions + .map(t => props.solutions! .map(s => s.homeworks! .flatMap(h1 => h1.tasks!) .find(t1 => t1.id === t.id)?.solution || []) @@ -151,8 +149,8 @@ const StudentStats: React.FC = () => { return (
- {studentSolutions === undefined && } - {studentSolutions && studentSolutions.length === 0 && + {props.solutions === undefined && } + {props.solutions && props.solutions.length === 0 && На курс пока ещё никто не записался } {searched && @@ -301,7 +299,7 @@ const StudentStats: React.FC = () => { }} > {cm.reviewers && cm.reviewers - .filter(r => r.userId !== userId) + .filter(r => r.userId !== props.userId) .map(r => `${r.name} ${r.surname}`) .join(', ')} @@ -362,8 +360,8 @@ const StudentStats: React.FC = () => { solutions={cm.homeworks ?.find(h => h.id === homework.id)?.tasks ?.find(t => t.id === task.id)?.solution || []} - userId={userId!} - forMentor={isMentor} + userId={props.userId} + forMentor={props.isMentor} studentId={String(cm.id)} taskId={task.id!} taskMaxRating={task.maxRating!} From 1a82753020fb401c6d147c1eb071773715e52e3f Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 08:45:51 +0300 Subject: [PATCH 27/48] refactor: implement editing state hooks and selection --- hwproj.front/src/store/courseHooks.ts | 79 ++++++++ hwproj.front/src/store/editingHooks.ts | 187 ++++++++++++++++++ hwproj.front/src/store/slices/authSlice.ts | 37 ---- .../src/store/slices/courseEditingSlice.ts | 95 +++++++++ hwproj.front/src/store/store.ts | 2 + 5 files changed, 363 insertions(+), 37 deletions(-) create mode 100644 hwproj.front/src/store/courseHooks.ts create mode 100644 hwproj.front/src/store/editingHooks.ts delete mode 100644 hwproj.front/src/store/slices/authSlice.ts create mode 100644 hwproj.front/src/store/slices/courseEditingSlice.ts diff --git a/hwproj.front/src/store/courseHooks.ts b/hwproj.front/src/store/courseHooks.ts new file mode 100644 index 000000000..917ac19fe --- /dev/null +++ b/hwproj.front/src/store/courseHooks.ts @@ -0,0 +1,79 @@ +import {useCallback} from 'react'; +import {useCourseDispatch, useCourseState} from './hooks'; +import {setCourse, setMentors, setAcceptedStudents, setNewStudents} from './slices/courseSlice'; +import {setHomeworks} from './slices/homeworkSlice'; +import {setStudentSolutions} from './slices/solutionSlice'; +import {setCourseFiles, updateCourseFiles, setProcessingLoading} from './slices/courseFileSlice'; +import {setUser, UserRole} from './slices/userSlice'; +import {resetEditingState} from './slices/courseEditingSlice'; +import ApiSingleton from '@/api/ApiSingleton'; +import {FileInfoDTO} from '@/api'; +import {CourseUnitType} from '@/components/Files/CourseUnitType'; +import {enqueueSnackbar} from 'notistack'; +import ErrorsHandler from '@/components/Utils/ErrorsHandler'; + +export const useCourseLoader = (courseId: number) => { + const dispatch = useCourseDispatch(); + const userId = useCourseState(state => state.user.userId); + const isMentor = useCourseState(state => state.user.isLecturer || state.user.isExpert); + + const initUser = useCallback(() => { + const id = ApiSingleton.authService.getUserId(); + const role = ApiSingleton.authService.getRole() as UserRole; + dispatch(setUser({userId: id, role})); + }, [dispatch]); + + const loadCourse = useCallback(async () => { + const course = await ApiSingleton.coursesApi.coursesGetCourseData(courseId); + + const shouldRefreshToken = !isMentor && course && course.mentors!.some(t => t.userId === userId); + if (shouldRefreshToken) { + const newToken = await ApiSingleton.accountApi.accountRefreshToken(); + newToken.value && ApiSingleton.authService.refreshToken(newToken.value.accessToken!); + return null; + } + + dispatch(setCourse(course)); + dispatch(setMentors(course.mentors!)); + dispatch(setAcceptedStudents(course.acceptedStudents!)); + dispatch(setNewStudents(course.newStudents!)); + dispatch(setHomeworks(course.homeworks!)); + return course; + }, [dispatch, courseId, userId, isMentor]); + + const loadStudentSolutions = useCallback(async () => { + const res = await ApiSingleton.statisticsApi.statisticsGetCourseStatistics(courseId); + dispatch(setStudentSolutions(res)); + }, [dispatch, courseId]); + + const resetEditing = useCallback(() => { + dispatch(resetEditingState()); + }, [dispatch]); + + return {initUser, loadCourse, loadStudentSolutions, resetEditing}; +}; + +export const useCourseFiles = (courseId: number, isCourseMentor: boolean) => { + const dispatch = useCourseDispatch(); + + const loadCourseFiles = useCallback(async () => { + let files = [] as FileInfoDTO[]; + try { + files = await ApiSingleton.filesApi.filesGetFilesInfo(courseId, !isCourseMentor); + } catch (e) { + const errors = await ErrorsHandler.getErrorMessages(e as Response); + enqueueSnackbar(errors[0], {variant: 'warning', autoHideDuration: 1990}); + } + dispatch(setCourseFiles(files)); + }, [dispatch, courseId, isCourseMentor]); + + const updateFiles = useCallback((files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { + dispatch(updateCourseFiles({files, unitType, unitId})); + }, [dispatch]); + + const setFileLoading = useCallback((homeworkId: number, isLoading: boolean) => { + dispatch(setProcessingLoading({homeworkId, isLoading})); + }, [dispatch]); + + return {loadCourseFiles, updateFiles, setFileLoading}; +}; \ No newline at end of file diff --git a/hwproj.front/src/store/editingHooks.ts b/hwproj.front/src/store/editingHooks.ts new file mode 100644 index 000000000..1157a0187 --- /dev/null +++ b/hwproj.front/src/store/editingHooks.ts @@ -0,0 +1,187 @@ +import { useCourseDispatch, useCourseState } from './hooks'; +import { + addDraftHomework, + updateDraftHomework as updateDraftHomeworkAction, + removeDraftHomework, + addDraftTask, + updateDraftTask as updateDraftTaskAction, + removeDraftTask, + decrementDraftId, + setSelectedItem, + SelectedItem, +} from './slices/courseEditingSlice'; + +import { + updateOrInsertHomework, + deleteHomework, + updateTask, + deleteTask, +} from './slices/homeworkSlice'; + +import { HomeworkViewModel, HomeworkTaskViewModel } from '@/api'; +import { useCallback, useMemo } from 'react'; + +export const useMergedHomeworks = () => { + const committedHomeworks = useCourseState(state => state.homeworks.items); + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); + + return useMemo(() => { + const newDrafts = draftHomeworks.filter(dh => dh.id! < 0); + const result: HomeworkViewModel[] = []; + + for (const committed of committedHomeworks) { + const draft = draftHomeworks.find(dh => dh.id === committed.id); + result.push(draft || committed); + } + + result.push(...newDrafts); + return result; + }, [committedHomeworks, draftHomeworks]); +}; + +export const useEditingSelection = () => { + const dispatch = useCourseDispatch(); + const selectedItem = useCourseState(state => state.editing.selectedItem); + + const select = useCallback((item: SelectedItem) => { + dispatch(setSelectedItem(item)); + }, [dispatch]); + + return {selectedItem, select}; +}; + +export const useHomeworkEditing = () => { + const dispatch = useCourseDispatch(); + const draftIdCounter = useCourseState(state => state.editing.draftIdCounter); + + const addNewHomework = useCallback((courseId: number) => { + const newId = draftIdCounter; + const newHomework: HomeworkViewModel = { + courseId, + id: newId, + title: 'Новое задание', + description: '', + publicationDate: undefined, + publicationDateNotSet: false, + hasDeadline: false, + deadlineDate: undefined, + deadlineDateNotSet: false, + isDeadlineStrict: false, + isGroupWork: false, + tasks: [], + tags: [], + }; + dispatch(addDraftHomework(newHomework)); + dispatch(decrementDraftId()); + dispatch(setSelectedItem({ isHomework: true, id: newId })); + return newId; + }, [dispatch, draftIdCounter]); + + const startEditingHomework = useCallback((hw: HomeworkViewModel) => { + const copy: HomeworkViewModel = { + ...hw, + tasks: hw.tasks ? [...hw.tasks] : [], + }; + dispatch(addDraftHomework(copy)); + dispatch(setSelectedItem({ isHomework: true, id: hw.id })); + }, [dispatch]); + + const updateDraftHomework = useCallback((hw: HomeworkViewModel) => { + dispatch(updateDraftHomeworkAction(hw)); + }, [dispatch]); + + const cancelEditingHomework = useCallback((draftId: number) => { + dispatch(removeDraftHomework(draftId)); + }, [dispatch]); + + const commitHomework = useCallback((draftId: number, savedHw: HomeworkViewModel) => { + dispatch(updateOrInsertHomework(savedHw)); + dispatch(removeDraftHomework(draftId)); + }, [dispatch]); + + const commitHomeworkDeletion = useCallback((homeworkId: number) => { + dispatch(deleteHomework(homeworkId)); + dispatch(removeDraftHomework(homeworkId)); + }, [dispatch]); + + return { + addNewHomework, + startEditingHomework, + updateDraftHomework, + cancelEditingHomework, + commitHomework, + commitHomeworkDeletion, + }; +}; + +export const useTaskEditing = () => { + const dispatch = useCourseDispatch(); + const draftIdCounter = useCourseState(state => state.editing.draftIdCounter); + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); + + const ensureHomeworkInDrafts = useCallback((homework: HomeworkViewModel) => { + const existingDraft = draftHomeworks.find(dh => dh.id === homework.id); + if (!existingDraft) { + const copy: HomeworkViewModel = { + ...homework, + tasks: homework.tasks ? [...homework.tasks] : [], + }; + dispatch(addDraftHomework(copy)); + } + }, [draftHomeworks, dispatch]); + + const addNewTask = useCallback((homework: HomeworkViewModel, maxRating?: number, suggestedMaxRating?: number) => { + ensureHomeworkInDrafts(homework); + const newId = draftIdCounter; + const newTask = { + id: newId, + homeworkId: homework.id, + title: 'Новая задача', + description: '', + maxRating: maxRating ?? 10, + suggestedMaxRating, + isDeferred: homework.isDeferred || false, + deadlineDateNotSet: true, + deadlineDate: undefined, + tags: homework.tags || [], + } as HomeworkTaskViewModel; + dispatch(addDraftTask(newTask)); + dispatch(decrementDraftId()); + dispatch(setSelectedItem({ isHomework: false, id: newId })); + return newId; + }, [dispatch, draftIdCounter, ensureHomeworkInDrafts]); + + const startEditingTask = useCallback((task: HomeworkTaskViewModel, homework: HomeworkViewModel) => { + ensureHomeworkInDrafts(homework); + const copy: HomeworkTaskViewModel = { ...task }; + dispatch(addDraftTask(copy)); + dispatch(setSelectedItem({ isHomework: false, id: task.id })); + }, [dispatch, ensureHomeworkInDrafts]); + + const updateDraftTask = useCallback((task: HomeworkTaskViewModel) => { + dispatch(updateDraftTaskAction(task)); + }, [dispatch]); + + const cancelEditingTask = useCallback((taskId: number, homeworkId: number) => { + dispatch(removeDraftTask({ homeworkId, taskId })); + }, [dispatch]); + + const commitTask = useCallback((draftId: number, homeworkId: number, savedTask: HomeworkTaskViewModel) => { + dispatch(updateTask(savedTask)); + dispatch(removeDraftTask({ homeworkId, taskId: draftId })); + }, [dispatch]); + + const commitTaskDeletion = useCallback((taskId: number, homeworkId: number) => { + dispatch(deleteTask({ homeworkId, taskId })); + dispatch(removeDraftTask({ homeworkId, taskId })); + }, [dispatch]); + + return { + addNewTask, + startEditingTask, + updateDraftTask, + cancelEditingTask, + commitTask, + commitTaskDeletion, + }; +}; diff --git a/hwproj.front/src/store/slices/authSlice.ts b/hwproj.front/src/store/slices/authSlice.ts deleted file mode 100644 index 97f9b2aad..000000000 --- a/hwproj.front/src/store/slices/authSlice.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {createSlice, PayloadAction} from '@reduxjs/toolkit'; - -interface AuthState { - userId: string | null; - isLecturer: boolean; - isExpert: boolean; -} - -const initialState: AuthState = { - userId: null, - isLecturer: false, - isExpert: false, -}; - -const authSlice = createSlice({ - name: 'auth', - initialState, - reducers: { - setAuth: (state, action: PayloadAction) => { - state.userId = action.payload.userId; - state.isLecturer = action.payload.isLecturer; - state.isExpert = action.payload.isExpert; - }, - - clearAuth: (state) => { - state.userId = null; - state.isLecturer = false; - state.isExpert = false; - }, - }, -}); - -export const { setAuth, - clearAuth - } = authSlice.actions; - -export default authSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/courseEditingSlice.ts b/hwproj.front/src/store/slices/courseEditingSlice.ts new file mode 100644 index 000000000..13d5e609e --- /dev/null +++ b/hwproj.front/src/store/slices/courseEditingSlice.ts @@ -0,0 +1,95 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import {HomeworkTaskViewModel, HomeworkViewModel} from "@/api"; + +export interface SelectedItem { + isHomework: boolean; + id: number | undefined; +} + +interface CourseEditingState { + draftHomeworks: HomeworkViewModel[]; + draftIdCounter: number; + selectedItem: SelectedItem; +} + +const initialState: CourseEditingState = { + draftHomeworks: [], + draftIdCounter: -1, + selectedItem: {isHomework: true, id: undefined,}, +}; + +const courseEditingSlice = createSlice({ + name: 'editing', + initialState, + reducers: { + addDraftHomework: (state, action: PayloadAction) => { + const exists = state.draftHomeworks.some(dh => dh.id === action.payload.id); + if (!exists) { + state.draftHomeworks.push(action.payload); + } + }, + + updateDraftHomework: (state, action: PayloadAction) => { + const id = state.draftHomeworks.findIndex(dh => dh.id === action.payload.id); + if (id !== -1) { + state.draftHomeworks[id] = action.payload; + } + }, + + removeDraftHomework: (state, action: PayloadAction) => { + state.draftHomeworks = state.draftHomeworks.filter(dh => dh.id !== action.payload); + }, + + addDraftTask: (state, action: PayloadAction) => { + const hw = state.draftHomeworks.find(dh => dh.id === action.payload.homeworkId); + if (hw) { + const exists = hw.tasks?.some(t => t.id === action.payload.id); + if (!exists) { + if (!hw.tasks) hw.tasks = []; + hw.tasks.push(action.payload); + } + } + }, + + updateDraftTask: (state, action: PayloadAction) => { + const hw = state.draftHomeworks.find(dh => dh.id === action.payload.homeworkId); + if (hw && hw.tasks) { + const idx = hw.tasks.findIndex(t => t.id === action.payload.id); + if (idx !== -1) { + hw.tasks[idx] = action.payload; + } + } + }, + + removeDraftTask: (state, action: PayloadAction<{ homeworkId: number; taskId: number }>) => { + const hw = state.draftHomeworks.find(dh => dh.id === action.payload.homeworkId); + if (hw && hw.tasks) { + hw.tasks = hw.tasks.filter(t => t.id !== action.payload.taskId); + } + }, + + decrementDraftId: (state) => { + state.draftIdCounter -= 1; + }, + + setSelectedItem: (state, action: PayloadAction) => { + state.selectedItem = action.payload; + }, + + resetEditingState: () => initialState, + }, +}); + +export const { + addDraftHomework, + updateDraftHomework, + removeDraftHomework, + addDraftTask, + updateDraftTask, + removeDraftTask, + decrementDraftId, + setSelectedItem, + resetEditingState, +} = courseEditingSlice.actions; + +export default courseEditingSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/store.ts b/hwproj.front/src/store/store.ts index 64de082f9..4c21ad128 100644 --- a/hwproj.front/src/store/store.ts +++ b/hwproj.front/src/store/store.ts @@ -4,6 +4,7 @@ import homeworkReducer from './slices/homeworkSlice'; import solutionsReducer from './slices/solutionSlice'; import courseFilesReducer from './slices/courseFileSlice'; import userReducer from './slices/userSlice'; +import courseEditingReducer from './slices/courseEditingSlice'; export const store = configureStore({ reducer: { @@ -12,6 +13,7 @@ export const store = configureStore({ solutions: solutionsReducer, courseFiles: courseFilesReducer, user: userReducer, + editing: courseEditingReducer, }, }); From 30d8e3d2da13f2e8c091355d766412f82408bd0b Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 08:50:55 +0300 Subject: [PATCH 28/48] refactor: Course.tsx in accordance with state management through stores --- .../src/components/Courses/Course.tsx | 101 ++++-------------- 1 file changed, 18 insertions(+), 83 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index fdc250396..7c1b12b9f 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -1,8 +1,7 @@ import * as React from "react"; -import {FileInfoDTO,ScopeDTO,} from "@/api"; +import {ScopeDTO} from "@/api"; import {FC, useEffect, useState} from "react"; import {useNavigate, useParams, useSearchParams} from "react-router-dom"; -import {AccountDataDto, CourseViewModel, HomeworkViewModel, HomeworkTaskViewModel, StatisticsCourseMatesModel} from "@/api"; import StudentStats from "./StudentStats"; import NewCourseStudents from "./NewCourseStudents"; import ApiSingleton from "../../api/ApiSingleton"; @@ -34,16 +33,10 @@ import QrCode2Icon from '@mui/icons-material/QrCode2'; import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {FileStatus} from "../Files/FileStatus"; -import {useCourseDispatch, useCourseState} from "@/store/hooks"; -import {setCourse, setMentors, setAcceptedStudents, setNewStudents} from "@/store/slices/courseSlice"; -import {setHomeworks, updateOrInsertHomework, deleteHomework, updateTask, deleteTask} from "@/store/slices/homeworkSlice"; -import {setStudentSolutions} from "@/store/slices/solutionSlice"; -import {setCourseFiles, updateCourseFiles, setProcessingLoading} from "@/store/slices/courseFileSlice"; -import {setUser, UserRole} from "@/store/slices/userSlice"; -import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; +import {useCourseState} from "@/store/hooks"; +import {useCourseLoader, useCourseFiles} from "@/store/courseHooks"; import {CourseUnitType} from "@/components/Files/CourseUnitType"; import {enqueueSnackbar} from "notistack"; -import ErrorsHandler from "@/components/Utils/ErrorsHandler"; type TabValue = "homeworks" | "stats" | "applications" @@ -60,7 +53,6 @@ const Course: React.FC = () => { const [searchParams] = useSearchParams() const navigate = useNavigate() - const dispatch = useCourseDispatch(); const course = useCourseState(state => state.course.currentCourse); const isFound = useCourseState(state => state.course.isFound); const mentors = useCourseState(state => state.course.mentors); @@ -74,18 +66,6 @@ const Course: React.FC = () => { const intervalsRef = React.useRef>({}); - const handleUpdateCourseFiles = (files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { - dispatch(updateCourseFiles({ files, unitType, unitId })); - }; - - const setCommonLoading = (homeworkId: number) => { - dispatch(setProcessingLoading({ homeworkId, isLoading: true })); - } - - const unsetCommonLoading = (homeworkId: number) => { - dispatch(setProcessingLoading({ homeworkId, isLoading: false })); - } - const stopProcessing = (homeworkId: number) => { if (intervalsRef.current[homeworkId]) { const {interval, timeout} = intervalsRef.current[homeworkId]; @@ -128,22 +108,22 @@ const Course: React.FC = () => { // Первый вариант для явного отображения всех файлов if (waitingNewFilesCount === 0 && files.filter(f => f.status === FileStatus.ReadyToUse).length === previouslyExistingFilesCount - deletingFilesIds.length) { - handleUpdateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) - unsetCommonLoading(homeworkId) + updateFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) + setFileLoading(homeworkId, false) } // Второй вариант для явного отображения всех файлов if (waitingNewFilesCount > 0 && files.filter(f => !deletingFilesIds.some(dfi => dfi === f.id)).length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount) { - handleUpdateCourseFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) - unsetCommonLoading(homeworkId) + updateFiles(files, scopeDto.courseUnitType as CourseUnitType, scopeDto.courseUnitId!) + setFileLoading(homeworkId, false) } // Условие прекращения отправки запросов на получения записей файлов if (files.length === previouslyExistingFilesCount - deletingFilesIds.length + waitingNewFilesCount && files.every(f => f.status !== FileStatus.Uploading && f.status !== FileStatus.Deleting)) { stopProcessing(homeworkId); - unsetCommonLoading(homeworkId) + setFileLoading(homeworkId, false) } } catch (error) { @@ -157,14 +137,14 @@ const Course: React.FC = () => { // Создаем таймаут для автоматической остановки const timeout = setTimeout(() => { stopProcessing(homeworkId); - unsetCommonLoading(homeworkId) + setFileLoading(homeworkId, false) }, 10000); // Сохраняем интервал и таймаут в ref intervalsRef.current[homeworkId] = {interval, timeout}; // Сигнализируем о начале загрузки через состояние - setCommonLoading(homeworkId) + setFileLoading(homeworkId, true) } // Останавливаем все активные интевалы при размонтировании @@ -183,9 +163,7 @@ const Course: React.FC = () => { }) useEffect(() => { - const userId = ApiSingleton.authService.getUserId(); - const role = ApiSingleton.authService.getRole() as UserRole; - dispatch(setUser({ userId, role })) + initUser(); }, []) const userId = useCourseState(state => state.user.userId); @@ -195,12 +173,9 @@ const Course: React.FC = () => { const isCourseMentor = mentors.some(t => t.userId === userId) const isSignedInCourse = newStudents!.some(cm => cm.userId === userId) - const { - courseFilesState, - updateCourseUnitFiles, - } = FilesUploadWaiter(+courseId!, CourseUnitType.Homework, !isCourseMentor); - const isAcceptedStudent = acceptedStudents!.some(cm => cm.userId === userId) + const {initUser, loadCourse, loadStudentSolutions, resetEditing} = useCourseLoader(+courseId!); + const {loadCourseFiles, updateFiles, setFileLoading} = useCourseFiles(+courseId!, isCourseMentor); const showStatsTab = isCourseMentor || isAcceptedStudent const showApplicationsTab = isCourseMentor @@ -218,46 +193,18 @@ const Course: React.FC = () => { } const setCurrentState = async () => { - const course = await ApiSingleton.coursesApi.coursesGetCourseData(+courseId!) - - // У пользователя изменилась роль (иначе он не может стать лектором в курсе), - // однако он все ещё использует токен с прежней ролью - const shouldRefreshToken = - !isMentor && - course && - course.mentors!.some(t => t.userId === userId) - if (shouldRefreshToken) { - const newToken = await ApiSingleton.accountApi.accountRefreshToken() - newToken.value && ApiSingleton.authService.refreshToken(newToken.value.accessToken!) - return - } - - dispatch(setCourse(course)); - dispatch(setMentors(course.mentors!)); - dispatch(setAcceptedStudents(course.acceptedStudents!)); - dispatch(setNewStudents(course.newStudents!)); - dispatch(setHomeworks(course.homeworks!)); - await getCourseFilesInfo(); - } - - const getCourseFilesInfo = async () => { - let courseFilesInfo = [] as FileInfoDTO[] - try { - courseFilesInfo = await ApiSingleton.filesApi.filesGetFilesInfo(+courseId!, !isCourseMentor); - } catch (e) { - const responseErrors = await ErrorsHandler.getErrorMessages(e as Response) - enqueueSnackbar(responseErrors[0], {variant: "warning", autoHideDuration: 1990}); - } - dispatch(setCourseFiles(courseFilesInfo)); + const loadedCourse = await loadCourse(); + if (loadedCourse == null) return; + await loadCourseFiles(); } useEffect(() => { + resetEditing() setCurrentState() }, [courseId]) useEffect(() => { - ApiSingleton.statisticsApi.statisticsGetCourseStatistics(+courseId!) - .then(res => dispatch(setStudentSolutions(res))) + loadStudentSolutions() }, [courseId]) useEffect(() => changeTab(tab || "homeworks"), [tab, courseId, isFound]) @@ -270,15 +217,6 @@ const Course: React.FC = () => { const {tabValue} = pageState const searchedHomeworkId = searchParams.get("homeworkId") - const handleHomeworkUpdate = (update: { homework: HomeworkViewModel } & { isDeleted?: boolean }) => { - if (update.isDeleted) dispatch(deleteHomework(update.homework.id!)) - else dispatch(updateOrInsertHomework(update.homework)) - } - const handleTaskUpdate = (update: { task: HomeworkTaskViewModel, isDeleted?: boolean }) => { - if (update.isDeleted) dispatch(deleteTask({homeworkId: update.task.homeworkId!, taskId: update.task.id!})) - else dispatch(updateTask(update.task)) - } - const unratedSolutionsCount = studentSolutions .flatMap(x => x.homeworks) .flatMap(x => x!.tasks) @@ -442,7 +380,6 @@ const Course: React.FC = () => { {tabValue === "homeworks" && { userId={userId!} processingFiles={processingFilesState} onStartProcessing={getFilesByInterval} - onHomeworkUpdate={handleHomeworkUpdate} - onTaskUpdate={handleTaskUpdate} /> } {tabValue === "stats" && From 7f0581c367366b7eea5f0622d29b0fc486efebf6 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 08:57:06 +0300 Subject: [PATCH 29/48] refactor: unlink CourseExperimental from props, connect to store --- .../components/Courses/CourseExperimental.tsx | 219 +++++++----------- 1 file changed, 79 insertions(+), 140 deletions(-) diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index 316f80e5c..36b01e3c6 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -36,9 +36,10 @@ import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import SwitchAccessShortcutIcon from '@mui/icons-material/SwitchAccessShortcut'; import Lodash from "lodash"; import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import {useEditingSelection, useHomeworkEditing, useMergedHomeworks, useTaskEditing} from "@/store/editingHooks"; +import {useCourseState} from "@/store/hooks"; interface ICourseExperimentalProps { - homeworks: HomeworkViewModel[] courseFilesInfo: FileInfoDTO[] studentSolutions: StatisticsCourseMatesModel[] courseId: number @@ -46,10 +47,6 @@ interface ICourseExperimentalProps { isStudentAccepted: boolean userId: string selectedHomeworkId: number | undefined - onHomeworkUpdate: (update: { homework: HomeworkViewModel } & { - isDeleted?: boolean - }) => void - onTaskUpdate: (update: { task: HomeworkTaskViewModel, isDeleted?: boolean }) => void, processingFiles: { [homeworkId: number]: { isLoading: boolean; @@ -62,14 +59,6 @@ interface ICourseExperimentalProps { deletingFilesIds: number[]) => void; } -interface ICourseExperimentalState { - initialEditMode: boolean, - selectedItem: { - isHomework: boolean, - id: number | undefined, - } -} - export const CourseExperimental: FC = (props) => { const [hideDeferred, setHideDeferred] = useState(false) const [showOnlyGroupedTest, setShowOnlyGroupedTest] = useState(undefined) @@ -81,8 +70,27 @@ export const CourseExperimental: FC = (props) => { // Состояние для кнопки "Наверх" const [showScrollButton, setShowScrollButton] = useState(false); - - const homeworks = props.homeworks.slice().reverse().filter(x => { + const [initialEditMode, setInitialEditMode] = useState(false) + + const mergedHomeworks = useMergedHomeworks() + const {selectedItem, select} = useEditingSelection() + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks) + const { + addNewHomework: addNewHomeworkToStore, + startEditingHomework, + updateDraftHomework, + commitHomework, + commitHomeworkDeletion, + } = useHomeworkEditing() + const { + addNewTask: addNewTaskToStore, + startEditingTask, + updateDraftTask, + commitTask, + commitTaskDeletion, + } = useTaskEditing() + + const homeworks = mergedHomeworks.slice().reverse().filter(x => { if (hideDeferred) return !x.isDeferred if (showOnlyGroupedTest !== undefined) return x.tags!.includes(TestTag) && x.tags!.includes(showOnlyGroupedTest) return true @@ -90,18 +98,12 @@ export const CourseExperimental: FC = (props) => { const {isMentor, studentSolutions, isStudentAccepted, userId, selectedHomeworkId, courseFilesInfo} = props - const [state, setState] = useState({ - initialEditMode: false, - selectedItem: {id: undefined, isHomework: true}, - }) + const {id, isHomework} = selectedItem useEffect(() => { const defaultHomeworkIndex = Math.max(selectedHomeworkId ? homeworks?.findIndex(x => x.id === selectedHomeworkId) : 0, 0) const defaultHomework = homeworks?.[defaultHomeworkIndex] - setState((prevState) => ({ - ...prevState, - selectedItem: {isHomework: true, id: defaultHomework?.id}, - })) + select({isHomework: true, id: defaultHomework?.id}) }, [hideDeferred]) // Обработчик прокрутки страницы @@ -126,9 +128,6 @@ export const CourseExperimental: FC = (props) => { }); }; - const initialEditMode = state.initialEditMode - const {id, isHomework} = state.selectedItem - const renderDate = (date: Date) => { date = new Date(date) const options: Intl.DateTimeFormatOptions = { @@ -184,18 +183,18 @@ export const CourseExperimental: FC = (props) => { return result !== true && result.hasErrors } - const renderHomeworkStatus = (homework: HomeworkViewModel & { isModified?: boolean, hasErrors?: boolean }) => { + const renderHomeworkStatus = (homework: HomeworkViewModel & { hasErrors?: boolean }) => { const hasErrors = homework.id! < 0 && (homework.hasErrors || homework.tasks!.some((t: HomeworkTaskViewModel & { hasErrors?: boolean }) => t.hasErrors)) if (hasErrors) return

- if (homework.isModified) + if (draftHomeworks.some(d => d.id === homework.id && d.id! > 0)) return

return showWarningsForEntity(homework, true) &&
⚠️
} - const renderTaskStatus = (task: HomeworkTaskViewModel & { isModified?: boolean, hasErrors?: boolean }) => { + const renderTaskStatus = (task: HomeworkTaskViewModel & { hasErrors?: boolean }) => { if (taskSolutionsMap.has(task.id!)) { const solutions = taskSolutionsMap.get(task.id!) const { @@ -214,7 +213,8 @@ export const CourseExperimental: FC = (props) => { ) } if (task.hasErrors) return - if (task.isModified) return + if (task.id! > 0 && draftHomeworks.some(d => d.tasks?.some(t => t.id === task.id))) + return return showWarningsForEntity(task, false) ? ( ⚠️ @@ -222,17 +222,12 @@ export const CourseExperimental: FC = (props) => { ) : } - const onSelectedItemMount = () => - setState((prevState) => ({ - ...prevState, - initialEditMode: false, - })) + const onSelectedItemMount = () => setInitialEditMode(false) - const toEditHomework = (homework: HomeworkViewModel) => - setState({ - initialEditMode: true, - selectedItem: {id: homework.id!, isHomework: true}, - }) + const toEditHomework = (homework: HomeworkViewModel) => { + setInitialEditMode(true) + select({id: homework.id!, isHomework: true}) + } const validateTestGrouping = (homework: HomeworkViewModel) => { if (!homework.tags!.includes(TestTag)) return true @@ -271,13 +266,10 @@ export const CourseExperimental: FC = (props) => { }>Часть добавления нового задания
@@ -339,41 +331,15 @@ export const CourseExperimental: FC = (props) => { ? homeworks.find(x => x.id === id)! : homeworks.find(x => x.tasks!.some(t => t.id === id))! - const selectedItem = isHomework + const selectedItemData = isHomework ? selectedItemHomework : selectedItemHomework?.tasks!.find(x => x.id === id) as HomeworkTaskViewModel - const [newTaskCounter, setNewTaskCounter] = useState(-1) - const addNewHomework = () => { - props.onHomeworkUpdate({ - homework: { - courseId: props.courseId, - title: "Новое задание", - publicationDateNotSet: false, - publicationDate: undefined, - hasDeadline: false, - id: -1, - isGroupWork: false, - deadlineDateNotSet: false, - deadlineDate: undefined, - isDeadlineStrict: false, - description: "", - tasks: [], - tags: [] - } - }) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: true, - id: -1 - } - })) + addNewHomeworkToStore(props.courseId) } const addNewTask = (homework: HomeworkViewModel) => { - const id = newTaskCounter const tags = homework.tags! const isTest = tags.includes(TestTag) const isBonus = tags.includes(BonusTag) @@ -391,31 +357,12 @@ export const CourseExperimental: FC = (props) => { .entries() .sortBy(x => x[1].length).last()?.[1][0] - const task = { - homeworkId: homework.id, - maxRating: ratingCandidate || 10, - suggestedMaxRating: ratingCandidate, - title: `Новая задача`, - tags: homework.tags, - isDeferred: homework.isDeferred, - description: "", - id - } - - props.onTaskUpdate({task}) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: false, - id: id - } - })) - setNewTaskCounter(id - 1) + addNewTaskToStore(homework, ratingCandidate || 10, ratingCandidate) } - const renderHomework = (homework: HomeworkViewModel & { isModified?: boolean }) => { + const renderHomework = (homework: HomeworkViewModel) => { const filesInfo = id ? FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, id) : [] - const homeworkEditMode = homework && (homework.id! < 0 || homework.isModified === true) + const homeworkEditMode = homework && (homework.id! < 0 || draftHomeworks.some(d => d.id === homework.id && d.id! > 0)) return homework && {isMentor && getGroupingAlert(homework)} @@ -427,14 +374,20 @@ export const CourseExperimental: FC = (props) => { onMount={onSelectedItemMount} onAddTask={addNewTask} onUpdate={update => { - props.onHomeworkUpdate(update) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: true, - id: update.isDeleted ? undefined : update.homework.id! + if (update.isDeleted) { + commitHomeworkDeletion(homework.id!) + select({isHomework: true, id: undefined}) + } else if ((update as { isSaved?: boolean }).isSaved) { + commitHomework(homework.id!, update.homework) + select({isHomework: true, id: update.homework.id!}) + } else { + if (update.homework.id! > 0) { + const hasHomeworkDraft = draftHomeworks.some(d => d.id === update.homework.id) + if (!hasHomeworkDraft) startEditingHomework(update.homework) } - })) + updateDraftHomework(update.homework) + select({isHomework: true, id: update.homework.id!}) + } }} isProcessing={props.processingFiles[homework.id!]?.isLoading || false} onStartProcessing={props.onStartProcessing} @@ -443,8 +396,8 @@ export const CourseExperimental: FC = (props) => { } - const renderTask = (task: HomeworkTaskViewModel & { isModified?: boolean }, homework: HomeworkViewModel) => { - const taskEditMode = task && (task.id! < 0 || task.isModified === true) + const renderTask = (task: HomeworkTaskViewModel, homework: HomeworkViewModel) => { + const taskEditMode = task && (task.id! < 0 || draftHomeworks.some(d => d.tasks?.some(t => t.id === task.id) && task.id! > 0)) return task && {isMentor && getDatesAlert(task, false)} = (props) => { initialEditMode={initialEditMode || taskEditMode} onMount={onSelectedItemMount} onUpdate={update => { - props.onTaskUpdate(update) - if (update.isDeleted) - setState((prevState) => ({ - ...prevState, - selectedItem: { - isHomework: true, - id: homework!.id - } - })) + if (update.isDeleted) { + commitTaskDeletion(task.id!, task.homeworkId!) + select({isHomework: true, id: homework!.id}) + } else if ((update as { isSaved?: boolean }).isSaved) { + commitTask(task.id!, task.homeworkId!, update.task) + } else { + if (update.task.id! > 0) { + const hasTaskDraft = draftHomeworks + .find(d => d.id === update.task.homeworkId) + ?.tasks + ?.some(t => t.id === update.task.id) === true + if (!hasTaskDraft) startEditingTask(update.task, homework) + } + updateDraftTask(update.task) + } }} toEditHomework={() => toEditHomework(homework!)}/> {!props.isMentor && props.isStudentAccepted && < CardActions> @@ -541,7 +500,7 @@ export const CourseExperimental: FC = (props) => { } {isMentor && homeworks.length === 0 && renderLecturerWelcomeScreen()} - {homeworks.map((x: HomeworkViewModel & { isModified?: boolean, hasErrors?: boolean }) => { + {homeworks.map((x: HomeworkViewModel & { hasErrors?: boolean }) => { return
= (props) => { alignContent={"center"} sx={{":hover": hoveredItemStyle}} style={{...getStyle(true, x.id!), minHeight: 50}} - onClick={() => { - setState(prevState => ({ - ...prevState, - selectedItem: { - data: x, - isHomework: true, - id: x.id, - homeworkFilesInfo: FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, x.id!) - } - })) - }}> + onClick={() => select({isHomework: true, id: x.id})}> = (props) => { {x.tasks!.map(t => { - setState(prevState => ({ - ...prevState, - selectedItem: { - data: t, - isHomework: false, - id: t.id, - homeworkFilesInfo: [] - } - })) - }} + onClick={() => select({isHomework: false, id: t.id})} style={getStyle(false, t.id!)} sx={{":hover": hoveredItemStyle}}> {!t.deadlineDateNotSet && @@ -633,8 +572,8 @@ export const CourseExperimental: FC = (props) => { {isHomework - ? renderHomework(selectedItem as HomeworkViewModel) - : renderTask(selectedItem as HomeworkTaskViewModel, selectedItemHomework!)} + ? renderHomework(selectedItemData as HomeworkViewModel) + : renderTask(selectedItemData as HomeworkTaskViewModel, selectedItemHomework!)} {renderGif()} From 687a5c2978cf5a1238f673b3647d3b4c284acc3a Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 11:49:55 +0300 Subject: [PATCH 30/48] refactor: improve merging of draft tasks in useMergedHomeworks --- hwproj.front/src/store/editingHooks.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/hwproj.front/src/store/editingHooks.ts b/hwproj.front/src/store/editingHooks.ts index 1157a0187..bdb9f0b19 100644 --- a/hwproj.front/src/store/editingHooks.ts +++ b/hwproj.front/src/store/editingHooks.ts @@ -29,9 +29,25 @@ export const useMergedHomeworks = () => { const newDrafts = draftHomeworks.filter(dh => dh.id! < 0); const result: HomeworkViewModel[] = []; + const mergeTasks = (committed: HomeworkTaskViewModel[] = [], draft: HomeworkTaskViewModel[] = []) => { + const byId = new Map(); + for (const task of committed) byId.set(task.id!, task); + for (const task of draft) byId.set(task.id!, task); + return Array.from(byId.values()); + }; + for (const committed of committedHomeworks) { const draft = draftHomeworks.find(dh => dh.id === committed.id); - result.push(draft || committed); + if (!draft) { + result.push(committed); + continue; + } + + result.push({ + ...committed, + ...draft, + tasks: mergeTasks(committed.tasks, draft.tasks), + }); } result.push(...newDrafts); @@ -124,7 +140,7 @@ export const useTaskEditing = () => { if (!existingDraft) { const copy: HomeworkViewModel = { ...homework, - tasks: homework.tasks ? [...homework.tasks] : [], + tasks: [], }; dispatch(addDraftHomework(copy)); } From 85f68a5ecdc8db6593b1389eac73f94e0e41f10b Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 11:57:55 +0300 Subject: [PATCH 31/48] refactor: editing logic is now in the editor component --- .../Tasks/CourseTaskExperimental.tsx | 85 ++++++++++++------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx index 7620c4878..43828ee25 100644 --- a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx +++ b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx @@ -26,6 +26,7 @@ import TaskPublicationAndDeadlineDates from "../Common/TaskPublicationAndDeadlin import DeletionConfirmation from "../DeletionConfirmation"; import ActionOptionsUI from "../Common/ActionOptions"; import {useCourseState} from "@/store/hooks"; +import {useTaskEditing, useEditingSelection} from "@/store/editingHooks"; import {Stack} from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import Collapse from "@mui/material/Collapse"; @@ -50,19 +51,23 @@ type TaskEditData = HomeworkTaskViewModel & { }; const CourseTaskEditor: FC<{ - speculativeTask: TaskEditData, - speculativeHomework: HomeworkViewModel, - onUpdate: (update: { task: TaskEditData, isDeleted?: boolean, isSaved?: boolean }) => void, + task: TaskEditData, + homework: HomeworkViewModel, + onDone: () => void, toEditHomework: () => void, }> = (props) => { + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks) + const {startEditingTask, updateDraftTask, cancelEditingTask, commitTask, commitTaskDeletion} = useTaskEditing() + const {select} = useEditingSelection() + const [taskData, setTaskData] = useState<{ task: HomeworkTaskViewModel, homework: HomeworkViewModel, isLoaded: boolean }>({ - task: props.speculativeTask, - homework: props.speculativeHomework, - isLoaded: props.speculativeTask.id! < 0 || props.speculativeTask.isModified === true + task: props.task, + homework: props.homework, + isLoaded: props.task.id! < 0 || draftHomeworks.some(d => d.tasks?.some(t => t.id === props.task.id)) }) const [criteria, setCriteria] = useState(taskData.task.criteria || []) @@ -147,9 +152,9 @@ const CourseTaskEditor: FC<{ criteria.length > 0 ? criteriaTotalPoints : task.maxRating! ) const [description, setDescription] = useState(task.description || "") - const [isBonusExplicit, setIsBonusExplicit] = useState(props.speculativeTask.tags!.includes(BonusTag) && !props.speculativeHomework.tags!.includes(BonusTag)) + const [isBonusExplicit, setIsBonusExplicit] = useState(props.task.tags!.includes(BonusTag) && !props.homework.tags!.includes(BonusTag)) - const [hasErrors, setHasErrors] = useState(props.speculativeTask.hasErrors || false) + const [hasErrors, setHasErrors] = useState(props.task.hasErrors || false) const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) const [handleSubmitLoading, setHandleSubmitLoading] = useState(false); @@ -159,7 +164,7 @@ const CourseTaskEditor: FC<{ useEffect(() => { const update = { - ...props.speculativeTask, + ...props.task, ...metadata!, title: title!, description: description, @@ -170,7 +175,11 @@ const CourseTaskEditor: FC<{ hasErrors: hasErrors, criteria: criteria, } - props.onUpdate({task: update}); + + if (props.task.id! > 0 && !draftHomeworks.some(d => d.tasks?.some(t => t.id === props.task.id))) { + startEditingTask(update, homework) + } + updateDraftTask(update) }, [title, description, maxRating, metadata, isBonusExplicit, hasErrors, criteria]); useEffect(() => { @@ -195,17 +204,29 @@ const CourseTaskEditor: FC<{ ? await ApiSingleton.tasksApi.tasksAddTask(homework.id!, update) : await ApiSingleton.tasksApi.tasksUpdateTask(+id!, update) - if (isNewTask) - props.onUpdate({ - task: props.speculativeTask, - isDeleted: true, - }) - props.onUpdate({task: updatedTask.value!, isSaved: true}) + if (isNewTask) { + commitTaskDeletion(props.task.id!, props.task.homeworkId!) + } + commitTask(props.task.id!, props.task.homeworkId!, updatedTask.value!) + select({isHomework: false, id: updatedTask.value!.id}) + props.onDone() } const deleteTask = async () => { if (!isNewTask) await ApiSingleton.tasksApi.tasksDeleteTask(id!) - props.onUpdate({task, isDeleted: true}) + commitTaskDeletion(props.task.id!, props.task.homeworkId!) + select({isHomework: true, id: homework.id}) + } + + const handleCancel = () => { + if (isNewTask) { + commitTaskDeletion(props.task.id!, props.task.homeworkId!) + select({isHomework: true, id: homework.id}) + } else { + cancelEditingTask(props.task.id!, props.task.homeworkId!) + select({isHomework: false, id: props.task.id}) + } + props.onDone() } const isDisabled = hasErrors || !isLoaded @@ -214,10 +235,19 @@ const CourseTaskEditor: FC<{ const homeworkPublicationDateIsSet = !homework.publicationDateNotSet const maxRatingLabel = - criteria.length > 0 ? "Критерии" : props.speculativeTask.suggestedMaxRating === maxRating ? "Вычислено" : undefined + criteria.length > 0 ? "Критерии" : props.task.suggestedMaxRating === maxRating ? "Вычислено" : undefined return ( - + + + + @@ -495,7 +525,6 @@ const CourseTaskExperimental: FC<{ homework: HomeworkViewModel, initialEditMode: boolean, onMount: () => void, - onUpdate: (x: { task: TaskEditData, isDeleted?: boolean }) => void toEditHomework: () => void, }> = (props) => { const mentors = useCourseState(state => state.course.mentors); @@ -514,19 +543,9 @@ const CourseTaskExperimental: FC<{ if (editMode) { return { - const updateFix = { - ...update, - task: { - ...update.task, - isModified: !update.isSaved, - } - } - props.onUpdate(updateFix) - if (update.isSaved) setEditMode(false) - }} + task={task} + homework={homework} + onDone={() => setEditMode(false)} toEditHomework={props.toEditHomework} /> } From 6836dbab7b434ac12b16fe04eb296ceba1aaf665 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 11:59:33 +0300 Subject: [PATCH 32/48] refactor: simplify CourseExperimental, remove callbacks before components --- .../components/Courses/CourseExperimental.tsx | 46 +------------------ 1 file changed, 2 insertions(+), 44 deletions(-) diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index 36b01e3c6..0a649bd3c 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -77,17 +77,9 @@ export const CourseExperimental: FC = (props) => { const draftHomeworks = useCourseState(state => state.editing.draftHomeworks) const { addNewHomework: addNewHomeworkToStore, - startEditingHomework, - updateDraftHomework, - commitHomework, - commitHomeworkDeletion, } = useHomeworkEditing() const { addNewTask: addNewTaskToStore, - startEditingTask, - updateDraftTask, - commitTask, - commitTaskDeletion, } = useTaskEditing() const homeworks = mergedHomeworks.slice().reverse().filter(x => { @@ -364,7 +356,7 @@ export const CourseExperimental: FC = (props) => { const filesInfo = id ? FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, id) : [] const homeworkEditMode = homework && (homework.id! < 0 || draftHomeworks.some(d => d.id === homework.id && d.id! > 0)) return homework && - + {isMentor && getGroupingAlert(homework)} {isMentor && getDatesAlert(homework, true)} = (props) => { initialEditMode={initialEditMode || homeworkEditMode} onMount={onSelectedItemMount} onAddTask={addNewTask} - onUpdate={update => { - if (update.isDeleted) { - commitHomeworkDeletion(homework.id!) - select({isHomework: true, id: undefined}) - } else if ((update as { isSaved?: boolean }).isSaved) { - commitHomework(homework.id!, update.homework) - select({isHomework: true, id: update.homework.id!}) - } else { - if (update.homework.id! > 0) { - const hasHomeworkDraft = draftHomeworks.some(d => d.id === update.homework.id) - if (!hasHomeworkDraft) startEditingHomework(update.homework) - } - updateDraftHomework(update.homework) - select({isHomework: true, id: update.homework.id!}) - } - }} - isProcessing={props.processingFiles[homework.id!]?.isLoading || false} onStartProcessing={props.onStartProcessing} /> @@ -398,7 +373,7 @@ export const CourseExperimental: FC = (props) => { const renderTask = (task: HomeworkTaskViewModel, homework: HomeworkViewModel) => { const taskEditMode = task && (task.id! < 0 || draftHomeworks.some(d => d.tasks?.some(t => t.id === task.id) && task.id! > 0)) - return task && + return task && {isMentor && getDatesAlert(task, false)} = (props) => { homework={homework!} initialEditMode={initialEditMode || taskEditMode} onMount={onSelectedItemMount} - onUpdate={update => { - if (update.isDeleted) { - commitTaskDeletion(task.id!, task.homeworkId!) - select({isHomework: true, id: homework!.id}) - } else if ((update as { isSaved?: boolean }).isSaved) { - commitTask(task.id!, task.homeworkId!, update.task) - } else { - if (update.task.id! > 0) { - const hasTaskDraft = draftHomeworks - .find(d => d.id === update.task.homeworkId) - ?.tasks - ?.some(t => t.id === update.task.id) === true - if (!hasTaskDraft) startEditingTask(update.task, homework) - } - updateDraftTask(update.task) - } - }} toEditHomework={() => toEditHomework(homework!)}/> {!props.isMentor && props.isStudentAccepted && < CardActions> Date: Tue, 10 Mar 2026 12:04:57 +0300 Subject: [PATCH 33/48] refactor: move the homework editing logic to a component --- .../Homeworks/CourseHomeworkExperimental.tsx | 68 +++++++++++-------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 0fafa9e87..2346c29d5 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -29,6 +29,7 @@ import EditIcon from "@mui/icons-material/Edit"; import AddTaskIcon from '@mui/icons-material/AddTask'; import {LoadingButton} from "@mui/lab"; import DeletionConfirmation from "../DeletionConfirmation"; +import CloseIcon from "@mui/icons-material/Close"; import DeleteIcon from "@mui/icons-material/Delete"; import ActionOptionsUI from "components/Common/ActionOptions"; import {BonusTag, DefaultTags, isBonusWork, isTestWork, TestTag} from "@/components/Common/HomeworkTags"; @@ -37,9 +38,10 @@ import {CourseUnitType} from "../Files/CourseUnitType" import ProcessFilesUtils from "../Utils/ProcessFilesUtils"; import {useCourseState} from "@/store/hooks"; import {FilesHandler} from "@/components/Files/FilesHandler"; +import {useEditingSelection, useHomeworkEditing} from "@/store/editingHooks"; export interface HomeworkAndFilesInfo { - homework: HomeworkViewModel & { isModified?: boolean }, + homework: HomeworkViewModel, filesInfo: IFileInfo[] } @@ -53,11 +55,7 @@ interface IEditHomeworkState { const CourseHomeworkEditor: FC<{ homeworkAndFilesInfo: HomeworkAndFilesInfo, - getAllHomeworks: () => HomeworkViewModel[], - onUpdate: (update: { homework: HomeworkViewModel } & { - isDeleted?: boolean, - isSaved?: boolean - }) => void + onDone: () => void, onStartProcessing: (homeworkId: number, courseUnitType: CourseUnitType, previouslyExistingFilesCount: number, @@ -67,11 +65,14 @@ const CourseHomeworkEditor: FC<{ const homework = props.homeworkAndFilesInfo.homework const isNewHomework = homework.id! < 0 const homeworks = useCourseState(state => state.homeworks.items); + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks) + const {startEditingHomework, updateDraftHomework, cancelEditingHomework, commitHomework, commitHomeworkDeletion} = useHomeworkEditing() + const {select} = useEditingSelection() const [homeworkData, setHomeworkData] = useState<{ loadedHomework: HomeworkViewModel, isLoaded: boolean - }>({loadedHomework: homework, isLoaded: isNewHomework || homework.isModified == true}) + }>({loadedHomework: homework, isLoaded: isNewHomework || draftHomeworks.some(d => d.id === homework.id)}) useEffect(() => { if (homeworkData.isLoaded) return @@ -167,10 +168,12 @@ const CourseHomeworkEditor: FC<{ tags: tags, hasErrors: hasErrors, deadlineDateNotSet: metadata.hasDeadline && !metadata.deadlineDate, - isModified: true, } - props.onUpdate({homework: update}) + if (homework.id! > 0 && !draftHomeworks.some(d => d.id === homework.id)) { + startEditingHomework(update) + } + updateDraftHomework(update) }, [title, description, tags, metadata, hasErrors, filesState.selectedFilesInfo]) useEffect(() => { @@ -201,7 +204,19 @@ const CourseHomeworkEditor: FC<{ newFiles: [] }) - props.onUpdate({homework: loadedHomework, isDeleted: true}) + commitHomeworkDeletion(homeworkId) + select({isHomework: true, id: undefined}) + } + + const cancelEditing = () => { + if (isNewHomework) { + commitHomeworkDeletion(homework.id!) + select({isHomework: true, id: undefined}) + } else { + cancelEditingHomework(homework.id!) + select({isHomework: true, id: homework.id}) + } + props.onDone() } const getDeleteMessage = (homeworkName: string, filesInfo: IFileInfo[]) => { @@ -249,11 +264,10 @@ const CourseHomeworkEditor: FC<{ courseId, CourseUnitType.Homework, updatedHomeworkId, props.onStartProcessing, () => { - if (isNewHomework) props.onUpdate({ - homework: update, - isDeleted: true - }) // remove fake homework - props.onUpdate({homework: updatedHomework.value!, isSaved: true}); + if (isNewHomework) commitHomeworkDeletion(homework.id!) // remove fake homework + commitHomework(homework.id!, updatedHomework.value!) + select({isHomework: true, id: updatedHomeworkId}) + props.onDone() }, ); } @@ -261,7 +275,16 @@ const CourseHomeworkEditor: FC<{ const isDisabled = hasErrors || !isLoaded || taskHasErrors return ( - + + + + @@ -383,11 +406,7 @@ const CourseHomeworkExperimental: FC<{ homeworkAndFilesInfo: HomeworkAndFilesInfo, initialEditMode: boolean, onMount: () => void, - onUpdate: (x: { homework: HomeworkViewModel } & { - isDeleted?: boolean - }) => void onAddTask: (homework: HomeworkViewModel) => void, - isProcessing: boolean; onStartProcessing: (homeworkId: number, courseUnitType: CourseUnitType, previouslyExistingFilesCount: number, @@ -398,7 +417,6 @@ const CourseHomeworkExperimental: FC<{ const userId = useCourseState(state => state.user.userId); const isMentor = mentors.some(m => m.userId === userId); const processingFilesState = useCourseState(state => state.courseFiles.processingFilesState); - const homeworks = useCourseState(state => state.homeworks.items); const {homework, filesInfo} = props.homeworkAndFilesInfo const deferredTasks = homework.tasks!.filter(t => t.isDeferred!) @@ -413,11 +431,7 @@ const CourseHomeworkExperimental: FC<{ if (editMode) return homeworks} - onUpdate={update => { - if (update.isSaved) setEditMode(false) - props.onUpdate(update) - }} + onDone={() => setEditMode(false)} onStartProcessing={props.onStartProcessing} /> @@ -468,7 +482,7 @@ const CourseHomeworkExperimental: FC<{ {filesInfo.length > 0 && (
- {props.isProcessing && + {processingFilesState[homework.id!]?.isLoading &&
  Обрабатываем файлы... From 4e1638dfeb766d5d69b1764af29c45512889d420 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 12:49:53 +0300 Subject: [PATCH 34/48] fix: editing icon --- hwproj.front/src/components/Courses/Course.tsx | 2 -- .../src/components/Courses/CourseExperimental.tsx | 13 ++++--------- hwproj.front/src/store/editingHooks.ts | 14 +++++++++++--- .../src/store/slices/courseEditingSlice.ts | 3 ++- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 7c1b12b9f..3b92757be 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -61,7 +61,6 @@ const Course: React.FC = () => { const courseHomeworks = useCourseState(state => state.homeworks.items); const studentSolutions = useCourseState(state => state.solutions.studentSolutions); const courseFiles = useCourseState(state => state.courseFiles.items); - const processingFilesState = useCourseState(state => state.courseFiles.processingFilesState); const [showQrCode, setShowQrCode] = useState(false); const intervalsRef = React.useRef>({}); @@ -386,7 +385,6 @@ const Course: React.FC = () => { isStudentAccepted={isAcceptedStudent} selectedHomeworkId={searchedHomeworkId == null ? undefined : +searchedHomeworkId} userId={userId!} - processingFiles={processingFilesState} onStartProcessing={getFilesByInterval} /> } diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index 0a649bd3c..ee8d9a463 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -47,11 +47,6 @@ interface ICourseExperimentalProps { isStudentAccepted: boolean userId: string selectedHomeworkId: number | undefined - processingFiles: { - [homeworkId: number]: { - isLoading: boolean; - }; - }; onStartProcessing: (homeworkId: number, courseUnitType: CourseUnitType, previouslyExistingFilesCount: number, @@ -181,7 +176,7 @@ export const CourseExperimental: FC = (props) => { }) => t.hasErrors)) if (hasErrors) return

- if (draftHomeworks.some(d => d.id === homework.id && d.id! > 0)) + if (draftHomeworks.some(d => d.id === homework.id)) return

return showWarningsForEntity(homework, true) &&
⚠️
} @@ -205,7 +200,7 @@ export const CourseExperimental: FC = (props) => { ) } if (task.hasErrors) return - if (task.id! > 0 && draftHomeworks.some(d => d.tasks?.some(t => t.id === task.id))) + if (draftHomeworks.some(d => d.tasks?.some(t => t.id === task.id))) return return showWarningsForEntity(task, false) ? ( @@ -354,7 +349,7 @@ export const CourseExperimental: FC = (props) => { const renderHomework = (homework: HomeworkViewModel) => { const filesInfo = id ? FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, id) : [] - const homeworkEditMode = homework && (homework.id! < 0 || draftHomeworks.some(d => d.id === homework.id && d.id! > 0)) + const homeworkEditMode = homework && draftHomeworks.some(d => d.id === homework.id) return homework && {isMentor && getGroupingAlert(homework)} @@ -372,7 +367,7 @@ export const CourseExperimental: FC = (props) => { } const renderTask = (task: HomeworkTaskViewModel, homework: HomeworkViewModel) => { - const taskEditMode = task && (task.id! < 0 || draftHomeworks.some(d => d.tasks?.some(t => t.id === task.id) && task.id! > 0)) + const taskEditMode = task && draftHomeworks.some(d => d.tasks?.some(t => t.id === task.id)) return task && {isMentor && getDatesAlert(task, false)} { const startEditingHomework = useCallback((hw: HomeworkViewModel) => { const copy: HomeworkViewModel = { ...hw, - tasks: hw.tasks ? [...hw.tasks] : [], + tasks: [], }; dispatch(addDraftHomework(copy)); dispatch(setSelectedItem({ isHomework: true, id: hw.id })); @@ -180,12 +180,20 @@ export const useTaskEditing = () => { const cancelEditingTask = useCallback((taskId: number, homeworkId: number) => { dispatch(removeDraftTask({ homeworkId, taskId })); - }, [dispatch]); + const draftHw = draftHomeworks.find(dh => dh.id === homeworkId); + if (draftHw && (draftHw.tasks || []).filter(t => t.id !== taskId).length === 0) { + dispatch(removeDraftHomework(homeworkId)); + } + }, [dispatch, draftHomeworks]); const commitTask = useCallback((draftId: number, homeworkId: number, savedTask: HomeworkTaskViewModel) => { dispatch(updateTask(savedTask)); dispatch(removeDraftTask({ homeworkId, taskId: draftId })); - }, [dispatch]); + const draftHw = draftHomeworks.find(dh => dh.id === homeworkId); + if (draftHw && (draftHw.tasks || []).filter(t => t.id !== draftId).length === 0) { + dispatch(removeDraftHomework(homeworkId)); + } + }, [dispatch, draftHomeworks]); const commitTaskDeletion = useCallback((taskId: number, homeworkId: number) => { dispatch(deleteTask({ homeworkId, taskId })); diff --git a/hwproj.front/src/store/slices/courseEditingSlice.ts b/hwproj.front/src/store/slices/courseEditingSlice.ts index 13d5e609e..6c6631824 100644 --- a/hwproj.front/src/store/slices/courseEditingSlice.ts +++ b/hwproj.front/src/store/slices/courseEditingSlice.ts @@ -32,7 +32,8 @@ const courseEditingSlice = createSlice({ updateDraftHomework: (state, action: PayloadAction) => { const id = state.draftHomeworks.findIndex(dh => dh.id === action.payload.id); if (id !== -1) { - state.draftHomeworks[id] = action.payload; + const existingTasks = state.draftHomeworks[id].tasks; + state.draftHomeworks[id] = {...action.payload, tasks: existingTasks}; } }, From 27f2213155e22f4d0e10b278977ad2287d206631 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 13:18:45 +0300 Subject: [PATCH 35/48] refactor: remove prop drilling from CourseExperimental --- .../src/components/Courses/Course.tsx | 5 ----- .../components/Courses/CourseExperimental.tsx | 20 ++++++++++--------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 3b92757be..ed628938e 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -379,12 +379,7 @@ const Course: React.FC = () => { {tabValue === "homeworks" && } diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index ee8d9a463..405a3d213 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -40,12 +40,7 @@ import {useEditingSelection, useHomeworkEditing, useMergedHomeworks, useTaskEdit import {useCourseState} from "@/store/hooks"; interface ICourseExperimentalProps { - courseFilesInfo: FileInfoDTO[] - studentSolutions: StatisticsCourseMatesModel[] courseId: number - isMentor: boolean - isStudentAccepted: boolean - userId: string selectedHomeworkId: number | undefined onStartProcessing: (homeworkId: number, courseUnitType: CourseUnitType, @@ -83,7 +78,14 @@ export const CourseExperimental: FC = (props) => { return true }) - const {isMentor, studentSolutions, isStudentAccepted, userId, selectedHomeworkId, courseFilesInfo} = props + const {selectedHomeworkId} = props + const mentors = useCourseState(state => state.course.mentors); + const acceptedStudents = useCourseState(state => state.course.acceptedStudents); + const userId = useCourseState(state => state.user.userId); + const studentSolutions = useCourseState(state => state.solutions.studentSolutions); + const courseFilesInfo = useCourseState(state => state.courseFiles.items); + const isMentor = mentors.some(m => m.userId === userId); + const isStudentAccepted = acceptedStudents.some(s => s.userId === userId); const {id, isHomework} = selectedItem @@ -377,7 +379,7 @@ export const CourseExperimental: FC = (props) => { initialEditMode={initialEditMode || taskEditMode} onMount={onSelectedItemMount} toEditHomework={() => toEditHomework(homework!)}/> - {!props.isMentor && props.isStudentAccepted && < CardActions> + {!isMentor && isStudentAccepted && < CardActions> @@ -428,7 +430,7 @@ export const CourseExperimental: FC = (props) => { borderRadius: 9 } }}> - {props.isMentor && filterAdded && + {isMentor && filterAdded && } - {isMentor && homeworks.length === 0 && renderLecturerWelcomeScreen()} + {isCourseMentor && homeworks.length === 0 && renderLecturerWelcomeScreen()} {homeworks.map((x: HomeworkViewModel & { hasErrors?: boolean }) => { return
= (props) => { color={x.isDeferred ? "textSecondary" : x.tags!.includes(TestTag) ? "primary" : "textPrimary"}> - {isMentor && renderHomeworkStatus(x)} + {isCourseMentor && renderHomeworkStatus(x)} {x.title}{getTip(x)} {x.isDeferred && !x.publicationDateNotSet && diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 48508c31f..ded40d830 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -15,7 +15,7 @@ import { import {MarkdownEditor, MarkdownPreview} from "components/Common/MarkdownEditor"; import FilesPreviewList from "components/Files/FilesPreviewList"; import {IFileInfo} from "components/Files/IFileInfo"; -import {FC, useEffect, useState} from "react" +import {FC, useEffect, useMemo, useState} from "react" import Utils from "services/Utils"; import { HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel @@ -37,25 +37,19 @@ import Lodash from "lodash"; import {CourseUnitType} from "../Files/CourseUnitType" import ProcessFilesUtils from "../Utils/ProcessFilesUtils"; import {useCourseState} from "@/store/hooks"; +import {useIsCourseMentor} from "@/store/courseHooks"; import {FilesHandler} from "@/components/Files/FilesHandler"; -import {useEditingSelection, useHomeworkEditing} from "@/store/editingHooks"; +import {useEditingSelection} from "@/store/courseEditingHooks"; +import {useDraftHomework, useHomeworkEditing, getHomeworkDeleteMessage} from "@/store/homeworkEditorHooks"; export interface HomeworkAndFilesInfo { homework: HomeworkViewModel, filesInfo: IFileInfo[] } -interface IEditHomeworkState { - publicationDate?: Date; - hasDeadline: boolean; - deadlineDate?: Date; - isDeadlineStrict: boolean; - hasErrors: boolean; -} - const CourseHomeworkEditor: FC<{ homeworkAndFilesInfo: HomeworkAndFilesInfo, - onDone: () => void, + onDone?: () => void, onStartProcessing: (homeworkId: number, courseUnitType: CourseUnitType, previouslyExistingFilesCount: number, @@ -64,138 +58,87 @@ const CourseHomeworkEditor: FC<{ }> = (props) => { const homework = props.homeworkAndFilesInfo.homework const isNewHomework = homework.id! < 0 - const homeworks = useCourseState(state => state.homeworks.items); - const draftHomeworks = useCourseState(state => state.editing.draftHomeworks) - const {startEditingHomework, updateDraftHomework, cancelEditingHomework, commitHomework, commitHomeworkDeletion} = useHomeworkEditing() + const homeworks = useCourseState(state => state.homeworks.items) + const draft = useDraftHomework(homework.id!) + const { + startEditingHomework, + updateDraftHomework, + commitHomework, + commitHomeworkDeletion, + cancelHomeworkEdit, + loadHomeworkForEditing, + submitHomeworkApi, + deleteHomeworkApi, + } = useHomeworkEditing() const {select} = useEditingSelection() - const [homeworkData, setHomeworkData] = useState<{ - loadedHomework: HomeworkViewModel, - isLoaded: boolean - }>({loadedHomework: homework, isLoaded: isNewHomework || draftHomeworks.some(d => d.id === homework.id)}) - - useEffect(() => { - if (homeworkData.isLoaded) return - ApiSingleton.homeworksApi - .homeworksGetForEditingHomework(homework.id!) - .then(homework => setHomeworkData({loadedHomework: homework, isLoaded: true})) - }, []) - - const {loadedHomework, isLoaded} = homeworkData + const [handleSubmitLoading, setHandleSubmitLoading] = useState(false) + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) + const [editOptions, setEditOptions] = useState({sendNotification: false}) const {filesState, setFilesState, handleFilesChange} = FilesHandler(props.homeworkAndFilesInfo.filesInfo) const initialFilesInfo = props.homeworkAndFilesInfo.filesInfo.filter(x => x.id !== undefined) + useEffect(() => { + if (draft || homework.id! < 0) return + loadHomeworkForEditing(homework.id!) + .then(loaded => startEditingHomework(loaded)) + .catch(() => {}) + }, [homework.id, draft, startEditingHomework, loadHomeworkForEditing]) + + const loadedHomework = draft ?? homework const homeworkId = loadedHomework.id! const courseId = loadedHomework.courseId! - const publicationDate = loadedHomework.publicationDateNotSet || !loadedHomework.publicationDate ? undefined : new Date(loadedHomework.publicationDate!) - const deadlineDate = loadedHomework.deadlineDateNotSet || !loadedHomework.deadlineDate ? undefined : new Date(loadedHomework.deadlineDate!) - const isPublished = !loadedHomework.isDeferred - const changedTaskPublicationDates = loadedHomework.tasks! + const changedTaskPublicationDates = (loadedHomework.tasks || []) .filter(t => t.publicationDate != null) .map(t => new Date(t.publicationDate!)) - - const taskHasErrors = homework.tasks!.some((x: HomeworkTaskViewModel & { - hasErrors?: boolean - }) => x.hasErrors === true) - - const [metadata, setMetadata] = useState({ - publicationDate: publicationDate, - hasDeadline: loadedHomework.hasDeadline!, - deadlineDate: deadlineDate, - isDeadlineStrict: loadedHomework.isDeadlineStrict!, - hasErrors: false, - }) - const [title, setTitle] = useState(loadedHomework.title!) - const [tags, setTags] = useState(loadedHomework.tags!) - const [description, setDescription] = useState(loadedHomework.description!) - - const [hasErrors, setHasErrors] = useState(false) - - const [handleSubmitLoading, setHandleSubmitLoading] = useState(false) - const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) - const [editOptions, setEditOptions] = useState({sendNotification: false}) - - const [deadlineSuggestion, setDeadlineSuggestion] = useState(undefined) - const [tagSuggestion, setTagSuggestion] = useState(undefined) - - useEffect(() => { - if (!isNewHomework || !metadata.publicationDate) return - const isTest = tags.includes(TestTag) - const isBonus = tags.includes(BonusTag) - - const dateCandidate = Lodash(homeworks + const taskHasErrors = (homework.tasks || []).some((x: HomeworkTaskViewModel & { hasErrors?: boolean }) => x.hasErrors === true) + const hasErrors = !loadedHomework.title || !!(loadedHomework as HomeworkViewModel & { hasErrors?: boolean }).hasErrors + + const deadlineSuggestion = useMemo(() => { + if (!isNewHomework || !publicationDate) return undefined + const isTest = (loadedHomework.tags || []).includes(TestTag) + const isBonus = (loadedHomework.tags || []).includes(BonusTag) + type DateCandidate = { deadlineDate: Date; daysDiff: number } + const mapped: DateCandidate[] = homeworks .filter(x => { const xIsTest = isTestWork(x) const xIsBonus = isBonusWork(x) return x.id! > 0 && x.hasDeadline && (isTest && xIsTest || isBonus && xIsBonus || !isTest && !isBonus && !xIsTest && !xIsBonus) }) - .map(x => { - const deadlineDate = new Date(x.deadlineDate!) - return ({ - deadlineDate: deadlineDate, - daysDiff: Math.floor((deadlineDate.getTime() - new Date(x.publicationDate!).getTime()) / (1000 * 3600 * 24)) - }); + .map(x => ({ + deadlineDate: new Date(x.deadlineDate!), + daysDiff: Math.floor((new Date(x.deadlineDate!).getTime() - new Date(x.publicationDate!).getTime()) / (1000 * 3600 * 24)) })) - .groupBy(x => [x.daysDiff, x.deadlineDate.getHours(), x.deadlineDate.getMinutes()]) + const dateCandidate = Lodash(mapped) + .groupBy((x: DateCandidate) => [x.daysDiff, x.deadlineDate.getHours(), x.deadlineDate.getMinutes()]) .entries() - .sortBy(x => x[1].length).last()?.[1][0] - if (dateCandidate) { - const publicationDate = new Date(metadata.publicationDate) - const dateTime = dateCandidate.deadlineDate - publicationDate.setDate(publicationDate.getDate() + dateCandidate.daysDiff) - publicationDate.setHours(dateTime.getHours(), dateTime.getMinutes(), 0, 0) - setDeadlineSuggestion(publicationDate) - } else { - setDeadlineSuggestion(undefined) - } - }, [tags, metadata.publicationDate]) - - useEffect(() => { - const update = { - ...homework, - ...metadata, - tasks: homework.tasks, - title: title, - description: description, - tags: tags, - hasErrors: hasErrors, - deadlineDateNotSet: metadata.hasDeadline && !metadata.deadlineDate, - } - - if (homework.id! > 0 && !draftHomeworks.some(d => d.id === homework.id)) { - startEditingHomework(update) - } - updateDraftHomework(update) - }, [title, description, tags, metadata, hasErrors, filesState.selectedFilesInfo]) - - useEffect(() => { - setHasErrors(!title || metadata.hasErrors) - }, [title, metadata.hasErrors]) - - useEffect(() => { - const x = title.toLowerCase() - setTagSuggestion( - !tags.includes(TestTag) && ( - x.includes("контрольн") || - x.includes("проверочн") || - x.includes("переписывание") || - x.includes("тест")) - ? TestTag : undefined) - }, [title, tags]); + .sortBy((x: [string, DateCandidate[]]) => x[1].length) + .last()?.[1][0] + if (!dateCandidate) return undefined + const out = new Date(publicationDate) + out.setDate(out.getDate() + dateCandidate.daysDiff) + out.setHours(dateCandidate.deadlineDate.getHours(), dateCandidate.deadlineDate.getMinutes(), 0, 0) + return out + }, [isNewHomework, publicationDate, loadedHomework.tags, homeworks]) + + const tagSuggestion = useMemo(() => { + const title = (loadedHomework.title || '').toLowerCase() + const tags = loadedHomework.tags || [] + if (tags.includes(TestTag)) return undefined + return (title.includes("контрольн") || title.includes("проверочн") || title.includes("переписывание") || title.includes("тест")) ? TestTag : undefined + }, [loadedHomework.title, loadedHomework.tags]) const deleteHomework = async () => { - if (!isNewHomework) await ApiSingleton.homeworksApi.homeworksDeleteHomework(homeworkId) - - // Удаляем файлы домашней работы с сервера - var deletingFileIds = initialFilesInfo.filter(fileInfo => fileInfo.id).map(fileInfo => fileInfo.id!) + await deleteHomeworkApi(homeworkId, isNewHomework) + const deletingFileIds = initialFilesInfo.filter(fileInfo => fileInfo.id).map(fileInfo => fileInfo.id!) await ProcessFilesUtils.processFilesWithErrorsHadling({ courseId: courseId!, courseUnitType: CourseUnitType.Homework, @@ -203,75 +146,55 @@ const CourseHomeworkEditor: FC<{ deletingFileIds: deletingFileIds, newFiles: [] }) - commitHomeworkDeletion(homeworkId) select({isHomework: true, id: undefined}) } const cancelEditing = () => { - if (isNewHomework) { - commitHomeworkDeletion(homework.id!) - select({isHomework: true, id: undefined}) - } else { - cancelEditingHomework(homework.id!) - select({isHomework: true, id: homework.id}) - } - props.onDone() - } - - const getDeleteMessage = (homeworkName: string, filesInfo: IFileInfo[]) => { - let message = `Вы точно хотите удалить задание "${homeworkName}"?`; - if (filesInfo.length > 0) { - message += ` Будет также удален файл ${filesInfo[0].name}`; - if (filesInfo.length > 1) { - message += ` и другие прикрепленные файлы`; - } - } - - return message; + cancelHomeworkEdit(homework.id!, isNewHomework) + props.onDone?.() } - const handleSubmit = async (e: any) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setHandleSubmitLoading(true) - - const update = { - homeworkId: homeworkId, - title: title!, - description: description, - tags: tags, - hasDeadline: metadata.hasDeadline, - deadlineDate: metadata.deadlineDate, - isDeadlineStrict: metadata.isDeadlineStrict, - publicationDate: metadata.publicationDate, - actionOptions: editOptions, - tasks: isNewHomework ? homework.tasks!.map(t => { - const task: PostTaskViewModel = { + try { + const update = { + title: loadedHomework.title!, + description: loadedHomework.description, + tags: loadedHomework.tags || [], + hasDeadline: loadedHomework.hasDeadline, + deadlineDate: deadlineDate, + isDeadlineStrict: loadedHomework.isDeadlineStrict, + publicationDate: publicationDate, + actionOptions: editOptions, + tasks: isNewHomework ? (homework.tasks || []).map(t => ({ ...t, title: t.title!, maxRating: t.maxRating! - } - return task - }) : [] - } - - const updatedHomework = isNewHomework - ? await ApiSingleton.homeworksApi.homeworksAddHomework(courseId!, update) - : await ApiSingleton.homeworksApi.homeworksUpdateHomework(+homeworkId!, update) + } as PostTaskViewModel)) : [] + } - const updatedHomeworkId = updatedHomework.value!.id! - await handleFilesChange( - courseId, CourseUnitType.Homework, updatedHomeworkId, - props.onStartProcessing, - () => { - commitHomework(homework.id!, updatedHomework.value!) - select({isHomework: true, id: updatedHomeworkId}) - props.onDone() - }, - ); + const updatedHomework = await submitHomeworkApi(courseId!, homeworkId, isNewHomework, update) + + const updatedHomeworkId = updatedHomework.value!.id! + await handleFilesChange( + courseId, CourseUnitType.Homework, updatedHomeworkId, + props.onStartProcessing, + () => { + commitHomework(homework.id!, updatedHomework.value!) + select({isHomework: true, id: updatedHomeworkId}) + props.onDone?.() + }, + ) + } finally { + setHandleSubmitLoading(false) + } } - const isDisabled = hasErrors || !isLoaded || taskHasErrors + const isDisabled = hasErrors || taskHasErrors + + if (!draft) return null return ( @@ -290,27 +213,26 @@ const CourseHomeworkEditor: FC<{ { - e.persist() - setHasErrors(prevState => prevState || !e.target.value) - setTitle(e.target.value) - }} + error={!loadedHomework.title} + value={loadedHomework.title || ''} + onChange={(e) => updateDraftHomework({ ...draft!, title: e.target.value })} /> - ApiSingleton.coursesApi.coursesGetAllTagsForCourse(courseId)}/> + updateDraftHomework({ ...draft!, tags })} + isElementSmall={false} + suggestion={tagSuggestion} + requestTags={() => ApiSingleton.coursesApi.coursesGetAllTagsForCourse(courseId)}/> - {tags.includes(TestTag) && + {(loadedHomework.tags || []).includes(TestTag) && Вы можете сгруппировать контрольные работы и переписывания с помощью @@ -322,10 +244,8 @@ const CourseHomeworkEditor: FC<{ label={"Общее описание задания"} height={240} maxHeight={400} - value={description} - onChange={(value) => { - setDescription(value) - }} + value={loadedHomework.description || ''} + onChange={(value) => updateDraftHomework({ ...draft!, description: value })} /> @@ -342,21 +262,23 @@ const CourseHomeworkEditor: FC<{ courseUnitType={CourseUnitType.Homework} courseUnitId={homeworkId}/> { - const conflictsWithTasks = changedTaskPublicationDates.some(d => d < metadata.publicationDate!) - setMetadata({ - hasDeadline: state.hasDeadline, - isDeadlineStrict: state.isDeadlineStrict, + const conflictsWithTasks = changedTaskPublicationDates.some(d => publicationDate && d < publicationDate) + updateDraftHomework({ + ...draft!, publicationDate: state.publicationDate, + hasDeadline: state.hasDeadline, deadlineDate: state.deadlineDate, + isDeadlineStrict: state.isDeadlineStrict, + deadlineDateNotSet: state.hasDeadline && !state.deadlineDate, hasErrors: state.hasErrors || conflictsWithTasks, - }) + } as unknown as HomeworkViewModel) }} /> @@ -366,7 +288,7 @@ const CourseHomeworkEditor: FC<{ } - {metadata.publicationDate && new Date() >= new Date(metadata.publicationDate) && = new Date(publicationDate) && setEditOptions(value)}/>} @@ -403,8 +325,6 @@ const CourseHomeworkEditor: FC<{ const CourseHomeworkExperimental: FC<{ homeworkAndFilesInfo: HomeworkAndFilesInfo, - initialEditMode: boolean, - onMount: () => void, onAddTask: (homework: HomeworkViewModel) => void, onStartProcessing: (homeworkId: number, courseUnitType: CourseUnitType, @@ -412,30 +332,30 @@ const CourseHomeworkExperimental: FC<{ waitingNewFilesCount: number, deletingFilesIds: number[]) => void; }> = (props) => { - const mentors = useCourseState(state => state.course.mentors); - const userId = useCourseState(state => state.user.userId); - const isMentor = mentors.some(m => m.userId === userId); + const isCourseMentor = useIsCourseMentor(); const processingFilesState = useCourseState(state => state.courseFiles.processingFilesState); + const draft = useDraftHomework(props.homeworkAndFilesInfo.homework.id!); + const {loadHomeworkForEditing, startEditingHomework} = useHomeworkEditing(); const {homework, filesInfo} = props.homeworkAndFilesInfo const deferredTasks = homework.tasks!.filter(t => t.isDeferred!) const tasksCount = homework.tasks!.length const [showEditMode, setShowEditMode] = useState(false) - const [editMode, setEditMode] = useState(false) - useEffect(() => { - setEditMode(props.initialEditMode) - props.onMount() - }, [homework.id]) - - if (editMode) return setEditMode(false)} onStartProcessing={props.onStartProcessing} /> + const openEditor = () => { + if (homework.id! < 0) return + loadHomeworkForEditing(homework.id!) + .then(loaded => startEditingHomework(loaded)) + .catch(() => {}) + } + return setShowEditMode(isMentor)} + onMouseEnter={() => setShowEditMode(isCourseMentor)} onMouseLeave={() => setShowEditMode(false)}> @@ -467,8 +387,8 @@ const CourseHomeworkExperimental: FC<{ { - setEditMode(true) setShowEditMode(false) + openEditor() }}> @@ -486,8 +406,8 @@ const CourseHomeworkExperimental: FC<{   Обрабатываем файлы...
} - { const url = await ApiSingleton.customFilesApi.getDownloadFileLink(fileInfo.id!); diff --git a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx index 856e61bad..a6d0f3c1a 100644 --- a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx +++ b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx @@ -25,8 +25,8 @@ import {LoadingButton} from "@mui/lab"; import TaskPublicationAndDeadlineDates from "../Common/TaskPublicationAndDeadlineDates"; import DeletionConfirmation from "../DeletionConfirmation"; import ActionOptionsUI from "../Common/ActionOptions"; -import {useCourseState} from "@/store/hooks"; -import {useTaskEditing, useEditingSelection} from "@/store/editingHooks"; +import {useDraftTask, useTaskEditing} from "@/store/taskEditorHooks"; +import {useIsCourseMentor} from "@/store/courseHooks"; import {Stack} from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import Collapse from "@mui/material/Collapse"; @@ -35,15 +35,6 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import TaskCriteria from "./TaskCriteria"; import {BonusTag} from "@/components/Common/HomeworkTags"; -interface IEditTaskMetadataState { - hasDeadline: boolean | undefined; - deadlineDate: Date | undefined; - isDeadlineStrict: boolean | undefined; - publicationDate: Date | undefined; - isPublished: boolean; - hasErrors: boolean -} - type TaskEditData = HomeworkTaskViewModel & { isModified?: boolean; hasErrors?: boolean; @@ -53,186 +44,104 @@ type TaskEditData = HomeworkTaskViewModel & { const CourseTaskEditor: FC<{ task: TaskEditData, homework: HomeworkViewModel, - onDone: () => void, + onDone?: () => void, toEditHomework: () => void, }> = (props) => { - const draftHomeworks = useCourseState(state => state.editing.draftHomeworks) - const {startEditingTask, updateDraftTask, cancelEditingTask, commitTask, commitTaskDeletion} = useTaskEditing() - const {select} = useEditingSelection() - - const [taskData, setTaskData] = useState<{ - task: HomeworkTaskViewModel, - homework: HomeworkViewModel, - isLoaded: boolean - }>({ - task: props.task, - homework: props.homework, - isLoaded: props.task.id! < 0 || draftHomeworks.some(d => d.tasks?.some(t => t.id === props.task.id)) - }) + const draft = useDraftTask(props.task.id!, props.task.homeworkId!) + const { + startEditingTask, + updateDraftTask, + addCriterion, + updateCriterion: updateCriterionInStore, + removeCriterion: removeCriterionInStore, + submitTaskEdit, + deleteTaskEdit, + cancelTaskEdit, + } = useTaskEditing() - const [criteria, setCriteria] = useState(taskData.task.criteria || []) const [isCriteriaOpen, setIsCriteriaOpen] = useState(false) - - const addDefaultCriterion = () => { - setCriteria(prev => [ - ...prev, - {id: 0, type: 0, name: `Критерий №${prev.length + 1}`, maxPoints: 1} - ]); - setIsCriteriaOpen(true); - }; - - const updateCriterion = (index: number, patch: Partial) => - setCriteria(prev => - prev.map((c, i) => (i === index ? {...c, ...patch} : c)) - ) - - const removeCriterion = (index: number) => - setCriteria(prev => prev.filter((_, i) => i !== index)) - - const criteriaTotalPoints = useMemo( - () => - (criteria).reduce( - (sum, c) => sum + (c.maxPoints || 0), - 0 - ), - [criteria] - ) - - const autoMaxFromCriteria = criteria.length > 0; - - useEffect(() => { - if (autoMaxFromCriteria) setMaxRating(criteriaTotalPoints); - }, [criteriaTotalPoints, autoMaxFromCriteria]); - - const isNewTask = taskData.task.id! < 0 - - const [metadata, setMetadata] = useState( - isNewTask || taskData.isLoaded ? { - publicationDate: taskData.task.publicationDate, - hasDeadline: taskData.task.hasDeadline, - deadlineDate: taskData.task.deadlineDate, - isDeadlineStrict: taskData.task.isDeadlineStrict, - isPublished: taskData.task.isDeferred || !taskData.homework.isDeferred, - hasErrors: false, - } : undefined) + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) + const [handleSubmitLoading, setHandleSubmitLoading] = useState(false) + const [editOptions, setEditOptions] = useState({sendNotification: false}) useEffect(() => { - if (isNewTask || taskData.isLoaded) return + if (draft || props.task.id! < 0) return ApiSingleton.tasksApi - .tasksGetForEditingTask(task.id!) - .then(r => { - const task = r.task! - setTaskData({ - homework: r.homework!, - task: r.task!, - isLoaded: true, - }) - setCriteria(task.criteria || []) - setMetadata({ - hasDeadline: task.hasDeadline!, - deadlineDate: task.deadlineDateNotSet - ? undefined - : new Date(task.deadlineDate!), - isDeadlineStrict: task.isDeadlineStrict!, - publicationDate: task.publicationDateNotSet - ? undefined - : new Date(task.publicationDate!), - isPublished: !task.isDeferred, - hasErrors: false, - }) - }) - }, []) + .tasksGetForEditingTask(props.task.id!) + .then(r => startEditingTask(r.task!, r.homework!)) + .catch(() => {}) + }, [props.task.id, draft, startEditingTask]) - const {task, homework, isLoaded} = taskData + const task = draft ?? props.task + const homework = props.homework const {id} = task - - //TODO: suggested max rating - const [title, setTitle] = useState(task.title!) - const [maxRating, setMaxRating] = useState( - criteria.length > 0 ? criteriaTotalPoints : task.maxRating! + const isNewTask = task.id! < 0 + const criteria = task.criteria || [] + const criteriaTotalPoints = useMemo( + () => criteria.reduce((sum, c) => sum + (c.maxPoints || 0), 0), + [criteria] ) - const [description, setDescription] = useState(task.description || "") - const [isBonusExplicit, setIsBonusExplicit] = useState(props.task.tags!.includes(BonusTag) && !props.homework.tags!.includes(BonusTag)) - - const [hasErrors, setHasErrors] = useState(props.task.hasErrors || false) - const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) - - const [handleSubmitLoading, setHandleSubmitLoading] = useState(false); - const [editOptions, setEditOptions] = useState({sendNotification: false}) + const autoMaxFromCriteria = criteria.length > 0 + const maxRating = autoMaxFromCriteria ? criteriaTotalPoints : (task.maxRating ?? 0) + const isBonusExplicit = (task.tags || []).includes(BonusTag) && !(homework.tags || []).includes(BonusTag) + const publicationDate = task.publicationDate ?? homework.publicationDate + const taskHasErrors = !!(task as TaskEditData).hasErrors + const hasErrors = !task.title || maxRating <= 0 || taskHasErrors || !!(task as HomeworkTaskViewModel & { hasErrors?: boolean }).hasErrors - const publicationDate = metadata?.publicationDate || homework.publicationDate - - useEffect(() => { - const update = { - ...props.task, - ...metadata!, - title: title!, - description: description, - deadlineDateNotSet: metadata?.hasDeadline === true && !metadata.deadlineDate, - maxRating: maxRating, - isBonusExplicit: isBonusExplicit, - tags: isBonusExplicit ? [...homework.tags!, BonusTag] : homework.tags!, - hasErrors: hasErrors, - criteria: criteria, - } + const addDefaultCriterion = () => { + addCriterion(task, { id: 0, type: 0, name: `Критерий №${criteria.length + 1}`, maxPoints: 1 }) + setIsCriteriaOpen(true) + } - if (props.task.id! > 0 && !draftHomeworks.some(d => d.tasks?.some(t => t.id === props.task.id))) { - startEditingTask(update, homework) - } - updateDraftTask(update) - }, [title, description, maxRating, metadata, isBonusExplicit, hasErrors, criteria]); + const updateCriterion = (index: number, patch: Partial) => { + updateCriterionInStore(task, index, patch) + } - useEffect(() => { - setHasErrors(!title || maxRating <= 0 || metadata?.hasErrors === true) - }, [title, maxRating, metadata?.hasErrors]) + const removeCriterion = (index: number) => { + removeCriterionInStore(task, index) + } - const handleSubmit = async (e: any) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setHandleSubmitLoading(true) - - const update = { - ...metadata!, - title: title!, - description: description, - isBonusExplicit: isBonusExplicit, - maxRating: maxRating, - actionOptions: editOptions, - criteria: criteria, - }; - - const updatedTask = isNewTask - ? await ApiSingleton.tasksApi.tasksAddTask(homework.id!, update) - : await ApiSingleton.tasksApi.tasksUpdateTask(+id!, update) - - commitTask(props.task.id!, props.task.homeworkId!, updatedTask.value!) - select({isHomework: false, id: updatedTask.value!.id}) - props.onDone() + try { + const update = { + title: task.title!, + description: task.description || '', + isBonusExplicit, + maxRating, + actionOptions: editOptions, + criteria, + hasDeadline: task.hasDeadline, + isDeadlineStrict: task.isDeadlineStrict, + publicationDate: task.publicationDate, + deadlineDate: task.deadlineDate, + } + await submitTaskEdit(task, homework, isNewTask, update) + props.onDone?.() + } finally { + setHandleSubmitLoading(false) + } } const deleteTask = async () => { - if (!isNewTask) await ApiSingleton.tasksApi.tasksDeleteTask(id!) - commitTaskDeletion(props.task.id!, props.task.homeworkId!) - select({isHomework: true, id: homework.id}) + await deleteTaskEdit(task, homework, isNewTask) } const handleCancel = () => { - if (isNewTask) { - commitTaskDeletion(props.task.id!, props.task.homeworkId!) - select({isHomework: true, id: homework.id}) - } else { - cancelEditingTask(props.task.id!, props.task.homeworkId!) - select({isHomework: false, id: props.task.id}) - } - props.onDone() + cancelTaskEdit(task, homework, isNewTask) + props.onDone?.() } - const isDisabled = hasErrors || !isLoaded - const isNewHomework = taskData.task.homeworkId! < 0 - + const isDisabled = hasErrors + const isNewHomework = task.homeworkId! < 0 const homeworkPublicationDateIsSet = !homework.publicationDateNotSet + const taskPublicationDate = task.publicationDateNotSet ? undefined : (task.publicationDate ? new Date(task.publicationDate) : undefined) + const taskDeadlineDate = task.deadlineDateNotSet ? undefined : (task.deadlineDate ? new Date(task.deadlineDate) : undefined) + const isPublished = task.isDeferred || !homework.isDeferred + const maxRatingLabel = criteria.length > 0 ? "Критерии" : (props.task as TaskEditData).suggestedMaxRating === maxRating ? "Вычислено" : undefined - const maxRatingLabel = - criteria.length > 0 ? "Критерии" : props.task.suggestedMaxRating === maxRating ? "Вычислено" : undefined + if (!draft) return null return ( @@ -252,17 +161,14 @@ const CourseTaskEditor: FC<{ { - e.persist() - setTitle(e.target.value) - }} + value={task.title || ''} + onChange={(e) => updateDraftTask({ ...task, title: e.target.value })} /> - {!homework.tags!.includes(BonusTag) && { - setIsBonusExplicit(prevState => !prevState) - }} + onChange={() => updateDraftTask({ + ...task, + tags: isBonusExplicit ? (task.tags || []).filter(t => t !== BonusTag) : [...(homework.tags || []), BonusTag] + })} /> } />} @@ -292,11 +199,18 @@ const CourseTaskEditor: FC<{ margin="normal" type="number" value={maxRating} + inputProps={{min: 1, max: 100}} InputProps={{readOnly: autoMaxFromCriteria}} onChange={(e) => { if (!autoMaxFromCriteria) { - e.persist(); - setMaxRating(+e.target.value); + const raw = +e.target.value || 0; + const clamped = raw < 1 ? 1 : raw > 100 ? 100 : Math.round(raw); + const ratingErrors = clamped < 1 || clamped > 100; + updateDraftTask({ + ...task, + maxRating: clamped, + hasErrors: ratingErrors || !!(task as TaskEditData).hasErrors, + } as TaskEditData); } }} /> @@ -308,35 +222,32 @@ const CourseTaskEditor: FC<{ label={"Условие задачи"} height={240} maxHeight={400} - value={description} - onChange={(value) => { - setDescription(value) - }} + value={task.description || ''} + onChange={(value) => updateDraftTask({ ...task, description: value })} /> - {metadata && homeworkPublicationDateIsSet && + {homeworkPublicationDateIsSet && { - setMetadata({ - hasDeadline: state.hasDeadline, - isDeadlineStrict: state.isDeadlineStrict, - publicationDate: state.publicationDate, - deadlineDate: state.deadlineDate, - isPublished: metadata.isPublished, - hasErrors: state.hasErrors - }) - }} + hasDeadline={task.hasDeadline ?? false} + isDeadlineStrict={task.isDeadlineStrict ?? false} + publicationDate={taskPublicationDate} + deadlineDate={taskDeadlineDate} + disabledPublicationDate={isPublished} + onChange={(state) => updateDraftTask({ + ...task, + hasDeadline: state.hasDeadline, + isDeadlineStrict: state.isDeadlineStrict, + publicationDate: state.publicationDate, + deadlineDate: state.deadlineDate, + deadlineDateNotSet: state.hasDeadline && !state.deadlineDate, + hasErrors: state.hasErrors, + } as unknown as HomeworkTaskViewModel)} /> } - {metadata && !homeworkPublicationDateIsSet && + {!homeworkPublicationDateIsSet && @@ -520,36 +431,34 @@ const CourseTaskEditor: FC<{ const CourseTaskExperimental: FC<{ task: TaskEditData, homework: HomeworkViewModel, - initialEditMode: boolean, - onMount: () => void, toEditHomework: () => void, }> = (props) => { - const mentors = useCourseState(state => state.course.mentors); - const userId = useCourseState(state => state.user.userId); - const isMentor = mentors.some(m => m.userId === userId); + const isCourseMentor = useIsCourseMentor(); + const draft = useDraftTask(props.task.id!, props.task.homeworkId!); + const {loadTaskForEditing, startEditingTask} = useTaskEditing(); const {task, homework} = props const [showEditMode, setShowEditMode] = useState(false) - const [editMode, setEditMode] = useState(false) - - useEffect(() => { - setEditMode(props.initialEditMode) - props.onMount() - }, [task.id]) - if (editMode) { + if (draft) { return setEditMode(false)} toEditHomework={props.toEditHomework} /> } + const openEditor = () => { + if (task.id! < 0) return + loadTaskForEditing(task.id!) + .then(r => startEditingTask(r.task!, r.homework!)) + .catch(() => {}) + } + return ( setShowEditMode(isMentor)} + onMouseEnter={() => setShowEditMode(isCourseMentor)} onMouseLeave={() => setShowEditMode(false)} > { setShowEditMode(false); - setEditMode(true); + openEditor(); }} > From 1ef48f9c17d0ed31a31a372eb6d140acf7247c47 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 21:30:16 +0300 Subject: [PATCH 41/48] refactor: replace dispatch with the useRefreshCourse hook --- .../components/Courses/NewCourseStudents.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/hwproj.front/src/components/Courses/NewCourseStudents.tsx b/hwproj.front/src/components/Courses/NewCourseStudents.tsx index 7a03e9425..5550b7f6e 100644 --- a/hwproj.front/src/components/Courses/NewCourseStudents.tsx +++ b/hwproj.front/src/components/Courses/NewCourseStudents.tsx @@ -2,23 +2,23 @@ import * as React from 'react'; import ApiSingleton from "../../api/ApiSingleton"; import {FC} from "react"; import {Card, CardContent, CardActions, Grid, Button, Typography, Alert, AlertTitle} from '@mui/material'; -import {useCourseState, useCourseDispatch} from "@/store/hooks"; -import {fetchCourseData} from '@/store/slices/courseSlice'; +import {useCourseState} from "@/store/hooks"; +import {useRefreshCourse} from "@/store/courseHooks"; const NewCourseStudents: FC = () => { const course = useCourseState(state => state.course.currentCourse); const students = useCourseState(state => state.course.newStudents); - const dispatch = useCourseDispatch(); + const refreshCourse = useRefreshCourse(); const acceptStudent = async (studentId: string) => { - await ApiSingleton.coursesApi.coursesAcceptStudent(course?.id!, studentId) - dispatch(fetchCourseData(course?.id!)); - } + await ApiSingleton.coursesApi.coursesAcceptStudent(course?.id!, studentId); + refreshCourse(course?.id!); + }; const rejectStudent = async (studentId: string) => { - await ApiSingleton.coursesApi.coursesRejectStudent(course?.id!, studentId) - dispatch(fetchCourseData(course?.id!)); - } + await ApiSingleton.coursesApi.coursesRejectStudent(course?.id!, studentId); + refreshCourse(course?.id!); + }; const studentsLength = students.length From d7ffc986853fabdadcdab847186d47becbd2104e Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Tue, 10 Mar 2026 23:39:29 +0300 Subject: [PATCH 42/48] fix: fix saving homework with a task in multiple drafts mode --- .../components/Homeworks/CourseHomeworkExperimental.tsx | 9 +++++++-- hwproj.front/src/store/taskEditorHooks.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index ded40d830..c3b524aa0 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -169,9 +169,14 @@ const CourseHomeworkEditor: FC<{ publicationDate: publicationDate, actionOptions: editOptions, tasks: isNewHomework ? (homework.tasks || []).map(t => ({ - ...t, title: t.title!, - maxRating: t.maxRating! + description: t.description, + hasDeadline: t.hasDeadline, + deadlineDate: t.deadlineDate, + isDeadlineStrict: t.isDeadlineStrict, + publicationDate: t.publicationDate, + maxRating: t.maxRating!, + criteria: t.criteria || [] } as PostTaskViewModel)) : [] } diff --git a/hwproj.front/src/store/taskEditorHooks.ts b/hwproj.front/src/store/taskEditorHooks.ts index 840485ec2..b1335ad98 100644 --- a/hwproj.front/src/store/taskEditorHooks.ts +++ b/hwproj.front/src/store/taskEditorHooks.ts @@ -139,7 +139,7 @@ export const useTaskEditing = () => { const cancelTaskEdit = useCallback((task: HomeworkTaskViewModel, homework: HomeworkViewModel, isNewTask: boolean) => { dispatch(removeDraftTask({ homeworkId: task.homeworkId!, taskId: task.id! })); const draftHw = draftHomeworks.find(dh => dh.id === task.homeworkId); - if (draftHw && (draftHw.tasks || []).filter(t => t.id !== task.id).length === 0) { + if (draftHw && (draftHw.tasks || []).filter(t => t.id !== task.id).length === 0 && !isNewTask) { dispatch(removeDraftHomework(task.homeworkId!)); } if (isNewTask) { From 4745f03df03207cb376a27f852aa8b9ae0d5c353 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Thu, 12 Mar 2026 22:29:53 +0300 Subject: [PATCH 43/48] refactor: Rebuild the course's store hooks and simplify the work of homework and task editors --- .../src/components/Courses/Course.tsx | 65 +-- .../components/Courses/CourseExperimental.tsx | 165 ++++---- .../components/Courses/NewCourseStudents.tsx | 4 +- .../src/components/Courses/StudentStats.tsx | 5 +- .../Homeworks/CourseHomeworkExperimental.tsx | 216 ++++------ .../Tasks/CourseTaskExperimental.tsx | 335 +++++----------- .../components/Tasks/TaskCriteriaEditor.tsx | 170 ++++++++ hwproj.front/src/store/courseActions.ts | 26 ++ hwproj.front/src/store/courseEditingHooks.ts | 49 --- hwproj.front/src/store/homeworkEditorHooks.ts | 124 ------ .../src/store/slices/courseFileSlice.ts | 11 +- hwproj.front/src/store/slices/courseSlice.ts | 42 +- .../src/store/slices/solutionSlice.ts | 12 +- hwproj.front/src/store/slices/userSlice.ts | 14 +- .../store/storeHooks/courseEditingHooks.ts | 145 +++++++ .../src/store/{ => storeHooks}/courseHooks.ts | 52 +-- .../store/storeHooks/homeworkEditorHooks.ts | 372 ++++++++++++++++++ .../src/store/storeHooks/taskEditorHooks.ts | 331 ++++++++++++++++ hwproj.front/src/store/taskEditorHooks.ts | 171 -------- 19 files changed, 1392 insertions(+), 917 deletions(-) create mode 100644 hwproj.front/src/components/Tasks/TaskCriteriaEditor.tsx create mode 100644 hwproj.front/src/store/courseActions.ts delete mode 100644 hwproj.front/src/store/courseEditingHooks.ts delete mode 100644 hwproj.front/src/store/homeworkEditorHooks.ts create mode 100644 hwproj.front/src/store/storeHooks/courseEditingHooks.ts rename hwproj.front/src/store/{ => storeHooks}/courseHooks.ts (86%) create mode 100644 hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts create mode 100644 hwproj.front/src/store/storeHooks/taskEditorHooks.ts delete mode 100644 hwproj.front/src/store/taskEditorHooks.ts diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index e8b920158..e16874ced 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import {FC, useEffect, useState} from "react"; +import {FC, useCallback, useEffect, useState} from "react"; import {useNavigate, useParams, useSearchParams} from "react-router-dom"; import StudentStats from "./StudentStats"; import NewCourseStudents from "./NewCourseStudents"; @@ -31,7 +31,7 @@ import {QRCodeSVG} from 'qrcode.react'; import QrCode2Icon from '@mui/icons-material/QrCode2'; import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; -import {useCourseLoader, useCourseFiles, useIsCourseMentor, useCoursePageData} from "@/store/courseHooks"; +import {useCourseLoader, useCourseFiles, useIsCourseMentor, useCoursePageData, useUnratedSolutionsCount} from "@/store/storeHooks/courseHooks"; type TabValue = "homeworks" | "stats" | "applications" @@ -63,63 +63,68 @@ const Course: React.FC = () => { isAcceptedStudent, } = useCoursePageData(); const isCourseMentor = useIsCourseMentor(); + const unratedSolutionsCount = useUnratedSolutionsCount(); const {initUser, loadCourse, loadStudentSolutions, resetEditing} = useCourseLoader(+courseId!); const [showQrCode, setShowQrCode] = useState(false); + const [shouldLoadFilesAfterCourseReload, setShouldLoadFilesAfterCourseReload] = useState(false); const [pageState, setPageState] = useState({ tabValue: "homeworks" }) - useEffect(() => { - initUser(); - }, []) - const {loadCourseFiles} = useCourseFiles(+courseId!, isCourseMentor); + const {loadCourseFiles} = useCourseFiles(+courseId!); const showStatsTab = isCourseMentor || isAcceptedStudent const showApplicationsTab = isCourseMentor - const changeTab = (newTab: string) => { - if (isAcceptableTabValue(newTab) && newTab !== pageState.tabValue) { - if (newTab === "stats" && !showStatsTab) return; - if (newTab === "applications" && !showApplicationsTab) return; + const changeTab = useCallback((newTab: string) => { + if (!isAcceptableTabValue(newTab)) return; + if (newTab === "stats" && !showStatsTab) return; + if (newTab === "applications" && !showApplicationsTab) return; - setPageState(prevState => ({ + setPageState(prevState => prevState.tabValue === newTab + ? prevState + : { ...prevState, tabValue: newTab - })); - } - } + }); + }, [showApplicationsTab, showStatsTab]) - const setCurrentState = async () => { + const reloadCoursePage = useCallback(async () => { + initUser(); + resetEditing(); + setShouldLoadFilesAfterCourseReload(false); const loadedCourse = await loadCourse(); if (loadedCourse == null) return; - await loadCourseFiles(); - } + setShouldLoadFilesAfterCourseReload(true); + }, [initUser, loadCourse, resetEditing]) useEffect(() => { - resetEditing() - setCurrentState() - }, [courseId]) + reloadCoursePage() + }, [courseId, reloadCoursePage]) + + useEffect(() => { + if (!shouldLoadFilesAfterCourseReload || userId == null || !isFound) return; + + loadCourseFiles(isCourseMentor); + setShouldLoadFilesAfterCourseReload(false); + }, [shouldLoadFilesAfterCourseReload, userId, isFound, isCourseMentor, loadCourseFiles]) useEffect(() => { loadStudentSolutions() - }, [courseId]) + }, [loadStudentSolutions]) - useEffect(() => changeTab(tab || "homeworks"), [tab, courseId, isFound]) + useEffect(() => { + changeTab(tab || "homeworks") + }, [changeTab, tab]) const joinCourse = async () => { - await ApiSingleton.coursesApi.coursesSignInCourse(+courseId!) - .then(() => setCurrentState()); + await ApiSingleton.coursesApi.coursesSignInCourse(+courseId!); + await reloadCoursePage(); } const {tabValue} = pageState const searchedHomeworkId = searchParams.get("homeworkId") - const unratedSolutionsCount = studentSolutions - .flatMap(x => x.homeworks) - .flatMap(x => x!.tasks) - .filter(t => t!.solution!.slice(-1)[0]?.state === 0) //last solution - .length - const [lecturerStatsState, setLecturerStatsState] = useState(false); const CourseMenu: FC = () => { diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index e0e8ab9e0..8c352ce42 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -13,7 +13,7 @@ import { useTheme, Zoom } from "@mui/material"; -import {FC, useEffect, useMemo, useState} from "react"; +import {FC, useCallback, useEffect, useMemo, useState} from "react"; import Timeline from '@mui/lab/Timeline'; import TimelineItem from '@mui/lab/TimelineItem'; import TimelineSeparator from '@mui/lab/TimelineSeparator'; @@ -24,7 +24,7 @@ import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent'; import {Alert, Card, CardActions, Chip, Paper, Stack, Tooltip} from "@mui/material"; import {Link} from "react-router-dom"; import StudentStatsUtils from "../../services/StudentStatsUtils"; -import {BonusTag, DefaultTags, getTip, isBonusWork, isTestWork, TestTag} from "../Common/HomeworkTags"; +import {DefaultTags, getTip, TestTag} from "../Common/HomeworkTags"; import FileInfoConverter from "components/Utils/FileInfoConverter"; import CourseHomeworkExperimental from "components/Homeworks/CourseHomeworkExperimental"; import CourseTaskExperimental from "../Tasks/CourseTaskExperimental"; @@ -33,12 +33,17 @@ import EditIcon from "@mui/icons-material/Edit"; import ErrorIcon from '@mui/icons-material/Error'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import SwitchAccessShortcutIcon from '@mui/icons-material/SwitchAccessShortcut'; -import Lodash from "lodash"; import {CourseUnitType} from "@/components/Files/CourseUnitType"; -import {useEditingSelection, useMergedHomeworks} from "@/store/courseEditingHooks"; -import {useHomeworkEditing} from "@/store/homeworkEditorHooks"; -import {useTaskEditing} from "@/store/taskEditorHooks"; -import {useCourseFiles, useCourseFilePolling, useIsCourseMentor, useIsAcceptedStudent} from "@/store/courseHooks"; +import { + useEditingStatus, + useEditingSelection, + useEnsureSelectedHomework, + useMergedHomeworks, + useSelectedCourseItemData, + useVisibleCourseHomeworks +} from "@/store/storeHooks/courseEditingHooks"; +import {useCourseActions} from "@/store/courseActions"; +import {useCourseFiles, useCourseFilePolling, useCoursePageData, useIsCourseMentor} from "@/store/storeHooks/courseHooks"; import {useCourseState} from "@/store/hooks"; interface ICourseExperimentalProps { @@ -59,40 +64,47 @@ export const CourseExperimental: FC = (props) => { const [showScrollButton, setShowScrollButton] = useState(false); const mergedHomeworks = useMergedHomeworks() - const {selectedItem, select} = useEditingSelection() - const draftHomeworks = useCourseState(state => state.editing.draftHomeworks) - const { - addNewHomework: addNewHomeworkToStore, - } = useHomeworkEditing() - const { - addNewTask: addNewTaskToStore, - } = useTaskEditing() - - const homeworks = useMemo( - () => mergedHomeworks.slice().reverse().filter(x => { - if (hideDeferred) return !x.isDeferred - if (showOnlyGroupedTest !== undefined) return x.tags!.includes(TestTag) && x.tags!.includes(showOnlyGroupedTest) - return true - }), - [mergedHomeworks, hideDeferred, showOnlyGroupedTest] - ) + const {createHomework, createTask} = useCourseActions() + const {select: selectItem} = useEditingSelection() + const {isHomeworkEditing, isTaskEditing} = useEditingStatus() + const homeworks = useVisibleCourseHomeworks(mergedHomeworks, hideDeferred, showOnlyGroupedTest) const {selectedHomeworkId} = props - const userId = useCourseState(state => state.user.userId); - const studentSolutions = useCourseState(state => state.solutions.studentSolutions); + const {userId, studentSolutions, isAcceptedStudent} = useCoursePageData(); const courseFilesInfo = useCourseState(state => state.courseFiles.items); const isCourseMentor = useIsCourseMentor(); - const isStudentAccepted = useIsAcceptedStudent(); const {updateFiles, setFileLoading} = useCourseFiles(props.courseId, isCourseMentor); const {startProcessing} = useCourseFilePolling(props.courseId, updateFiles, setFileLoading); + const { + selectedItem, + selectedHomework: selectedItemHomework, + selectedItemData, + } = useSelectedCourseItemData(homeworks) const {id, isHomework} = selectedItem - useEffect(() => { - const defaultHomeworkIndex = Math.max(selectedHomeworkId ? homeworks?.findIndex(x => x.id === selectedHomeworkId) : 0, 0) - const defaultHomework = homeworks?.[defaultHomeworkIndex] - select({isHomework: true, id: defaultHomework?.id}) - }, [hideDeferred]) + useEnsureSelectedHomework(homeworks, selectedHomeworkId, hideDeferred) + + const handleSelectHomework = useCallback((homeworkId: number) => { + selectItem({isHomework: true, id: homeworkId}) + }, [selectItem]) + + const handleSelectTask = useCallback((taskId: number) => { + selectItem({isHomework: false, id: taskId}) + }, [selectItem]) + + const handleResetFilters = useCallback(() => { + setHideDeferred(false) + setShowOnlyGroupedTest(undefined) + }, []) + + const handleShowGroupedTest = useCallback((groupingTag: string) => { + setShowOnlyGroupedTest(groupingTag) + }, []) + + const handleHideDeferred = useCallback(() => { + setHideDeferred(true) + }, []) // Обработчик прокрутки страницы useEffect(() => { @@ -154,14 +166,14 @@ export const CourseExperimental: FC = (props) => { const taskSolutionsMap = useMemo(() => { const map = new Map() - if (isCourseMentor || !isStudentAccepted) return map + if (isCourseMentor || !isAcceptedStudent) return map studentSolutions .filter(t => t.id === userId) .flatMap(t => t.homeworks!) .flatMap(t => t.tasks!) .forEach(x => map.set(x.id!, x.solution!)) return map - }, [studentSolutions, userId, isCourseMentor, isStudentAccepted]) + }, [studentSolutions, userId, isCourseMentor, isAcceptedStudent]) const showWarningsForEntity = (entity: HomeworkViewModel | HomeworkTaskViewModel, isHomework: boolean) => { if (!isCourseMentor) return false @@ -181,7 +193,7 @@ export const CourseExperimental: FC = (props) => { const hasErrors = homework.id! < 0 && (homework.hasErrors || homework.tasks!.some(hasInvalidTaskMaxRating)) if (hasErrors) return

- if (draftHomeworks.some(d => d.id === homework.id)) + if (isHomeworkEditing(homework.id!)) return

return showWarningsForEntity(homework, true) &&
⚠️
} @@ -205,7 +217,7 @@ export const CourseExperimental: FC = (props) => { ) } if (hasInvalidTaskMaxRating(task)) return - if (draftHomeworks.some(d => d.tasks?.some(t => t.id === task.id))) + if (isTaskEditing(task.id!)) return return showWarningsForEntity(task, false) ? ( @@ -215,7 +227,7 @@ export const CourseExperimental: FC = (props) => { } const toEditHomework = (homework: HomeworkViewModel) => { - select({id: homework.id!, isHomework: true}) + handleSelectHomework(homework.id!) } const validateTestGrouping = (homework: HomeworkViewModel) => { @@ -224,7 +236,7 @@ export const CourseExperimental: FC = (props) => { const groupingTag = homework.tags!.find(x => !DefaultTags.includes(x)) if (groupingTag === undefined) return true - const groupedHomeworks = homeworks.filter(x => x.tags!.includes(TestTag) && x.tags!.includes(groupingTag)) + const groupedHomeworks = mergedHomeworks.filter(x => x.tags!.includes(TestTag) && x.tags!.includes(groupingTag)) if (groupedHomeworks.length === 1) return true const keys = new Set(groupedHomeworks.map(h => h.tasks!.map(t => t.maxRating).join(";"))) @@ -255,10 +267,7 @@ export const CourseExperimental: FC = (props) => { }>Часть добавления нового задания
@@ -271,7 +280,7 @@ export const CourseExperimental: FC = (props) => { }> @@ -290,7 +299,7 @@ export const CourseExperimental: FC = (props) => { fullWidth color="inherit" size="small" - onClick={() => setShowOnlyGroupedTest(groupingTag)} + onClick={() => handleShowGroupedTest(groupingTag)} > Задания }> @@ -303,7 +312,7 @@ export const CourseExperimental: FC = (props) => { fullWidth color="inherit" size="small" - onClick={() => setShowOnlyGroupedTest(groupingTag)} + onClick={() => handleShowGroupedTest(groupingTag)} > Задания }> @@ -316,42 +325,19 @@ export const CourseExperimental: FC = (props) => { } - const selectedItemHomework = isHomework - ? homeworks.find(x => x.id === id)! - : homeworks.find(x => x.tasks!.some(t => t.id === id))! - - const selectedItemData = isHomework - ? selectedItemHomework - : selectedItemHomework?.tasks!.find(x => x.id === id) as HomeworkTaskViewModel - - const addNewHomework = () => { - addNewHomeworkToStore(props.courseId) + const handleCreateHomework = () => { + createHomework(props.courseId) } - const addNewTask = (homework: HomeworkViewModel) => { - const tags = homework.tags! - const isTest = tags.includes(TestTag) - const isBonus = tags.includes(BonusTag) - - const ratingCandidate = Lodash(homeworks - .map(h => h.tasks![0]) - .filter(x => { - if (x === undefined) return false - const xIsTest = isTestWork(x) - const xIsBonus = isBonusWork(x) - return x.id! > 0 && (isTest && xIsTest || isBonus && xIsBonus || !isTest && !isBonus && !xIsTest && !xIsBonus) - })) - .map(x => x.maxRating!) - .groupBy(x => [x]) - .entries() - .sortBy(x => x[1].length).last()?.[1][0] - - addNewTaskToStore(homework, ratingCandidate || 10, ratingCandidate) + const handleCreateTask = (homework: HomeworkViewModel) => { + createTask(homework) } const renderHomework = (homework: HomeworkViewModel) => { - const filesInfo = id ? FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, id) : [] - const homeworkEditMode = homework && draftHomeworks.some(d => d.id === homework.id) + const filesInfo = homework.id + ? FileInfoConverter.getCourseUnitFilesInfo(courseFilesInfo, CourseUnitType.Homework, homework.id) + : [] + const homeworkEditMode = homework && isHomeworkEditing(homework.id!) return homework && {isCourseMentor && getGroupingAlert(homework)} @@ -359,7 +345,7 @@ export const CourseExperimental: FC = (props) => { @@ -367,7 +353,7 @@ export const CourseExperimental: FC = (props) => { } const renderTask = (task: HomeworkTaskViewModel, homework: HomeworkViewModel) => { - const taskEditMode = task && draftHomeworks.some(d => d.tasks?.some(t => t.id === task.id)) + const taskEditMode = task && isTaskEditing(task.id!) return task && {isCourseMentor && getDatesAlert(task, false)} = (props) => { task={task} homework={homework!} toEditHomework={() => toEditHomework(homework!)}/> - {!isCourseMentor && isStudentAccepted && < CardActions> + {!isCourseMentor && isAcceptedStudent && < CardActions> @@ -429,10 +415,7 @@ export const CourseExperimental: FC = (props) => { {isCourseMentor && filterAdded && @@ -445,7 +428,7 @@ export const CourseExperimental: FC = (props) => {
} {isCourseMentor && !filterAdded && } @@ -465,7 +448,7 @@ export const CourseExperimental: FC = (props) => { alignContent={"center"} sx={{":hover": hoveredItemStyle}} style={{...getStyle(true, x.id!), minHeight: 50}} - onClick={() => select({isHomework: true, id: x.id})}> + onClick={() => handleSelectHomework(x.id!)}> = (props) => { {x.tasks!.map(t => select({isHomework: false, id: t.id})} + onClick={() => handleSelectTask(t.id!)} style={getStyle(false, t.id!)} sx={{":hover": hoveredItemStyle}}> {!t.deadlineDateNotSet && @@ -510,7 +493,7 @@ export const CourseExperimental: FC = (props) => { {x.id! < 0 && - - - - )} - + setIsCriteriaOpen(prev => !prev)} + onAddCriterion={addDefaultCriterion} + onUpdateCriterion={updateCriterion} + onRemoveCriterion={removeCriterion} + /> {!isNewHomework && publicationDate && new Date() >= new Date(publicationDate) && setShowDeleteConfirmation(false)} - onSubmit={deleteTask} + onSubmit={handleDeleteTask} isOpen={showDeleteConfirmation} dialogTitle={'Удаление задачи'} dialogContentText={`Вы точно хотите удалить задачу '${task.title || ""}'?`} @@ -434,25 +316,24 @@ const CourseTaskExperimental: FC<{ toEditHomework: () => void, }> = (props) => { const isCourseMentor = useIsCourseMentor(); - const draft = useDraftTask(props.task.id!, props.task.homeworkId!); - const {loadTaskForEditing, startEditingTask} = useTaskEditing(); + const draftTask = useDraftTask(props.task.id!, props.task.homeworkId!); + const {startTaskEdit} = useCourseActions(); const {task, homework} = props const [showEditMode, setShowEditMode] = useState(false) - if (draft) { + if (draftTask) { return } - const openEditor = () => { + const handleOpenEditor = () => { if (task.id! < 0) return - loadTaskForEditing(task.id!) - .then(r => startEditingTask(r.task!, r.homework!)) + startTaskEdit(task.id!) .catch(() => {}) } @@ -461,9 +342,9 @@ const CourseTaskExperimental: FC<{ onMouseEnter={() => setShowEditMode(isCourseMentor)} onMouseLeave={() => setShowEditMode(false)} > - - + {task.title} @@ -483,7 +364,7 @@ const CourseTaskExperimental: FC<{ { setShowEditMode(false); - openEditor(); + handleOpenEditor(); }} > diff --git a/hwproj.front/src/components/Tasks/TaskCriteriaEditor.tsx b/hwproj.front/src/components/Tasks/TaskCriteriaEditor.tsx new file mode 100644 index 000000000..494e2aa53 --- /dev/null +++ b/hwproj.front/src/components/Tasks/TaskCriteriaEditor.tsx @@ -0,0 +1,170 @@ +import {CriterionViewModel} from "@/api"; +import { + Box, + Button, + Chip, + Collapse, + Grid, + IconButton, + Link, + Stack, + TextField, + Typography, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import {FC} from "react"; + +const TaskCriteriaEditor: FC<{ + criteria: CriterionViewModel[]; + isOpen: boolean; + onToggleOpen: () => void; + onAddCriterion: () => void; + onUpdateCriterion: (index: number, patch: Partial) => void; + onRemoveCriterion: (index: number) => void; +}> = ({ + criteria, + isOpen, + onToggleOpen, + onAddCriterion, + onUpdateCriterion, + onRemoveCriterion, +}) => { + return ( + + {criteria.length === 0 && ( + + + + Критерии оценивания не указаны.  + + + + + Добавить критерий оценивания + + + + )} + + {criteria.length > 0 && ( + <> + + + + {isOpen ? ( + + ) : ( + + )} + + + + + + Критерии оценивания + + + + + + + {criteria.map((criterion, index) => ( + + + { + const raw = e.target.value; + const limited = raw.slice(0, 50); + onUpdateCriterion(index, {name: limited}); + }} + /> + + + + { + if (e.key === "-") e.preventDefault(); + }} + onChange={(e) => + onUpdateCriterion(index, { + maxPoints: Math.max(+e.target.value, 1), + }) + } + onBlur={(e) => + onUpdateCriterion(index, { + maxPoints: Math.max(+e.target.value, 1), + }) + } + /> + + + + onRemoveCriterion(index)} + color={"error"} + size="small" + > + + + + + ))} + + + + + + )} + + ); +}; + +export default TaskCriteriaEditor; \ No newline at end of file diff --git a/hwproj.front/src/store/courseActions.ts b/hwproj.front/src/store/courseActions.ts new file mode 100644 index 000000000..71c7269f4 --- /dev/null +++ b/hwproj.front/src/store/courseActions.ts @@ -0,0 +1,26 @@ +import {useHomeworkEditing} from './storeHooks/homeworkEditorHooks'; +import {useTaskEditing} from './storeHooks/taskEditorHooks'; + +export const useCourseActions = () => { + const homework = useHomeworkEditing(); + const task = useTaskEditing(); + return { + createHomework: homework.createHomework, + startHomeworkEdit: homework.startHomeworkEdit, + patchHomeworkDraft: homework.patchHomeworkDraft, + saveHomework: homework.saveHomework, + cancelHomeworkEdit: homework.cancelHomeworkEdit, + deleteHomework: homework.deleteHomework, + + createTask: task.createTask, + startTaskEdit: task.startTaskEdit, + patchTaskDraft: task.patchTaskDraft, + saveTask: task.saveTask, + cancelTaskEdit: task.cancelTaskEdit, + deleteTask: task.deleteTask, + + addCriterion: task.addCriterion, + updateCriterion: task.updateCriterion, + removeCriterion: task.removeCriterion, + }; +}; \ No newline at end of file diff --git a/hwproj.front/src/store/courseEditingHooks.ts b/hwproj.front/src/store/courseEditingHooks.ts deleted file mode 100644 index 0b9ee8a96..000000000 --- a/hwproj.front/src/store/courseEditingHooks.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { useCourseDispatch, useCourseState } from './hooks'; -import { setSelectedItem, SelectedItem } from './slices/courseEditingSlice'; -import { HomeworkViewModel, HomeworkTaskViewModel} from '@/api'; - -export const useMergedHomeworks = () => { - const committedHomeworks = useCourseState(state => state.homeworks.items); - const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); - - return useMemo(() => { - const newDrafts = draftHomeworks.filter(dh => dh.id! < 0); - const result: HomeworkViewModel[] = []; - - const mergeTasks = (committed: HomeworkTaskViewModel[] = [], draft: HomeworkTaskViewModel[] = []) => { - const byId = new Map(); - for (const task of committed) byId.set(task.id!, task); - for (const task of draft) byId.set(task.id!, task); - return Array.from(byId.values()); - }; - - for (const committed of committedHomeworks) { - const draft = draftHomeworks.find(dh => dh.id === committed.id); - if (!draft) { - result.push(committed); - continue; - } - - result.push({ - ...committed, - ...draft, - tasks: mergeTasks(committed.tasks, draft.tasks), - }); - } - - result.push(...newDrafts); - return result; - }, [committedHomeworks, draftHomeworks]); -}; - -export const useEditingSelection = () => { - const dispatch = useCourseDispatch(); - const selectedItem = useCourseState(state => state.editing.selectedItem); - - const select = useCallback((item: SelectedItem) => { - dispatch(setSelectedItem(item)); - }, [dispatch]); - - return { selectedItem, select }; -}; \ No newline at end of file diff --git a/hwproj.front/src/store/homeworkEditorHooks.ts b/hwproj.front/src/store/homeworkEditorHooks.ts deleted file mode 100644 index af03bd7a3..000000000 --- a/hwproj.front/src/store/homeworkEditorHooks.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { useCallback } from 'react'; -import { useCourseDispatch, useCourseState } from './hooks'; -import { - addDraftHomework, - updateDraftHomework as updateDraftHomeworkAction, - removeDraftHomework, - decrementDraftId, - setSelectedItem, -} from './slices/courseEditingSlice'; -import { updateOrInsertHomework, deleteHomework } from './slices/homeworkSlice'; -import { HomeworkViewModel, CreateHomeworkViewModel } from '@/api'; -import ApiSingleton from '@/api/ApiSingleton'; - -export const useDraftHomework = (homeworkId: number) => - useCourseState(state => state.editing.draftHomeworks.find(dh => dh.id === homeworkId)); - -export const useHomeworkEditing = () => { - const dispatch = useCourseDispatch(); - const draftIdCounter = useCourseState(state => state.editing.draftIdCounter); - - const addNewHomework = useCallback((courseId: number) => { - const newId = draftIdCounter; - const newHomework: HomeworkViewModel = { - courseId, - id: newId, - title: 'Новое задание', - description: '', - publicationDate: undefined, - publicationDateNotSet: false, - hasDeadline: false, - deadlineDate: undefined, - deadlineDateNotSet: false, - isDeadlineStrict: false, - isGroupWork: false, - tasks: [], - tags: [], - }; - dispatch(addDraftHomework(newHomework)); - dispatch(decrementDraftId()); - dispatch(setSelectedItem({ isHomework: true, id: newId })); - return newId; - }, [dispatch, draftIdCounter]); - - const startEditingHomework = useCallback((hw: HomeworkViewModel) => { - const copy: HomeworkViewModel = { - ...hw, - tasks: [], - }; - dispatch(addDraftHomework(copy)); - dispatch(setSelectedItem({ isHomework: true, id: hw.id })); - }, [dispatch]); - - const updateDraftHomework = useCallback((hw: HomeworkViewModel) => { - dispatch(updateDraftHomeworkAction(hw)); - }, [dispatch]); - - const cancelEditingHomework = useCallback((draftId: number) => { - dispatch(removeDraftHomework(draftId)); - }, [dispatch]); - - const commitHomework = useCallback((draftId: number, savedHw: HomeworkViewModel) => { - dispatch(updateOrInsertHomework(savedHw)); - dispatch(removeDraftHomework(draftId)); - }, [dispatch]); - - const commitHomeworkDeletion = useCallback((homeworkId: number) => { - dispatch(deleteHomework(homeworkId)); - dispatch(removeDraftHomework(homeworkId)); - }, [dispatch]); - - const cancelHomeworkEdit = useCallback((homeworkId: number, isNewHomework: boolean) => { - if (isNewHomework) { - dispatch(removeDraftHomework(homeworkId)); - dispatch(setSelectedItem({ isHomework: true, id: undefined })); - } else { - dispatch(removeDraftHomework(homeworkId)); - dispatch(setSelectedItem({ isHomework: true, id: homeworkId })); - } - }, [dispatch]); - - const loadHomeworkForEditing = useCallback((homeworkId: number) => { - return ApiSingleton.homeworksApi.homeworksGetForEditingHomework(homeworkId); - }, []); - - const submitHomeworkApi = useCallback(( - courseId: number, - homeworkId: number, - isNewHomework: boolean, - body: CreateHomeworkViewModel - ) => { - return isNewHomework - ? ApiSingleton.homeworksApi.homeworksAddHomework(courseId, body) - : ApiSingleton.homeworksApi.homeworksUpdateHomework(homeworkId, body); - }, []); - - const deleteHomeworkApi = useCallback((homeworkId: number, isNewHomework: boolean) => { - if (isNewHomework) return Promise.resolve(); - return ApiSingleton.homeworksApi.homeworksDeleteHomework(homeworkId); - }, []); - - return { - addNewHomework, - startEditingHomework, - updateDraftHomework, - cancelEditingHomework, - commitHomework, - commitHomeworkDeletion, - cancelHomeworkEdit, - loadHomeworkForEditing, - submitHomeworkApi, - deleteHomeworkApi, - }; -}; - -export const getHomeworkDeleteMessage = (homeworkName: string, filesInfo: { name?: string }[]): string => { - let message = `Вы точно хотите удалить задание "${homeworkName}"?`; - if (filesInfo.length > 0) { - message += ` Будет также удален файл ${filesInfo[0].name ?? ''}`; - if (filesInfo.length > 1) { - message += ` и другие прикрепленные файлы`; - } - } - return message; -}; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/courseFileSlice.ts b/hwproj.front/src/store/slices/courseFileSlice.ts index 301c01043..ab64880bf 100644 --- a/hwproj.front/src/store/slices/courseFileSlice.ts +++ b/hwproj.front/src/store/slices/courseFileSlice.ts @@ -40,18 +40,9 @@ const courseFilesSlice = createSlice({ const { homeworkId, isLoading } = action.payload; state.processingFilesState[homeworkId] = { isLoading }; }, - - clearCourseFiles(state) { - state.items = []; - state.processingFilesState = {}; - }, }, }) -export const { setCourseFiles, - updateCourseFiles, - setProcessingLoading, - clearCourseFiles -} = courseFilesSlice.actions; +export const {setCourseFiles, updateCourseFiles, setProcessingLoading} = courseFilesSlice.actions; export default courseFilesSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/courseSlice.ts b/hwproj.front/src/store/slices/courseSlice.ts index 8ef8e098b..a18972d66 100644 --- a/hwproj.front/src/store/slices/courseSlice.ts +++ b/hwproj.front/src/store/slices/courseSlice.ts @@ -1,10 +1,23 @@ import {createSlice, PayloadAction} from '@reduxjs/toolkit'; import {AccountDataDto, CourseViewModel} from '@/api'; +export type CurrentCourseMeta = Pick< + CourseViewModel, + 'id' | 'name' | 'groupName' | 'isCompleted' +>; + +export const toCurrentCourseMeta = ( + course: CourseViewModel +): CurrentCourseMeta => ({ + id: course.id, + name: course.name, + groupName: course.groupName, + isCompleted: course.isCompleted, +}); + interface CourseState { isFound: boolean; - isLoading: boolean; - currentCourse: CourseViewModel | null; + currentCourseMeta: CurrentCourseMeta | null; mentors: AccountDataDto[]; acceptedStudents: AccountDataDto[]; newStudents: AccountDataDto[]; @@ -12,8 +25,7 @@ interface CourseState { const initialState: CourseState = { isFound: false, - isLoading: false, - currentCourse: null, + currentCourseMeta: null, mentors: [], acceptedStudents: [], newStudents: [], @@ -23,10 +35,9 @@ const courseSlice = createSlice({ name: 'course', initialState, reducers: { - setCourse(state, action: PayloadAction) { - state.currentCourse = action.payload; + setCourse(state, action: PayloadAction) { + state.currentCourseMeta = action.payload; state.isFound = true; - state.isLoading = false; }, setMentors(state, action: PayloadAction) { @@ -40,19 +51,6 @@ const courseSlice = createSlice({ setNewStudents(state, action: PayloadAction) { state.newStudents = action.payload }, - - setLoading(state, action: PayloadAction) { - state.isLoading = action.payload; - }, - - resetCourse(state) { - state.currentCourse = null; - state.isFound = false; - state.isLoading = false; - state.mentors = []; - state.acceptedStudents = []; - state.newStudents = []; - }, }, }); @@ -60,9 +58,7 @@ export const { setCourse, setMentors, setAcceptedStudents, - setNewStudents, - setLoading, - resetCourse + setNewStudents, } = courseSlice.actions; export default courseSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/solutionSlice.ts b/hwproj.front/src/store/slices/solutionSlice.ts index d16cb3461..fa0328ed9 100644 --- a/hwproj.front/src/store/slices/solutionSlice.ts +++ b/hwproj.front/src/store/slices/solutionSlice.ts @@ -3,12 +3,10 @@ import {StatisticsCourseMatesModel} from '@/api'; interface SolutionState { studentSolutions: StatisticsCourseMatesModel[]; - isLoaded: boolean; } const initialState: SolutionState = { studentSolutions: [], - isLoaded: false, }; const solutionSlice = createSlice({ @@ -17,18 +15,10 @@ const solutionSlice = createSlice({ reducers: { setStudentSolutions(state, action: PayloadAction) { state.studentSolutions = action.payload; - state.isLoaded = true; - }, - - clearStudentSolutions(state) { - state.studentSolutions = []; - state.isLoaded = false; }, }, }); -export const { setStudentSolutions, - clearStudentSolutions -} = solutionSlice.actions; +export const {setStudentSolutions} = solutionSlice.actions; export default solutionSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/slices/userSlice.ts b/hwproj.front/src/store/slices/userSlice.ts index a3c37b383..5e5024410 100644 --- a/hwproj.front/src/store/slices/userSlice.ts +++ b/hwproj.front/src/store/slices/userSlice.ts @@ -4,7 +4,6 @@ export type UserRole = "Lecturer" | "Expert" | "Student" | null; interface UserState { userId: string | null; - role : UserRole; isLecturer: boolean; isExpert: boolean; } @@ -16,7 +15,6 @@ type SetUserPayload = { const initialState: UserState = { userId: null, - role: null, isLecturer: false, isExpert: false, }; @@ -28,22 +26,12 @@ const userSlice = createSlice({ setUser: (state, action: PayloadAction) => { const {userId, role} = action.payload; state.userId = userId; - state.role = role; state.isLecturer = role === "Lecturer"; state.isExpert = role === "Expert"; }, - - clearUser: (state) => { - state.userId = null; - state.role = null; - state.isLecturer = false; - state.isExpert = false; - }, }, }); -export const { setUser, - clearUser - } = userSlice.actions; +export const {setUser} = userSlice.actions; export default userSlice.reducer; \ No newline at end of file diff --git a/hwproj.front/src/store/storeHooks/courseEditingHooks.ts b/hwproj.front/src/store/storeHooks/courseEditingHooks.ts new file mode 100644 index 000000000..6009ff4e9 --- /dev/null +++ b/hwproj.front/src/store/storeHooks/courseEditingHooks.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useCourseDispatch, useCourseState } from '../hooks'; +import { setSelectedItem, SelectedItem } from '../slices/courseEditingSlice'; +import { HomeworkViewModel, HomeworkTaskViewModel} from '@/api'; +import {TestTag} from "@/components/Common/HomeworkTags"; + +export const useMergedHomeworks = () => { + const committedHomeworks = useCourseState(state => state.homeworks.items); + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); + + return useMemo(() => { + const newDrafts = draftHomeworks.filter(dh => dh.id! < 0); + const result: HomeworkViewModel[] = []; + + const mergeTasks = (committed: HomeworkTaskViewModel[] = [], draft: HomeworkTaskViewModel[] = []) => { + const byId = new Map(); + for (const task of committed) byId.set(task.id!, task); + for (const task of draft) byId.set(task.id!, task); + return Array.from(byId.values()); + }; + + for (const committed of committedHomeworks) { + const draft = draftHomeworks.find(dh => dh.id === committed.id); + if (!draft) { + result.push(committed); + continue; + } + + result.push({ + ...committed, + ...draft, + tasks: mergeTasks(committed.tasks, draft.tasks), + }); + } + + result.push(...newDrafts); + return result; + }, [committedHomeworks, draftHomeworks]); +}; + +export const useEditingSelection = () => { + const dispatch = useCourseDispatch(); + const selectedItem = useCourseState(state => state.editing.selectedItem); + + const select = useCallback((item: SelectedItem) => { + dispatch(setSelectedItem(item)); + }, [dispatch]); + + return { selectedItem, select }; +}; + +export const useEditingStatus = () => { + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); + + const homeworkEditingIds = useMemo( + () => new Set(draftHomeworks.map(homework => homework.id!)), + [draftHomeworks] + ); + + const taskEditingIds = useMemo( + () => new Set( + draftHomeworks + .flatMap(homework => homework.tasks ?? []) + .map(task => task.id!) + ), + [draftHomeworks] + ); + + const isHomeworkEditing = useCallback((homeworkId: number) => { + return homeworkEditingIds.has(homeworkId); + }, [homeworkEditingIds]); + + const isTaskEditing = useCallback((taskId: number) => { + return taskEditingIds.has(taskId); + }, [taskEditingIds]); + + return { + isHomeworkEditing, + isTaskEditing, + }; +}; + +export const useSelectedCourseItemData = (homeworks: HomeworkViewModel[]) => { + const selectedItem = useCourseState(state => state.editing.selectedItem); + + return useMemo(() => { + const {id, isHomework} = selectedItem; + + const selectedHomework = isHomework + ? homeworks.find(homework => homework.id === id) + : homeworks.find(homework => homework.tasks?.some(task => task.id === id)); + + const selectedItemData = isHomework + ? selectedHomework + : selectedHomework?.tasks?.find(task => task.id === id); + + return { + selectedItem, + selectedHomework, + selectedItemData, + }; + }, [homeworks, selectedItem]); +}; + +export const useVisibleCourseHomeworks = ( + mergedHomeworks: HomeworkViewModel[], + hideDeferred: boolean, + showOnlyGroupedTest: string | undefined +) => { + return useMemo( + () => mergedHomeworks.slice().reverse().filter(homework => { + if (hideDeferred) return !homework.isDeferred; + if (showOnlyGroupedTest !== undefined) { + return homework.tags!.includes(TestTag) && homework.tags!.includes(showOnlyGroupedTest); + } + return true; + }), + [mergedHomeworks, hideDeferred, showOnlyGroupedTest] + ); +}; + +export const useEnsureSelectedHomework = ( + homeworks: HomeworkViewModel[], + selectedHomeworkId: number | undefined, + resetKey: unknown +) => { + const {selectedItem, select} = useEditingSelection(); + + useEffect(() => { + const currentSelectionExists = selectedItem.isHomework + ? homeworks.some(homework => homework.id === selectedItem.id) + : homeworks.some(homework => homework.tasks?.some(task => task.id === selectedItem.id)); + + if (currentSelectionExists) { + return; + } + + const defaultHomeworkIndex = Math.max( + selectedHomeworkId ? homeworks.findIndex(homework => homework.id === selectedHomeworkId) : 0, + 0 + ); + const defaultHomework = homeworks[defaultHomeworkIndex]; + select({isHomework: true, id: defaultHomework?.id}); + }, [homeworks, resetKey, select, selectedHomeworkId, selectedItem.id, selectedItem.isHomework]); +}; \ No newline at end of file diff --git a/hwproj.front/src/store/courseHooks.ts b/hwproj.front/src/store/storeHooks/courseHooks.ts similarity index 86% rename from hwproj.front/src/store/courseHooks.ts rename to hwproj.front/src/store/storeHooks/courseHooks.ts index d924f7b1f..05e96345a 100644 --- a/hwproj.front/src/store/courseHooks.ts +++ b/hwproj.front/src/store/storeHooks/courseHooks.ts @@ -1,13 +1,19 @@ import {useCallback, useEffect, useRef} from 'react'; -import {useCourseDispatch, useCourseState} from './hooks'; -import {setCourse, setMentors, setAcceptedStudents, setNewStudents} from './slices/courseSlice'; -import {setHomeworks} from './slices/homeworkSlice'; -import {setStudentSolutions} from './slices/solutionSlice'; -import {setCourseFiles, updateCourseFiles, setProcessingLoading} from './slices/courseFileSlice'; -import {setUser, UserRole} from './slices/userSlice'; -import {resetEditingState} from './slices/courseEditingSlice'; +import {useCourseDispatch, useCourseState} from '../hooks'; +import { + setCourse, + setMentors, + setAcceptedStudents, + setNewStudents, + toCurrentCourseMeta +} from '../slices/courseSlice'; +import {setHomeworks} from '../slices/homeworkSlice'; +import {setStudentSolutions} from '../slices/solutionSlice'; +import {setCourseFiles, updateCourseFiles, setProcessingLoading} from '../slices/courseFileSlice'; +import {setUser, UserRole} from '../slices/userSlice'; +import {resetEditingState} from '../slices/courseEditingSlice'; import ApiSingleton from '@/api/ApiSingleton'; -import {FileInfoDTO, ScopeDTO} from '@/api'; +import {FileInfoDTO, ScopeDTO, StatisticsCourseHomeworksModel, StatisticsCourseMatesModel, StatisticsCourseTasksModel} from '@/api'; import {CourseUnitType} from '@/components/Files/CourseUnitType'; import {FileStatus} from '@/components/Files/FileStatus'; import {enqueueSnackbar} from 'notistack'; @@ -27,21 +33,17 @@ export const useIsCourseMentor = () => { return mentors.some(m => m.userId === userId); }; -export const useIsSignedInCourse = () => { - const newStudents = useCourseState(state => state.course.newStudents); - const userId = useCourseState(state => state.user.userId); - return newStudents?.some(cm => cm.userId === userId) ?? false; -}; - - -export const useIsAcceptedStudent = () => { - const acceptedStudents = useCourseState(state => state.course.acceptedStudents); - const userId = useCourseState(state => state.user.userId); - return acceptedStudents?.some(cm => cm.userId === userId) ?? false; +export const useUnratedSolutionsCount = () => { + const studentSolutions = useCourseState(state => state.solutions.studentSolutions); + return studentSolutions + .flatMap((x: StatisticsCourseMatesModel) => x.homeworks ?? []) + .flatMap((x: StatisticsCourseHomeworksModel) => x.tasks ?? []) + .filter((t: StatisticsCourseTasksModel) => t.solution?.slice(-1)[0]?.state === 0) + .length; }; export const useCoursePageData = () => { - const course = useCourseState(state => state.course.currentCourse); + const course = useCourseState(state => state.course.currentCourseMeta); const isFound = useCourseState(state => state.course.isFound); const mentors = useCourseState(state => state.course.mentors); const acceptedStudents = useCourseState(state => state.course.acceptedStudents); @@ -90,7 +92,7 @@ export const useCourseLoader = (courseId: number) => { return null; } - dispatch(setCourse(course)); + dispatch(setCourse(toCurrentCourseMeta(course))); dispatch(setMentors(course.mentors!)); dispatch(setAcceptedStudents(course.acceptedStudents!)); dispatch(setNewStudents(course.newStudents!)); @@ -114,7 +116,7 @@ export const useRefreshCourse = () => { const dispatch = useCourseDispatch(); return useCallback(async (courseId: number) => { const course = await ApiSingleton.coursesApi.coursesGetCourseData(courseId); - dispatch(setCourse(course)); + dispatch(setCourse(toCurrentCourseMeta(course))); dispatch(setMentors(course.mentors ?? [])); dispatch(setAcceptedStudents(course.acceptedStudents ?? [])); dispatch(setNewStudents(course.newStudents ?? [])); @@ -122,10 +124,10 @@ export const useRefreshCourse = () => { }, [dispatch]); }; -export const useCourseFiles = (courseId: number, isCourseMentor: boolean) => { +export const useCourseFiles = (courseId: number, defaultIsCourseMentor?: boolean) => { const dispatch = useCourseDispatch(); - const loadCourseFiles = useCallback(async () => { + const loadCourseFiles = useCallback(async (isCourseMentor = defaultIsCourseMentor ?? false) => { let files = [] as FileInfoDTO[]; try { files = await ApiSingleton.filesApi.filesGetFilesInfo(courseId, !isCourseMentor); @@ -134,7 +136,7 @@ export const useCourseFiles = (courseId: number, isCourseMentor: boolean) => { enqueueSnackbar(errors[0], {variant: 'warning', autoHideDuration: 1990}); } dispatch(setCourseFiles(files)); - }, [dispatch, courseId, isCourseMentor]); + }, [dispatch, courseId, defaultIsCourseMentor]); const updateFiles = useCallback((files: FileInfoDTO[], unitType: CourseUnitType, unitId: number) => { dispatch(updateCourseFiles({files, unitType, unitId})); diff --git a/hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts b/hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts new file mode 100644 index 000000000..9fb6f053b --- /dev/null +++ b/hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts @@ -0,0 +1,372 @@ +import { useCallback, useMemo } from 'react'; +import { useCourseDispatch, useCourseState } from '../hooks'; +import { + addDraftHomework, + updateDraftHomework as updateDraftHomeworkAction, + removeDraftHomework, + decrementDraftId, + setSelectedItem, +} from '../slices/courseEditingSlice'; +import { updateOrInsertHomework, deleteHomework } from '../slices/homeworkSlice'; +import { HomeworkViewModel, CreateHomeworkViewModel, ActionOptions, PostTaskViewModel } from '@/api'; +import ApiSingleton from '@/api/ApiSingleton'; +import {BonusTag, isBonusWork, isTestWork, TestTag} from "@/components/Common/HomeworkTags"; +import Lodash from "lodash"; +import {IFileInfo} from "@/components/Files/IFileInfo"; +import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import ProcessFilesUtils from "@/components/Utils/ProcessFilesUtils"; + +type HomeworkFileOptions = { + initialFilesInfo: IFileInfo[]; + selectedFilesInfo: IFileInfo[]; + onStartProcessing: ( + homeworkId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[] + ) => void; + onDone?: () => void; +}; + +type HomeworkDeleteFileOptions = { + initialFilesInfo: IFileInfo[]; +}; + +export const useDraftHomework = (homeworkId: number) => + useCourseState(state => state.editing.draftHomeworks.find(dh => dh.id === homeworkId)); + +const normalizeDraftDate = (value: unknown): Date | undefined => { + if (value instanceof Date) return value.toISOString() as unknown as Date; + return value as Date | undefined; +}; + +const normalizeHomeworkDraft = (homework: HomeworkViewModel): HomeworkViewModel => ({ + ...homework, + publicationDate: normalizeDraftDate(homework.publicationDate), + deadlineDate: normalizeDraftDate(homework.deadlineDate), + tasks: homework.tasks?.map(task => ({ + ...task, + publicationDate: normalizeDraftDate(task.publicationDate), + deadlineDate: normalizeDraftDate(task.deadlineDate), + })) ?? [], +}); + +export const useHomeworkEditorState = (homework: HomeworkViewModel) => { + const committedHomeworks = useCourseState(state => state.homeworks.items); + type HomeworkTaskDraft = PostTaskViewModel & { id?: number; hasErrors?: boolean; publicationDate?: Date }; + const isNewHomework = homework.id! < 0; + const publicationDate = homework.publicationDateNotSet || !homework.publicationDate + ? undefined + : new Date(homework.publicationDate); + const deadlineDate = homework.deadlineDateNotSet || !homework.deadlineDate + ? undefined + : new Date(homework.deadlineDate); + const isPublished = !homework.isDeferred; + const changedTaskPublicationDates = (homework.tasks || []) + .filter(task => task.publicationDate != null) + .map(task => new Date(task.publicationDate!)); + const taskHasErrors = (homework.tasks || []).some(task => (task as HomeworkTaskDraft).hasErrors === true); + const hasErrors = !homework.title || !!(homework as HomeworkViewModel & { hasErrors?: boolean }).hasErrors; + + const deadlineSuggestion = useMemo(() => { + if (!isNewHomework || !publicationDate) return undefined; + + const isTest = (homework.tags || []).includes(TestTag); + const isBonus = (homework.tags || []).includes(BonusTag); + type DateCandidate = { deadlineDate: Date; daysDiff: number }; + + const mapped: DateCandidate[] = committedHomeworks + .filter(candidate => { + const candidateIsTest = isTestWork(candidate); + const candidateIsBonus = isBonusWork(candidate); + return candidate.id! > 0 + && candidate.hasDeadline + && ( + (isTest && candidateIsTest) + || (isBonus && candidateIsBonus) + || (!isTest && !isBonus && !candidateIsTest && !candidateIsBonus) + ); + }) + .map(candidate => ({ + deadlineDate: new Date(candidate.deadlineDate!), + daysDiff: Math.floor( + (new Date(candidate.deadlineDate!).getTime() - new Date(candidate.publicationDate!).getTime()) / (1000 * 3600 * 24) + ) + })); + + const dateCandidate = Lodash(mapped) + .groupBy(candidate => [candidate.daysDiff, candidate.deadlineDate.getHours(), candidate.deadlineDate.getMinutes()]) + .entries() + .sortBy((entry: [string, DateCandidate[]]) => entry[1].length) + .last()?.[1][0]; + + if (!dateCandidate) return undefined; + + const suggestedDeadline = new Date(publicationDate); + suggestedDeadline.setDate(suggestedDeadline.getDate() + dateCandidate.daysDiff); + suggestedDeadline.setHours(dateCandidate.deadlineDate.getHours(), dateCandidate.deadlineDate.getMinutes(), 0, 0); + return suggestedDeadline; + }, [committedHomeworks, homework.tags, isNewHomework, publicationDate]); + + const tagSuggestion = useMemo(() => { + const title = (homework.title || '').toLowerCase(); + const tags = homework.tags || []; + if (tags.includes(TestTag)) return undefined; + return (title.includes("контрольн") || title.includes("проверочн") || title.includes("переписывание") || title.includes("тест")) + ? TestTag + : undefined; + }, [homework.title, homework.tags]); + + return { + isNewHomework, + publicationDate, + deadlineDate, + isPublished, + changedTaskPublicationDates, + taskHasErrors, + hasErrors, + deadlineSuggestion, + tagSuggestion, + }; +}; + +export const useHomeworkEditing = () => { + const dispatch = useCourseDispatch(); + const draftIdCounter = useCourseState(state => state.editing.draftIdCounter); + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); + const committedHomeworks = useCourseState(state => state.homeworks.items); + + const createHomeworkDraft = useCallback((courseId: number) => { + const newId = draftIdCounter; + const newHomework: HomeworkViewModel = { + courseId, + id: newId, + title: 'Новое задание', + description: '', + publicationDate: undefined, + publicationDateNotSet: false, + hasDeadline: false, + deadlineDate: undefined, + deadlineDateNotSet: false, + isDeadlineStrict: false, + isGroupWork: false, + tasks: [], + tags: [], + }; + dispatch(addDraftHomework(newHomework)); + dispatch(decrementDraftId()); + dispatch(setSelectedItem({ isHomework: true, id: newId })); + return newId; + }, [dispatch, draftIdCounter]); + + const setHomeworkDraftFromLoaded = useCallback((hw: HomeworkViewModel) => { + const copy: HomeworkViewModel = normalizeHomeworkDraft({ + ...hw, + tasks: [], + }); + dispatch(addDraftHomework(copy)); + dispatch(setSelectedItem({ isHomework: true, id: hw.id })); + }, [dispatch]); + + const updateHomeworkDraftState = useCallback((hw: HomeworkViewModel) => { + dispatch(updateDraftHomeworkAction(normalizeHomeworkDraft(hw))); + }, [dispatch]); + + const commitHomeworkSave = useCallback((draftId: number, savedHomework: HomeworkViewModel) => { + dispatch(updateOrInsertHomework(savedHomework)); + dispatch(removeDraftHomework(draftId)); + }, [dispatch]); + + const commitHomeworkRemoval = useCallback((homeworkId: number) => { + dispatch(deleteHomework(homeworkId)); + dispatch(removeDraftHomework(homeworkId)); + }, [dispatch]); + + const discardHomeworkDraft = useCallback((homeworkId: number, isNewHomework: boolean) => { + if (isNewHomework) { + dispatch(removeDraftHomework(homeworkId)); + dispatch(setSelectedItem({ isHomework: true, id: undefined })); + } else { + dispatch(removeDraftHomework(homeworkId)); + dispatch(setSelectedItem({ isHomework: true, id: homeworkId })); + } + }, [dispatch]); + + const loadHomeworkForEditing = useCallback((homeworkId: number) => { + return ApiSingleton.homeworksApi.homeworksGetForEditingHomework(homeworkId); + }, []); + + const submitHomeworkApi = useCallback(( + courseId: number, + homeworkId: number, + isNewHomework: boolean, + body: CreateHomeworkViewModel + ) => { + return isNewHomework + ? ApiSingleton.homeworksApi.homeworksAddHomework(courseId, body) + : ApiSingleton.homeworksApi.homeworksUpdateHomework(homeworkId, body); + }, []); + + const deleteHomeworkApi = useCallback((homeworkId: number, isNewHomework: boolean) => { + if (isNewHomework) return Promise.resolve(); + return ApiSingleton.homeworksApi.homeworksDeleteHomework(homeworkId); + }, []); + + const getCurrentHomework = useCallback((homeworkId: number) => { + return draftHomeworks.find(dh => dh.id === homeworkId) + ?? committedHomeworks.find(hw => hw.id === homeworkId); + }, [draftHomeworks, committedHomeworks]); + + const buildHomeworkPayload = useCallback((homework: HomeworkViewModel, editOptions: ActionOptions): CreateHomeworkViewModel => ({ + title: homework.title!, + description: homework.description, + tags: homework.tags || [], + hasDeadline: homework.hasDeadline, + deadlineDate: homework.deadlineDateNotSet || !homework.deadlineDate ? undefined : new Date(homework.deadlineDate), + isDeadlineStrict: homework.isDeadlineStrict, + publicationDate: homework.publicationDateNotSet || !homework.publicationDate ? undefined : new Date(homework.publicationDate), + actionOptions: editOptions, + tasks: homework.id! < 0 ? (homework.tasks || []).map(t => ({ + title: t.title!, + description: t.description, + hasDeadline: t.hasDeadline, + deadlineDate: t.deadlineDate, + isDeadlineStrict: t.isDeadlineStrict, + publicationDate: t.publicationDate, + maxRating: t.maxRating!, + criteria: t.criteria || [], + } as PostTaskViewModel)) : [], + }), []); + + const processHomeworkFiles = useCallback(async ( + courseId: number, + courseUnitId: number, + initialFilesInfo: IFileInfo[], + selectedFilesInfo: IFileInfo[], + onStartProcessing: ( + courseUnitId: number, + courseUnitType: CourseUnitType, + previouslyExistingFilesCount: number, + waitingNewFilesCount: number, + deletingFilesIds: number[] + ) => void, + onComplete?: () => void, + ) => { + const deletingFileIds = initialFilesInfo + .filter(initialFile => initialFile.id && !selectedFilesInfo.some(selectedFile => selectedFile.id === initialFile.id)) + .map(fileInfo => fileInfo.id!); + const newFiles = selectedFilesInfo + .filter(selectedFile => selectedFile.file && selectedFile.id == undefined) + .map(fileInfo => fileInfo.file!); + + if (deletingFileIds.length + newFiles.length > 0) { + await ProcessFilesUtils.processFilesWithErrorsHadling({ + courseId, + courseUnitType: CourseUnitType.Homework, + courseUnitId, + deletingFileIds, + newFiles, + }); + } + + onComplete?.(); + + if (deletingFileIds.length + newFiles.length > 0) { + onStartProcessing( + courseUnitId, + CourseUnitType.Homework, + initialFilesInfo.length, + newFiles.length, + deletingFileIds, + ); + } + }, []); + + const startHomeworkEdit = useCallback(async (homeworkId: number) => { + const loaded = await loadHomeworkForEditing(homeworkId); + setHomeworkDraftFromLoaded(loaded); + }, [loadHomeworkForEditing, setHomeworkDraftFromLoaded]); + + const saveHomework = useCallback(async ( + homeworkId: number, + editOptions: ActionOptions, + fileOptions?: HomeworkFileOptions, + ) => { + const homework = getCurrentHomework(homeworkId); + if (!homework) throw new Error(`Homework ${homeworkId} not found for saving`); + const courseId = homework.courseId!; + const isNewHomework = homework.id! < 0; + const body = buildHomeworkPayload(homework, editOptions); + const updatedHomework = await submitHomeworkApi(courseId, homeworkId, isNewHomework, body); + const savedHomework = updatedHomework.value!; + const finalize = () => { + commitHomeworkSave(homeworkId, savedHomework); + dispatch(setSelectedItem({isHomework: true, id: savedHomework.id})); + }; + + if (fileOptions) { + await processHomeworkFiles( + savedHomework.courseId!, + savedHomework.id!, + fileOptions.initialFilesInfo, + fileOptions.selectedFilesInfo, + fileOptions.onStartProcessing, + () => { + finalize(); + fileOptions.onDone?.(); + }, + ); + } else { + finalize(); + } + return savedHomework; + }, [getCurrentHomework, buildHomeworkPayload, submitHomeworkApi, commitHomeworkSave, dispatch, processHomeworkFiles]); + + const deleteHomeworkWithFiles = useCallback(async ( + homeworkId: number, + fileOptions?: HomeworkDeleteFileOptions, + ) => { + const homework = getCurrentHomework(homeworkId); + if (!homework) throw new Error(`Homework ${homeworkId} not found for deletion`); + const isNewHomework = homework.id! < 0; + await deleteHomeworkApi(homeworkId, isNewHomework); + const finalize = () => { + commitHomeworkRemoval(homeworkId); + dispatch(setSelectedItem({isHomework: true, id: undefined})); + }; + + if (fileOptions && fileOptions.initialFilesInfo.length > 0) { + await ProcessFilesUtils.processFilesWithErrorsHadling({ + courseId: homework.courseId!, + courseUnitType: CourseUnitType.Homework, + courseUnitId: homeworkId, + deletingFileIds: fileOptions.initialFilesInfo.filter(fileInfo => fileInfo.id).map(fileInfo => fileInfo.id!), + newFiles: [] + }); + finalize(); + } else { + finalize(); + } + }, [getCurrentHomework, deleteHomeworkApi, commitHomeworkRemoval, dispatch]); + + return { + createHomework: createHomeworkDraft, + startHomeworkEdit, + patchHomeworkDraft: updateHomeworkDraftState, + saveHomework, + deleteHomework: deleteHomeworkWithFiles, + cancelHomeworkEdit: (homeworkId: number) => discardHomeworkDraft(homeworkId, homeworkId < 0), + }; +}; + +export const getHomeworkDeleteMessage = (homeworkName: string, filesInfo: { name?: string }[]): string => { + let message = `Вы точно хотите удалить задание "${homeworkName}"?`; + if (filesInfo.length > 0) { + message += ` Будет также удален файл ${filesInfo[0].name ?? ''}`; + if (filesInfo.length > 1) { + message += ` и другие прикрепленные файлы`; + } + } + return message; +}; \ No newline at end of file diff --git a/hwproj.front/src/store/storeHooks/taskEditorHooks.ts b/hwproj.front/src/store/storeHooks/taskEditorHooks.ts new file mode 100644 index 000000000..0977c6a0c --- /dev/null +++ b/hwproj.front/src/store/storeHooks/taskEditorHooks.ts @@ -0,0 +1,331 @@ +import {useCallback, useMemo} from 'react'; +import {useCourseDispatch, useCourseState } from '../hooks'; +import { + addDraftHomework, + addDraftTask, + updateDraftTask as updateDraftTaskAction, + removeDraftTask, + removeDraftHomework, + decrementDraftId, + setSelectedItem, +} from '../slices/courseEditingSlice'; +import {updateTask, deleteTask as deleteTaskAction } from '../slices/homeworkSlice'; +import {HomeworkViewModel, HomeworkTaskViewModel, CriterionViewModel, PostTaskViewModel, ActionOptions } from '@/api'; +import ApiSingleton from '@/api/ApiSingleton'; +import {BonusTag, isBonusWork, isTestWork, TestTag} from "@/components/Common/HomeworkTags"; +import {useMergedHomeworks} from "@/store/storeHooks/courseEditingHooks"; + +export const useDraftTask = (taskId: number, homeworkId: number) => { + const draftHw = useCourseState(state => + state.editing.draftHomeworks.find(dh => dh.id === homeworkId)); + return draftHw?.tasks?.find(t => t.id === taskId); +}; + +const normalizeDraftDate = (value: unknown): Date | undefined => { + if (value instanceof Date) return value.toISOString() as unknown as Date; + return value as Date | undefined; +}; + +const normalizeTaskDraft = (task: HomeworkTaskViewModel): HomeworkTaskViewModel => ({ + ...task, + publicationDate: normalizeDraftDate(task.publicationDate), + deadlineDate: normalizeDraftDate(task.deadlineDate), +}); + +const getTaskEditorState = ( + task: HomeworkTaskViewModel & { + hasErrors?: boolean; + suggestedMaxRating?: number; + }, + homework: HomeworkViewModel, +) => { + const criteria = task.criteria || []; + const autoMaxFromCriteria = criteria.length > 0; + const maxRating = autoMaxFromCriteria + ? criteria.reduce((sum, c) => sum + (c.maxPoints || 0), 0) + : (task.maxRating ?? 0); + const isBonusExplicit = (task.tags || []).includes(BonusTag) && !(homework.tags || []).includes(BonusTag); + const publicationDate = task.publicationDate ?? homework.publicationDate; + const taskHasErrors = task.hasErrors === true; + const hasErrors = !task.title || maxRating <= 0 || maxRating > 100 || taskHasErrors; + const isNewTask = task.id! < 0; + const isNewHomework = homework.id! < 0; + const homeworkPublicationDateIsSet = !homework.publicationDateNotSet; + const taskPublicationDate = task.publicationDateNotSet + ? undefined + : (task.publicationDate ? new Date(task.publicationDate) : undefined); + const taskDeadlineDate = task.deadlineDateNotSet + ? undefined + : (task.deadlineDate ? new Date(task.deadlineDate) : undefined); + const isPublicationDateDisabled = task.isDeferred || !homework.isDeferred; + const maxRatingLabel = criteria.length > 0 + ? "Критерии" + : task.suggestedMaxRating === maxRating + ? "Вычислено" + : undefined; + + return { + criteria, + autoMaxFromCriteria, + maxRating, + isBonusExplicit, + publicationDate, + taskHasErrors, + hasErrors, + isNewTask, + isNewHomework, + homeworkPublicationDateIsSet, + taskPublicationDate, + taskDeadlineDate, + isPublicationDateDisabled, + maxRatingLabel, + }; +}; + +export const useTaskEditorState = ( + task: HomeworkTaskViewModel & {hasErrors?: boolean;suggestedMaxRating?: number;}, + homework: HomeworkViewModel, +) => useMemo( + () => getTaskEditorState(task, homework), + [task, homework], +); + +export const useTaskEditing = () => { + const dispatch = useCourseDispatch(); + const draftIdCounter = useCourseState(state => state.editing.draftIdCounter); + const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); + const committedHomeworks = useCourseState(state => state.homeworks.items); + const mergedHomeworks = useMergedHomeworks(); + + const ensureHomeworkInDrafts = useCallback((homework: HomeworkViewModel) => { + const existingDraft = draftHomeworks.find(dh => dh.id === homework.id); + if (!existingDraft) { + const copy: HomeworkViewModel = { + ...homework, + tasks: [], + }; + dispatch(addDraftHomework(copy)); + } + }, [draftHomeworks, dispatch]); + + const getSuggestedTaskRating = useCallback((homework: HomeworkViewModel) => { + const tags = homework.tags || []; + const isTest = tags.includes(TestTag); + const isBonus = tags.includes(BonusTag); + const counts = new Map(); + + for (const candidateHomework of mergedHomeworks) { + const firstTask = candidateHomework.tasks?.[0]; + if (!firstTask || firstTask.id! <= 0) continue; + + const candidateIsTest = isTestWork(firstTask); + const candidateIsBonus = isBonusWork(firstTask); + const matchesKind = + (isTest && candidateIsTest) + || (isBonus && candidateIsBonus) + || (!isTest && !isBonus && !candidateIsTest && !candidateIsBonus); + + if (!matchesKind) continue; + + const rating = firstTask.maxRating!; + counts.set(rating, (counts.get(rating) ?? 0) + 1); + } + + let suggestedRating: number | undefined = undefined; + let bestCount = -1; + for (const [rating, count] of counts.entries()) { + if (count >= bestCount) { + suggestedRating = rating; + bestCount = count; + } + } + + return suggestedRating; + }, [mergedHomeworks]); + + const createTaskDraft = useCallback((homework: HomeworkViewModel) => { + ensureHomeworkInDrafts(homework); + const newId = draftIdCounter; + const suggestedMaxRating = getSuggestedTaskRating(homework); + const newTask = { + id: newId, + homeworkId: homework.id, + title: 'Новая задача', + description: '', + maxRating: suggestedMaxRating ?? 10, + suggestedMaxRating, + isDeferred: homework.isDeferred || false, + deadlineDateNotSet: true, + deadlineDate: undefined, + tags: homework.tags || [], + } as HomeworkTaskViewModel; + dispatch(addDraftTask(newTask)); + dispatch(decrementDraftId()); + dispatch(setSelectedItem({ isHomework: false, id: newId })); + return newId; + }, [dispatch, draftIdCounter, ensureHomeworkInDrafts, getSuggestedTaskRating]); + + const setTaskDraftFromLoaded = useCallback((task: HomeworkTaskViewModel, homework: HomeworkViewModel) => { + ensureHomeworkInDrafts(homework); + const copy: HomeworkTaskViewModel = normalizeTaskDraft({ ...task }); + dispatch(addDraftTask(copy)); + dispatch(setSelectedItem({ isHomework: false, id: task.id })); + }, [dispatch, ensureHomeworkInDrafts]); + + const updateTaskDraftState = useCallback((task: HomeworkTaskViewModel) => { + dispatch(updateDraftTaskAction(normalizeTaskDraft(task))); + }, [dispatch]); + + const discardTaskDraftByIds = useCallback((taskId: number, homeworkId: number) => { + dispatch(removeDraftTask({ homeworkId, taskId })); + const draftHw = draftHomeworks.find(dh => dh.id === homeworkId); + if (draftHw && (draftHw.tasks || []).filter(t => t.id !== taskId).length === 0) { + dispatch(removeDraftHomework(homeworkId)); + } + }, [dispatch, draftHomeworks]); + + const commitTaskSave = useCallback((draftId: number, homeworkId: number, savedTask: HomeworkTaskViewModel) => { + dispatch(updateTask(savedTask)); + dispatch(removeDraftTask({ homeworkId, taskId: draftId })); + const draftHw = draftHomeworks.find(dh => dh.id === homeworkId); + if (draftHw && (draftHw.tasks || []).filter(t => t.id !== draftId).length === 0) { + dispatch(removeDraftHomework(homeworkId)); + } + }, [dispatch, draftHomeworks]); + + const commitTaskRemoval = useCallback((taskId: number, homeworkId: number) => { + dispatch(deleteTaskAction({ homeworkId, taskId })); + dispatch(removeDraftTask({ homeworkId, taskId })); + }, [dispatch]); + + const addCriterion = useCallback((task: HomeworkTaskViewModel, criterion: CriterionViewModel) => { + const next = [...(task.criteria || []), criterion]; + dispatch(updateDraftTaskAction({ ...task, criteria: next })); + }, [dispatch]); + + const updateCriterion = useCallback((task: HomeworkTaskViewModel, index: number, patch: Partial) => { + const criteria = task.criteria || []; + const next = criteria.map((c, i) => (i === index ? { ...c, ...patch } : c)); + dispatch(updateDraftTaskAction({ ...task, criteria: next })); + }, [dispatch]); + + const removeCriterion = useCallback((task: HomeworkTaskViewModel, index: number) => { + const criteria = task.criteria || []; + const next = criteria.filter((_, i) => i !== index); + dispatch(updateDraftTaskAction({ ...task, criteria: next })); + }, [dispatch]); + + const persistTask = useCallback(async ( + task: HomeworkTaskViewModel, + homework: HomeworkViewModel, + isNewTask: boolean, + updatePayload: PostTaskViewModel + ) => { + const updatedTask = isNewTask + ? await ApiSingleton.tasksApi.tasksAddTask(homework.id!, updatePayload) + : await ApiSingleton.tasksApi.tasksUpdateTask(+task.id!, updatePayload); + commitTaskSave(task.id!, task.homeworkId!, updatedTask.value!); + dispatch(setSelectedItem({ isHomework: false, id: updatedTask.value!.id })); + return updatedTask; + }, [commitTaskSave, dispatch]); + + const removeTask = useCallback(async ( + task: HomeworkTaskViewModel, + homework: HomeworkViewModel, + isNewTask: boolean + ) => { + if (!isNewTask) await ApiSingleton.tasksApi.tasksDeleteTask(task.id!); + commitTaskRemoval(task.id!, task.homeworkId!); + dispatch(setSelectedItem({ isHomework: true, id: homework.id })); + }, [commitTaskRemoval, dispatch]); + + const discardTaskDraft = useCallback((task: HomeworkTaskViewModel, homework: HomeworkViewModel, isNewTask: boolean) => { + discardTaskDraftByIds(task.id!, task.homeworkId!); + if (isNewTask) { + dispatch(setSelectedItem({ isHomework: true, id: homework.id })); + } else { + dispatch(setSelectedItem({ isHomework: false, id: task.id })); + } + }, [discardTaskDraftByIds, dispatch]); + + const loadTaskForEditing = useCallback((taskId: number) => { + return ApiSingleton.tasksApi.tasksGetForEditingTask(taskId); + }, []); + + const getCurrentHomework = useCallback((homeworkId: number) => { + return draftHomeworks.find(dh => dh.id === homeworkId) + ?? committedHomeworks.find(hw => hw.id === homeworkId); + }, [draftHomeworks, committedHomeworks]); + + const getCurrentTask = useCallback((taskId: number, homeworkId: number) => { + return draftHomeworks + .find(dh => dh.id === homeworkId) + ?.tasks?.find(t => t.id === taskId) + ?? committedHomeworks + .find(hw => hw.id === homeworkId) + ?.tasks?.find(t => t.id === taskId); + }, [draftHomeworks, committedHomeworks]); + + const buildTaskPayload = useCallback((task: HomeworkTaskViewModel, homework: HomeworkViewModel, editOptions: ActionOptions): PostTaskViewModel => { + const {criteria, maxRating, isBonusExplicit} = getTaskEditorState(task, homework); + + return { + title: task.title!, + description: task.description || '', + isBonusExplicit, + maxRating, + actionOptions: editOptions, + criteria, + hasDeadline: task.hasDeadline, + isDeadlineStrict: task.isDeadlineStrict, + publicationDate: task.publicationDateNotSet ? undefined : task.publicationDate, + deadlineDate: task.deadlineDateNotSet ? undefined : task.deadlineDate, + }; + }, []); + + const startTaskEdit = useCallback(async (taskId: number) => { + const loaded = await loadTaskForEditing(taskId); + setTaskDraftFromLoaded(loaded.task!, loaded.homework!); + }, [loadTaskForEditing, setTaskDraftFromLoaded]); + + const saveTask = useCallback(async ( + taskId: number, + homeworkId: number, + editOptions: ActionOptions, + ) => { + const task = getCurrentTask(taskId, homeworkId); + const homework = getCurrentHomework(homeworkId); + if (!task || !homework) throw new Error(`Task ${taskId} or homework ${homeworkId} not found for saving`); + const isNewTask = task.id! < 0; + const body = buildTaskPayload(task, homework, editOptions); + return persistTask(task, homework, isNewTask, body); + }, [getCurrentTask, getCurrentHomework, buildTaskPayload, persistTask]); + + const deleteTask = useCallback(async ( + taskId: number, + homeworkId: number, + ) => { + const task = getCurrentTask(taskId, homeworkId); + const homework = getCurrentHomework(homeworkId); + if (!task || !homework) throw new Error(`Task ${taskId} or homework ${homeworkId} not found for deletion`); + const isNewTask = task.id! < 0; + return removeTask(task, homework, isNewTask); + }, [getCurrentTask, getCurrentHomework, removeTask]); + + return { + createTask: createTaskDraft, + startTaskEdit, + patchTaskDraft: updateTaskDraftState, + saveTask, + deleteTask, + cancelTaskEdit: (taskId: number, homeworkId: number) => { + const task = getCurrentTask(taskId, homeworkId); + const homework = getCurrentHomework(homeworkId); + if (!task || !homework) throw new Error(`Task ${taskId} or homework ${homeworkId} not found for cancel`); + discardTaskDraft(task, homework, task.id! < 0); + }, + addCriterion, + updateCriterion, + removeCriterion, + }; +}; \ No newline at end of file diff --git a/hwproj.front/src/store/taskEditorHooks.ts b/hwproj.front/src/store/taskEditorHooks.ts deleted file mode 100644 index b1335ad98..000000000 --- a/hwproj.front/src/store/taskEditorHooks.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { useCallback } from 'react'; -import { useCourseDispatch, useCourseState } from './hooks'; -import { - addDraftHomework, - addDraftTask, - updateDraftTask as updateDraftTaskAction, - removeDraftTask, - removeDraftHomework, - decrementDraftId, - setSelectedItem, -} from './slices/courseEditingSlice'; -import { updateTask, deleteTask } from './slices/homeworkSlice'; -import {HomeworkViewModel, HomeworkTaskViewModel, CriterionViewModel, PostTaskViewModel } from '@/api'; -import ApiSingleton from '@/api/ApiSingleton'; - -export const useDraftTask = (taskId: number, homeworkId: number) => { - const draftHw = useCourseState(state => - state.editing.draftHomeworks.find(dh => dh.id === homeworkId)); - return draftHw?.tasks?.find(t => t.id === taskId); -}; - -export const useTaskEditing = () => { - const dispatch = useCourseDispatch(); - const draftIdCounter = useCourseState(state => state.editing.draftIdCounter); - const draftHomeworks = useCourseState(state => state.editing.draftHomeworks); - - const ensureHomeworkInDrafts = useCallback((homework: HomeworkViewModel) => { - const existingDraft = draftHomeworks.find(dh => dh.id === homework.id); - if (!existingDraft) { - const copy: HomeworkViewModel = { - ...homework, - tasks: [], - }; - dispatch(addDraftHomework(copy)); - } - }, [draftHomeworks, dispatch]); - - const addNewTask = useCallback((homework: HomeworkViewModel, maxRating?: number, suggestedMaxRating?: number) => { - ensureHomeworkInDrafts(homework); - const newId = draftIdCounter; - const newTask = { - id: newId, - homeworkId: homework.id, - title: 'Новая задача', - description: '', - maxRating: maxRating ?? 10, - suggestedMaxRating, - isDeferred: homework.isDeferred || false, - deadlineDateNotSet: true, - deadlineDate: undefined, - tags: homework.tags || [], - } as HomeworkTaskViewModel; - dispatch(addDraftTask(newTask)); - dispatch(decrementDraftId()); - dispatch(setSelectedItem({ isHomework: false, id: newId })); - return newId; - }, [dispatch, draftIdCounter, ensureHomeworkInDrafts]); - - const startEditingTask = useCallback((task: HomeworkTaskViewModel, homework: HomeworkViewModel) => { - ensureHomeworkInDrafts(homework); - const copy: HomeworkTaskViewModel = { ...task }; - dispatch(addDraftTask(copy)); - dispatch(setSelectedItem({ isHomework: false, id: task.id })); - }, [dispatch, ensureHomeworkInDrafts]); - - const updateDraftTask = useCallback((task: HomeworkTaskViewModel) => { - dispatch(updateDraftTaskAction(task)); - }, [dispatch]); - - const cancelEditingTask = useCallback((taskId: number, homeworkId: number) => { - dispatch(removeDraftTask({ homeworkId, taskId })); - const draftHw = draftHomeworks.find(dh => dh.id === homeworkId); - if (draftHw && (draftHw.tasks || []).filter(t => t.id !== taskId).length === 0) { - dispatch(removeDraftHomework(homeworkId)); - } - }, [dispatch, draftHomeworks]); - - const commitTask = useCallback((draftId: number, homeworkId: number, savedTask: HomeworkTaskViewModel) => { - dispatch(updateTask(savedTask)); - dispatch(removeDraftTask({ homeworkId, taskId: draftId })); - const draftHw = draftHomeworks.find(dh => dh.id === homeworkId); - if (draftHw && (draftHw.tasks || []).filter(t => t.id !== draftId).length === 0) { - dispatch(removeDraftHomework(homeworkId)); - } - }, [dispatch, draftHomeworks]); - - const commitTaskDeletion = useCallback((taskId: number, homeworkId: number) => { - dispatch(deleteTask({ homeworkId, taskId })); - dispatch(removeDraftTask({ homeworkId, taskId })); - }, [dispatch]); - - const addCriterion = useCallback((task: HomeworkTaskViewModel, criterion: CriterionViewModel) => { - const next = [...(task.criteria || []), criterion]; - dispatch(updateDraftTaskAction({ ...task, criteria: next })); - }, [dispatch]); - - const updateCriterion = useCallback((task: HomeworkTaskViewModel, index: number, patch: Partial) => { - const criteria = task.criteria || []; - const next = criteria.map((c, i) => (i === index ? { ...c, ...patch } : c)); - dispatch(updateDraftTaskAction({ ...task, criteria: next })); - }, [dispatch]); - - const removeCriterion = useCallback((task: HomeworkTaskViewModel, index: number) => { - const criteria = task.criteria || []; - const next = criteria.filter((_, i) => i !== index); - dispatch(updateDraftTaskAction({ ...task, criteria: next })); - }, [dispatch]); - - const submitTaskEdit = useCallback(async ( - task: HomeworkTaskViewModel, - homework: HomeworkViewModel, - isNewTask: boolean, - updatePayload: PostTaskViewModel - ) => { - const updatedTask = isNewTask - ? await ApiSingleton.tasksApi.tasksAddTask(homework.id!, updatePayload) - : await ApiSingleton.tasksApi.tasksUpdateTask(+task.id!, updatePayload); - dispatch(updateTask(updatedTask.value!)); - dispatch(removeDraftTask({ homeworkId: task.homeworkId!, taskId: task.id! })); - const draftHw = draftHomeworks.find(dh => dh.id === task.homeworkId); - if (draftHw && (draftHw.tasks || []).filter(t => t.id !== task.id).length === 0) { - dispatch(removeDraftHomework(task.homeworkId!)); - } - dispatch(setSelectedItem({ isHomework: false, id: updatedTask.value!.id })); - return updatedTask; - }, [dispatch, draftHomeworks]); - - const deleteTaskEdit = useCallback(async ( - task: HomeworkTaskViewModel, - homework: HomeworkViewModel, - isNewTask: boolean - ) => { - if (!isNewTask) await ApiSingleton.tasksApi.tasksDeleteTask(task.id!); - dispatch(deleteTask({ homeworkId: task.homeworkId!, taskId: task.id! })); - dispatch(removeDraftTask({ homeworkId: task.homeworkId!, taskId: task.id! })); - dispatch(setSelectedItem({ isHomework: true, id: homework.id })); - }, [dispatch]); - - const cancelTaskEdit = useCallback((task: HomeworkTaskViewModel, homework: HomeworkViewModel, isNewTask: boolean) => { - dispatch(removeDraftTask({ homeworkId: task.homeworkId!, taskId: task.id! })); - const draftHw = draftHomeworks.find(dh => dh.id === task.homeworkId); - if (draftHw && (draftHw.tasks || []).filter(t => t.id !== task.id).length === 0 && !isNewTask) { - dispatch(removeDraftHomework(task.homeworkId!)); - } - if (isNewTask) { - dispatch(setSelectedItem({ isHomework: true, id: homework.id })); - } else { - dispatch(setSelectedItem({ isHomework: false, id: task.id })); - } - }, [dispatch, draftHomeworks]); - - const loadTaskForEditing = useCallback((taskId: number) => { - return ApiSingleton.tasksApi.tasksGetForEditingTask(taskId); - }, []); - - return { - addNewTask, - startEditingTask, - loadTaskForEditing, - updateDraftTask, - cancelEditingTask, - commitTask, - commitTaskDeletion, - addCriterion, - updateCriterion, - removeCriterion, - submitTaskEdit, - deleteTaskEdit, - cancelTaskEdit, - }; -}; \ No newline at end of file From fb5f0855f0926e9d8b6902930e00a6777fcadc03 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Thu, 12 Mar 2026 22:30:39 +0300 Subject: [PATCH 44/48] fix: unnecessary updates when the deadline is auto-inserted --- .../Common/PublicationAndDeadlineDates.tsx | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/hwproj.front/src/components/Common/PublicationAndDeadlineDates.tsx b/hwproj.front/src/components/Common/PublicationAndDeadlineDates.tsx index 708ac9168..031286938 100644 --- a/hwproj.front/src/components/Common/PublicationAndDeadlineDates.tsx +++ b/hwproj.front/src/components/Common/PublicationAndDeadlineDates.tsx @@ -60,21 +60,32 @@ const PublicationAndDeadlineDates: React.FC = (props) => { const deadlineDateNotSet = state.hasDeadline && !state.deadlineDate const deadlineSoonerThanHomework = isDeadlineSoonerThanPublication(state.publicationDate, state.deadlineDate) + const autoCalculatedDeadlineTime = props.autoCalculatedDeadline?.getTime() ?? null useEffect(() => { const validationResult = deadlineDateNotSet || deadlineSoonerThanHomework - props.onChange({...state, hasErrors: validationResult}) }, [state]) useEffect(() => { - setState(prevState => ({ - ...prevState, - deadlineDate: state.hasDeadline - ? props.autoCalculatedDeadline || state.deadlineDate || getInitialDeadlineDate(prevState.publicationDate) - : undefined, - })) - }, [props.autoCalculatedDeadline]) + setState(prevState => { + const nextDeadlineDate = prevState.hasDeadline + ? props.autoCalculatedDeadline || prevState.deadlineDate || getInitialDeadlineDate(prevState.publicationDate) + : undefined + + const prevDeadlineTime = prevState.deadlineDate?.getTime() ?? null + const nextDeadlineTime = nextDeadlineDate?.getTime() ?? null + + if (prevDeadlineTime === nextDeadlineTime) { + return prevState + } + + return { + ...prevState, + deadlineDate: nextDeadlineDate, + } + }) + }, [autoCalculatedDeadlineTime]) return
From 72f45ab8ac89dd2d0db9d045298ab29832c61c3c Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Thu, 12 Mar 2026 22:58:59 +0300 Subject: [PATCH 45/48] style: returned to normal appearance --- .../components/Homeworks/CourseHomeworkExperimental.tsx | 6 +----- hwproj.front/src/store/storeHooks/courseEditingHooks.ts | 8 ++++---- hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts | 8 ++++---- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 34ad3c556..ec3a54307 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -37,11 +37,7 @@ import {CourseUnitType} from "../Files/CourseUnitType" import {useCourseState} from "@/store/hooks"; import {useIsCourseMentor} from "@/store/storeHooks/courseHooks"; import {FilesHandler} from "@/components/Files/FilesHandler"; -import { - useDraftHomework, - getHomeworkDeleteMessage, - useHomeworkEditorState -} from "@/store/storeHooks/homeworkEditorHooks"; +import {useDraftHomework, getHomeworkDeleteMessage, useHomeworkEditorState} from "@/store/storeHooks/homeworkEditorHooks"; import {useCourseActions} from "@/store/courseActions"; export interface HomeworkAndFilesInfo { diff --git a/hwproj.front/src/store/storeHooks/courseEditingHooks.ts b/hwproj.front/src/store/storeHooks/courseEditingHooks.ts index 6009ff4e9..e82f9c6fe 100644 --- a/hwproj.front/src/store/storeHooks/courseEditingHooks.ts +++ b/hwproj.front/src/store/storeHooks/courseEditingHooks.ts @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useMemo } from 'react'; -import { useCourseDispatch, useCourseState } from '../hooks'; -import { setSelectedItem, SelectedItem } from '../slices/courseEditingSlice'; -import { HomeworkViewModel, HomeworkTaskViewModel} from '@/api'; +import {useCallback, useEffect, useMemo} from 'react'; +import {useCourseDispatch, useCourseState} from '../hooks'; +import {setSelectedItem, SelectedItem} from '../slices/courseEditingSlice'; +import {HomeworkViewModel, HomeworkTaskViewModel} from '@/api'; import {TestTag} from "@/components/Common/HomeworkTags"; export const useMergedHomeworks = () => { diff --git a/hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts b/hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts index 9fb6f053b..4baa5938d 100644 --- a/hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts +++ b/hwproj.front/src/store/storeHooks/homeworkEditorHooks.ts @@ -1,5 +1,5 @@ -import { useCallback, useMemo } from 'react'; -import { useCourseDispatch, useCourseState } from '../hooks'; +import {useCallback, useMemo} from 'react'; +import {useCourseDispatch, useCourseState} from '../hooks'; import { addDraftHomework, updateDraftHomework as updateDraftHomeworkAction, @@ -7,8 +7,8 @@ import { decrementDraftId, setSelectedItem, } from '../slices/courseEditingSlice'; -import { updateOrInsertHomework, deleteHomework } from '../slices/homeworkSlice'; -import { HomeworkViewModel, CreateHomeworkViewModel, ActionOptions, PostTaskViewModel } from '@/api'; +import {updateOrInsertHomework, deleteHomework} from '../slices/homeworkSlice'; +import {HomeworkViewModel, CreateHomeworkViewModel, ActionOptions, PostTaskViewModel} from '@/api'; import ApiSingleton from '@/api/ApiSingleton'; import {BonusTag, isBonusWork, isTestWork, TestTag} from "@/components/Common/HomeworkTags"; import Lodash from "lodash"; From 737c36dcc60d411be54b697e6163cb1d9b00a528 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Thu, 12 Mar 2026 23:03:08 +0300 Subject: [PATCH 46/48] refactor: when you cancel editing a new task in the new homework, we return to the remaining task, if there is one, otherwise to the homework. For an existing task, we keep the previous transition to the initial state --- hwproj.front/src/store/storeHooks/taskEditorHooks.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hwproj.front/src/store/storeHooks/taskEditorHooks.ts b/hwproj.front/src/store/storeHooks/taskEditorHooks.ts index 0977c6a0c..14c13e31c 100644 --- a/hwproj.front/src/store/storeHooks/taskEditorHooks.ts +++ b/hwproj.front/src/store/storeHooks/taskEditorHooks.ts @@ -240,9 +240,15 @@ export const useTaskEditing = () => { }, [commitTaskRemoval, dispatch]); const discardTaskDraft = useCallback((task: HomeworkTaskViewModel, homework: HomeworkViewModel, isNewTask: boolean) => { + const draftHomework = draftHomeworks.find(dh => dh.id === task.homeworkId) + const remainingTasks = (draftHomework?.tasks || []).filter(t => t.id !== task.id); discardTaskDraftByIds(task.id!, task.homeworkId!); if (isNewTask) { - dispatch(setSelectedItem({ isHomework: true, id: homework.id })); + if (homework.id! < 0 && remainingTasks.length > 0) { + dispatch(setSelectedItem({ isHomework: false, id: remainingTasks[0].id })); + } else { + dispatch(setSelectedItem({ isHomework: true, id: homework.id })); + } } else { dispatch(setSelectedItem({ isHomework: false, id: task.id })); } From ba7816754c624f2a04ddeb2ffc669c059a740189 Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sat, 14 Mar 2026 01:34:12 +0300 Subject: [PATCH 47/48] style: another option is selected for the "Undo changes to homework/task" button --- .../src/components/Homeworks/CourseHomeworkExperimental.tsx | 4 ++-- hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index ec3a54307..913e2622f 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -29,7 +29,7 @@ import EditIcon from "@mui/icons-material/Edit"; import AddTaskIcon from '@mui/icons-material/AddTask'; import {LoadingButton} from "@mui/lab"; import DeletionConfirmation from "../DeletionConfirmation"; -import CloseIcon from "@mui/icons-material/Close"; +import UndoIcon from '@mui/icons-material/Undo'; import DeleteIcon from "@mui/icons-material/Delete"; import ActionOptionsUI from "components/Common/ActionOptions"; import {DefaultTags, TestTag} from "@/components/Common/HomeworkTags"; @@ -123,7 +123,7 @@ const CourseHomeworkEditor: FC<{ color="error" style={{position: 'absolute', top: -16, right: -16, zIndex: 1, backgroundColor: 'white', boxShadow: '0 0 4px rgba(0,0,0,0.2)'}} > - + diff --git a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx index fadce5cb2..54bd1f7f6 100644 --- a/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx +++ b/hwproj.front/src/components/Tasks/CourseTaskExperimental.tsx @@ -26,7 +26,7 @@ import {useDraftTask, useTaskEditorState} from "@/store/storeHooks/taskEditorHoo import {useCourseActions} from "@/store/courseActions"; import {useIsCourseMentor} from "@/store/storeHooks/courseHooks"; import {Stack} from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; +import UndoIcon from "@mui/icons-material/Undo"; import TaskCriteria from "./TaskCriteria"; import TaskCriteriaEditor from "./TaskCriteriaEditor"; import {BonusTag} from "@/components/Common/HomeworkTags"; @@ -173,7 +173,7 @@ const CourseTaskEditor: FC<{ color="error" style={{position: 'absolute', top: -16, right: -16, zIndex: 1, backgroundColor: 'white', boxShadow: '0 0 4px rgba(0,0,0,0.2)'}} > - + From 9f69a8f4221d163bc37b8acd8d717604ce84b2bf Mon Sep 17 00:00:00 2001 From: Andrew Kalinin Date: Sun, 15 Mar 2026 00:51:17 +0300 Subject: [PATCH 48/48] refactor: undo changes --- .../Common/PublicationAndDeadlineDates.tsx | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/hwproj.front/src/components/Common/PublicationAndDeadlineDates.tsx b/hwproj.front/src/components/Common/PublicationAndDeadlineDates.tsx index 031286938..708ac9168 100644 --- a/hwproj.front/src/components/Common/PublicationAndDeadlineDates.tsx +++ b/hwproj.front/src/components/Common/PublicationAndDeadlineDates.tsx @@ -60,32 +60,21 @@ const PublicationAndDeadlineDates: React.FC = (props) => { const deadlineDateNotSet = state.hasDeadline && !state.deadlineDate const deadlineSoonerThanHomework = isDeadlineSoonerThanPublication(state.publicationDate, state.deadlineDate) - const autoCalculatedDeadlineTime = props.autoCalculatedDeadline?.getTime() ?? null useEffect(() => { const validationResult = deadlineDateNotSet || deadlineSoonerThanHomework + props.onChange({...state, hasErrors: validationResult}) }, [state]) useEffect(() => { - setState(prevState => { - const nextDeadlineDate = prevState.hasDeadline - ? props.autoCalculatedDeadline || prevState.deadlineDate || getInitialDeadlineDate(prevState.publicationDate) - : undefined - - const prevDeadlineTime = prevState.deadlineDate?.getTime() ?? null - const nextDeadlineTime = nextDeadlineDate?.getTime() ?? null - - if (prevDeadlineTime === nextDeadlineTime) { - return prevState - } - - return { - ...prevState, - deadlineDate: nextDeadlineDate, - } - }) - }, [autoCalculatedDeadlineTime]) + setState(prevState => ({ + ...prevState, + deadlineDate: state.hasDeadline + ? props.autoCalculatedDeadline || state.deadlineDate || getInitialDeadlineDate(prevState.publicationDate) + : undefined, + })) + }, [props.autoCalculatedDeadline]) return