diff --git a/client/src/Tools/_framework/Paths/Activities.tsx b/client/src/Tools/_framework/Paths/Activities.tsx index 6354d378f..3dfd675f9 100644 --- a/client/src/Tools/_framework/Paths/Activities.tsx +++ b/client/src/Tools/_framework/Paths/Activities.tsx @@ -16,6 +16,9 @@ import { Input, Spacer, Show, + HStack, + ButtonGroup, + VStack, Hide, Spinner, } from "@chakra-ui/react"; @@ -30,7 +33,10 @@ import { } from "react-router-dom"; import { RiEmotionSadLine } from "react-icons/ri"; +import { FaListAlt, FaRegListAlt } from "react-icons/fa"; +import { IoGrid, IoGridOutline } from "react-icons/io5"; import ContentCard, { contentCardActions } from "../../../Widgets/ContentCard"; +import ActivityTable from "../../../Widgets/ActivityTable"; import axios from "axios"; import MoveContentToFolder, { moveContentActions, @@ -128,6 +134,11 @@ export async function action({ request, params }) { desiredPosition: formObj.desiredPosition, }); return true; + } else if (formObj?._action == "Set List View Preferred") { + await axios.post(`/api/setPreferredFolderView`, { + cardView: formObj.listViewPref === "false", + }); + return true; } throw Error(`Action "${formObj?._action}" not defined or not handled.`); @@ -156,6 +167,9 @@ export async function loader({ params, request }) { } } + let prefData = await axios.get(`/api/getPreferredFolderView`); + let listViewPref = !prefData.data.cardView; + return { folderId: params.folderId ? Number(params.folderId) : null, content: data.content, @@ -163,6 +177,7 @@ export async function loader({ params, request }) { allLicenses: data.allLicenses, userId: params.userId, folder: data.folder, + listViewPref, query: q, }; } @@ -175,6 +190,7 @@ export function Activities() { allLicenses, userId, folder, + listViewPref, query, } = useLoaderData() as { folderId: number | null; @@ -183,6 +199,7 @@ export function Activities() { allLicenses: License[]; userId: number; folder: ContentStructure | null; + listViewPref: Boolean; query: string | null; }; const [settingsContentId, setSettingsContentId] = useState( @@ -230,6 +247,8 @@ export function Activities() { const navigate = useNavigate(); + const [listView, setListView] = useState(listViewPref); + const [moveToFolderContent, setMoveToFolderContent] = useState<{ id: number; isPublic: boolean; @@ -314,7 +333,7 @@ export function Activities() { ); }} > - Move Left + {listView ? "Move Up" : "Move Left"} ) : null} {position < numCards - 1 && !haveQuery ? ( @@ -332,7 +351,7 @@ export function Activities() { ); }} > - Move Right + {listView ? "Move Down" : "Move Right"} ) : null} {haveQuery ? null : ( @@ -497,130 +516,193 @@ export function Activities() { width="100%" textAlign="center" > - - - {headingText} - - - - -
- { - setSearchString((e.target as HTMLInputElement).value); - }} - onKeyDown={(e) => { - if (e.key == "Enter") { - } - }} - onBlur={() => { - searchBlurTimeout.current = setTimeout(() => { - setSearchOpen(false); - }, 200); - }} - /> - - } - aria-label={ + + {headingText} + + + + + + { - if (searchOpen) { - clearTimeout(searchBlurTimeout.current); - searchRef.current?.focus(); - } else { - setSearchOpen(true); - e.preventDefault(); + value={searchString} + name="q" + onInput={(e) => { + setSearchString((e.target as HTMLInputElement).value); + }} + onKeyDown={(e) => { + if (e.key == "Enter") { } }} + onBlur={() => { + searchBlurTimeout.current = setTimeout(() => { + setSearchOpen(false); + }, 200); + }} /> - -
-
- - - - { - setHaveContentSpinner(true); - //Create an activity and redirect to the editor for it - // let { data } = await axios.post("/api/createActivity"); - // let { activityId } = data; - // navigate(`/activityEditor/${activityId}`); - - // TODO - review this, elsewhere the fetcher is being used, and - // there was code up in the action() method for this action - // that was unused. This appears to work okay though? And it - // would make it consistent with how API requests are done elsewhere - fetcher.submit( - { _action: "Add Activity" }, - { method: "post" }, - ); - }} + + } + aria-label={ + folder ? `Search in folder` : `Search my activities` + } + type="submit" + onClick={(e) => { + if (searchOpen) { + clearTimeout(searchBlurTimeout.current); + searchRef.current?.focus(); + } else { + setSearchOpen(true); + e.preventDefault(); + } + }} + /> + + + + + + + { + setHaveContentSpinner(true); + //Create an activity and redirect to the editor for it + // let { data } = await axios.post("/api/createActivity"); + // let { activityId } = data; + // navigate(`/activityEditor/${activityId}`); + + // TODO - review this, elsewhere the fetcher is being used, and + // there was code up in the action() method for this action + // that was unused. This appears to work okay though? And it + // would make it consistent with how API requests are done elsewhere + fetcher.submit( + { _action: "Add Activity" }, + { method: "post" }, + ); + }} + > + Activity + + { + setHaveContentSpinner(true); + fetcher.submit( + { _action: "Add Folder" }, + { method: "post" }, + ); + }} + > + Folder + + + + + {folderId !== null ? ( + - - {folderId !== null ? ( + Share + + ) : null} - ) : null} - -
+ + + + + + + + {folder && !haveQuery ? ( @@ -671,37 +753,84 @@ export function Activities() { ) : null} 0 ? "white" : "var(--lightBlue)" + } + minHeight="calc(100vh - 189px)" + direction="column" > - - {content.length < 1 ? ( - - - - {haveQuery ? "No Results Found" : "No Activities Yet"} - - - ) : ( - <> + {content.length < 1 ? ( + + + + {haveQuery ? "No Results Found" : "No Activities Yet"} + + + ) : listView ? ( + { + const getCardRef = (element) => { + contentCardRefs.current[position] = element; + }; + const justCreated = folderJustCreated === activity.id; + if (justCreated) { + folderJustCreated = -1; + } + return { + ref: getCardRef, + ...activity, + title: activity.name, + menuItems: getCardMenuList({ + id: activity.id, + position, + numCards: content.length, + assignmentStatus: activity.assignmentStatus, + isFolder: activity.isFolder, + isPublic: activity.isPublic, + isShared: activity.isShared, + sharedWith: activity.sharedWith, + licenseCode: activity.license?.code ?? null, + parentFolderId: activity.parentFolder?.id ?? null, + }), + cardLink: activity.isFolder + ? `/activities/${activity.ownerId}/${activity.id}` + : `/activityEditor/${activity.id}`, + editableTitle: true, + autoFocusTitle: justCreated, + }; + })} + /> + ) : ( + + {content.map((activity, position) => { const getCardRef = (element) => { contentCardRefs.current[position] = element; }; + const justCreated = folderJustCreated === activity.id; + if (justCreated) { + folderJustCreated = -1; + } return ( ); })} - - )} - + + + )} ); diff --git a/client/src/Tools/_framework/Paths/Assigned.tsx b/client/src/Tools/_framework/Paths/Assigned.tsx index 68fe1a560..bf0248b5d 100644 --- a/client/src/Tools/_framework/Paths/Assigned.tsx +++ b/client/src/Tools/_framework/Paths/Assigned.tsx @@ -1,41 +1,127 @@ // import axios from 'axios'; -import { Button, Box, Icon, Text, Flex, Wrap, Heading } from "@chakra-ui/react"; -import React, { useEffect } from "react"; +import { + Button, + Box, + Icon, + Text, + Flex, + Wrap, + Heading, + ButtonGroup, + Tooltip, + VStack, + HStack, +} from "@chakra-ui/react"; +import React, { useEffect, useState } from "react"; import { useLoaderData, useNavigate, useFetcher } from "react-router-dom"; import { RiEmotionSadLine } from "react-icons/ri"; +import { FaListAlt, FaRegListAlt } from "react-icons/fa"; +import { IoGrid, IoGridOutline } from "react-icons/io5"; import ContentCard from "../../../Widgets/ContentCard"; import axios from "axios"; import { createFullName } from "../../../_utils/names"; import { ContentStructure } from "./ActivityEditor"; import { DateTime } from "luxon"; +import ActivityTable from "../../../Widgets/ActivityTable"; + +export async function action({ request }) { + const formData = await request.formData(); + let formObj = Object.fromEntries(formData); + + if (formObj?._action == "Set List View Preferred") { + await axios.post(`/api/setPreferredFolderView`, { + cardView: formObj.listViewPref === "false", + }); + return true; + } + + throw Error(`Action "${formObj?._action}" not defined or not handled.`); +} export async function loader({ params }) { const { data: assignmentData } = await axios.get(`/api/getAssigned`); + let prefData = await axios.get(`/api/getPreferredFolderView`); + let listViewPref = !prefData.data.cardView; + return { user: assignmentData.user, assignments: assignmentData.assignments, + listViewPref, }; } export function Assigned() { - let { user, assignments } = useLoaderData() as { + let { user, assignments, listViewPref } = useLoaderData() as { user: { userId: number; firstNames: string | null; lastNames: string; }; assignments: ContentStructure[]; + listViewPref: Boolean; }; const navigate = useNavigate(); + const fetcher = useFetcher(); + + const [listView, setListView] = useState(listViewPref); useEffect(() => { document.title = `Assigned - Doenet`; }, []); - const fetcher = useFetcher(); + function formatTime(time: string | null) { + let timeFormatted: string | undefined; + + if (time !== null) { + const sameDay = (a: DateTime, b: DateTime): boolean => { + return ( + a.hasSame(b, "day") && a.hasSame(b, "month") && a.hasSame(b, "year") + ); + }; + + let closeDateTime = DateTime.fromISO(time); + let now = DateTime.now(); + let tomorrow = now.plus({ day: 1 }); + + if (sameDay(closeDateTime, now)) { + if (closeDateTime.minute === 0) { + timeFormatted = `today, ${closeDateTime.toLocaleString({ hour: "2-digit" })}`; + } else { + timeFormatted = `today, ${closeDateTime.toLocaleString({ hour: "2-digit", minute: "2-digit" })}`; + } + } else if (sameDay(closeDateTime, tomorrow)) { + if (closeDateTime.minute === 0) { + timeFormatted = `tomorrow, ${closeDateTime.toLocaleString({ hour: "2-digit" })}`; + } else { + timeFormatted = `tomorrow, ${closeDateTime.toLocaleString({ hour: "2-digit", minute: "2-digit" })}`; + } + } else if (closeDateTime.year === now.year) { + if (closeDateTime.minute === 0) { + timeFormatted = closeDateTime.toLocaleString({ + weekday: "short", + month: "short", + day: "numeric", + hour: "2-digit", + }); + } else { + timeFormatted = closeDateTime.toLocaleString({ + weekday: "short", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } + } else { + timeFormatted = closeDateTime.toLocaleString(DateTime.DATETIME_MED); + } + } + + return timeFormatted; + } return ( <> @@ -45,6 +131,7 @@ export function Assigned() { height="80px" width="100%" textAlign="center" + padding=".5em 0" > {createFullName(user)} @@ -52,103 +139,126 @@ export function Assigned() { Assigned Activities -
- - + + + - See Scores - -
+ + + + + + + +
0 ? "white" : "var(--lightBlue)" + } + minHeight="calc(100vh - 188px)" + flexDirection="column" > - - {assignments.length < 1 ? ( - - - Nothing Assigned - - ) : ( - <> + {assignments.length < 1 ? ( + + + Nothing Assigned + + ) : listView ? ( + { + return { + id: assignment.id, + title: assignment.name, + cardLink: + assignment.assignmentStatus === "Open" + ? `/code/${assignment.classCode}` + : `/assignedData/${assignment.id}`, + assignmentStatus: assignment.assignmentStatus, + closeTime: formatTime(assignment.codeValidUntil), + }; + })} + /> + ) : ( + + {assignments.map((assignment) => { - let closes: string | undefined; - if (assignment.codeValidUntil !== null) { - const sameDay = (a: DateTime, b: DateTime): boolean => { - return ( - a.hasSame(b, "day") && - a.hasSame(b, "month") && - a.hasSame(b, "year") - ); - }; - - let closeDateTime = DateTime.fromISO( - assignment.codeValidUntil, - ); - let now = DateTime.now(); - let tomorrow = now.plus({ day: 1 }); - - if (sameDay(closeDateTime, now)) { - if (closeDateTime.minute === 0) { - closes = `today, ${closeDateTime.toLocaleString({ hour: "2-digit" })}`; - } else { - closes = `today, ${closeDateTime.toLocaleString({ hour: "2-digit", minute: "2-digit" })}`; - } - } else if (sameDay(closeDateTime, tomorrow)) { - if (closeDateTime.minute === 0) { - closes = `tomorrow, ${closeDateTime.toLocaleString({ hour: "2-digit" })}`; - } else { - closes = `tomorrow, ${closeDateTime.toLocaleString({ hour: "2-digit", minute: "2-digit" })}`; - } - } else if (closeDateTime.year === now.year) { - if (closeDateTime.minute === 0) { - closes = closeDateTime.toLocaleString({ - weekday: "short", - month: "short", - day: "numeric", - hour: "2-digit", - }); - } else { - closes = closeDateTime.toLocaleString({ - weekday: "short", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - } - } else { - closes = closeDateTime.toLocaleString( - DateTime.DATETIME_MED, - ); - } - } return ( ); })} - - )} - + + + )} ); diff --git a/client/src/Tools/_framework/Paths/Community.tsx b/client/src/Tools/_framework/Paths/Community.tsx index 509d7e7ef..2037cf7b5 100644 --- a/client/src/Tools/_framework/Paths/Community.tsx +++ b/client/src/Tools/_framework/Paths/Community.tsx @@ -26,16 +26,21 @@ import { VStack, Checkbox, FormLabel, + ButtonGroup, + Tooltip, } from "@chakra-ui/react"; import { useLoaderData } from "react-router"; import { Carousel } from "../../../Widgets/Carousel"; import Searchbar from "../../../Widgets/SearchBar"; import { Form, useFetcher } from "react-router-dom"; import { RiEmotionSadLine } from "react-icons/ri"; +import { FaListAlt, FaRegListAlt } from "react-icons/fa"; +import { IoGrid, IoGridOutline } from "react-icons/io5"; import ContentCard from "../../../Widgets/ContentCard"; import AuthorCard from "../../../Widgets/AuthorCard"; import { createFullName } from "../../../_utils/names"; import { ContentStructure } from "./ActivityEditor"; +import ActivityTable from "../../../Widgets/ActivityTable"; type SearchMatch = | (ContentStructure & { type: "content" }) @@ -57,6 +62,7 @@ export async function action({ request }) { groupId, currentlyFeatured, homepage, + listViewPref, } = formObj; // TODO: should this function exist? @@ -116,6 +122,11 @@ export async function action({ request }) { groupId, }); } + case "Set List View Preferred": { + return postApiAlertOnError(`/api/setPreferredFolderView`, { + cardView: listViewPref === "false", + }); + } } } @@ -123,6 +134,9 @@ export async function loader({ request }) { const url = new URL(request.url); const q = url.searchParams.get("q"); + let prefData = await axios.get(`/api/getPreferredFolderView`); + let listViewPref = !prefData.data.cardView; + if (q) { //Show search results const { data: searchData } = await axios.get( @@ -146,6 +160,7 @@ export async function loader({ request }) { searchResults: searchData, carouselGroups, isAdmin, + listViewPref, }; } else { const { data: isAdminData } = await axios.get( @@ -153,7 +168,7 @@ export async function loader({ request }) { ); const isAdmin = isAdminData.isAdmin; const { data: carouselData } = await axios.get("/api/loadPromotedContent"); - return { carouselData, isAdmin }; + return { carouselData, isAdmin, listViewPref }; } } @@ -379,24 +394,33 @@ export function MoveToGroupMenuItem({ activityId, carouselGroups }) { } export function Community() { - const { carouselData, q, searchResults, carouselGroups, isAdmin } = - useLoaderData() as { - carouselData: any; - q: string; - searchResults: { - content: ContentStructure[]; - users: { - userId: number; - firstNames: string | null; - lastNames: string; - }[]; - }; - carouselGroups: any; - isAdmin: boolean; + const { + carouselData, + q, + searchResults, + carouselGroups, + isAdmin, + listViewPref, + } = useLoaderData() as { + carouselData: any; + q: string; + searchResults: { + content: ContentStructure[]; + users: { + userId: number; + firstNames: string | null; + lastNames: string; + }[]; }; + carouselGroups: any; + isAdmin: boolean; + listViewPref: boolean; + }; const [currentTab, setCurrentTab] = useState(0); const fetcher = useFetcher(); + const [listView, setListView] = useState(listViewPref); + useEffect(() => { document.title = `Community - Doenet`; }, []); @@ -494,6 +518,52 @@ export function Community() { } } + function displayResultsTable(allMatches: SearchMatch[]) { + return ( + { + if (itemObj.type === "content") { + const { id, imagePath, name, owner, isFolder } = itemObj; + const cardLink = + isFolder && owner != undefined + ? `/sharedActivities/${owner.userId}/${id}` + : `/activityViewer/${id}`; + + return { + id: Number(id), + title: name, + ownerName: owner != undefined ? createFullName(owner) : "", + cardLink, + menuItems: isAdmin ? ( + <> + + + ) : undefined, + }; + } else if (itemObj?.type == "author") { + const cardLink = `/sharedActivities/${itemObj.userId}`; + return { + id: itemObj.userId, + title: createFullName(itemObj), + ownerName: createFullName(itemObj), + cardLink, + authorRow: true, + }; + } else { + return { id: 0, title: "" }; + } + })} + /> + ); + } + return ( <> + + + + + + + + + + @@ -580,15 +705,12 @@ export function Community() { - - {allMatches.map(displayCard)} {allMatches.length == 0 ? ( No Matches Found! - ) : null} - + ) : listView ? ( + displayResultsTable(allMatches) + ) : ( + + {allMatches.map(displayCard)} + + )} + - - {contentMatches.map(displayCard)} {contentMatches.length == 0 ? ( No Matching Activities Found! - ) : null} - {/* */} - + ) : listView ? ( + displayResultsTable(contentMatches) + ) : ( + + {contentMatches.map(displayCard)} + + )} + - - {authorMatches.map(displayCard)} {searchResults?.users?.length == 0 ? ( No Matching Authors Found! - ) : null} - + ) : listView ? ( + displayResultsTable(authorMatches) + ) : ( + + {authorMatches.map(displayCard)} + + )} + diff --git a/client/src/Tools/_framework/ToolPanels/GeneralContentControls.tsx b/client/src/Tools/_framework/ToolPanels/GeneralContentControls.tsx index eb45bd62e..cc32c4438 100644 --- a/client/src/Tools/_framework/ToolPanels/GeneralContentControls.tsx +++ b/client/src/Tools/_framework/ToolPanels/GeneralContentControls.tsx @@ -48,7 +48,7 @@ export async function generalContentActions({ formObj }: { [k: string]: any }) { } await axios.post("/api/updateContentSettings", { - name, + name: formObj.name, imagePath: formObj.imagePath, id: formObj.id, learningOutcomes, diff --git a/client/src/Widgets/ActivityTable.tsx b/client/src/Widgets/ActivityTable.tsx new file mode 100644 index 000000000..14af010e9 --- /dev/null +++ b/client/src/Widgets/ActivityTable.tsx @@ -0,0 +1,276 @@ +import React, { forwardRef, ReactElement, useState } from "react"; +import { + Avatar, + Text, + Icon, + Tooltip, + Editable, + EditableInput, + EditablePreview, + TableContainer, + Table, + Thead, + Tr, + Th, + Td, + Tbody, + Box, + HStack, + Menu, + MenuButton, + MenuList, + Show, +} from "@chakra-ui/react"; +import { GoKebabVertical } from "react-icons/go"; +import { FaFolder } from "react-icons/fa"; +import { RiDraftFill } from "react-icons/ri"; +import { MdAssignment } from "react-icons/md"; +import { BsPeopleFill } from "react-icons/bs"; +import { useFetcher, useNavigate } from "react-router-dom"; +import { AssignmentStatus } from "../Tools/_framework/Paths/ActivityEditor"; + +export default forwardRef(function ActivityTable( + { + content, + suppressAvatar, + showOwnerName, + showAssignmentStatus, + showPublicStatus, + }: { + content: { + cardLink?: string; + id: number; + assignmentStatus?: AssignmentStatus; + isFolder?: boolean; + isPublic?: boolean; + isShared?: boolean; + title: string; + ownerName?: string; + menuItems?: ReactElement; + editableTitle?: boolean; + autoFocusTitle?: boolean; + closeTime?: string; + authorRow?: boolean; + }[]; + suppressAvatar?: boolean; + showOwnerName?: boolean; + showAssignmentStatus?: boolean; + showPublicStatus?: boolean; + }, + ref: React.ForwardedRef, +) { + const navigate = useNavigate(); + const fetcher = useFetcher(); + + // this state does not do much; its purpose is to prevent the edge case of someone clicking to select the text input, and, + // without letting go, dragging it on top of a link so that the link is triggered + const [titleBeingEdited, setTitleBeingEdited] = useState(false); + + return ( + + + + + + + {showPublicStatus ? : null} + + {showAssignmentStatus ? : null} + + {showOwnerName || !suppressAvatar ? : null} + + + + + {content.map(function (activity) { + let assignmentStatusString: string = + activity.assignmentStatus !== null && + activity.assignmentStatus !== undefined && + activity.assignmentStatus !== "Unassigned" + ? activity.assignmentStatus + : ""; + + if ( + activity.assignmentStatus === "Open" && + activity.closeTime !== undefined + ) { + assignmentStatusString = + assignmentStatusString + " until " + activity.closeTime; + } + + function saveUpdatedTitle(newTitle: string) { + if (newTitle !== activity.title && activity.id !== undefined) { + fetcher.submit( + { + _action: "update title", + id: activity.id, + cardTitle: newTitle, + isFolder: Boolean(activity.isFolder), + }, + { method: "post" }, + ); + } + } + + return ( + + activity.cardLink && !titleBeingEdited + ? navigate(activity.cardLink) + : null + } + > + + + {showPublicStatus ? ( + + ) : null} + + {showAssignmentStatus ? ( + + ) : null} + + {(!suppressAvatar && !activity.authorRow) || + (showOwnerName && !activity.authorRow) ? ( + + ) : null} + + + ); + })} + +
VisibilityAssignment StatusOwner
+ + + {activity.authorRow ? ( + + ) : ( + + )} + + + + + + activity.editableTitle ? e.stopPropagation() : null + } + onEdit={() => setTitleBeingEdited(true)} + > + + { + saveUpdatedTitle(e.target.value); + setTitleBeingEdited(false); + // prevent click default/propagation behavior one time (aka right now as user is clicking to blur input) + document.addEventListener( + "click", + (e) => { + e.preventDefault(); + e.stopPropagation(); + }, + { capture: true, once: true }, + ); + }} + /> + + {activity.isPublic || activity.isShared ? ( + + ) : null} + + + {showAssignmentStatus ? ( + {assignmentStatusString} + ) : null} + + {activity.isPublic ? "Public" : "Private"}{assignmentStatusString} + + {suppressAvatar || activity.authorRow ? null : ( + + + + )} + {showOwnerName && !activity.authorRow ? ( + {activity.ownerName} + ) : null} + + + {activity.menuItems ? ( + + e.stopPropagation()} + > + + + e.stopPropagation()} + > + {activity.menuItems} + + + ) : null} +
+
+ ); +}); diff --git a/client/src/Widgets/ContentCard.tsx b/client/src/Widgets/ContentCard.tsx index b091ebcf2..eeaca42db 100644 --- a/client/src/Widgets/ContentCard.tsx +++ b/client/src/Widgets/ContentCard.tsx @@ -158,7 +158,18 @@ export default forwardRef(function ContentCard( autoFocus={autoFocusTitle} onFocus={(e) => e.target.select()} onChange={(e) => setCardTitle(e.target.value)} - onBlur={saveUpdatedTitle} + onBlur={(e) => { + saveUpdatedTitle(); + // prevent click default/propagation behavior one time (aka right now as user is clicking to blur input) + document.addEventListener( + "click", + (e) => { + e.preventDefault(); + e.stopPropagation(); + }, + { capture: true, once: true }, + ); + }} onKeyDown={(e) => { if (e.key == "Enter") { (e.target as HTMLElement).blur(); diff --git a/client/src/index.tsx b/client/src/index.tsx index b664eea4d..b44cae5f1 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -45,6 +45,7 @@ import { } from "./Tools/_framework/Paths/ActivityViewer"; import { loader as assignedLoader, + action as assignedAction, Assigned, } from "./Tools/_framework/Paths/Assigned"; import { @@ -295,6 +296,7 @@ const router = createBrowserRouter([ }, { path: "assigned", + action: assignedAction, loader: assignedLoader, element: , errorElement: , diff --git a/server/prisma/migrations/20240813155931_user_card_view/migration.sql b/server/prisma/migrations/20240813155931_user_card_view/migration.sql new file mode 100644 index 000000000..f45e80509 --- /dev/null +++ b/server/prisma/migrations/20240813155931_user_card_view/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `users` ADD COLUMN `cardView` BOOLEAN NOT NULL DEFAULT false; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b13fa395e..b2d009d1d 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -206,6 +206,7 @@ model users { lastNames String isAdmin Boolean @default(false) isAnonymous Boolean @default(false) + cardView Boolean @default(false) content content[] assignmentScores assignmentScores[] documentState documentState[] diff --git a/server/src/index.ts b/server/src/index.ts index b695a6c22..2fe481205 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -77,6 +77,8 @@ import { getActivityContributorHistory, getActivityRemixes, getDocumentSource, + setPreferredFolderView, + getPreferredFolderView, } from "./model"; import session from "express-session"; import { PrismaSessionStore } from "@quixo3/prisma-session-store"; @@ -91,6 +93,7 @@ import { Strategy as AnonymIdStrategy } from "passport-anonym-uuid"; import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses"; import * as fs from "fs/promises"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; const client = new SESClient({ region: "us-east-2" }); @@ -2062,6 +2065,7 @@ app.get( } }, ); + app.get( "/api/searchPossibleClassifications", async (req: Request, res: Response, next: NextFunction) => { @@ -2079,6 +2083,56 @@ app.get( }, ); +app.post( + "/api/setPreferredFolderView", + async (req: Request, res: Response, next: NextFunction) => { + const cardView = req.body.cardView as boolean; + + if (!req.user) { + // if not signed in, then don't set anything and report back their choice + res.send({ cardView }); + return; + } + + try { + const loggedInUserId = Number(req.user.userId); + + const results = await setPreferredFolderView(loggedInUserId, cardView); + res.send(results); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + res.status(404).send("Not logged in"); + return; + } + next(e); + } + }, +); + +app.get( + "/api/getPreferredFolderView", + async (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { + // if not signed in, just have the default behavior + res.send({ cardView: false }); + return; + } + + try { + const loggedInUserId = Number(req.user.userId); + + const results = await getPreferredFolderView(loggedInUserId); + res.send(results); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + res.status(404).send("Not logged in"); + 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 946af73e5..20f3d2532 100644 --- a/server/src/model.test.ts +++ b/server/src/model.test.ts @@ -71,6 +71,8 @@ import { getDocumentRemixes, getDocumentDirectRemixes, getDocumentSource, + getPreferredFolderView, + setPreferredFolderView, } from "./model"; import { DateTime } from "luxon"; @@ -552,6 +554,7 @@ test( const { isAdmin: _isAdmin1, isAnonymous: _isAnonymous1, + cardView: _cardView1, ...userFields1 } = user1; let user2 = await createTestUser(); @@ -564,6 +567,7 @@ test( const { isAdmin: _isAdmin2, isAnonymous: _isAnonymous2, + cardView: _cardView2, ...userFields2 } = user2; const user3 = await createTestUser(); @@ -1024,7 +1028,7 @@ test("content in shared folder is created shared", async () => { const ownerId = owner.userId; const user = await createTestUser(); const userId = user.userId; - const { isAdmin, isAnonymous, ...userFields } = user; + const { isAdmin, isAnonymous, cardView, ...userFields } = user; const { folderId: publicFolderId } = await createFolder(ownerId, null); @@ -1192,6 +1196,7 @@ test( const { isAdmin: _isAdmin1, isAnonymous: _isAnonymous1, + cardView: _cardView1, ...userFields1 } = user1; let user2 = await createTestUser(); @@ -1204,6 +1209,7 @@ test( const { isAdmin: _isAdmin2, isAnonymous: _isAnonymous2, + cardView: _cardView2, ...userFields2 } = user2; let user3 = await createTestUser(); @@ -1216,6 +1222,7 @@ test( const { isAdmin: _isAdmin3, isAnonymous: _isAnonymous3, + cardView: _cardView3, ...userFields3 } = user3; @@ -1596,7 +1603,12 @@ test("moving content into shared folder shares it", async () => { const ownerId = owner.userId; const user = await createTestUser(); const userId = user.userId; - const { isAdmin: _isAdmin, isAnonymous: _isAnonymous, ...userFields } = user; + const { + isAdmin: _isAdmin, + isAnonymous: _isAnonymous, + cardView: _cardView, + ...userFields + } = user; const { folderId: sharedFolderId } = await createFolder(ownerId, null); await shareFolder({ @@ -7403,3 +7415,17 @@ test("set license to make public", async () => { "/creative_commons_by_nc_sa.png", ); }); + +test("set and get preferred folder view", async () => { + const user = await createTestUser(); + const userId = user.userId; + + let result = await getPreferredFolderView(userId); + expect(result).eqls({ cardView: false }); + + result = await setPreferredFolderView(userId, true); + expect(result).eqls({ cardView: true }); + + result = await getPreferredFolderView(userId); + expect(result).eqls({ cardView: true }); +}); diff --git a/server/src/model.ts b/server/src/model.ts index a9008bc6a..81defadd0 100644 --- a/server/src/model.ts +++ b/server/src/model.ts @@ -5109,3 +5109,21 @@ export async function unshareFolder({ }, }); } + +export async function setPreferredFolderView( + loggedInUserId: number, + cardView: boolean, +) { + return await prisma.users.update({ + where: { userId: loggedInUserId }, + data: { cardView }, + select: { cardView: true }, + }); +} + +export async function getPreferredFolderView(loggedInUserId: number) { + return await prisma.users.findUniqueOrThrow({ + where: { userId: loggedInUserId }, + select: { cardView: true }, + }); +}