From 2b888fd3795d356b5575ef520c3694c22ec1f535 Mon Sep 17 00:00:00 2001 From: Charles Nykamp Date: Sat, 10 Aug 2024 21:37:45 -0500 Subject: [PATCH 1/4] Content classifications (#2469) --- .env | 1 - client/package-lock.json | 108 +++-- client/package.json | 1 + .../src/Tools/_framework/Paths/Activities.tsx | 16 + .../Tools/_framework/Paths/ActivityEditor.tsx | 60 ++- .../ToolPanels/GeneralContentControls.tsx | 184 +++++++- .../migration.sql | 38 ++ server/prisma/schema.prisma | 53 ++- server/prisma/seed.ts | 134 ++++++ server/src/index.ts | 96 +++- server/src/model.test.ts | 139 ++++++ server/src/model.ts | 411 +++++++++++++++++- 12 files changed, 1180 insertions(+), 61 deletions(-) delete mode 100644 .env create mode 100644 server/prisma/migrations/20240810204144_content_classification/migration.sql diff --git a/.env b/.env deleted file mode 100644 index a2801f4359..0000000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -COMPOSE_PROJECT_NAME=doenet \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index ffea519239..2209d48eda 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -32,6 +32,7 @@ "react-qrcode-logo": "^3.0.0", "react-router": "^6.25.1", "react-router-dom": "^6.25.1", + "react-select": "^5.8.0", "recharts": "^2.12.7", "styled-components": "5.3.11", "swiper": "^9.4.1" @@ -1851,7 +1852,6 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", "integrity": "sha512-y2WQb+oP8Jqvvclh8Q55gLUyb7UFvgv7eJfsj7td5TToBrIUtPay2kMrZi4xjq9qw2vD0ZR5fSho0yqoFgX7Rw==", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -1869,14 +1869,12 @@ "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "peer": true + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/@emotion/cache": { "version": "11.13.0", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.0.tgz", "integrity": "sha512-hPV345J/tH0Cwk2wnU/3PBzORQ9HeX+kQSbwI+jslzpRCHE6fSGTohswksA/Ensr8znPzwfzKZCmAM9Lmlhp7g==", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", @@ -1888,8 +1886,7 @@ "node_modules/@emotion/hash": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "peer": true + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" }, "node_modules/@emotion/is-prop-valid": { "version": "1.3.0", @@ -1908,7 +1905,6 @@ "version": "11.13.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.13.0.tgz", "integrity": "sha512-WkL+bw1REC2VNV1goQyfxjx1GYJkcc23CRQkXX+vZNLINyfI7o+uUn/rTGPt/xJ3bJHd5GcljgnxHf4wRw5VWQ==", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.12.0", @@ -1932,7 +1928,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.0.tgz", "integrity": "sha512-jACuBa9SlYajnpIVXB+XOXnfJHyckDfe6fOpORIM6yhBDlqGuExvDdZYHDQGoDf3bZXGv7tNr+LpLjJqiEQ6EA==", - "peer": true, "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", @@ -1944,8 +1939,7 @@ "node_modules/@emotion/sheet": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "peer": true + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" }, "node_modules/@emotion/styled": { "version": "11.13.0", @@ -1978,14 +1972,12 @@ "node_modules/@emotion/unitless": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.9.0.tgz", - "integrity": "sha512-TP6GgNZtmtFaFcsOgExdnfxLLpRDla4Q66tnenA9CktvVSdNKDvMVuUah4QvWPIpNjrWsGg3qeGo9a43QooGZQ==", - "peer": true + "integrity": "sha512-TP6GgNZtmtFaFcsOgExdnfxLLpRDla4Q66tnenA9CktvVSdNKDvMVuUah4QvWPIpNjrWsGg3qeGo9a43QooGZQ==" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz", "integrity": "sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==", - "peer": true, "peerDependencies": { "react": ">=16.8.0" } @@ -1993,14 +1985,12 @@ "node_modules/@emotion/utils": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.0.tgz", - "integrity": "sha512-spEnrA1b6hDR/C68lC2M7m6ALPUHZC0lIY7jAS/B/9DuuO1ZP04eov8SMv/6fwRd8pzmsn2AuJEznRREWlQrlQ==", - "peer": true + "integrity": "sha512-spEnrA1b6hDR/C68lC2M7m6ALPUHZC0lIY7jAS/B/9DuuO1ZP04eov8SMv/6fwRd8pzmsn2AuJEznRREWlQrlQ==" }, "node_modules/@emotion/weak-memoize": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "peer": true + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" }, "node_modules/@esbuild-plugins/node-globals-polyfill": { "version": "0.2.3", @@ -2486,6 +2476,28 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.5.tgz", + "integrity": "sha512-8GrTWmoFhm5BsMZOTHeGD2/0FLKLQQHvO/ZmQga4tKempYRLz8aqJGqXVuQgisnMObq2YZ2SgkwctN1LOOxcqA==", + "dependencies": { + "@floating-ui/utils": "^0.2.5" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.8.tgz", + "integrity": "sha512-kx62rP19VZ767Q653wsP1XZCGIirkE09E0QUGNYTM/ttbbQHqcGPdSfWFxUyyNLc/W6aoJRBajOSXhP6GXjC0Q==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.5" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.5.tgz", + "integrity": "sha512-sTcG+QZ6fdEUObICavU+aB3Mp8HY4n14wYHdxK4fXjPmv3PXZZeY5RaguJmGyeH/CJQhX3fqKUtS4qc1LoHwhQ==" + }, "node_modules/@fontsource/jost": { "version": "5.0.18", "resolved": "https://registry.npmjs.org/@fontsource/jost/-/jost-5.0.18.tgz", @@ -3674,8 +3686,7 @@ "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "peer": true + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" }, "node_modules/@types/parse5": { "version": "6.0.3", @@ -3686,8 +3697,7 @@ "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "devOptional": true + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/qs": { "version": "6.9.15", @@ -3705,7 +3715,6 @@ "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3720,6 +3729,14 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", + "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -5021,7 +5038,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -6430,7 +6446,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "peer": true, "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -9164,8 +9179,7 @@ "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "peer": true + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" }, "node_modules/find-up": { "version": "5.0.0", @@ -11693,6 +11707,11 @@ "node": ">= 0.6" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -13339,6 +13358,26 @@ "react-dom": ">=16.8" } }, + "node_modules/react-select": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.0.tgz", + "integrity": "sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-smooth": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz", @@ -14803,8 +14842,7 @@ "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "peer": true + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" }, "node_modules/supports-color": { "version": "5.5.0", @@ -15480,6 +15518,19 @@ } } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", @@ -16219,7 +16270,6 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "peer": true, "engines": { "node": ">= 6" } diff --git a/client/package.json b/client/package.json index 5ef0b34c5c..efff6e8d83 100644 --- a/client/package.json +++ b/client/package.json @@ -53,6 +53,7 @@ "react-qrcode-logo": "^3.0.0", "react-router": "^6.25.1", "react-router-dom": "^6.25.1", + "react-select": "^5.8.0", "recharts": "^2.12.7", "styled-components": "5.3.11", "swiper": "^9.4.1" diff --git a/client/src/Tools/_framework/Paths/Activities.tsx b/client/src/Tools/_framework/Paths/Activities.tsx index 4bbdc94a00..b5bde1bb44 100644 --- a/client/src/Tools/_framework/Paths/Activities.tsx +++ b/client/src/Tools/_framework/Paths/Activities.tsx @@ -190,6 +190,22 @@ export async function action({ request, params }) { }); } return true; + } else if (formObj._action == "add content classification") { + if (formObj.isFolder !== "true") { + await axios.post("/api/addClassification", { + activityId: Number(formObj.activityId), + classificationId: Number(formObj.classificationId), + }); + return true; + } + } else if (formObj._action == "remove content classification") { + if (formObj.isFolder !== "true") { + await axios.post("/api/removeClassification", { + activityId: Number(formObj.activityId), + classificationId: Number(formObj.classificationId), + }); + return true; + } } else if (formObj._action == "go to data") { return redirect(`/assignmentData/${formObj.activityId}`); } else if (formObj?._action == "noop") { diff --git a/client/src/Tools/_framework/Paths/ActivityEditor.tsx b/client/src/Tools/_framework/Paths/ActivityEditor.tsx index 2f73fd55e9..8ccebef13d 100644 --- a/client/src/Tools/_framework/Paths/ActivityEditor.tsx +++ b/client/src/Tools/_framework/Paths/ActivityEditor.tsx @@ -69,6 +69,18 @@ export type License = { export type AssignmentStatus = "Unassigned" | "Closed" | "Open"; +export type ContentClassification = { + id: number; + code: string; + grade: string | null; + category: string; + description: string; + system: { + id: number; + name: string; + }; +}; + export type ContentStructure = { id: number; ownerId: number; @@ -80,6 +92,7 @@ export type ContentStructure = { codeValidUntil: string | null; isPublic: boolean; license: License | null; + classifications: ContentClassification[]; documents: { id: number; versionNum?: number; @@ -201,19 +214,52 @@ export async function action({ params, request }) { } if (formObj._action == "make content public") { - await axios.post("/api/makeContentPublic", { - id: Number(formObj.id), - licenseCode: formObj.licenseCode, - }); + if (formObj.isFolder === "true") { + await axios.post("/api/makeFolderPublic", { + id: Number(formObj.id), + licenseCode: formObj.licenseCode, + }); + } else { + await axios.post("/api/makeActivityPublic", { + id: Number(formObj.id), + licenseCode: formObj.licenseCode, + }); + } return true; } + if (formObj._action == "make content private") { - await axios.post("/api/makeContentPrivate", { - id: Number(formObj.id), - }); + if (formObj.isFolder === "true") { + await axios.post("/api/makeFolderPrivate", { + id: Number(formObj.id), + }); + } else { + await axios.post("/api/makeActivityPrivate", { + id: Number(formObj.id), + }); + } return true; } + if (formObj._action == "add content classification") { + if (formObj.isFolder !== "true") { + await axios.post("/api/addClassification", { + activityId: Number(formObj.activityId), + classificationId: Number(formObj.classificationId), + }); + return true; + } + } + if (formObj._action == "remove content classification") { + if (formObj.isFolder !== "true") { + await axios.post("/api/removeClassification", { + activityId: Number(formObj.activityId), + classificationId: Number(formObj.classificationId), + }); + return true; + } + } + if (formObj._action == "go to data") { return redirect(`/assignmentData/${params.activityId}`); } diff --git a/client/src/Tools/_framework/ToolPanels/GeneralContentControls.tsx b/client/src/Tools/_framework/ToolPanels/GeneralContentControls.tsx index b0fff0e2c6..7fac18c85f 100644 --- a/client/src/Tools/_framework/ToolPanels/GeneralContentControls.tsx +++ b/client/src/Tools/_framework/ToolPanels/GeneralContentControls.tsx @@ -15,15 +15,30 @@ import { Input, FormErrorMessage, Flex, - IconButton, - Center, - Checkbox, Select, + Heading, + Tag, + Highlight, + Spacer, + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + CloseButton, + HStack, + Tooltip, + Spinner, } from "@chakra-ui/react"; +import AsyncSelect from "react-select/async"; import { FaFileImage } from "react-icons/fa"; import { HiOutlineX, HiPlus } from "react-icons/hi"; import { readAndCompressImage } from "browser-image-resizer"; -import { ContentStructure, DoenetmlVersion } from "../Paths/ActivityEditor"; +import { + ContentClassification, + ContentStructure, + DoenetmlVersion, +} from "../Paths/ActivityEditor"; export function GeneralContentControls({ fetcher, @@ -42,6 +57,25 @@ export function GeneralContentControls({ let [imagePath, setImagePath] = useState(dataImagePath); let [alerts, setAlerts] = useState([]); + let [classifySelectorInput, setClassifySelectorInput] = useState(""); + const classificationsAlreadyAdded = contentData.classifications.map( + (c2) => c2.id, + ); + + const getClassificationOptions = async (inputValue: string) => { + let results = await axios.get( + `/api/searchPossibleClassifications?q=${inputValue}`, + ); + let classifications: ContentClassification[] = results.data; + let options = classifications + .filter((c) => !classificationsAlreadyAdded.includes(c.id)) + .map((c) => ({ + value: c.id, + label: c, + })); + return options; + }; + // let learningOutcomesInit = activityData.learningOutcomes; // if (learningOutcomesInit == null) { // learningOutcomesInit = [""]; @@ -63,6 +97,13 @@ export function GeneralContentControls({ // let [learningOutcomes, setLearningOutcomes] = useState(learningOutcomesInit); let [doenetmlVersion, setDoenetmlVersion] = useState(doenetmlVersionInit); + let [classifySpinnerHidden, setClassifySpinnerHidden] = useState(true); + let [classifyItemRemoveSpinner, setClassifyItemRemoveSpinner] = useState(0); + useEffect(() => { + setClassifySpinnerHidden(true); + setClassifyItemRemoveSpinner(0); + }, [contentData]); + let contentType = contentData.isFolder ? "Folder" : "Activity"; let contentTypeLower = contentData.isFolder ? "folder" : "activity"; @@ -397,6 +438,141 @@ export function GeneralContentControls({ + + {!contentData.isFolder ? ( + + + Content Classifications + + + {contentData.classifications.map((classification, i) => ( + + +

+ + + ;{classification.code} + + {classification.system.name} + + + + +

+ + + +
+ + Category: + {classification.category} + Description: + {classification.description} + +
+ ))} +
+ + + + c.id).join(",")}`} // force this component to reload when classifications change + placeholder="Add a classification" + defaultOptions + isClearable + value={null} + loadOptions={getClassificationOptions} + onInputChange={(newVal) => { + setClassifySelectorInput(newVal); + }} + onChange={(newValueLabel) => { + if (newValueLabel) { + setClassifySpinnerHidden(false); + fetcher.submit( + { + _action: "add content classification", + activityId: id, + classificationId: newValueLabel.value, + }, + { method: "post" }, + ); + } + }} + formatOptionLabel={(val) => + val ? ( + + + + + {val.label.code + + (val.label.grade + ? " (" + val.label.grade + ")" + : "")} + + + + + + {val.label.system.name} + + + + + Category:{" "} + + {val.label.category} + + + + Description: + + {val.label.description} + + + + ) : null + } + /> + + +
+
+ ) : null} ); diff --git a/server/prisma/migrations/20240810204144_content_classification/migration.sql b/server/prisma/migrations/20240810204144_content_classification/migration.sql new file mode 100644 index 0000000000..ad6461b7a1 --- /dev/null +++ b/server/prisma/migrations/20240810204144_content_classification/migration.sql @@ -0,0 +1,38 @@ +-- CreateTable +CREATE TABLE `contentClassifications` ( + `contentId` INTEGER NOT NULL, + `classificationId` INTEGER NOT NULL, + + PRIMARY KEY (`contentId`, `classificationId`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `classifications` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `code` VARCHAR(191) NOT NULL, + `systemId` INTEGER NOT NULL, + `category` TINYTEXT NOT NULL, + `description` TEXT NOT NULL, + `grade` VARCHAR(191) NULL, + + UNIQUE INDEX `classifications_code_systemId_key`(`code`, `systemId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `classificationSystems` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `classificationSystems_name_key`(`name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `contentClassifications` ADD CONSTRAINT `contentClassifications_contentId_fkey` FOREIGN KEY (`contentId`) REFERENCES `content`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `contentClassifications` ADD CONSTRAINT `contentClassifications_classificationId_fkey` FOREIGN KEY (`classificationId`) REFERENCES `classifications`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `classifications` ADD CONSTRAINT `classifications_systemId_fkey` FOREIGN KEY (`systemId`) REFERENCES `classificationSystems`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 43a5a8f666..3fddd3ee84 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -15,28 +15,29 @@ model Session { } model content { - id Int @id @unique() @default(autoincrement()) + id Int @id @unique() @default(autoincrement()) ownerId Int isFolder Boolean parentFolderId Int? name String - createdAt DateTime @default(now()) - lastEdited DateTime @default(now()) + createdAt DateTime @default(now()) + lastEdited DateTime @default(now()) imagePath String? - isAssigned Boolean @default(false) - classCode String? @db.VarChar(45) + isAssigned Boolean @default(false) + classCode String? @db.VarChar(45) codeValidUntil DateTime? - isPublic Boolean @default(false) - isDeleted Boolean @default(false) + isPublic Boolean @default(false) + isDeleted Boolean @default(false) sortIndex BigInt - licenseCode String? @db.VarChar(10) - owner users @relation(fields: [ownerId], references: [userId], onDelete: NoAction, onUpdate: NoAction) - parentFolder content? @relation("folderStructure", fields: [parentFolderId], references: [id], onDelete: NoAction, onUpdate: NoAction) - subFolders content[] @relation("folderStructure") + licenseCode String? @db.VarChar(10) + owner users @relation(fields: [ownerId], references: [userId], onDelete: NoAction, onUpdate: NoAction) + parentFolder content? @relation("folderStructure", fields: [parentFolderId], references: [id], onDelete: NoAction, onUpdate: NoAction) + subFolders content[] @relation("folderStructure") documents documents[] - license licenses? @relation(fields: [licenseCode], references: [code], onDelete: NoAction, onUpdate: NoAction) + license licenses? @relation(fields: [licenseCode], references: [code], onDelete: NoAction, onUpdate: NoAction) assignmentScores assignmentScores[] promotedContent promotedContent[] + classifications contentClassifications[] @@index([ownerId, parentFolderId, sortIndex]) @@index([classCode]) @@ -211,3 +212,31 @@ model promotedContent { @@id([activityId, promotedGroupId]) } + +model contentClassifications { + contentId Int + classificationId Int + content content @relation(fields: [contentId], references: [id]) + classification classifications @relation(fields: [classificationId], references: [id]) + + @@id([contentId, classificationId]) +} + +model classifications { + id Int @id @default(autoincrement()) + code String + systemId Int + category String @db.TinyText + description String @db.Text + grade String? + system classificationSystems @relation(fields: [systemId], references: [id]) + contentClassifications contentClassifications[] + + @@unique([code, systemId]) +} + +model classificationSystems { + id Int @id @default(autoincrement()) + name String @unique + classifications classifications[] +} diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index 4de74d748c..fa21260e2b 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -80,6 +80,140 @@ async function main() { }, }); + const { id: commonCoreId } = await prisma.classificationSystems.upsert({ + where: { name: "Common Core" }, + update: { name: "Common Core" }, + create: { name: "Common Core" }, + }); + const classification1 = await prisma.classifications.upsert({ + where: { + code_systemId: { + code: "K.CC.1", + systemId: commonCoreId, + }, + }, + update: { + code: "K.CC.1", + grade: "Kindergarten", + category: + "Counting and Cardinality: Know number names and the count sequence.", + description: "Count to 100 by ones and by tens.", + systemId: commonCoreId, + }, + create: { + code: "K.CC.1", + grade: "Kindergarten", + category: + "Counting and Cardinality: Know number names and the count sequence.", + description: "Count to 100 by ones and by tens.", + systemId: commonCoreId, + }, + }); + + const classification2 = await prisma.classifications.upsert({ + where: { + code_systemId: { code: "K.OA.1", systemId: commonCoreId }, + }, + update: { + code: "K.OA.1", + grade: "Kindergarten", + category: + "Operations and Algebraic Thinking: Understand addition as putting together and adding to, and understand subtraction as taking apart and taking from.", + description: + "Represent addition and subtraction with objects, fingers, mental images, drawings (Drawings need not show details, but should show the mathematics in the problem.(This applies wherever drawings are mentioned in the Standards.)), sounds (e.g., claps), acting out situations, verbal explanations, expressions, or equations.", + systemId: commonCoreId, + }, + create: { + code: "K.OA.1", + grade: "Kindergarten", + category: + "Operations and Algebraic Thinking: Understand addition as putting together and adding to, and understand subtraction as taking apart and taking from.", + description: + "Represent addition and subtraction with objects, fingers, mental images, drawings (Drawings need not show details, but should show the mathematics in the problem.(This applies wherever drawings are mentioned in the Standards.)), sounds (e.g., claps), acting out situations, verbal explanations, expressions, or equations.", + systemId: commonCoreId, + }, + }); + + const classification3 = await prisma.classifications.upsert({ + where: { + code_systemId: { code: "A.SSE.3 c.", systemId: commonCoreId }, + }, + update: { + code: "A.SSE.3 c.", + grade: "HS", + category: + "Seeing Structure in Expressions: Write expressions in equivalent forms to solve problems.", + description: + "Use the properties of exponents to transform expressions for exponential functions. For example the expression 1.15t can be rewritten as (1.151/12)12t ≈1.01212t to reveal the approximate equivalent monthly interest rate if the annual rate is 15%.", + systemId: commonCoreId, + }, + create: { + code: "A.SSE.3 c.", + grade: "HS", + category: + "Seeing Structure in Expressions: Write expressions in equivalent forms to solve problems.", + description: + "Use the properties of exponents to transform expressions for exponential functions. For example the expression 1.15t can be rewritten as (1.151/12)12t ≈1.01212t to reveal the approximate equivalent monthly interest rate if the annual rate is 15%.", + systemId: commonCoreId, + }, + }); + + const { id: minnesotaMathId } = await prisma.classificationSystems.upsert({ + where: { name: "Minnesota Academic Standards in Math" }, + update: { name: "Minnesota Academic Standards in Math" }, + create: { name: "Minnesota Academic Standards in Math" }, + }); + + const minnesotaClassification1 = await prisma.classifications.upsert({ + where: { + code_systemId: { + code: "8.2.1.5", + systemId: minnesotaMathId, + }, + }, + update: { + code: "8.2.1.5", + category: + "Understand the concept of function in real-world and mathematical situations, and distinguish between linear and nonlinear functions.", + description: + "Understand that a geometric sequence is a non-linear function that can be expressed in the form f(x) = ab^x, where x = 0, 1, 2, 3,….", + systemId: minnesotaMathId, + }, + create: { + code: "8.2.1.5", + category: + "Understand the concept of function in real-world and mathematical situations, and distinguish between linear and nonlinear functions.", + description: + "Understand that a geometric sequence is a non-linear function that can be expressed in the form f(x) = ab^x, where x = 0, 1, 2, 3,….", + systemId: minnesotaMathId, + }, + }); + + const minnesotaClassification2 = await prisma.classifications.upsert({ + where: { + code_systemId: { + code: "9.4.3.9", + systemId: minnesotaMathId, + }, + }, + update: { + code: "9.4.3.9", + category: + "Calculate probabilities and apply probability concepts to solve real-world and mathematical problems.", + description: + "Use the relationship between conditional probabilities and relative frequencies in contingency tables.", + systemId: minnesotaMathId, + }, + create: { + code: "9.4.3.9", + category: + "Calculate probabilities and apply probability concepts to solve real-world and mathematical problems.", + description: + "Use the relationship between conditional probabilities and relative frequencies in contingency tables.", + systemId: minnesotaMathId, + }, + }); + await prisma.licenses.upsert({ where: { code: "CCBYSA" }, update: { diff --git a/server/src/index.ts b/server/src/index.ts index a3a24edf79..1f4d4c7218 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,7 +22,6 @@ import { updateDoc, searchPublicContent, updateContent, - getDoc, assignActivity, listUserAssigned, getAssignmentDataFromCode, @@ -52,6 +51,10 @@ import { moveContent, getMyFolderContent, getAssignedScores, + addClassification, + removeClassification, + getClassifications, + searchPossibleClassifications, getPublicFolderContent, searchUsersWithPublicContent, getPublicEditorData, @@ -1680,6 +1683,97 @@ app.get( }, ); +app.post( + "/api/addClassification", + async (req: Request, res: Response, next: NextFunction) => { + const { classificationId, activityId } = req.body; + const loggedInUserId = Number(req.user?.userId ?? 0); + try { + await addClassification(activityId, classificationId, loggedInUserId); + res.send({}); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + // The .code property can be accessed in a type-safe manner + if (e.code === "P2002") { + res + .status(400) + .send("This activity already has that classification."); + return; + } else if (e.code === "P2003") { + res.status(400).send("That classification does not exist."); + return; + } + } else if (e instanceof InvalidRequestError) { + res.status(e.errorCode).send(e.message); + return; + } + next(e); + } + }, +); + +app.post( + "/api/removeClassification", + async (req: Request, res: Response, next: NextFunction) => { + try { + const classificationId = Number(req.body.classificationId); + const activityId = Number(req.body.activityId); + const loggedInUserId = Number(req.user?.userId ?? 0); + + await removeClassification(activityId, classificationId, loggedInUserId); + res.send({}); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === "P2025") { + res.status(400).send("That classification does not exist."); + return; + } + } else if (e instanceof InvalidRequestError) { + res.status(e.errorCode).send(e.message); + return; + } + next(e); + } + }, +); + +app.get( + "/api/getClassifications", + async (req: Request, res: Response, next: NextFunction) => { + try { + const { activityId } = req.body; + const loggedInUserId = Number(req.user?.userId ?? 0); + const classifications = await getClassifications( + activityId, + loggedInUserId, + ); + res.send(classifications); + } catch (e) { + if (e instanceof InvalidRequestError) { + res.status(e.errorCode).send(e.message); + return; + } + next(e); + } + }, +); +app.get( + "/api/searchPossibleClassifications", + async (req: Request, res: Response, next: NextFunction) => { + try { + const query = req.query.q as string; + const searchResults = await searchPossibleClassifications(query); + res.send(searchResults); + } catch (e) { + if (e instanceof InvalidRequestError) { + res.status(e.errorCode).send(e.message); + return; + } + next(e); + } + }, +); + // handle every other route with index.html, which will contain // a script tag to your application's JavaScript file(s). app.get("*", function (request, response) { diff --git a/server/src/model.test.ts b/server/src/model.test.ts index ef00271d45..6eab24a792 100644 --- a/server/src/model.test.ts +++ b/server/src/model.test.ts @@ -44,6 +44,10 @@ import { moveContent, deleteFolder, getAssignedScores, + searchPossibleClassifications, + addClassification, + getClassifications, + removeClassification, getPublicFolderContent, getPublicEditorData, searchUsersWithPublicContent, @@ -143,6 +147,7 @@ test("New activity starts out private, then delete it", async () => { classCode: null, codeValidUntil: null, license: null, + classifications: [], documents: [ { id: docId, @@ -1662,6 +1667,33 @@ test("searchPublicContent returns public folders and public content even in a pr expect(namesInOrder).eqls([publicActivityName, publicFolderName]); }); +test("searchPublicContent includes public content where a classification matches", async () => { + const owner = await createTestUser(); + const ownerId = owner.userId; + const { activityId } = await createActivity(ownerId, null); + await makeActivityPublic({ + id: activityId, + ownerId: ownerId, + licenseCode: "CCDUAL", + }); + const initialResults = await searchPublicContent("K.CC.1 comMMon cOREe"); + expect(initialResults.filter((r) => r.id === activityId)).toHaveLength(0); + + const {id: classifyId} = (await searchPossibleClassifications("K.CC.1 common core")) + .find((k) => k.code === "K.CC.1")!; + + await addClassification(activityId, classifyId, ownerId); + // With code + const resultsCode = await searchPublicContent("K.C"); + expect(resultsCode.filter((r) => r.id === activityId)).toHaveLength(1); + // With system + const resultsSystem = await searchPublicContent(" CORE"); + expect(resultsSystem.filter((r) => r.id === activityId)).toHaveLength(1); + // With both + const resultsBoth = await searchPublicContent("common C.1"); + expect(resultsBoth.filter((r) => r.id === activityId)).toHaveLength(1); +}); + test("searchUsersWithPublicContent returns only users with public content", async () => { // owner 1 has only private content const owner1 = await createTestUser(); @@ -2847,6 +2879,7 @@ test("get activity editor data only if owner or limited data for public", async codeValidUntil: null, isPublic: true, license: null, + classifications: [], documents: [], hasScoreData: false, parentFolder: null, @@ -2900,6 +2933,7 @@ test("activity editor data and my folder contents before and after assigned", as classCode: null, codeValidUntil: null, license: null, + classifications: [], documents: [ { id: docId, @@ -2947,6 +2981,7 @@ test("activity editor data and my folder contents before and after assigned", as classCode, codeValidUntil: closeAt.toJSDate(), license: null, + classifications: [], documents: [ { id: docId, @@ -2991,6 +3026,7 @@ test("activity editor data and my folder contents before and after assigned", as classCode, codeValidUntil: null, license: null, + classifications: [], documents: [ { id: docId, @@ -3041,6 +3077,7 @@ test("activity editor data and my folder contents before and after assigned", as classCode, codeValidUntil: closeAt.toJSDate(), license: null, + classifications: [], documents: [ { id: docId, @@ -3094,6 +3131,7 @@ test("activity editor data and my folder contents before and after assigned", as classCode, codeValidUntil: closeAt.toJSDate(), license: null, + classifications: [], documents: [ { id: docId, @@ -3138,6 +3176,7 @@ test("activity editor data and my folder contents before and after assigned", as classCode, codeValidUntil: null, license: null, + classifications: [], documents: [ { id: docId, @@ -5080,6 +5119,106 @@ test("get data for user's assignments", { timeout: 30000 }, async () => { ]); }); +test("Content classifications can only be edited by activity owner", async () => { + const { userId } = await createTestUser(); + const { userId: otherId } = await createTestUser(); + const allClassifications = await searchPossibleClassifications(""); + const { id: classificationId } = allClassifications.find( + (k) => k.code === "K.CC.1", + )!; + const { activityId } = await createActivity(userId, null); + + // Add + await expect(() => + addClassification(activityId, classificationId, otherId), + ).rejects.toThrowError(); + await addClassification(activityId, classificationId, userId); + { + const classifications = await getClassifications(activityId, userId); + expect(classifications.length).toBe(1); + expect(classifications[0].classification).toHaveProperty("code", "K.CC.1"); + expect(classifications[0].classification).toHaveProperty( + "id", + classificationId, + ); + } + + // Remove + await expect(() => + removeClassification(activityId, classificationId, otherId), + ).rejects.toThrowError(); + await removeClassification(activityId, classificationId, userId); + { + const classifications = await getClassifications(activityId, userId); + expect(classifications).toEqual([]); + } +}); + +test("Get classifications of public activity", async () => { + const allClassifications = await searchPossibleClassifications(""); + const { id: classId1 } = allClassifications.find((k) => k.code === "K.CC.1")!; + const { id: classId2 } = allClassifications.find( + (k) => k.code === "8.2.1.5", + )!; + const { userId: ownerId } = await createTestUser(); + const { activityId } = await createActivity(ownerId, null); + + await addClassification(activityId, classId1, ownerId); + await addClassification(activityId, classId2, ownerId); + + const { userId: viewerId } = await createTestUser(); + await expect(() => + getClassifications(activityId, viewerId), + ).rejects.toThrowError("cannot be accessed"); + + await updateContent({ + id: activityId, + ownerId, + }); + await makeActivityPublic({ + id: activityId, + ownerId, + licenseCode: "CCDUAL", + }); + const classifications = await getClassifications(activityId, viewerId); + expect(classifications.length).toBe(2); +}); + +test("Search for content classifications", async () => { + { + // Code + const results = await searchPossibleClassifications("CC.1"); + expect(results.find((i) => i.code === "K.CC.1")).toBeDefined(); + } + { + // Category + const results = await searchPossibleClassifications("nonlinear functions"); + expect(results.find((i) => i.code === "8.2.1.5")).toBeDefined(); + } + { + // Grade + const results = await searchPossibleClassifications("Kind"); + expect(results.find((i) => i.code === "K.CC.1")).toBeDefined(); + } + { + // Description + const results = await searchPossibleClassifications("exponents"); + expect(results.find((i) => i.code === "A.SSE.3 c.")).toBeDefined(); + } + { + // System name + const results = await searchPossibleClassifications("coMMoN cOrE"); + expect(results.find((i) => i.code === "A.SSE.3 c.")).toBeDefined(); + } + { + // Combination of fields + const results = await searchPossibleClassifications( + "mention addition SUBTRACTION kindergarten OA operations", + ); + expect(results.find((i) => i.code === "K.OA.1")).toBeDefined(); + } +}); + test("search my folder content searches all subfolders", async () => { const owner = await createTestUser(); const ownerId = owner.userId; diff --git a/server/src/model.ts b/server/src/model.ts index 2278449640..883415eefd 100644 --- a/server/src/model.ts +++ b/server/src/model.ts @@ -14,6 +14,18 @@ export type DoenetmlVersion = { export type AssignmentStatus = "Unassigned" | "Closed" | "Open"; +export type ContentClassification = { + id: number; + code: string; + grade: string | null; + category: string; + description: string; + system: { + id: number; + name: string; + }; +}; + export type ContentStructure = { id: number; ownerId: number; @@ -25,6 +37,7 @@ export type ContentStructure = { codeValidUntil: Date | null; isPublic: boolean; license: License | null; + classifications: ContentClassification[]; documents: { id: number; versionNum?: number; @@ -845,6 +858,7 @@ export async function getActivityEditorData( codeValidUntil: null, isPublic: contentCheck.isPublic, license: null, + classifications: [], documents: [], hasScoreData: false, parentFolder: null, @@ -883,6 +897,25 @@ export async function getActivityEditorData( }, }, }, + classifications: { + select: { + classification: { + select: { + id: true, + grade: true, + code: true, + category: true, + description: true, + system: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, documents: { select: { id: true, @@ -919,6 +952,9 @@ export async function getActivityEditorData( license: assignedActivity.license ? processLicense(assignedActivity.license) : null, + classifications: assignedActivity.classifications.map( + (c) => c.classification, + ), documents: assignedActivity.documents.map((doc) => ({ id: doc.id, versionNum: doc.assignedVersion!.versionNum, @@ -953,6 +989,25 @@ export async function getActivityEditorData( }, }, }, + classifications: { + select: { + classification: { + select: { + id: true, + grade: true, + code: true, + category: true, + description: true, + system: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, documents: { select: { name: true, @@ -972,6 +1027,9 @@ export async function getActivityEditorData( license: unassignedActivity.license ? processLicense(unassignedActivity.license) : null, + classifications: unassignedActivity.classifications.map( + (c) => c.classification, + ), assignmentStatus: "Unassigned", hasScoreData: false, }; @@ -1021,6 +1079,25 @@ export async function getPublicEditorData(activityId: number) { }, }, }, + classifications: { + select: { + classification: { + select: { + id: true, + grade: true, + code: true, + category: true, + description: true, + system: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, parentFolder: { select: { id: true, name: true, isPublic: true } }, }, }); @@ -1031,6 +1108,9 @@ export async function getPublicEditorData(activityId: number) { license: preliminaryActivity.license ? processLicense(preliminaryActivity.license) : null, + classifications: preliminaryActivity.classifications.map( + (c) => c.classification, + ), classCode: null, codeValidUntil: null, assignmentStatus: "Unassigned", @@ -1066,6 +1146,26 @@ export async function getActivityViewerData( }, }, }, + + classifications: { + select: { + classification: { + select: { + id: true, + grade: true, + code: true, + category: true, + description: true, + system: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, documents: { where: { isDeleted: false }, select: { @@ -1097,6 +1197,9 @@ export async function getActivityViewerData( license: preliminaryActivity2.license ? processLicense(preliminaryActivity2.license) : null, + classifications: preliminaryActivity.classifications.map( + (c) => c.classification, + ), classCode: null, codeValidUntil: null, assignmentStatus: "Unassigned", @@ -1197,10 +1300,36 @@ export async function getAssignmentDataFromCode(code: string) { export async function searchPublicContent(query: string) { // TODO: how should we sort these? - let query_words = query.split(" "); - let content = await prisma.content.findMany({ + const query_words = query.split(" "); + const content = await prisma.content.findMany({ where: { - AND: query_words.map((qw) => ({ name: { contains: "%" + qw + "%" } })), + AND: query_words.map((qw) => ({ + OR: [ + { name: { contains: "%" + qw + "%" } }, + { + classifications: { + some: { + classification: { + OR: [ + { + code: { contains: "%" + qw + "%" }, + }, + { + system: { name: { contains: "%" + qw + "%" } }, + }, + { + category: { contains: "%" + qw + "%" }, + }, + { + description: { contains: "%" + qw + "%" }, + }, + ], + }, + }, + }, + }, + ], + })), isPublic: true, isDeleted: false, }, @@ -1222,8 +1351,8 @@ export async function searchPublicContent(query: string) { export async function searchUsersWithPublicContent(query: string) { // TODO: how should we sort these? - let query_words = query.split(" "); - let usersWithPublic = await prisma.users.findMany({ + const query_words = query.split(" "); + const usersWithPublic = await prisma.users.findMany({ where: { AND: query_words.map((qw) => ({ OR: [ @@ -1286,6 +1415,7 @@ export async function listUserAssigned(userId: number) { return { ...obj, license: obj.license ? processLicense(obj.license) : null, + classifications: [], assignmentStatus, documents: [], hasScoreData: false, @@ -2851,6 +2981,7 @@ export async function getMyFolderContent({ license: preliminaryFolder.license ? processLicense(preliminaryFolder.license) : null, + classifications: [], }; } @@ -2878,6 +3009,25 @@ export async function getMyFolderContent({ }, }, }, + classifications: { + select: { + classification: { + select: { + id: true, + code: true, + grade: true, + category: true, + description: true, + system: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, documents: { select: { id: true, doenetmlVersion: true } }, parentFolder: { select: { id: true, name: true, isPublic: true } }, _count: { select: { assignmentScores: true } }, @@ -2895,9 +3045,11 @@ export async function getMyFolderContent({ : !isOpen ? "Closed" : "Open"; + let classifications = obj.classifications.map((c) => c.classification); return { ...activity, license: activity.license ? processLicense(activity.license) : null, + classifications, assignmentStatus, hasScoreData: _count.assignmentScores > 0, }; @@ -2922,7 +3074,7 @@ export async function searchMyFolderContent({ if (folderId !== null) { // if ask for a folder, make sure it exists and is owned by logged in user - let preliminaryFolder = await prisma.content.findUniqueOrThrow({ + const preliminaryFolder = await prisma.content.findUniqueOrThrow({ where: { id: folderId, isDeleted: false, @@ -2958,10 +3110,11 @@ export async function searchMyFolderContent({ license: preliminaryFolder.license ? processLicense(preliminaryFolder.license) : null, + classifications: [], }; } - let query_words = query.split(" "); + const query_words = query.split(" "); let preliminaryResults; @@ -2990,6 +3143,25 @@ export async function searchMyFolderContent({ }, }, }, + classifications: { + select: { + classification: { + select: { + id: true, + code: true, + category: true, + grade: true, + description: true, + system: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, documents: { select: { id: true, doenetmlVersion: true } }, parentFolder: { select: { id: true, name: true, isPublic: true } }, _count: { select: { assignmentScores: true } }, @@ -3037,6 +3209,25 @@ export async function searchMyFolderContent({ }, }, }, + classifications: { + select: { + classification: { + select: { + id: true, + code: true, + grade: true, + category: true, + description: true, + system: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, documents: { select: { id: true, doenetmlVersion: true } }, parentFolder: { select: { id: true, name: true, isPublic: true } }, _count: { select: { assignmentScores: true } }, @@ -3054,9 +3245,12 @@ export async function searchMyFolderContent({ : !isOpen ? "Closed" : "Open"; + let classifications = obj.classifications.map((c) => c.classification); + return { ...activity, license: activity.license ? processLicense(activity.license) : null, + classifications, assignmentStatus, hasScoreData: _count.assignmentScores > 0, }; @@ -3121,6 +3315,7 @@ export async function getPublicFolderContent({ license: preliminaryFolder.license ? processLicense(preliminaryFolder.license) : null, + classifications: [], }; } @@ -3137,6 +3332,25 @@ export async function getPublicFolderContent({ ownerId: true, name: true, imagePath: true, + classifications: { + select: { + classification: { + select: { + id: true, + grade: true, + code: true, + category: true, + description: true, + system: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, license: { include: { composedOf: { @@ -3169,6 +3383,25 @@ export async function getPublicFolderContent({ ownerId: true, name: true, imagePath: true, + classifications: { + select: { + classification: { + select: { + id: true, + grade: true, + code: true, + category: true, + description: true, + system: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }, license: { include: { composedOf: { @@ -3191,6 +3424,7 @@ export async function getPublicFolderContent({ isPublic: true, documents: [], license: content.license ? processLicense(content.license) : null, + classifications: content.classifications.map((c) => c.classification), classCode: null, codeValidUntil: null, assignmentStatus: "Unassigned", @@ -3211,6 +3445,169 @@ export async function getPublicFolderContent({ }; } +export async function searchPossibleClassifications(query: string) { + const query_words = query.split(" "); + const results: ContentClassification[] = + await prisma.classifications.findMany({ + where: { + AND: query_words.map((query_word) => ({ + OR: [ + { code: { contains: query_word } }, + { grade: { contains: query_word } }, + { category: { contains: query_word } }, + { description: { contains: query_word } }, + { system: { name: { contains: query_word } } }, + ], + })), + }, + select: { + id: true, + grade: true, + code: true, + category: true, + description: true, + system: { + select: { + id: true, + name: true, + }, + }, + }, + }); + return results; +} + +/** + * Add a classification to an activity. The activity must be owned by the logged in user. + * Activity id must be an activity, not a folder. + * @param activityId + * @param classificationId + * @param loggedInUserId + */ +export async function addClassification( + activityId: number, + classificationId: number, + loggedInUserId: number, +) { + const activity = await prisma.content.findUnique({ + where: { + id: activityId, + isFolder: false, + isDeleted: false, + ownerId: loggedInUserId, + }, + select: { + // not using this, we just need to select one field + id: true, + }, + }); + if (!activity) { + throw new InvalidRequestError( + "This activity does not exist or is not owned by this user.", + ); + } + await prisma.contentClassifications.create({ + data: { + contentId: activityId, + classificationId, + }, + }); +} + +/** + * Remove a classification to an activity. The activity must be owned by the logged in user. + * Activity id must be an activity, not a folder. + * @param activityId + * @param classificationId + * @param loggedInUserId + */ +export async function removeClassification( + activityId: number, + classificationId: number, + loggedInUserId: number, +) { + const activity = await prisma.content.findUnique({ + where: { + id: activityId, + isFolder: false, + isDeleted: false, + ownerId: loggedInUserId, + }, + select: { + // not using this, we just need to select one field + id: true, + }, + }); + if (!activity) { + throw new InvalidRequestError( + "This activity does not exist or is not owned by this user.", + ); + } + await prisma.contentClassifications.delete({ + where: { + contentId_classificationId: { contentId: activityId, classificationId }, + }, + }); +} + +/** + * Get all classifications for an activity. The activity must be either public or owned by + * loggedInUser. + * @param activityId + * @param loggedInUserId + */ +export async function getClassifications( + activityId: number, + loggedInUserId: number, +) { + const activity = await prisma.content.findUnique({ + where: { + id: activityId, + isFolder: false, + isDeleted: false, + OR: [ + { + ownerId: loggedInUserId, + }, + { + isPublic: true, + }, + ], + }, + select: { + // not using this, we just need to select one field + id: true, + }, + }); + if (!activity) { + throw new InvalidRequestError( + "This activity does not exist or cannot be accessed.", + ); + } + + const classifications = await prisma.contentClassifications.findMany({ + where: { + contentId: activityId, + }, + select: { + classification: { + select: { + id: true, + system: { + select: { + name: true, + }, + }, + code: true, + category: true, + description: true, + }, + }, + }, + }); + return classifications; +} + export async function getLicense(code: string) { const preliminary_license = await prisma.licenses.findUniqueOrThrow({ where: { code }, From 625e1786d2671148d0e63c0a791972860c7f11c3 Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Mon, 12 Aug 2024 20:02:46 -0500 Subject: [PATCH 2/4] Add ability to share content with individuals (#2472) --- client/package-lock.json | 6 + client/package.json | 1 + .../src/Tools/_framework/Paths/Activities.tsx | 227 +- .../Tools/_framework/Paths/ActivityEditor.tsx | 213 +- .../Tools/_framework/Paths/ActivityViewer.tsx | 129 +- .../src/Tools/_framework/Paths/ChangeName.tsx | 93 +- .../src/Tools/_framework/Paths/Community.tsx | 38 +- .../Tools/_framework/Paths/ConfirmSignIn.tsx | 3 +- client/src/Tools/_framework/Paths/Home.tsx | 5 +- client/src/Tools/_framework/Paths/Library.jsx | 6 +- .../Tools/_framework/Paths/PublicEditor.tsx | 2 +- ...licActivities.tsx => SharedActivities.tsx} | 28 +- .../src/Tools/_framework/Paths/SiteHeader.tsx | 11 +- .../ToolPanels/AssignActivityControls.tsx | 46 +- .../ToolPanels/AssignmentSettingsDrawer.tsx | 18 +- .../ToolPanels/ContentSettingsDrawer.tsx | 53 +- .../ToolPanels/ContributorsMenu.jsx | 100 - .../ToolPanels/ContributorsMenu.tsx | 180 ++ .../CopyActivityAndReportFinish.tsx | 8 +- .../ToolPanels/GeneralContentControls.tsx | 45 + .../ToolPanels/MoveContentToFolder.tsx | 64 +- ...oPublicAlert.tsx => MoveToSharedAlert.tsx} | 10 +- .../_framework/ToolPanels/RemixedFrom.tsx | 130 ++ .../Tools/_framework/ToolPanels/Remixes.tsx | 100 + .../_framework/ToolPanels/ShareDrawer.tsx | 183 ++ .../_framework/ToolPanels/ShareSettings.tsx | 609 +++++ .../_framework/ToolPanels/SharingControls.tsx | 245 -- .../ToolPanels/SupportFilesControls.tsx | 32 + client/src/Widgets/ContentCard.tsx | 18 + client/src/_utils/cid.ts | 32 + client/src/index.tsx | 18 +- .../20240806193656_sharing/migration.sql | 23 + .../migration.sql | 10 + .../migration.sql | 2 + server/prisma/schema.prisma | 42 +- server/prisma/seed.ts | 2 +- server/src/index.ts | 334 ++- server/src/model.test.ts | 1978 +++++++++++++++-- server/src/model.ts | 1558 +++++++++++-- server/src/utils/cid.ts | 1 + 40 files changed, 5462 insertions(+), 1141 deletions(-) rename client/src/Tools/_framework/Paths/{PublicActivities.tsx => SharedActivities.tsx} (85%) delete mode 100644 client/src/Tools/_framework/ToolPanels/ContributorsMenu.jsx create mode 100644 client/src/Tools/_framework/ToolPanels/ContributorsMenu.tsx rename client/src/Tools/_framework/ToolPanels/{MoveToPublicAlert.tsx => MoveToSharedAlert.tsx} (88%) create mode 100644 client/src/Tools/_framework/ToolPanels/RemixedFrom.tsx create mode 100644 client/src/Tools/_framework/ToolPanels/Remixes.tsx create mode 100644 client/src/Tools/_framework/ToolPanels/ShareDrawer.tsx create mode 100644 client/src/Tools/_framework/ToolPanels/ShareSettings.tsx delete mode 100644 client/src/Tools/_framework/ToolPanels/SharingControls.tsx create mode 100644 client/src/_utils/cid.ts create mode 100644 server/prisma/migrations/20240806193656_sharing/migration.sql create mode 100644 server/prisma/migrations/20240807221303_remix_timestamps/migration.sql create mode 100644 server/prisma/migrations/20240812042023_increase_classification_category/migration.sql diff --git a/client/package-lock.json b/client/package-lock.json index 2209d48eda..232f1d7cde 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -19,6 +19,7 @@ "browser-image-resizer": "^2.4.1", "copy-to-clipboard": "^3.3.3", "cssesc": "^3.0.0", + "hi-base32": "^0.5.1", "js-cookie": "^3.0.5", "luxon": "^3.4.4", "math-expressions": "^2.0.0-alpha64", @@ -9875,6 +9876,11 @@ "he": "bin/he" } }, + "node_modules/hi-base32": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz", + "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", diff --git a/client/package.json b/client/package.json index efff6e8d83..5cfbf11d86 100644 --- a/client/package.json +++ b/client/package.json @@ -40,6 +40,7 @@ "browser-image-resizer": "^2.4.1", "copy-to-clipboard": "^3.3.3", "cssesc": "^3.0.0", + "hi-base32": "^0.5.1", "js-cookie": "^3.0.5", "luxon": "^3.4.4", "math-expressions": "^2.0.0-alpha64", diff --git a/client/src/Tools/_framework/Paths/Activities.tsx b/client/src/Tools/_framework/Paths/Activities.tsx index b5bde1bb44..6354d378f4 100644 --- a/client/src/Tools/_framework/Paths/Activities.tsx +++ b/client/src/Tools/_framework/Paths/Activities.tsx @@ -16,6 +16,8 @@ import { Input, Spacer, Show, + Hide, + Spinner, } from "@chakra-ui/react"; import React, { useEffect, useRef, useState } from "react"; import { @@ -28,20 +30,30 @@ import { } from "react-router-dom"; import { RiEmotionSadLine } from "react-icons/ri"; -import ContentCard from "../../../Widgets/ContentCard"; +import ContentCard, { contentCardActions } from "../../../Widgets/ContentCard"; import axios from "axios"; -import MoveContentToFolder from "../ToolPanels/MoveContentToFolder"; -import { ContentSettingsDrawer } from "../ToolPanels/ContentSettingsDrawer"; +import MoveContentToFolder, { + moveContentActions, +} from "../ToolPanels/MoveContentToFolder"; +import { + contentSettingsActions, + ContentSettingsDrawer, +} from "../ToolPanels/ContentSettingsDrawer"; +import { + assignmentSettingsActions, + AssignmentSettingsDrawer, +} from "../ToolPanels/AssignmentSettingsDrawer"; import { AssignmentStatus, ContentStructure, DoenetmlVersion, License, LicenseCode, + UserInfo, } from "./ActivityEditor"; import { DateTime } from "luxon"; import { MdClose, MdOutlineSearch } from "react-icons/md"; -import { AssignmentSettingsDrawer } from "../ToolPanels/AssignmentSettingsDrawer"; +import { ShareDrawer, shareDrawerActions } from "../ToolPanels/ShareDrawer"; // what is a better solution than this? let folderJustCreated = -1; // if a folder was just created, set autoFocusName true for the card with the matching id @@ -50,35 +62,31 @@ export async function action({ request, params }) { const formData = await request.formData(); let formObj = Object.fromEntries(formData); - if (formObj._action == "update general") { - //Don't let name be blank - let name = formObj?.name?.trim(); - if (name == "") { - name = "Untitled"; - } + let resultCS = await contentSettingsActions({ formObj }); + if (resultCS) { + return resultCS; + } - let learningOutcomes; - if (formObj.learningOutcomes) { - learningOutcomes = JSON.parse(formObj.learningOutcomes); - } + let resultSD = await shareDrawerActions({ formObj }); + if (resultSD) { + return resultSD; + } + let resultAS = await assignmentSettingsActions({ formObj }); + if (resultAS) { + return resultAS; + } - await axios.post("/api/updateContentSettings", { - name, - imagePath: formObj.imagePath, - id: formObj.id, - learningOutcomes, - }); + let resultCC = await contentCardActions({ formObj }); + if (resultCC) { + return resultCC; + } - if (formObj.doenetmlVersionId) { - // TODO: handle other updates to just a document - await axios.post("/api/updateDocumentSettings", { - docId: formObj.docId, - doenetmlVersionId: formObj.doenetmlVersionId, - }); - } + let resultMC = await moveContentActions({ formObj }); + if (resultMC) { + return resultMC; + } - return true; - } else if (formObj?._action == "Add Activity") { + if (formObj?._action == "Add Activity") { //Create an activity and redirect to the editor for it let { data } = await axios.post( `/api/createActivity/${params.folderId ?? ""}`, @@ -120,96 +128,6 @@ export async function action({ request, params }) { desiredPosition: formObj.desiredPosition, }); return true; - } else if (formObj._action == "update title") { - //Don't let name be blank - let name = formObj?.cardTitle?.trim(); - if (name == "") { - name = "Untitled " + (formObj.isFolder ? "Folder" : "Activity"); - } - await axios.post(`/api/updateContentName`, { - id: Number(formObj.id), - name, - }); - return true; - } else if (formObj._action == "open assignment") { - let closeAt: DateTime; - if (formObj.duration === "custom") { - closeAt = DateTime.fromISO(formObj.customCloseAt); - } else { - closeAt = DateTime.fromSeconds( - Math.round(DateTime.now().toSeconds() / 60) * 60, - ).plus(JSON.parse(formObj.duration)); - } - await axios.post("/api/openAssignmentWithCode", { - activityId: Number(formObj.activityId), - closeAt, - }); - return true; - } else if (formObj._action == "update assignment close time") { - const closeAt = DateTime.fromISO(formObj.closeAt); - await axios.post("/api/updateAssignmentSettings", { - activityId: Number(formObj.activityId), - closeAt, - }); - return true; - } else if (formObj._action == "close assignment") { - await axios.post("/api/closeAssignmentWithCode", { - activityId: Number(formObj.activityId), - }); - return true; - } else if (formObj._action == "unassign activity") { - try { - await axios.post("/api/unassignActivity", { - activityId: Number(formObj.activityId), - }); - } catch (e) { - alert("Unable to unassign activity"); - } - return true; - } else if (formObj._action == "make content public") { - if (formObj.isFolder === "true") { - await axios.post("/api/makeFolderPublic", { - id: Number(formObj.id), - licenseCode: formObj.licenseCode, - }); - } else { - await axios.post("/api/makeActivityPublic", { - id: Number(formObj.id), - licenseCode: formObj.licenseCode, - }); - } - return true; - } else if (formObj._action == "make content private") { - if (formObj.isFolder === "true") { - await axios.post("/api/makeFolderPrivate", { - id: Number(formObj.id), - }); - } else { - await axios.post("/api/makeActivityPrivate", { - id: Number(formObj.id), - }); - } - return true; - } else if (formObj._action == "add content classification") { - if (formObj.isFolder !== "true") { - await axios.post("/api/addClassification", { - activityId: Number(formObj.activityId), - classificationId: Number(formObj.classificationId), - }); - return true; - } - } else if (formObj._action == "remove content classification") { - if (formObj.isFolder !== "true") { - await axios.post("/api/removeClassification", { - activityId: Number(formObj.activityId), - classificationId: Number(formObj.classificationId), - }); - return true; - } - } else if (formObj._action == "go to data") { - return redirect(`/assignmentData/${formObj.activityId}`); - } else if (formObj?._action == "noop") { - return true; } throw Error(`Action "${formObj?._action}" not defined or not handled.`); @@ -233,7 +151,7 @@ export async function loader({ params, request }) { if (data.notMe) { return redirect( - `/publicActivities/${params.userId}${params.folderId ? "/" + params.folderId : ""}`, + `/sharedActivities/${params.userId}${params.folderId ? "/" + params.folderId : ""}`, ); } } @@ -276,6 +194,12 @@ export function Activities() { onClose: settingsOnClose, } = useDisclosure(); + const { + isOpen: sharingIsOpen, + onOpen: sharingOnOpen, + onClose: sharingOnClose, + } = useDisclosure(); + const { isOpen: assignmentSettingsAreOpen, onOpen: assignmentSettingsOnOpen, @@ -286,6 +210,8 @@ export function Activities() { const folderSettingsRef = useRef(null); const finalFocusRef = useRef(null); + const [haveContentSpinner, setHaveContentSpinner] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); const [searchString, setSearchString] = useState(query ?? ""); const searchRef = useRef(null); @@ -298,13 +224,25 @@ export function Activities() { } }, [searchOpen]); + useEffect(() => { + setHaveContentSpinner(false); + }, [content]); + const navigate = useNavigate(); const [moveToFolderContent, setMoveToFolderContent] = useState<{ id: number; isPublic: boolean; + isShared: boolean; + sharedWith: UserInfo[]; licenseCode: LicenseCode | null; - }>({ id: -1, isPublic: false, licenseCode: null }); + }>({ + id: -1, + isPublic: false, + isShared: false, + sharedWith: [], + licenseCode: null, + }); const { isOpen: moveToFolderIsOpen, @@ -312,9 +250,8 @@ export function Activities() { onClose: moveToFolderOnClose, } = useDisclosure(); - const [displaySettingsTab, setSettingsDisplayTab] = useState< - "general" | "share" - >("general"); + const [displaySettingsTab, setSettingsDisplayTab] = + useState<"general">("general"); useEffect(() => { document.title = `Activities - Doenet`; @@ -329,6 +266,8 @@ export function Activities() { assignmentStatus, isFolder, isPublic, + isShared, + sharedWith, licenseCode, parentFolderId, }: { @@ -338,6 +277,8 @@ export function Activities() { assignmentStatus: AssignmentStatus; isFolder?: boolean; isPublic: boolean; + isShared: boolean; + sharedWith: UserInfo[]; licenseCode: LicenseCode | null; parentFolderId: number | null; }) { @@ -398,7 +339,13 @@ export function Activities() { { - setMoveToFolderContent({ id, isPublic, licenseCode }); + setMoveToFolderContent({ + id, + isPublic, + isShared, + sharedWith, + licenseCode, + }); moveToFolderOnOpen(); }} > @@ -433,8 +380,7 @@ export function Activities() { data-test="Share Menu Item" onClick={() => { setSettingsContentId(id); - setSettingsDisplayTab("share"); - settingsOnOpen(); + sharingOnOpen(); }} > Share @@ -497,12 +443,23 @@ export function Activities() { id={settingsContentId} contentData={contentData} allDoenetmlVersions={allDoenetmlVersions} - allLicenses={allLicenses} finalFocusRef={finalFocusRef} fetcher={fetcher} displayTab={displaySettingsTab} /> ) : null; + + let shareDrawer = + contentData && settingsContentId ? ( + + ) : null; let assignmentDrawer = contentData && settingsContentId ? ( {settingsDrawer} + {shareDrawer} {assignmentDrawer}