diff --git a/backend/canvas_app_explorer/views.py b/backend/canvas_app_explorer/views.py index 9783f33..22d076b 100644 --- a/backend/canvas_app_explorer/views.py +++ b/backend/canvas_app_explorer/views.py @@ -28,7 +28,8 @@ def list(self, request: Request) -> Response: available_tools = manager.get_tools_available_in_course() logger.debug('available_tools: ' + ', '.join([tool.__str__() for tool in available_tools])) available_tool_ids = [t.id for t in available_tools] - queryset = models.LtiTool.objects.filter(canvas_id__isnull=False, canvas_id__in=available_tool_ids) + queryset = models.LtiTool.objects.filter(canvas_id__isnull=False, canvas_id__in=available_tool_ids)\ + .order_by('name') serializer = serializers.LtiToolWithNavSerializer( queryset, many=True, context={ 'available_tools': available_tools } ) diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 97b3149..62dc463 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -19,6 +19,7 @@ module.exports = { }, 'plugins': [ 'react', + 'react-hooks', '@typescript-eslint' ], 'settings': { @@ -42,6 +43,8 @@ module.exports = { 'semi': [ 'error', 'always' - ] + ], + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': ['warn'] } }; diff --git a/frontend/app/api.ts b/frontend/app/api.ts new file mode 100644 index 0000000..4e458d9 --- /dev/null +++ b/frontend/app/api.ts @@ -0,0 +1,67 @@ +import Cookies from 'js-cookie'; + +import { Tool } from './interfaces'; + +const API_BASE = '/api'; +const JSON_MIME_TYPE = 'application/json'; + +const BASE_MUTATION_HEADERS: HeadersInit = { + Accept: JSON_MIME_TYPE, + 'Content-Type': JSON_MIME_TYPE, + 'X-Requested-With': 'XMLHttpRequest' +}; + +const getCSRFToken = (): string | undefined => Cookies.get('csrftoken'); + +const createErrorMessage = async (res: Response): Promise => { + let errorBody; + try { + errorBody = await res.json(); + } catch { + console.error('Error body was not JSON.'); + errorBody = undefined; + } + return ( + 'Error occurred! ' + + `Status: ${res.status}` + (res.statusText !== '' ? ` (${res.statusText})` : '') + + (errorBody !== undefined ? '; Body: ' + JSON.stringify(errorBody) : '.') + ); +}; + +async function getTools (): Promise { + const url = `${API_BASE}/lti_tools/`; + const res = await fetch(url); + if (!res.ok) { + console.error(res); + throw new Error(await createErrorMessage(res)); + } + const data: Tool[] = await res.json(); + return data; +} + +interface UpdateToolNavData { + canvasToolId: number + navEnabled: boolean +} + +async function updateToolNav (data: UpdateToolNavData): Promise { + const { canvasToolId, navEnabled } = data; + const body = { navigation_enabled: navEnabled }; + const url = `${API_BASE}/lti_tools/${canvasToolId}/`; + const requestInit: RequestInit = { + method: 'PUT', + body: JSON.stringify(body), + headers: { + ...BASE_MUTATION_HEADERS, + 'X-CSRFTOKEN': getCSRFToken() ?? '' + } + }; + const res = await fetch(url, requestInit); + if (!res.ok) { + console.error(res); + throw new Error(await createErrorMessage(res)); + } + return; +} + +export { getTools, updateToolNav }; diff --git a/frontend/app/components/ErrorsDisplay.tsx b/frontend/app/components/ErrorsDisplay.tsx new file mode 100644 index 0000000..64da01a --- /dev/null +++ b/frontend/app/components/ErrorsDisplay.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Alert, Grid } from '@mui/material'; + +interface ErrorsDisplayProps { + errors: Error[] +} + +export default function ErrorsDisplay (props: ErrorsDisplayProps) { + return ( + + {props.errors.map((e, i) => ( + {e.message} + ))} + + ); +} diff --git a/frontend/app/components/Home.tsx b/frontend/app/components/Home.tsx index 3b503c5..89c7d49 100644 --- a/frontend/app/components/Home.tsx +++ b/frontend/app/components/Home.tsx @@ -1,9 +1,12 @@ -import React, { useEffect, useState } from 'react'; -import { Alert, Grid, Typography } from '@mui/material'; +import React, { useState } from 'react'; +import { useQuery } from 'react-query'; +import { Alert, Box, Grid, LinearProgress, Typography } from '@mui/material'; import { styled } from '@mui/material/styles'; +import ErrorsDisplay from './ErrorsDisplay'; import HeaderAppBar from './HeaderAppBar'; import ToolCard from './ToolCard'; +import { getTools } from '../api'; import '../css/Home.css'; import { Tool } from '../interfaces'; @@ -22,34 +25,56 @@ const filterTools = (tools: Tool[], filter: string): Tool[] => { }; function Home () { - const [tools, setTools] = useState(null); + const [tools, setTools] = useState(undefined); const [searchFilter, setSearchFilter] = useState(''); + const [showRefreshAlert, setShowRefreshAlert] = useState(undefined); - useEffect(() => { - const fetchToolData = async () => { - const url = '/api/lti_tools/'; - const response = await fetch(url); - const data: Tool[] = await response.json(); - // sort data alphabetically by name - data.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase()) - ? 1 - : ((b.name.toLowerCase() > a.name.toLowerCase()) ? -1 : 0) - ); - setTools(data); - }; - fetchToolData(); - }, []); + const { isLoading: getToolsLoading, error: getToolsError } = useQuery('getTools', getTools, { + onSuccess: (data) => setTools(data) + }); + + const onToolUpdate = (newTool: Tool) => { + /* + Creates new array with newTool replacing its previous version; + Uses function inside setState hook to handle overlapping requests + */ + setTools((oldTools) => { + if (oldTools === undefined) throw Error('Expected tools variable to be defined!'); + const newTools = oldTools.map(t => t.canvas_id === newTool.canvas_id ? newTool : t); + return newTools; + }); + + if (showRefreshAlert === undefined) setShowRefreshAlert(true); + }; + + const isLoading = getToolsLoading; + const errors = [getToolsError].filter(e => e !== null) as Error[]; + + let feedbackBlock; + if (isLoading || errors.length > 0 || showRefreshAlert) { + feedbackBlock = ( + + {isLoading && } + {errors.length > 0 && } + {showRefreshAlert && ( + setShowRefreshAlert(false)}> + Refresh the page to make tool changes appear in the left-hand navigation. + + )} + + ); + } let toolCardContainer; - if (tools === null) { - toolCardContainer = (
Loading . . .
); - } else { + if (tools !== undefined) { const filteredTools = searchFilter !== '' ? filterTools(tools, searchFilter) : tools; toolCardContainer = ( { filteredTools.length > 0 - ? filteredTools.map(t => ) + ? filteredTools.map(t => ( + + )) : No matching results } @@ -63,7 +88,10 @@ function Home () { Find the best tools for your class and students - {toolCardContainer} + {feedbackBlock} +
+ {toolCardContainer} +
Copyright © 2022 The Regents of the University of Michigan diff --git a/frontend/app/components/ToolCard.tsx b/frontend/app/components/ToolCard.tsx index d99259b..2dc1954 100644 --- a/frontend/app/components/ToolCard.tsx +++ b/frontend/app/components/ToolCard.tsx @@ -1,28 +1,52 @@ import React, { useState } from 'react'; import AddBox from '@mui/icons-material/AddBox'; +import { useMutation } from 'react-query'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { - Button, Card, CardActions, CardContent, CardMedia, Collapse, Grid, Typography + Button, Card, CardActions, CardContent, CardMedia, Collapse, Grid, LinearProgress, Typography } from '@mui/material'; import DataElement from './DataElement'; +import ErrorsDisplay from './ErrorsDisplay'; import ImageDialog from './ImageDialog'; import { AddToolButton, RemoveToolButton } from './toolButtons'; +import { updateToolNav } from '../api'; import { Tool } from '../interfaces'; interface ToolCardProps { tool: Tool + onToolUpdate: (tool: Tool) => void; } export default function ToolCard (props: ToolCardProps) { - const { tool } = props; + const { tool, onToolUpdate } = props; const [showMoreInfo, setShowMoreInfo] = useState(false); const [screenshotDialogOpen, setScreenshotDialogOpen] = useState(false); + const { + mutate: doUpdateToolNav, error: updateToolNavError, isLoading: updateToolNavLoading + } = useMutation(updateToolNav, { onSuccess: (data, variables) => { + const newTool = { ...tool, navigation_enabled: variables.navEnabled }; + onToolUpdate(newTool); + }}); + const moreOrLessText = !showMoreInfo ? 'More' : 'Less'; + const isLoading = updateToolNavLoading; + const errors = [updateToolNavError].filter(e => e !== null) as Error[]; + + let feedbackBlock; + if (isLoading || errors.length > 0) { + feedbackBlock = ( + + {isLoading && } + {errors.length > 0 && } + + ); + } + let mainImageBlock; if (tool.main_image !== null) { const defaultMainImageAltText = `Image of ${tool.name} tool in use`; @@ -67,9 +91,29 @@ export default function ToolCard (props: ToolCardProps) { + {feedbackBlock} - - {tool.navigation_enabled ? : } + + { + tool.navigation_enabled + ? ( + doUpdateToolNav({ canvasToolId: tool.canvas_id, navEnabled: false })} + /> + ) + : ( + doUpdateToolNav({ canvasToolId: tool.canvas_id, navEnabled: true })} + /> + ) + }