mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-26 15:48:32 -06:00
Compare commits
76 Commits
copilot/fi
...
4d934f27f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d934f27f3 | |||
| e960e2fa42 | |||
|
|
e3079cbc5a | ||
|
|
f5a50fdc02 | ||
| aa191fe90b | |||
| ca240811f2 | |||
| c224a6a9e2 | |||
| 865e86d11f | |||
| b62dcb9c62 | |||
|
|
77fde8198e | ||
|
|
8172724a4f | ||
|
|
07155991aa | ||
| 9ce42c21f9 | |||
|
|
55a9fffd54 | ||
| a4a8e3cbb6 | |||
| dda46a3c49 | |||
| 5b202f25e6 | |||
| 558eb74fbc | |||
| b6a84f2fbc | |||
| fb5ee94b55 | |||
| 678727c650 | |||
| b5899c02e4 | |||
| 767528560c | |||
| 8c01cb2422 | |||
| 076c0b1025 | |||
| 20b14da180 | |||
|
|
52b8967949 | ||
|
|
eb661a3e59 | ||
|
|
712a3e5155 | ||
|
|
7bb276d52a | ||
|
|
44c42d1abc | ||
|
|
1e3ff085f8 | ||
|
|
3c6ba35bce | ||
|
|
cef2323886 | ||
|
|
859bdf01f2 | ||
|
|
e9f33e0174 | ||
|
|
e07d0a6e47 | ||
| 51f2be1988 | |||
|
|
a203dc6e46 | ||
| f6b2427749 | |||
| cab8b881f2 | |||
| ae19b5a075 | |||
| 8aec682974 | |||
| 17bd460407 | |||
| 53c8422a5b | |||
| 890e08d1b2 | |||
| 7fec0424d7 | |||
| 11c2366f93 | |||
| 5988639378 | |||
| 15b184ddc0 | |||
| e35a5ffab6 | |||
| b53948db72 | |||
| d9f7e7b3e9 | |||
| 47c69251c8 | |||
| 4c978f392d | |||
| d6584fd338 | |||
| 6a56036782 | |||
| bd32599469 | |||
| 9638d7308e | |||
| bf835caa37 | |||
| e7e244222e | |||
| 2e474cb43a | |||
| 33120c40a5 | |||
| 2ec3d9349e | |||
| 5e088fb4eb | |||
|
|
aae9e7bba4 | ||
| 58175c1426 | |||
| 03529f875a | |||
|
|
95c9d07592 | ||
|
|
9918b63a1e | ||
|
|
f808a517d3 | ||
|
|
b47fa4cff5 | ||
|
|
efe2060fcd | ||
|
|
c60ba92f28 | ||
|
|
b65cfa73d7 | ||
|
|
dbc7887d82 |
152
.github/copilot-instructions.md
vendored
152
.github/copilot-instructions.md
vendored
@@ -1,152 +0,0 @@
|
|||||||
# Canvas Management Application
|
|
||||||
|
|
||||||
Canvas Management is a Next.js web application that provides a user interface for managing Canvas LMS courses, modules, assignments, quizzes, and pages. It features real-time file synchronization through WebSocket connections and supports Docker containerization.
|
|
||||||
|
|
||||||
Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.
|
|
||||||
|
|
||||||
## Working Effectively
|
|
||||||
|
|
||||||
### Bootstrap, Build, and Test the Repository
|
|
||||||
- Install pnpm globally: `npm install -g pnpm`
|
|
||||||
- Install dependencies: `pnpm install --config.confirmModulesPurge=false` -- takes 25 seconds. NEVER CANCEL. Set timeout to 60+ seconds.
|
|
||||||
- Build the application: `pnpm run build` -- takes 47 seconds. NEVER CANCEL. Set timeout to 90+ minutes for CI environments.
|
|
||||||
- Run tests: `pnpm run test` -- takes 8 seconds. NEVER CANCEL. Set timeout to 30+ seconds.
|
|
||||||
- Run linting: `pnpm run lint` -- takes 13 seconds. NEVER CANCEL. Set timeout to 30+ seconds.
|
|
||||||
|
|
||||||
### Environment Setup
|
|
||||||
- Copy `.env.test` to `.env.local` for local development
|
|
||||||
- Set required environment variables:
|
|
||||||
- `STORAGE_DIRECTORY="./temp/storage"` - Directory for course data storage
|
|
||||||
- `NEXT_PUBLIC_ENABLE_FILE_SYNC=true` - Enable file synchronization features
|
|
||||||
- `CANVAS_TOKEN="your_canvas_api_token"` - Canvas API token (optional for local development)
|
|
||||||
- Create storage directory: `mkdir -p temp/storage`
|
|
||||||
- Create simple globalSettings.yml: `echo "courses: []" > globalSettings.yml`
|
|
||||||
|
|
||||||
### Run the Development Server
|
|
||||||
- **Development server without WebSocket**: `pnpm run devNoSocket` -- starts in 1.5 seconds
|
|
||||||
- **Development server with WebSocket**: `pnpm run dev` -- runs `pnpm install` then starts WebSocket server with Next.js
|
|
||||||
- Access the application at: `http://localhost:3000`
|
|
||||||
- WebSocket server enables real-time file watching and synchronization
|
|
||||||
|
|
||||||
### Run Production Build
|
|
||||||
- **Production server**: `pnpm run start` -- starts production WebSocket server with Next.js
|
|
||||||
- Requires completing the build step first
|
|
||||||
|
|
||||||
### Docker Development
|
|
||||||
- **Local Docker build**: `./build.sh` -- takes 5+ minutes. NEVER CANCEL. Set timeout to 120+ minutes.
|
|
||||||
- **Development with Docker**: Use `run.sh` or `docker-compose.dev.yml`
|
|
||||||
- **Production with Docker**: Use `docker-compose.yml`
|
|
||||||
- Note: Docker builds may fail in sandboxed environments due to certificate issues
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
### Manual Testing Scenarios
|
|
||||||
- ALWAYS run through complete end-to-end scenarios after making changes
|
|
||||||
- Start the development server and verify the main page loads with "Add New Course" and "Add Existing Course" buttons
|
|
||||||
- Test course creation workflow (will fail without valid Canvas API token, which is expected)
|
|
||||||
- Verify WebSocket connectivity shows "Socket connected successfully" in browser console
|
|
||||||
- Check that file system watching works when NEXT_PUBLIC_ENABLE_FILE_SYNC=true
|
|
||||||
|
|
||||||
### Required Validation Steps
|
|
||||||
- Always run `pnpm run lint` before committing changes
|
|
||||||
- Always run `pnpm run test` to ensure tests pass
|
|
||||||
- Always run `pnpm run build` to verify production build works
|
|
||||||
- Test both `devNoSocket` and `dev` modes to ensure WebSocket functionality works
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
### Build System Requirements
|
|
||||||
- **Node.js**: Version 20+ (validated with v20.19.5)
|
|
||||||
- **Package Manager**: pnpm v10+ (install with `npm install -g pnpm`)
|
|
||||||
- **Build Tool**: Next.js 15.3.5 with React 19
|
|
||||||
|
|
||||||
### Technology Stack
|
|
||||||
- **Frontend**: Next.js 15.3.5, React 19, TypeScript 5.8.3
|
|
||||||
- **Styling**: Tailwind CSS 4.1.11
|
|
||||||
- **Testing**: Vitest 3.2.4 with @testing-library/react
|
|
||||||
- **Linting**: ESLint 9.31.0 with Next.js and TypeScript configs
|
|
||||||
- **Real-time**: WebSocket with Socket.IO for file watching
|
|
||||||
- **API**: tRPC for type-safe API calls
|
|
||||||
- **Canvas Integration**: Axios for Canvas LMS API communication
|
|
||||||
|
|
||||||
### Key Project Structure
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── app/ # Next.js app router pages
|
|
||||||
├── components/ # Reusable React components
|
|
||||||
├── features/ # Feature-specific code (local file handling, Canvas API)
|
|
||||||
├── services/ # Utility services and API helpers
|
|
||||||
└── websocket.js # WebSocket server for file watching
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development Workflow Tips
|
|
||||||
- Use `pnpm dev` for full development with file watching
|
|
||||||
- Use `pnpm devNoSocket` for faster startup when WebSocket features not needed
|
|
||||||
- Monitor console for WebSocket connection status and Canvas API errors
|
|
||||||
- Canvas API errors are expected without valid CANVAS_TOKEN
|
|
||||||
- File sync requires NEXT_PUBLIC_ENABLE_FILE_SYNC=true
|
|
||||||
|
|
||||||
### Storage Configuration
|
|
||||||
- Course data stored in markdown files within storage directory
|
|
||||||
- `globalSettings.yml` controls which courses appear in UI
|
|
||||||
- Each course requires settings.yml file in its directory
|
|
||||||
- Images supported via volume mounts to `/app/public/images/`
|
|
||||||
|
|
||||||
### Frequent Command Outputs
|
|
||||||
|
|
||||||
#### Repository Root Structure
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── README.md
|
|
||||||
├── package.json
|
|
||||||
├── pnpm-lock.yaml
|
|
||||||
├── Dockerfile
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── docker-compose.dev.yml
|
|
||||||
├── build.sh
|
|
||||||
├── run.sh
|
|
||||||
├── globalSettings.yml
|
|
||||||
├── eslint.config.mjs
|
|
||||||
├── vitest.config.ts
|
|
||||||
├── tsconfig.json
|
|
||||||
├── tailwind.config.ts
|
|
||||||
├── next.config.mjs
|
|
||||||
├── postcss.config.mjs
|
|
||||||
└── src/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Key Package Scripts
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dev": "pnpm install --config.confirmModulesPurge=false && node src/websocket.js",
|
|
||||||
"devNoSocket": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "NODE_ENV=production node src/websocket.js",
|
|
||||||
"lint": "eslint . --config eslint.config.mjs && tsc && next lint",
|
|
||||||
"test": "vitest"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Critical Timing Information
|
|
||||||
|
|
||||||
- **NEVER CANCEL** any build or test commands - wait for completion
|
|
||||||
- **Dependency Installation**: ~25 seconds (set 60+ second timeout)
|
|
||||||
- **Production Build**: ~47 seconds (set 90+ minute timeout for CI)
|
|
||||||
- **Test Suite**: ~8 seconds (set 30+ second timeout)
|
|
||||||
- **Linting**: ~13 seconds (set 30+ second timeout)
|
|
||||||
- **Development Server Startup**: ~1.5 seconds
|
|
||||||
- **Docker Build**: 5+ minutes locally (set 120+ minute timeout)
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Expected Errors During Development
|
|
||||||
- Canvas API connection errors without valid CANVAS_TOKEN
|
|
||||||
- Socket.IO 404 errors when running devNoSocket mode
|
|
||||||
- Docker build certificate issues in sandboxed environments
|
|
||||||
- Course settings file not found errors with empty storage directory
|
|
||||||
|
|
||||||
### Troubleshooting
|
|
||||||
- If WebSocket connection fails, check NEXT_PUBLIC_ENABLE_FILE_SYNC environment variable
|
|
||||||
- If build fails, ensure pnpm is installed globally
|
|
||||||
- If tests fail with storage errors, check STORAGE_DIRECTORY environment variable
|
|
||||||
- For Canvas integration, set valid CANVAS_TOKEN environment variable
|
|
||||||
27
.github/workflows/docker-deploy.yml
vendored
Normal file
27
.github/workflows/docker-deploy.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Deploy to Docker Hub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
run: |
|
||||||
|
chmod +x ./build.sh
|
||||||
|
./build.sh -t -p
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ next-env.d.ts
|
|||||||
|
|
||||||
storage/
|
storage/
|
||||||
temp/
|
temp/
|
||||||
|
.claude/
|
||||||
12
build.sh
12
build.sh
@@ -44,9 +44,9 @@ if [ "$PUSH_FLAG" = true ]; then
|
|||||||
echo "alexmickelson/canvas_management:$MAJOR_VERSION"
|
echo "alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||||
echo "alexmickelson/canvas_management:latest"
|
echo "alexmickelson/canvas_management:latest"
|
||||||
|
|
||||||
docker push alexmickelson/canvas_management:"$VERSION"
|
docker push -q alexmickelson/canvas_management:"$VERSION"
|
||||||
docker push alexmickelson/canvas_management:"$MAJOR_VERSION"
|
docker push -q alexmickelson/canvas_management:"$MAJOR_VERSION"
|
||||||
docker push alexmickelson/canvas_management:latest
|
docker push -q alexmickelson/canvas_management:latest
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$TAG_FLAG" = false ] && [ "$PUSH_FLAG" = false ]; then
|
if [ "$TAG_FLAG" = false ] && [ "$PUSH_FLAG" = false ]; then
|
||||||
@@ -59,7 +59,7 @@ if [ "$TAG_FLAG" = false ] && [ "$PUSH_FLAG" = false ]; then
|
|||||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$VERSION"
|
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$VERSION"
|
||||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$MAJOR_VERSION"
|
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||||
echo "docker image tag canvas_management:latest alexmickelson/canvas_management:latest"
|
echo "docker image tag canvas_management:latest alexmickelson/canvas_management:latest"
|
||||||
echo "docker push alexmickelson/canvas_management:$VERSION"
|
echo "docker push -q alexmickelson/canvas_management:$VERSION"
|
||||||
echo "docker push alexmickelson/canvas_management:$MAJOR_VERSION"
|
echo "docker push -q alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||||
echo "docker push alexmickelson/canvas_management:latest"
|
echo "docker push -q alexmickelson/canvas_management:latest"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ services:
|
|||||||
- ~/projects/facultyFiles:/app/public/images/facultyFiles
|
- ~/projects/facultyFiles:/app/public/images/facultyFiles
|
||||||
|
|
||||||
|
|
||||||
redis:
|
# redis:
|
||||||
image: redis
|
# image: redis
|
||||||
container_name: redis
|
# container_name: redis
|
||||||
volumes:
|
# volumes:
|
||||||
- redis-data:/data
|
# - redis-data:/data
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
# volumes:
|
||||||
redis-data:
|
# redis-data:
|
||||||
|
|
||||||
# https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
|
# https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
|
||||||
# https://github.com/jonas-merkle/container-cloudflare-tunnel
|
# https://github.com/jonas-merkle/container-cloudflare-tunnel
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
courses:
|
courses:
|
||||||
- path: ./4850_AdvancedFE/2025-fall-alex/modules/
|
|
||||||
name: Adv Frontend
|
|
||||||
- path: ./1420/2025-fall-alex/modules/
|
- path: ./1420/2025-fall-alex/modules/
|
||||||
name: "1420"
|
name: "1420"
|
||||||
- path: ./1810/2025-fall-alex/modules/
|
|
||||||
name: Web Intro
|
|
||||||
- path: ./1430/2025-fall-alex/modules/
|
|
||||||
name: UX
|
|
||||||
- path: ./1425/2025-fall-alex/modules/
|
- path: ./1425/2025-fall-alex/modules/
|
||||||
name: "1425"
|
name: "1425"
|
||||||
- path: ./1405/2025_spring_alex/
|
- path: ./4850_AdvancedFE/2026-spring-alex/modules
|
||||||
name: 1405_old
|
name: Adv Frontend
|
||||||
- path: ./3840_Telemetry/2025_spring_alex/modules/
|
- path: ./1400/2026_spring_alex/modules
|
||||||
|
name: "1400"
|
||||||
|
- path: ./1405/2026_spring_alex
|
||||||
|
name: "1405"
|
||||||
|
- path: ./3840_Telemetry/2026_spring_alex
|
||||||
name: Telem and Ops
|
name: Telem and Ops
|
||||||
- path: ./4850_AdvancedFE/2024-fall-alex/modules/
|
- path: ./4620_Distributed/2026-spring-alex/modules
|
||||||
name: Old Adv Frontend
|
name: Distributed
|
||||||
- path: ./1430/2025-spring-jonathan/Modules/
|
- path: ./4620_Distributed/2025Spring/modules/
|
||||||
name: Jonathan UX
|
name: distributed-old
|
||||||
- path: ./1400/2025_spring_alex/modules/
|
- path: ./3840_Telemetry/2025_spring_alex/modules/
|
||||||
name: 1400-spring
|
name: telemetry-old
|
||||||
|
- path: ./4850_AdvancedFE/2025-fall-alex/modules/
|
||||||
|
name: adv-frontend-old
|
||||||
|
- path: ./1810/2026-spring-alex/modules/
|
||||||
|
name: Web Intro
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"marked-katex-extension": "^5.1.5",
|
"marked-katex-extension": "^5.1.5",
|
||||||
"mcp-handler": "^1.0.0",
|
"mcp-handler": "^1.0.0",
|
||||||
"next": "^15.3.5",
|
"next": "^15.3.5",
|
||||||
|
"pako": "^2.1.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -65,6 +65,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: ^15.3.5
|
specifier: ^15.3.5
|
||||||
version: 15.3.5(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 15.3.5(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
pako:
|
||||||
|
specifier: ^2.1.0
|
||||||
|
version: 2.1.0
|
||||||
react:
|
react:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.1.0
|
version: 19.1.0
|
||||||
@@ -2628,6 +2631,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
pako@2.1.0:
|
||||||
|
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -5853,6 +5859,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
pako@2.1.0: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ Content-Type: application/json
|
|||||||
GET https://snow.instructure.com/api/v1/courses/958185/assignments
|
GET https://snow.instructure.com/api/v1/courses/958185/assignments
|
||||||
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
|
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
GET https://snow.instructure.com/api/v1/courses/1155293/quizzes/4366122/questions
|
||||||
|
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
|
||||||
|
|
||||||
###
|
###
|
||||||
POST https://snow.instructure.com/api/v1/courses/958185/quizzes/3358912/questions
|
POST https://snow.instructure.com/api/v1/courses/958185/quizzes/3358912/questions
|
||||||
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
|
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { Toggle } from "@/components/form/Toggle";
|
||||||
import { useLocalCoursesSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
import { useLocalCoursesSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||||
|
import {
|
||||||
|
useGlobalSettingsQuery,
|
||||||
|
useUpdateGlobalSettingsMutation,
|
||||||
|
} from "@/features/local/globalSettings/globalSettingsHooks";
|
||||||
import {
|
import {
|
||||||
getDateKey,
|
getDateKey,
|
||||||
getTermName,
|
getTermName,
|
||||||
@@ -7,42 +12,121 @@ import {
|
|||||||
} from "@/features/local/utils/timeUtils";
|
} from "@/features/local/utils/timeUtils";
|
||||||
import { getCourseUrl } from "@/services/urlUtils";
|
import { getCourseUrl } from "@/services/urlUtils";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Modal, { useModal } from "@/components/Modal";
|
||||||
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
|
||||||
export default function CourseList() {
|
export default function CourseList() {
|
||||||
const { data: allSettings } = useLocalCoursesSettingsQuery();
|
const { data: allSettings } = useLocalCoursesSettingsQuery();
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
const coursesByStartDate = groupByStartDate(allSettings);
|
const coursesByStartDate = groupByStartDate(allSettings);
|
||||||
|
|
||||||
const sortedDates = Object.keys(coursesByStartDate).sort();
|
const sortedDates = Object.keys(coursesByStartDate).sort();
|
||||||
|
|
||||||
console.log(allSettings, coursesByStartDate);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row ">
|
<div>
|
||||||
{sortedDates.map((startDate) => (
|
<Toggle
|
||||||
<div
|
label={"Delete Mode"}
|
||||||
key={startDate}
|
value={isDeleting}
|
||||||
className=" border-4 border-slate-800 rounded p-3 m-3"
|
onChange={(set) => setIsDeleting(set)}
|
||||||
>
|
/>
|
||||||
<div className="text-center">{getTermName(startDate)}</div>
|
<div className="flex flex-row ">
|
||||||
{coursesByStartDate[getDateKey(startDate)].map((settings) => (
|
{sortedDates.map((startDate) => (
|
||||||
<div key={settings.name}>
|
<div
|
||||||
<Link
|
key={startDate}
|
||||||
href={getCourseUrl(settings.name)}
|
className=" border-4 border-slate-800 rounded p-3 m-3"
|
||||||
shallow={true}
|
>
|
||||||
prefetch={true}
|
<div className="text-center">{getTermName(startDate)}</div>
|
||||||
className="
|
{coursesByStartDate[getDateKey(startDate)].map((settings) => (
|
||||||
font-bold text-xl block
|
<CourseItem
|
||||||
transition-all hover:scale-105 hover:underline hover:text-slate-200
|
key={settings.name}
|
||||||
mb-3
|
courseName={settings.name}
|
||||||
"
|
isDeleting={isDeleting}
|
||||||
>
|
/>
|
||||||
{settings.name}
|
))}
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CourseItem({
|
||||||
|
courseName,
|
||||||
|
isDeleting,
|
||||||
|
}: {
|
||||||
|
courseName: string;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}) {
|
||||||
|
const { data: globalSettings } = useGlobalSettingsQuery();
|
||||||
|
const updateSettingsMutation = useUpdateGlobalSettingsMutation();
|
||||||
|
const modal = useModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
{isDeleting && (
|
||||||
|
<Modal
|
||||||
|
modalControl={modal}
|
||||||
|
buttonText="X"
|
||||||
|
buttonClass="
|
||||||
|
unstyled
|
||||||
|
text-red-200 hover:text-red-400
|
||||||
|
bg-red-950/50 hover:bg-red-950/70
|
||||||
|
transition-all hover:scale-110
|
||||||
|
mb-3
|
||||||
|
"
|
||||||
|
modalWidth="w-1/3"
|
||||||
|
>
|
||||||
|
{({ closeModal }) => (
|
||||||
|
<div>
|
||||||
|
<div className="text-center">
|
||||||
|
Are you sure you want to remove {courseName} from global
|
||||||
|
settings?
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div className="flex justify-around gap-3">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await updateSettingsMutation.mutateAsync({
|
||||||
|
globalSettings: {
|
||||||
|
...globalSettings,
|
||||||
|
courses: globalSettings.courses.filter(
|
||||||
|
(course) => course.name !== courseName
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
}}
|
||||||
|
disabled={updateSettingsMutation.isPending}
|
||||||
|
className="btn-danger"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
disabled={updateSettingsMutation.isPending}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{updateSettingsMutation.isPending && <Spinner />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={getCourseUrl(courseName)}
|
||||||
|
shallow={true}
|
||||||
|
prefetch={true}
|
||||||
|
className="
|
||||||
|
font-bold text-xl block
|
||||||
|
transition-all hover:scale-105 hover:underline hover:text-slate-200
|
||||||
|
mb-3
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{courseName}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ import {
|
|||||||
} from "@/features/local/course/localCourseSettings";
|
} from "@/features/local/course/localCourseSettings";
|
||||||
import { useCourseListInTermQuery } from "@/features/canvas/hooks/canvasCourseHooks";
|
import { useCourseListInTermQuery } from "@/features/canvas/hooks/canvasCourseHooks";
|
||||||
import { useCanvasTermsQuery } from "@/features/canvas/hooks/canvasHooks";
|
import { useCanvasTermsQuery } from "@/features/canvas/hooks/canvasHooks";
|
||||||
|
import { useDirectoryExistsQuery } from "@/features/local/utils/storageDirectoryHooks";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const sampleCompose = `services:
|
const sampleCompose = `services:
|
||||||
canvas_manager:
|
canvas_manager:
|
||||||
image: alexmickelson/canvas_management:2 # pull this image regularly
|
image: alexmickelson/canvas_management:2 # pull this image regularly
|
||||||
@@ -94,11 +96,16 @@ export default function AddNewCourseToGlobalSettingsForm() {
|
|||||||
disabled={!formIsComplete || createCourse.isPending}
|
disabled={!formIsComplete || createCourse.isPending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (formIsComplete) {
|
if (formIsComplete) {
|
||||||
console.log("Creating course with settings:", selectedDirectory);
|
console.log(
|
||||||
|
"Creating course with settings:",
|
||||||
|
selectedDirectory,
|
||||||
|
"old course",
|
||||||
|
courseToImport
|
||||||
|
);
|
||||||
const newSettings: LocalCourseSettings = courseToImport
|
const newSettings: LocalCourseSettings = courseToImport
|
||||||
? {
|
? {
|
||||||
...courseToImport,
|
...courseToImport,
|
||||||
name: selectedDirectory,
|
name: name,
|
||||||
daysOfWeek: selectedDaysOfWeek,
|
daysOfWeek: selectedDaysOfWeek,
|
||||||
canvasId: selectedCanvasCourse.id,
|
canvasId: selectedCanvasCourse.id,
|
||||||
startDate: selectedTerm.start_at ?? "",
|
startDate: selectedTerm.start_at ?? "",
|
||||||
@@ -114,7 +121,7 @@ export default function AddNewCourseToGlobalSettingsForm() {
|
|||||||
assets: [],
|
assets: [],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
name: selectedDirectory,
|
name: name,
|
||||||
assignmentGroups: [],
|
assignmentGroups: [],
|
||||||
daysOfWeek: selectedDaysOfWeek,
|
daysOfWeek: selectedDaysOfWeek,
|
||||||
canvasId: selectedCanvasCourse.id,
|
canvasId: selectedCanvasCourse.id,
|
||||||
@@ -144,11 +151,6 @@ export default function AddNewCourseToGlobalSettingsForm() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{createCourse.isPending && <Spinner />}
|
{createCourse.isPending && <Spinner />}
|
||||||
|
|
||||||
<pre>
|
|
||||||
<div>Example docker compose</div>
|
|
||||||
<code className="language-yml">{sampleCompose}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -180,12 +182,12 @@ function OtherSettings({
|
|||||||
name: string;
|
name: string;
|
||||||
setName: Dispatch<SetStateAction<string>>;
|
setName: Dispatch<SetStateAction<string>>;
|
||||||
}) {
|
}) {
|
||||||
const { data: canvasCourses } = useCourseListInTermQuery(selectedTerm.id);
|
const { data: canvasCourses, isLoading: canvasCoursesLoading } =
|
||||||
|
useCourseListInTermQuery(selectedTerm.id);
|
||||||
const { data: allSettings } = useLocalCoursesSettingsQuery();
|
const { data: allSettings } = useLocalCoursesSettingsQuery();
|
||||||
const [directory, setDirectory] = useState("./");
|
const [directory, setDirectory] = useState("./");
|
||||||
// const directoryIsCourseQuery = useDirectoryIsCourseQuery(
|
const { data: directoryExists, isLoading: directoryExistsLoading } =
|
||||||
// selectedDirectory ?? "./"
|
useDirectoryExistsQuery(directory);
|
||||||
// );
|
|
||||||
|
|
||||||
const populatedCanvasCourseIds = allSettings?.map((s) => s.canvasId) ?? [];
|
const populatedCanvasCourseIds = allSettings?.map((s) => s.canvasId) ?? [];
|
||||||
const availableCourses =
|
const availableCourses =
|
||||||
@@ -204,6 +206,20 @@ function OtherSettings({
|
|||||||
getOptionName={(c) => c?.name ?? ""}
|
getOptionName={(c) => c?.name ?? ""}
|
||||||
center={true}
|
center={true}
|
||||||
/>
|
/>
|
||||||
|
{canvasCoursesLoading && <Spinner />}
|
||||||
|
{!canvasCoursesLoading && availableCourses.length === 0 && (
|
||||||
|
<div className="text-center text-red-300">
|
||||||
|
<div className="flex justify-center ">
|
||||||
|
<div className="text-left">
|
||||||
|
No available courses in this term to add. Either
|
||||||
|
<ol>
|
||||||
|
<li>all courses have already been added, or</li>
|
||||||
|
<li>there are no courses in this term</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<StoragePathSelector
|
<StoragePathSelector
|
||||||
value={directory}
|
value={directory}
|
||||||
@@ -211,6 +227,15 @@ function OtherSettings({
|
|||||||
setLastTypedValue={setSelectedDirectory}
|
setLastTypedValue={setSelectedDirectory}
|
||||||
label={"Storage Folder"}
|
label={"Storage Folder"}
|
||||||
/>
|
/>
|
||||||
|
<div className="text-center mt-2 min-h-6">
|
||||||
|
{directoryExistsLoading && <Spinner />}
|
||||||
|
{!directoryExistsLoading && directoryExists && (
|
||||||
|
<div className="text-red-300">Directory must be a new folder</div>
|
||||||
|
)}
|
||||||
|
{!directoryExistsLoading && directoryExists === false && (
|
||||||
|
<div className="text-green-300">✓ New folder</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<DayOfWeekInput
|
<DayOfWeekInput
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const AddExistingCourseToGlobalSettings = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<button className="" onClick={() => setShowForm((i) => !i)}>
|
<button className="" onClick={() => setShowForm((i) => !i)}>
|
||||||
Add Existing Course
|
{showForm ? "Hide Form" : "Import Existing Course"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export default function AddCourseToGlobalSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<button className="" onClick={() => setShowForm((i) => !i)}>
|
<button className="" onClick={() => setShowForm((i) => !i)}>
|
||||||
Add New Course
|
{showForm ? "Hide Form" : "Add New Course"}
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ const collapseThreshold = 1400;
|
|||||||
|
|
||||||
export default function CollapsableSidebar() {
|
export default function CollapsableSidebar() {
|
||||||
const [windowCollapseRecommended, setWindowCollapseRecommended] =
|
const [windowCollapseRecommended, setWindowCollapseRecommended] =
|
||||||
useState(window.innerWidth <= collapseThreshold);
|
useState(false);
|
||||||
const [userCollapsed, setUserCollapsed] = useState<
|
const [userCollapsed, setUserCollapsed] = useState<
|
||||||
"unset" | "collapsed" | "uncollapsed"
|
"unset" | "collapsed" | "uncollapsed"
|
||||||
>("unset");
|
>("unset");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Initialize on mount
|
||||||
|
setWindowCollapseRecommended(window.innerWidth <= collapseThreshold);
|
||||||
|
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
if (window.innerWidth <= collapseThreshold) {
|
if (window.innerWidth <= collapseThreshold) {
|
||||||
setWindowCollapseRecommended(true);
|
setWindowCollapseRecommended(true);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||||
import { Spinner } from "@/components/Spinner";
|
import { Spinner } from "@/components/Spinner";
|
||||||
import {
|
import {
|
||||||
useCanvasAssignmentsQuery,
|
useCanvasAssignmentsQuery,
|
||||||
@@ -19,7 +20,6 @@ import {
|
|||||||
} from "@/features/canvas/hooks/canvasQuizHooks";
|
} from "@/features/canvas/hooks/canvasQuizHooks";
|
||||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export function CourseNavigation() {
|
export function CourseNavigation() {
|
||||||
const { data: settings } = useLocalCourseSettingsQuery();
|
const { data: settings } = useLocalCourseSettingsQuery();
|
||||||
@@ -33,9 +33,8 @@ export function CourseNavigation() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-1 flex flex-row gap-3">
|
<div className="pb-1 flex flex-row gap-3">
|
||||||
<Link href={"/"} className="btn" shallow={true}>
|
<BreadCrumbs />
|
||||||
Back to Course List
|
|
||||||
</Link>
|
|
||||||
<a
|
<a
|
||||||
href={`https://snow.instructure.com/courses/${settings.canvasId}`}
|
href={`https://snow.instructure.com/courses/${settings.canvasId}`}
|
||||||
className="btn"
|
className="btn"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
|
|||||||
className={
|
className={
|
||||||
"text-2xl transition-all duration-500 " +
|
"text-2xl transition-all duration-500 " +
|
||||||
"hover:text-slate-50 underline hover:scale-105 " +
|
"hover:text-slate-50 underline hover:scale-105 " +
|
||||||
"flex "
|
"flex cursor-pointer"
|
||||||
}
|
}
|
||||||
onClick={() => setIsExpanded((e) => !e)}
|
onClick={() => setIsExpanded((e) => !e)}
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ export default function CourseCalendar() {
|
|||||||
() => getDateFromStringOrThrow(settings.startDate, "course start date"),
|
() => getDateFromStringOrThrow(settings.startDate, "course start date"),
|
||||||
[settings.startDate]
|
[settings.startDate]
|
||||||
);
|
);
|
||||||
const endDateTime = useMemo(
|
const endDateTime = useMemo(() => {
|
||||||
() => getDateFromStringOrThrow(settings.endDate, "course end date"),
|
const date = getDateFromStringOrThrow(settings.endDate, "course end date");
|
||||||
[settings.endDate]
|
date.setDate(date.getDate() + 14); // buffer to make sure calendar shows week of finals and grades due
|
||||||
);
|
return date;
|
||||||
|
}, [settings.endDate]);
|
||||||
const months = useMemo(
|
const months = useMemo(
|
||||||
() => getMonthsBetweenDates(startDateTime, endDateTime),
|
() => getMonthsBetweenDates(startDateTime, endDateTime),
|
||||||
[endDateTime, startDateTime]
|
[endDateTime, startDateTime]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from "@/features/local/utils/timeUtils";
|
} from "@/features/local/utils/timeUtils";
|
||||||
import { useDraggingContext } from "../../context/drag/draggingContext";
|
import { useDraggingContext } from "../../context/drag/draggingContext";
|
||||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||||
import { ItemInDay } from "./ItemInDay";
|
import { ItemInDay } from "./itemInDay/ItemInDay";
|
||||||
import { useTodaysItems } from "./useTodaysItems";
|
import { useTodaysItems } from "./useTodaysItems";
|
||||||
import { DayTitle } from "./DayTitle";
|
import { DayTitle } from "./DayTitle";
|
||||||
import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
|
import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
|
||||||
@@ -13,7 +13,7 @@ import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
|
|||||||
export default function Day({ day, month }: { day: string; month: number }) {
|
export default function Day({ day, month }: { day: string; month: number }) {
|
||||||
const dayAsDate = getDateFromStringOrThrow(
|
const dayAsDate = getDateFromStringOrThrow(
|
||||||
day,
|
day,
|
||||||
"calculating same month in day"
|
"calculating same month in day",
|
||||||
);
|
);
|
||||||
const isToday =
|
const isToday =
|
||||||
getDateOnlyMarkdownString(new Date()) ===
|
getDateOnlyMarkdownString(new Date()) ===
|
||||||
@@ -31,8 +31,8 @@ export default function Day({ day, month }: { day: string; month: number }) {
|
|||||||
(holidaysHappeningToday, holiday) => {
|
(holidaysHappeningToday, holiday) => {
|
||||||
const holidayDates = holiday.days.map((d) =>
|
const holidayDates = holiday.days.map((d) =>
|
||||||
getDateOnlyMarkdownString(
|
getDateOnlyMarkdownString(
|
||||||
getDateFromStringOrThrow(d, "holiday date in day component")
|
getDateFromStringOrThrow(d, "holiday date in day component"),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
const today = getDateOnlyMarkdownString(dayAsDate);
|
const today = getDateOnlyMarkdownString(dayAsDate);
|
||||||
|
|
||||||
@@ -40,16 +40,16 @@ export default function Day({ day, month }: { day: string; month: number }) {
|
|||||||
return [...holidaysHappeningToday, holiday.name];
|
return [...holidaysHappeningToday, holiday.name];
|
||||||
return holidaysHappeningToday;
|
return holidaysHappeningToday;
|
||||||
},
|
},
|
||||||
[] as string[]
|
[] as string[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const semesterStart = getDateFromStringOrThrow(
|
const semesterStart = getDateFromStringOrThrow(
|
||||||
settings.startDate,
|
settings.startDate,
|
||||||
"comparing start date in day"
|
"comparing start date in day",
|
||||||
);
|
);
|
||||||
const semesterEnd = getDateFromStringOrThrow(
|
const semesterEnd = getDateFromStringOrThrow(
|
||||||
settings.endDate,
|
settings.endDate,
|
||||||
"comparing end date in day"
|
"comparing end date in day",
|
||||||
);
|
);
|
||||||
|
|
||||||
const isInSemester = semesterStart < dayAsDate && semesterEnd > dayAsDate;
|
const isInSemester = semesterStart < dayAsDate && semesterEnd > dayAsDate;
|
||||||
@@ -90,7 +90,7 @@ export default function Day({ day, month }: { day: string; month: number }) {
|
|||||||
status={status}
|
status={status}
|
||||||
message={message}
|
message={message}
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
)}
|
)}
|
||||||
{todaysQuizzes.map(({ quiz, moduleName, status, message }) => (
|
{todaysQuizzes.map(({ quiz, moduleName, status, message }) => (
|
||||||
<ItemInDay
|
<ItemInDay
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { getLectureForDay } from "@/features/local/utils/lectureUtils";
|
|||||||
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
|
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
|
||||||
import ClientOnly from "@/components/ClientOnly";
|
import ClientOnly from "@/components/ClientOnly";
|
||||||
import { Tooltip } from "@/components/Tooltip";
|
import { Tooltip } from "@/components/Tooltip";
|
||||||
import { useRef, useState } from "react";
|
import { useTooltip } from "@/components/useTooltip";
|
||||||
|
|
||||||
export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
||||||
const { courseName } = useCourseContext();
|
const { courseName } = useCourseContext();
|
||||||
@@ -17,8 +17,7 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
|||||||
const { setIsDragging } = useDragStyleContext();
|
const { setIsDragging } = useDragStyleContext();
|
||||||
const todaysLecture = getLectureForDay(weeks, dayAsDate);
|
const todaysLecture = getLectureForDay(weeks, dayAsDate);
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
const { visible, targetRef, showTooltip, hideTooltip } = useTooltip();
|
||||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
|
||||||
|
|
||||||
const lectureName = todaysLecture && (todaysLecture.name || "lecture");
|
const lectureName = todaysLecture && (todaysLecture.name || "lecture");
|
||||||
|
|
||||||
@@ -44,9 +43,9 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
|||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ref={linkRef}
|
ref={targetRef}
|
||||||
onMouseEnter={() => setTooltipVisible(true)}
|
onMouseEnter={showTooltip}
|
||||||
onMouseLeave={() => setTooltipVisible(false)}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
{dayAsDate.getDate()} {lectureName}
|
{dayAsDate.getDate()} {lectureName}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -65,15 +64,40 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
targetRef={linkRef}
|
targetRef={targetRef}
|
||||||
visible={tooltipVisible}
|
visible={visible}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<Modal
|
<Modal
|
||||||
|
buttonComponent={({ openModal }) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={22}
|
||||||
|
height={22}
|
||||||
|
className="cursor-pointer hover:scale-125 hover:stroke-slate-400 stroke-slate-500 transition-all m-0.5"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
onClick={openModal}
|
||||||
|
>
|
||||||
|
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||||
|
<g
|
||||||
|
id="SVGRepo_tracerCarrier"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
></g>
|
||||||
|
<g id="SVGRepo_iconCarrier">
|
||||||
|
<path
|
||||||
|
d="M6 12H18M12 6V18"
|
||||||
|
className=" "
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
modalControl={modal}
|
modalControl={modal}
|
||||||
buttonText="+"
|
|
||||||
buttonClass="unstyled hover:font-bold hover:scale-125 px-1 mb-auto mt-0 pt-0"
|
|
||||||
modalWidth="w-135"
|
modalWidth="w-135"
|
||||||
>
|
>
|
||||||
{({ closeModal }) => (
|
{({ closeModal }) => (
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
|
||||||
import { getModuleItemUrl } from "@/services/urlUtils";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ReactNode, useRef, useState } from "react";
|
|
||||||
import { useCourseContext } from "../../context/courseContext";
|
|
||||||
import { DraggableItem } from "../../context/drag/draggingContext";
|
|
||||||
import ClientOnly from "@/components/ClientOnly";
|
|
||||||
import { useDragStyleContext } from "../../context/drag/dragStyleContext";
|
|
||||||
import { Tooltip } from "../../../../../components/Tooltip";
|
|
||||||
|
|
||||||
export function ItemInDay({
|
|
||||||
type,
|
|
||||||
moduleName,
|
|
||||||
status,
|
|
||||||
item,
|
|
||||||
message,
|
|
||||||
}: {
|
|
||||||
type: "assignment" | "page" | "quiz";
|
|
||||||
status: "localOnly" | "incomplete" | "published";
|
|
||||||
moduleName: string;
|
|
||||||
item: IModuleItem;
|
|
||||||
message: ReactNode;
|
|
||||||
}) {
|
|
||||||
const { courseName } = useCourseContext();
|
|
||||||
const { setIsDragging } = useDragStyleContext();
|
|
||||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
|
||||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
|
||||||
return (
|
|
||||||
<div className={" relative group "}>
|
|
||||||
<Link
|
|
||||||
href={getModuleItemUrl(courseName, moduleName, type, item.name)}
|
|
||||||
shallow={true}
|
|
||||||
className={
|
|
||||||
" border rounded-sm sm:px-1 sm:mx-1 break-words mb-1 truncate sm:text-wrap text-nowrap " +
|
|
||||||
" bg-slate-800 " +
|
|
||||||
" block " +
|
|
||||||
(status === "localOnly" && " text-slate-500 border-slate-600 ") +
|
|
||||||
(status === "incomplete" && " border-rose-900 ") +
|
|
||||||
(status === "published" && " border-green-800 ")
|
|
||||||
}
|
|
||||||
role="button"
|
|
||||||
draggable="true"
|
|
||||||
onDragStart={(e) => {
|
|
||||||
const draggableItem: DraggableItem = {
|
|
||||||
type,
|
|
||||||
item,
|
|
||||||
sourceModuleName: moduleName,
|
|
||||||
};
|
|
||||||
e.dataTransfer.setData(
|
|
||||||
"draggableItem",
|
|
||||||
JSON.stringify(draggableItem)
|
|
||||||
);
|
|
||||||
setIsDragging(true);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setTooltipVisible(true)}
|
|
||||||
onMouseLeave={() => setTooltipVisible(false)}
|
|
||||||
ref={linkRef}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
<ClientOnly>
|
|
||||||
<Tooltip
|
|
||||||
message={message}
|
|
||||||
targetRef={linkRef}
|
|
||||||
visible={tooltipVisible && status === "incomplete"}
|
|
||||||
/>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, FC, useState } from "react";
|
||||||
|
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
||||||
|
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
|
||||||
|
import { useCalendarItemsContext } from "../../../context/calendarItemsContext";
|
||||||
|
import {
|
||||||
|
useCreateAssignmentMutation,
|
||||||
|
useDeleteAssignmentMutation,
|
||||||
|
} from "@/features/local/assignments/assignmentHooks";
|
||||||
|
import {
|
||||||
|
useCanvasAssignmentsQuery,
|
||||||
|
useUpdateAssignmentInCanvasMutation,
|
||||||
|
useDeleteAssignmentFromCanvasMutation,
|
||||||
|
useAddAssignmentToCanvasMutation,
|
||||||
|
canvasAssignmentKeys,
|
||||||
|
} from "@/features/canvas/hooks/canvasAssignmentHooks";
|
||||||
|
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||||
|
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
|
||||||
|
import { useCourseContext } from "../../../context/courseContext";
|
||||||
|
import Modal, { ModalControl } from "@/components/Modal";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
function getDuplicateName(name: string, existingNames: string[]): string {
|
||||||
|
const match = name.match(/^(.*)\s+(\d+)$/);
|
||||||
|
const baseName = match ? match[1] : name;
|
||||||
|
const startNum = match ? parseInt(match[2]) + 1 : 2;
|
||||||
|
let num = startNum;
|
||||||
|
while (existingNames.includes(`${baseName} ${num}`)) {
|
||||||
|
num++;
|
||||||
|
}
|
||||||
|
return `${baseName} ${num}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssignmentDayItemContextMenu: FC<{
|
||||||
|
modalControl: ModalControl;
|
||||||
|
item: IModuleItem;
|
||||||
|
moduleName: string;
|
||||||
|
}> = ({ modalControl, item, moduleName }) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
const calendarItems = useCalendarItemsContext();
|
||||||
|
const createAssignmentMutation = useCreateAssignmentMutation();
|
||||||
|
const deleteLocalMutation = useDeleteAssignmentMutation();
|
||||||
|
const updateInCanvasMutation = useUpdateAssignmentInCanvasMutation();
|
||||||
|
const deleteFromCanvasMutation = useDeleteAssignmentFromCanvasMutation();
|
||||||
|
const addToCanvasMutation = useAddAssignmentToCanvasMutation();
|
||||||
|
const { data: canvasAssignments } = useCanvasAssignmentsQuery();
|
||||||
|
const { data: settings } = useLocalCourseSettingsQuery();
|
||||||
|
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
|
|
||||||
|
const assignmentInCanvas = canvasAssignments?.find(
|
||||||
|
(a) => a.name === item.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
const canvasUrl = assignmentInCanvas
|
||||||
|
? `${baseCanvasUrl}/courses/${settings.canvasId}/assignments/${assignmentInCanvas.id}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setConfirmingDelete(false);
|
||||||
|
modalControl.closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [modalControl]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
for (let i = 1; i <= 8; i += 2) {
|
||||||
|
setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: canvasAssignmentKeys.assignments(settings.canvasId),
|
||||||
|
});
|
||||||
|
}, i * 1000);
|
||||||
|
}
|
||||||
|
setConfirmingDelete(false);
|
||||||
|
modalControl.closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
const assignment = item as LocalAssignment;
|
||||||
|
const existingNames = Object.values(calendarItems).flatMap((modules) =>
|
||||||
|
(modules[moduleName]?.assignments ?? []).map((a) => a.name),
|
||||||
|
);
|
||||||
|
const newName = getDuplicateName(item.name, existingNames);
|
||||||
|
createAssignmentMutation.mutate({
|
||||||
|
courseName,
|
||||||
|
moduleName,
|
||||||
|
assignmentName: newName,
|
||||||
|
assignment: { ...assignment, name: newName },
|
||||||
|
});
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteLocalMutation.mutate({
|
||||||
|
courseName,
|
||||||
|
moduleName,
|
||||||
|
assignmentName: item.name,
|
||||||
|
});
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateCanvas = () => {
|
||||||
|
if (assignmentInCanvas) {
|
||||||
|
updateInCanvasMutation.mutate({
|
||||||
|
canvasAssignmentId: assignmentInCanvas.id,
|
||||||
|
assignment: item as LocalAssignment,
|
||||||
|
});
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFromCanvas = () => {
|
||||||
|
if (assignmentInCanvas) {
|
||||||
|
deleteFromCanvasMutation.mutate({
|
||||||
|
canvasAssignmentId: assignmentInCanvas.id,
|
||||||
|
assignmentName: item.name,
|
||||||
|
});
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToCanvas = () => {
|
||||||
|
addToCanvasMutation.mutate({
|
||||||
|
assignment: item as LocalAssignment,
|
||||||
|
moduleName,
|
||||||
|
});
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseButtonClasses = " font-bold text-left py-1";
|
||||||
|
const normalButtonClass =
|
||||||
|
"hover:bg-blue-900 disabled:opacity-50 bg-blue-900/50 text-blue-50 border border-blue-800/70 rounded ";
|
||||||
|
const dangerClasses =
|
||||||
|
"bg-rose-900/30 hover:bg-rose-950 disabled:opacity-50 text-rose-50 border border-rose-900/40 rounded";
|
||||||
|
return (
|
||||||
|
<Modal modalControl={modalControl} backgroundCoverColor="bg-black/30">
|
||||||
|
{() => (
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="text-center p-1 text-slate-200 ">{item.name}</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{confirmingDelete ? (
|
||||||
|
<>
|
||||||
|
<div className={``}>Delete from disk?</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className={`unstyled ${baseButtonClasses} ${normalButtonClass}`}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className={`unstyled ${baseButtonClasses} ${dangerClasses}`}
|
||||||
|
>
|
||||||
|
Yes, delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{canvasUrl && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={canvasUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={` block px-3 ${baseButtonClasses} ${normalButtonClass}`}
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
View in Canvas
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateCanvas}
|
||||||
|
disabled={updateInCanvasMutation.isPending}
|
||||||
|
className={`unstyled ${baseButtonClasses} ${normalButtonClass}`}
|
||||||
|
>
|
||||||
|
Update in Canvas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteFromCanvas}
|
||||||
|
disabled={deleteFromCanvasMutation.isPending}
|
||||||
|
className={`unstyled ${baseButtonClasses} ${dangerClasses}`}
|
||||||
|
>
|
||||||
|
Delete from Canvas
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!canvasUrl && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleAddToCanvas}
|
||||||
|
disabled={addToCanvasMutation.isPending}
|
||||||
|
className={`unstyled ${baseButtonClasses} ${normalButtonClass}`}
|
||||||
|
>
|
||||||
|
Add to Canvas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmingDelete(true)}
|
||||||
|
className={`unstyled ${baseButtonClasses} ${dangerClasses}`}
|
||||||
|
>
|
||||||
|
Delete from Disk
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
className={`unstyled ${baseButtonClasses} ${normalButtonClass}`}
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
import MarkdownDisplay from "@/components/MarkdownDisplay";
|
||||||
|
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
export const GetPreviewContent: FC<{
|
||||||
|
type: "assignment" | "page" | "quiz";
|
||||||
|
item: IModuleItem;
|
||||||
|
}> = ({ type, item }) => {
|
||||||
|
if (type === "assignment" && "description" in item) {
|
||||||
|
const assignment = item as {
|
||||||
|
description: string;
|
||||||
|
githubClassroomAssignmentShareLink?: string;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<MarkdownDisplay
|
||||||
|
markdown={assignment.description}
|
||||||
|
replaceText={[
|
||||||
|
{
|
||||||
|
source: "insert_github_classroom_url",
|
||||||
|
destination: assignment.githubClassroomAssignmentShareLink || "",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (type === "page" && "text" in item) {
|
||||||
|
return <MarkdownDisplay markdown={item.text as string} />;
|
||||||
|
} else if (type === "quiz" && "questions" in item) {
|
||||||
|
const quiz = item as { questions: { text: string }[] };
|
||||||
|
return quiz.questions.map((q, i: number) => (
|
||||||
|
<div key={i} className="">
|
||||||
|
<MarkdownDisplay markdown={q.text as string} />
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
||||||
|
import { getModuleItemUrl } from "@/services/urlUtils";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { FC, ReactNode } from "react";
|
||||||
|
import { useCourseContext } from "../../../context/courseContext";
|
||||||
|
import { useTooltip } from "@/components/useTooltip";
|
||||||
|
import { DraggableItem } from "../../../context/drag/draggingContext";
|
||||||
|
import ClientOnly from "@/components/ClientOnly";
|
||||||
|
import { useDragStyleContext } from "../../../context/drag/dragStyleContext";
|
||||||
|
import { Tooltip } from "../../../../../../components/Tooltip";
|
||||||
|
import { AssignmentDayItemContextMenu } from "./DayItemContextMenu";
|
||||||
|
import { GetPreviewContent } from "./GetPreviewContent";
|
||||||
|
import { useModal } from "@/components/Modal";
|
||||||
|
|
||||||
|
export const ItemInDay: FC<{
|
||||||
|
type: "assignment" | "page" | "quiz";
|
||||||
|
status: "localOnly" | "incomplete" | "published";
|
||||||
|
moduleName: string;
|
||||||
|
item: IModuleItem;
|
||||||
|
message: ReactNode;
|
||||||
|
}> = ({ type, moduleName, status, item, message }) => {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
const { setIsDragging } = useDragStyleContext();
|
||||||
|
const { visible, targetRef, showTooltip, hideTooltip } = useTooltip(500);
|
||||||
|
const modalControl = useModal();
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
|
if (type !== "assignment") return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
modalControl.openModal({ x: e.clientX, y: e.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={" relative group "}>
|
||||||
|
<Link
|
||||||
|
href={getModuleItemUrl(courseName, moduleName, type, item.name)}
|
||||||
|
shallow={true}
|
||||||
|
className={
|
||||||
|
" border rounded-sm sm:px-1 sm:mx-1 break-words mb-1 truncate sm:text-wrap text-nowrap " +
|
||||||
|
" bg-slate-800 " +
|
||||||
|
" block " +
|
||||||
|
(status === "localOnly" && " text-slate-500 border-slate-600 ") +
|
||||||
|
(status === "incomplete" && " border-rose-900 ") +
|
||||||
|
(status === "published" && " border-green-800 ")
|
||||||
|
}
|
||||||
|
role="button"
|
||||||
|
draggable="true"
|
||||||
|
onDragStart={(e) => {
|
||||||
|
const draggableItem: DraggableItem = {
|
||||||
|
type,
|
||||||
|
item,
|
||||||
|
sourceModuleName: moduleName,
|
||||||
|
};
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
"draggableItem",
|
||||||
|
JSON.stringify(draggableItem),
|
||||||
|
);
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onMouseEnter={showTooltip}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
ref={targetRef}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
<ClientOnly>
|
||||||
|
{status === "published" ? (
|
||||||
|
<Tooltip
|
||||||
|
message={
|
||||||
|
<div className="max-w-md">
|
||||||
|
<GetPreviewContent type={type} item={item} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
targetRef={targetRef}
|
||||||
|
visible={visible}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tooltip message={message} targetRef={targetRef} visible={visible} />
|
||||||
|
)}
|
||||||
|
{type === "assignment" && (
|
||||||
|
<AssignmentDayItemContextMenu
|
||||||
|
modalControl={modalControl}
|
||||||
|
item={item}
|
||||||
|
moduleName={moduleName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function ItemNavigationButtons({
|
||||||
|
previousUrl,
|
||||||
|
nextUrl,
|
||||||
|
}: {
|
||||||
|
previousUrl: string | null;
|
||||||
|
nextUrl: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{previousUrl && (
|
||||||
|
<Link className="btn" href={previousUrl} shallow={true}>
|
||||||
|
Previous
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{nextUrl && (
|
||||||
|
<Link className="btn" href={nextUrl} shallow={true}>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/app/course/[courseName]/hooks/navigationLogic.test.ts
Normal file
70
src/app/course/[courseName]/hooks/navigationLogic.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
getOrderedItems,
|
||||||
|
getOrderedLectures,
|
||||||
|
getNavigationLinks,
|
||||||
|
OrderedCourseItem,
|
||||||
|
} from "./navigationLogic";
|
||||||
|
import { CalendarItemsInterface } from "../context/calendarItemsContext";
|
||||||
|
|
||||||
|
describe("navigationLogic", () => {
|
||||||
|
const courseName = "testCourse";
|
||||||
|
|
||||||
|
it("getOrderedItems should order items by date, then alphabetically by name", () => {
|
||||||
|
const createMock = (
|
||||||
|
date: string,
|
||||||
|
name: string,
|
||||||
|
key: "assignments" | "quizzes" | "pages"
|
||||||
|
) =>
|
||||||
|
({
|
||||||
|
[date]: { "Module 1": { [key]: [{ name }] } },
|
||||||
|
} as unknown as CalendarItemsInterface);
|
||||||
|
|
||||||
|
const orderedItems = getOrderedItems(
|
||||||
|
courseName,
|
||||||
|
createMock("2023-01-01", "Z Assignment", "assignments"),
|
||||||
|
createMock("2023-01-01", "A Quiz", "quizzes"),
|
||||||
|
createMock("2023-01-02", "B Assignment", "assignments"),
|
||||||
|
createMock("2023-01-02", "A Page", "pages")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orderedItems.map((i) => `${i.date} ${i.name}`)).toEqual([
|
||||||
|
"2023-01-01 A Quiz",
|
||||||
|
"2023-01-01 Z Assignment",
|
||||||
|
"2023-01-02 A Page",
|
||||||
|
"2023-01-02 B Assignment",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getNavigationLinks should handle wrapping and normal navigation", () => {
|
||||||
|
const items: OrderedCourseItem[] = [
|
||||||
|
{ type: "assignment", name: "1", moduleName: "M", date: "D", url: "u1" },
|
||||||
|
{ type: "quiz", name: "2", moduleName: "M", date: "D", url: "u2" },
|
||||||
|
{ type: "page", name: "3", moduleName: "M", date: "D", url: "u3" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Forward wrap (last -> first)
|
||||||
|
expect(getNavigationLinks(items, "page", "3", "M").nextUrl).toBe("u1");
|
||||||
|
|
||||||
|
// Backward wrap (first -> last)
|
||||||
|
expect(getNavigationLinks(items, "assignment", "1", "M").previousUrl).toBe(
|
||||||
|
"u3"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Normal navigation (middle)
|
||||||
|
const middle = getNavigationLinks(items, "quiz", "2", "M");
|
||||||
|
expect(middle.previousUrl).toBe("u1");
|
||||||
|
expect(middle.nextUrl).toBe("u3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOrderedLectures should flatten weeks and generate correct URLs", () => {
|
||||||
|
const weeks = [
|
||||||
|
{ lectures: [{ date: "01/01/2023" }] },
|
||||||
|
{ lectures: [{ date: "01/02/2023" }, { date: "01/03/2023" }] },
|
||||||
|
];
|
||||||
|
const lectures = getOrderedLectures(weeks, courseName);
|
||||||
|
expect(lectures).toHaveLength(3);
|
||||||
|
expect(lectures[0].url).toContain(encodeURIComponent("01/01/2023"));
|
||||||
|
expect(lectures[0].type).toBe("lecture");
|
||||||
|
});
|
||||||
|
});
|
||||||
83
src/app/course/[courseName]/hooks/navigationLogic.ts
Normal file
83
src/app/course/[courseName]/hooks/navigationLogic.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { CalendarItemsInterface } from "../context/calendarItemsContext";
|
||||||
|
import { getLectureUrl, getModuleItemUrl } from "@/services/urlUtils";
|
||||||
|
|
||||||
|
export type CourseItemType = "assignment" | "quiz" | "page" | "lecture";
|
||||||
|
|
||||||
|
export interface OrderedCourseItem {
|
||||||
|
type: CourseItemType;
|
||||||
|
name: string;
|
||||||
|
moduleName?: string;
|
||||||
|
date: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrderedItems(
|
||||||
|
courseName: string,
|
||||||
|
...calendars: CalendarItemsInterface[]
|
||||||
|
): OrderedCourseItem[] {
|
||||||
|
const itemTypes = [
|
||||||
|
{ key: "assignments" as const, type: "assignment" as const },
|
||||||
|
{ key: "quizzes" as const, type: "quiz" as const },
|
||||||
|
{ key: "pages" as const, type: "page" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
return calendars
|
||||||
|
.flatMap((calendar) =>
|
||||||
|
Object.entries(calendar).flatMap(([date, modules]) =>
|
||||||
|
Object.entries(modules).flatMap(([moduleName, moduleData]) =>
|
||||||
|
itemTypes.flatMap(({ key, type }) =>
|
||||||
|
(moduleData[key] || []).map((item) => ({
|
||||||
|
type,
|
||||||
|
name: item.name,
|
||||||
|
moduleName,
|
||||||
|
date,
|
||||||
|
url: getModuleItemUrl(courseName, moduleName, type, item.name),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateCompare = a.date.localeCompare(b.date);
|
||||||
|
if (dateCompare !== 0) return dateCompare;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrderedLectures(
|
||||||
|
weeks: { lectures: { date: string }[] }[],
|
||||||
|
courseName: string
|
||||||
|
): OrderedCourseItem[] {
|
||||||
|
return weeks
|
||||||
|
.flatMap((week) => week.lectures)
|
||||||
|
.map((lecture) => ({
|
||||||
|
type: "lecture",
|
||||||
|
name: lecture.date,
|
||||||
|
date: lecture.date,
|
||||||
|
url: getLectureUrl(courseName, lecture.date),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNavigationLinks(
|
||||||
|
list: OrderedCourseItem[],
|
||||||
|
type: CourseItemType,
|
||||||
|
name: string,
|
||||||
|
moduleName?: string
|
||||||
|
) {
|
||||||
|
const index = list.findIndex((item) => {
|
||||||
|
if (type === "lecture") return item.date === name;
|
||||||
|
return (
|
||||||
|
item.name === name && item.type === type && item.moduleName === moduleName
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (index === -1) return { previousUrl: null, nextUrl: null };
|
||||||
|
|
||||||
|
const previousIndex = (index - 1 + list.length) % list.length;
|
||||||
|
const nextIndex = (index + 1) % list.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
previousUrl: list[previousIndex].url,
|
||||||
|
nextUrl: list[nextIndex].url,
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/app/course/[courseName]/hooks/useItemNavigation.ts
Normal file
14
src/app/course/[courseName]/hooks/useItemNavigation.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useOrderedCourseItems } from "./useOrderedCourseItems";
|
||||||
|
import { getNavigationLinks, CourseItemType } from "./navigationLogic";
|
||||||
|
|
||||||
|
export function useItemNavigation(
|
||||||
|
type: CourseItemType,
|
||||||
|
name: string,
|
||||||
|
moduleName?: string
|
||||||
|
) {
|
||||||
|
const { orderedItems, orderedLectures } = useOrderedCourseItems();
|
||||||
|
|
||||||
|
const list = type === "lecture" ? orderedLectures : orderedItems;
|
||||||
|
|
||||||
|
return getNavigationLinks(list, type, name, moduleName);
|
||||||
|
}
|
||||||
24
src/app/course/[courseName]/hooks/useOrderedCourseItems.ts
Normal file
24
src/app/course/[courseName]/hooks/useOrderedCourseItems.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
useCourseAssignmentsByModuleByDateQuery,
|
||||||
|
useCoursePagesByModuleByDateQuery,
|
||||||
|
useCourseQuizzesByModuleByDateQuery,
|
||||||
|
} from "@/features/local/modules/localCourseModuleHooks";
|
||||||
|
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
|
||||||
|
import { useCourseContext } from "../context/courseContext";
|
||||||
|
import { getOrderedItems, getOrderedLectures } from "./navigationLogic";
|
||||||
|
|
||||||
|
export function useOrderedCourseItems() {
|
||||||
|
const { courseName } = useCourseContext();
|
||||||
|
const { data: weeks } = useLecturesSuspenseQuery();
|
||||||
|
|
||||||
|
const orderedItems = getOrderedItems(
|
||||||
|
courseName,
|
||||||
|
useCourseAssignmentsByModuleByDateQuery(),
|
||||||
|
useCourseQuizzesByModuleByDateQuery(),
|
||||||
|
useCoursePagesByModuleByDateQuery()
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderedLectures = getOrderedLectures(weeks, courseName);
|
||||||
|
|
||||||
|
return { orderedItems, orderedLectures };
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||||
import { getDateFromString } from "@/features/local/utils/timeUtils";
|
import { getDateFromString } from "@/features/local/utils/timeUtils";
|
||||||
import { getLectureWeekName } from "@/features/local/lectures/lectureUtils";
|
import { getLectureWeekName } from "@/features/local/lectures/lectureUtils";
|
||||||
import { getCourseUrl, getLecturePreviewUrl } from "@/services/urlUtils";
|
import { getLecturePreviewUrl } from "@/services/urlUtils";
|
||||||
import { useCourseContext } from "../../context/courseContext";
|
import { useCourseContext } from "../../context/courseContext";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
|
import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
|
||||||
|
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||||
|
|
||||||
export default function EditLectureTitle({
|
export default function EditLectureTitle({
|
||||||
lectureDay,
|
lectureDay,
|
||||||
@@ -17,16 +18,7 @@ export default function EditLectureTitle({
|
|||||||
const lectureWeekName = getLectureWeekName(settings.startDate, lectureDay);
|
const lectureWeekName = getLectureWeekName(settings.startDate, lectureDay);
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between sm:flex-row flex-col">
|
<div className="flex justify-between sm:flex-row flex-col">
|
||||||
<div className="my-auto">
|
<BreadCrumbs />
|
||||||
<Link
|
|
||||||
className="btn hidden sm:inline"
|
|
||||||
href={getCourseUrl(courseName)}
|
|
||||||
shallow={true}
|
|
||||||
prefetch={true}
|
|
||||||
>
|
|
||||||
{courseName}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center ">
|
<div className="flex justify-center ">
|
||||||
<h3 className="mt-auto me-3 text-slate-500 ">Lecture</h3>
|
<h3 className="mt-auto me-3 text-slate-500 ">Lecture</h3>
|
||||||
<h1 className="">
|
<h1 className="">
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useCourseContext } from "../../context/courseContext";
|
|||||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||||
import { useDeleteLectureMutation } from "@/features/local/lectures/lectureHooks";
|
import { useDeleteLectureMutation } from "@/features/local/lectures/lectureHooks";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useItemNavigation } from "../../hooks/useItemNavigation";
|
||||||
|
import ItemNavigationButtons from "../../components/ItemNavigationButtons";
|
||||||
|
|
||||||
export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
||||||
const { courseName } = useCourseContext();
|
const { courseName } = useCourseContext();
|
||||||
@@ -17,6 +19,7 @@ export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
const deleteLecture = useDeleteLectureMutation();
|
const deleteLecture = useDeleteLectureMutation();
|
||||||
|
const { previousUrl, nextUrl } = useItemNavigation("lecture", lectureDay);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-row justify-end gap-3">
|
<div className="p-5 flex flex-row justify-end gap-3">
|
||||||
@@ -61,6 +64,7 @@ export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
|||||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||||
Go Back
|
Go Back
|
||||||
</Link>
|
</Link>
|
||||||
|
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||||
{isLoading && <Spinner />}
|
{isLoading && <Spinner />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function LecturePreview({ lecture }: { lecture: Lecture }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<MarkdownDisplay markdown={lecture.content} />
|
<MarkdownDisplay markdown={lecture.content} convertImages={false} />
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LecturePreview from "../LecturePreview";
|
import LecturePreview from "../LecturePreview";
|
||||||
import { getCourseUrl, getLectureUrl } from "@/services/urlUtils";
|
|
||||||
import { useCourseContext } from "../../../context/courseContext";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
|
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
|
||||||
|
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||||
|
|
||||||
export default function LecturePreviewPage({
|
export default function LecturePreviewPage({
|
||||||
lectureDay,
|
lectureDay,
|
||||||
}: {
|
}: {
|
||||||
lectureDay: string;
|
lectureDay: string;
|
||||||
}) {
|
}) {
|
||||||
const { courseName } = useCourseContext();
|
|
||||||
const { data: weeks } = useLecturesSuspenseQuery();
|
const { data: weeks } = useLecturesSuspenseQuery();
|
||||||
const lecture = weeks
|
const lecture = weeks
|
||||||
.flatMap(({ lectures }) => lectures.map((lecture) => lecture))
|
.flatMap(({ lectures }) => lectures.map((lecture) => lecture))
|
||||||
@@ -23,20 +20,7 @@ export default function LecturePreviewPage({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full xl:flex-row flex-col ">
|
<div className="flex h-full xl:flex-row flex-col ">
|
||||||
<div className="flex-shrink flex-1 pb-1 ms-3 xl:ms-0 flex flex-row flex-wrap gap-3 content-start ">
|
<div className="flex-shrink flex-1 pb-1 ms-3 xl:ms-0 flex flex-row flex-wrap gap-3 content-start ">
|
||||||
<div className="">
|
<BreadCrumbs />
|
||||||
<Link
|
|
||||||
className="btn"
|
|
||||||
href={getLectureUrl(courseName, lectureDay)}
|
|
||||||
shallow={true}
|
|
||||||
>
|
|
||||||
Edit Lecture
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="">
|
|
||||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
|
||||||
Course Calendar
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center min-h-0 px-2">
|
<div className="flex justify-center min-h-0 px-2">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function ExpandableModule({
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<ExpandIcon
|
<ExpandIcon
|
||||||
style={{
|
style={{
|
||||||
...(isExpanded ? { rotate: "-90deg" } : {}),
|
...(isExpanded ? { rotate: "90deg" } : {rotate: "180deg"}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
getDateFromStringOrThrow,
|
getDateFromStringOrThrow,
|
||||||
} from "@/features/local/utils/timeUtils";
|
} from "@/features/local/utils/timeUtils";
|
||||||
import { useCreateAssignmentMutation } from "@/features/local/assignments/assignmentHooks";
|
import { useCreateAssignmentMutation } from "@/features/local/assignments/assignmentHooks";
|
||||||
|
import { validateFileName } from "@/services/fileNameValidation";
|
||||||
|
|
||||||
export default function NewItemForm({
|
export default function NewItemForm({
|
||||||
moduleName: defaultModuleName,
|
moduleName: defaultModuleName,
|
||||||
@@ -39,6 +40,13 @@ export default function NewItemForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [nameError, setNameError] = useState("");
|
||||||
|
|
||||||
|
const handleNameChange = (newName: string) => {
|
||||||
|
setName(newName);
|
||||||
|
const error = validateFileName(newName);
|
||||||
|
setNameError(error);
|
||||||
|
};
|
||||||
|
|
||||||
const defaultDate = getDateFromString(
|
const defaultDate = getDateFromString(
|
||||||
creationDate ? creationDate : dateToMarkdownString(new Date())
|
creationDate ? creationDate : dateToMarkdownString(new Date())
|
||||||
@@ -65,6 +73,12 @@ export default function NewItemForm({
|
|||||||
className="flex flex-col gap-3"
|
className="flex flex-col gap-3"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validate name before submission
|
||||||
|
if (nameError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const dueAt =
|
const dueAt =
|
||||||
dueDate === ""
|
dueDate === ""
|
||||||
? dueDate
|
? dueDate
|
||||||
@@ -160,7 +174,16 @@ export default function NewItemForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<TextInput label={type + " Name"} value={name} setValue={setName} />
|
<TextInput
|
||||||
|
label={type + " Name"}
|
||||||
|
value={name}
|
||||||
|
setValue={handleNameChange}
|
||||||
|
/>
|
||||||
|
{nameError && (
|
||||||
|
<div className="text-red-300 bg-red-950/50 border p-1 rounded border-red-900/50 text-sm mt-1">
|
||||||
|
{nameError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{type !== "Page" && (
|
{type !== "Page" && (
|
||||||
@@ -178,7 +201,9 @@ export default function NewItemForm({
|
|||||||
No assignment groups created, create them in the course settings page
|
No assignment groups created, create them in the course settings page
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button type="submit">Create</button>
|
<button disabled={!!nameError} type="submit">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
{isPending && <Spinner />}
|
{isPending && <Spinner />}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useAddAssignmentToCanvasMutation,
|
useAddAssignmentToCanvasMutation,
|
||||||
useDeleteAssignmentFromCanvasMutation,
|
useDeleteAssignmentFromCanvasMutation,
|
||||||
useUpdateAssignmentInCanvasMutation,
|
useUpdateAssignmentInCanvasMutation,
|
||||||
|
canvasAssignmentKeys,
|
||||||
} from "@/features/canvas/hooks/canvasAssignmentHooks";
|
} from "@/features/canvas/hooks/canvasAssignmentHooks";
|
||||||
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
|
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,9 @@ import { getCourseUrl } from "@/services/urlUtils";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||||
|
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export function AssignmentFooterButtons({
|
export function AssignmentFooterButtons({
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -32,9 +36,10 @@ export function AssignmentFooterButtons({
|
|||||||
const { data: settings } = useLocalCourseSettingsQuery();
|
const { data: settings } = useLocalCourseSettingsQuery();
|
||||||
const { data: canvasAssignments, isFetching: canvasIsFetching } =
|
const { data: canvasAssignments, isFetching: canvasIsFetching } =
|
||||||
useCanvasAssignmentsQuery();
|
useCanvasAssignmentsQuery();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { data: assignment, isFetching } = useAssignmentQuery(
|
const { data: assignment, isFetching } = useAssignmentQuery(
|
||||||
moduleName,
|
moduleName,
|
||||||
assignmentName
|
assignmentName,
|
||||||
);
|
);
|
||||||
const addToCanvas = useAddAssignmentToCanvasMutation();
|
const addToCanvas = useAddAssignmentToCanvasMutation();
|
||||||
const deleteFromCanvas = useDeleteAssignmentFromCanvasMutation();
|
const deleteFromCanvas = useDeleteAssignmentFromCanvasMutation();
|
||||||
@@ -42,9 +47,14 @@ export function AssignmentFooterButtons({
|
|||||||
const deleteLocal = useDeleteAssignmentMutation();
|
const deleteLocal = useDeleteAssignmentMutation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
|
const { previousUrl, nextUrl } = useItemNavigation(
|
||||||
|
"assignment",
|
||||||
|
assignmentName,
|
||||||
|
moduleName,
|
||||||
|
);
|
||||||
|
|
||||||
const assignmentInCanvas = canvasAssignments?.find(
|
const assignmentInCanvas = canvasAssignments?.find(
|
||||||
(a) => a.name === assignmentName
|
(a) => a.name === assignmentName,
|
||||||
);
|
);
|
||||||
|
|
||||||
const anythingIsLoading =
|
const anythingIsLoading =
|
||||||
@@ -77,6 +87,17 @@ export function AssignmentFooterButtons({
|
|||||||
className="btn"
|
className="btn"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={`${baseCanvasUrl}/courses/${settings.canvasId}/assignments/${assignmentInCanvas.id}`}
|
href={`${baseCanvasUrl}/courses/${settings.canvasId}/assignments/${assignmentInCanvas.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
for (let i = 1; i <= 8; i += 2) {
|
||||||
|
setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: canvasAssignmentKeys.assignments(
|
||||||
|
settings.canvasId,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, i * 1000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
View in Canvas
|
View in Canvas
|
||||||
</a>
|
</a>
|
||||||
@@ -155,6 +176,7 @@ export function AssignmentFooterButtons({
|
|||||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||||
Go Back
|
Go Back
|
||||||
</Link>
|
</Link>
|
||||||
|
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,15 +75,14 @@ export default function AssignmentPreview({
|
|||||||
{extraPoints !== 0 && (
|
{extraPoints !== 0 && (
|
||||||
<h5 className="text-center">{extraPoints} Extra Credit Points</h5>
|
<h5 className="text-center">{extraPoints} Extra Credit Points</h5>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-3">
|
<div className="grid grid-cols-[auto_auto_1fr]">
|
||||||
{assignment.rubric.map((rubricItem, i) => (
|
{assignment.rubric.map((rubricItem, i) => (
|
||||||
<Fragment key={rubricItem.label + i}>
|
<Fragment key={rubricItem.label + i}>
|
||||||
<div className="text-end pe-3 col-span-2">{rubricItem.label}</div>
|
<div className="text-end pe-1">
|
||||||
<div>
|
{rubricItemIsExtraCredit(rubricItem) ? "Extra Credit" : ""}
|
||||||
{rubricItem.points}
|
|
||||||
|
|
||||||
{rubricItemIsExtraCredit(rubricItem) ? " - Extra Credit" : ""}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-end pe-3">{rubricItem.points}</div>
|
||||||
|
<div>{rubricItem.label}</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||||
import { UpdateAssignmentName } from "./UpdateAssignmentName";
|
import { UpdateAssignmentName } from "./UpdateAssignmentName";
|
||||||
import { getCourseUrl } from "@/services/urlUtils";
|
import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function EditAssignmentHeader({
|
export default function EditAssignmentHeader({
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -10,22 +9,21 @@ export default function EditAssignmentHeader({
|
|||||||
assignmentName: string;
|
assignmentName: string;
|
||||||
moduleName: string;
|
moduleName: string;
|
||||||
}) {
|
}) {
|
||||||
const { courseName } = useCourseContext();
|
|
||||||
return (
|
return (
|
||||||
<div className="py-1 flex flex-row justify-start gap-3">
|
<div className="py-1 flex flex-row justify-between">
|
||||||
<Link
|
<div className="flex flex-row">
|
||||||
className="btn"
|
<BreadCrumbs />
|
||||||
href={getCourseUrl(courseName)}
|
<span className="text-slate-500 cursor-default select-none my-auto">
|
||||||
shallow={true}
|
<RightSingleChevron />
|
||||||
prefetch={true}
|
</span>
|
||||||
>
|
<div className="my-auto px-3">{assignmentName}</div>
|
||||||
{courseName}
|
</div>
|
||||||
</Link>
|
<div className="px-1">
|
||||||
<UpdateAssignmentName
|
<UpdateAssignmentName
|
||||||
assignmentName={assignmentName}
|
assignmentName={assignmentName}
|
||||||
moduleName={moduleName}
|
moduleName={moduleName}
|
||||||
/>
|
/>
|
||||||
<div className="my-auto">{assignmentName}</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,22 +40,37 @@ export function UpdateAssignmentName({
|
|||||||
if (name === assignmentName) closeModal();
|
if (name === assignmentName) closeModal();
|
||||||
|
|
||||||
setIsLoading(true); // page refresh resets flag
|
setIsLoading(true); // page refresh resets flag
|
||||||
await updateAssignment.mutateAsync({
|
try {
|
||||||
assignment: assignment,
|
await updateAssignment.mutateAsync({
|
||||||
moduleName,
|
assignment: assignment,
|
||||||
assignmentName: name,
|
moduleName,
|
||||||
previousModuleName: moduleName,
|
assignmentName: name,
|
||||||
previousAssignmentName: assignmentName,
|
previousModuleName: moduleName,
|
||||||
courseName,
|
previousAssignmentName: assignmentName,
|
||||||
});
|
courseName,
|
||||||
|
});
|
||||||
|
|
||||||
// update url (will trigger reload...)
|
// update url (will trigger reload...)
|
||||||
router.replace(
|
router.replace(
|
||||||
getModuleItemUrl(courseName, moduleName, "assignment", name),
|
getModuleItemUrl(courseName, moduleName, "assignment", name),
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
text-yellow-300
|
||||||
|
bg-yellow-950/30
|
||||||
|
border-2
|
||||||
|
rounded-lg
|
||||||
|
border-yellow-800
|
||||||
|
p-1 text-sm mb-2"
|
||||||
|
>
|
||||||
|
Warning: does not rename in Canvas
|
||||||
|
</div>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={name}
|
value={name}
|
||||||
setValue={setName}
|
setValue={setName}
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ flowchart TD
|
|||||||
C -->|Three| F[fa:fa-car Car]
|
C -->|Three| F[fa:fa-car Car]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
## LaTeX Math
|
||||||
|
|
||||||
|
**Inline math:** The Fibonacci sequence is defined as: \$F(n) = F(n-1) + F(n-2)\$ where \$F(0) = 0\$ and \$F(1) = 1\$.
|
||||||
|
|
||||||
|
**Block math:**
|
||||||
|
\$\$F(n) = F(n-1) + F(n-2)\$\$
|
||||||
|
|
||||||
|
**Complex equations:**
|
||||||
|
\$\$
|
||||||
|
F(n) = \\begin{cases}
|
||||||
|
0 & \\text{if } n = 0 \\\\
|
||||||
|
1 & \\text{if } n = 1 \\\\
|
||||||
|
F(n-1) + F(n-2) & \\text{if } n > 1
|
||||||
|
\\end{cases}
|
||||||
|
\$\$
|
||||||
|
|
||||||
## github classroom links will be replaced by the GithubClassroomAssignmentShareLink setting
|
## github classroom links will be replaced by the GithubClassroomAssignmentShareLink setting
|
||||||
|
|
||||||
[Github Classroom](insert_github_classroom_url)
|
[Github Classroom](insert_github_classroom_url)
|
||||||
|
|||||||
@@ -102,13 +102,13 @@ export default function EditPage({
|
|||||||
<EditLayout
|
<EditLayout
|
||||||
Header={<EditPageHeader pageName={pageName} moduleName={moduleName} />}
|
Header={<EditPageHeader pageName={pageName} moduleName={moduleName} />}
|
||||||
Body={
|
Body={
|
||||||
<div className="columns-2 min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
|
||||||
<div className="flex-1 h-full">
|
<div className="flex-1 h-full min-w-0 overflow-hidden">
|
||||||
<MonacoEditor key={monacoKey} value={text} onChange={textUpdate} />
|
<MonacoEditor key={monacoKey} value={text} onChange={textUpdate} />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full">
|
<div className="flex-1 h-full min-w-0 flex flex-col overflow-hidden">
|
||||||
<div className="text-red-300">{error && error}</div>
|
<div className="text-red-300">{error && error}</div>
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<br />
|
<br />
|
||||||
<PagePreview page={page} />
|
<PagePreview page={page} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { getCourseUrl } from "@/services/urlUtils";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||||
|
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||||
|
|
||||||
export default function EditPageButtons({
|
export default function EditPageButtons({
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -36,6 +38,11 @@ export default function EditPageButtons({
|
|||||||
const deletePageLocal = useDeletePageMutation();
|
const deletePageLocal = useDeletePageMutation();
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { previousUrl, nextUrl } = useItemNavigation(
|
||||||
|
"page",
|
||||||
|
pageName,
|
||||||
|
moduleName
|
||||||
|
);
|
||||||
|
|
||||||
const pageInCanvas = canvasPages?.find((p) => p.title === pageName);
|
const pageInCanvas = canvasPages?.find((p) => p.title === pageName);
|
||||||
|
|
||||||
@@ -125,6 +132,7 @@ export default function EditPageButtons({
|
|||||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||||
Go Back
|
Go Back
|
||||||
</Link>
|
</Link>
|
||||||
|
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
|
||||||
import { getCourseUrl } from "@/services/urlUtils";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { UpdatePageName } from "./UpdatePageName";
|
import { UpdatePageName } from "./UpdatePageName";
|
||||||
|
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||||
|
import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
|
||||||
|
|
||||||
export default function EditPageHeader({
|
export default function EditPageHeader({
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -10,19 +9,18 @@ export default function EditPageHeader({
|
|||||||
pageName: string;
|
pageName: string;
|
||||||
moduleName: string;
|
moduleName: string;
|
||||||
}) {
|
}) {
|
||||||
const { courseName } = useCourseContext();
|
|
||||||
return (
|
return (
|
||||||
<div className="py-1 flex flex-row justify-start gap-3">
|
<div className="py-1 flex flex-row justify-between">
|
||||||
<Link
|
<div className="flex flex-row">
|
||||||
className="btn"
|
<BreadCrumbs />
|
||||||
href={getCourseUrl(courseName)}
|
<span className="text-slate-500 cursor-default select-none my-auto">
|
||||||
shallow={true}
|
<RightSingleChevron />
|
||||||
prefetch={true}
|
</span>
|
||||||
>
|
<div className="my-auto px-3">{pageName}</div>
|
||||||
{courseName}
|
</div>
|
||||||
</Link>
|
<div className="px-1">
|
||||||
<UpdatePageName pageName={pageName} moduleName={moduleName} />
|
<UpdatePageName pageName={pageName} moduleName={moduleName} />
|
||||||
<div className="my-auto">{pageName}</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ export function UpdatePageName({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
text-yellow-300
|
||||||
|
bg-yellow-950/30
|
||||||
|
border-2
|
||||||
|
rounded-lg
|
||||||
|
border-yellow-800
|
||||||
|
p-1 text-sm mb-2"
|
||||||
|
>
|
||||||
|
Warning: does not rename in Canvas
|
||||||
|
</div>
|
||||||
<TextInput value={name} setValue={setName} label={"Rename Page"} />
|
<TextInput value={name} setValue={setName} label={"Rename Page"} />
|
||||||
<button className="w-full my-3">Save New Name</button>
|
<button className="w-full my-3">Save New Name</button>
|
||||||
{isLoading && <Spinner />}
|
{isLoading && <Spinner />}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import { useAuthoritativeUpdates } from "../../../../utils/useAuthoritativeUpdat
|
|||||||
import { extractLabelValue } from "@/features/local/assignments/models/utils/markdownUtils";
|
import { extractLabelValue } from "@/features/local/assignments/models/utils/markdownUtils";
|
||||||
import EditQuizHeader from "./EditQuizHeader";
|
import EditQuizHeader from "./EditQuizHeader";
|
||||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||||
|
import { useGlobalSettingsQuery } from "@/features/local/globalSettings/globalSettingsHooks";
|
||||||
|
import { getFeedbackDelimitersFromSettings } from "@/features/local/globalSettings/globalSettingsUtils";
|
||||||
|
import type { GlobalSettings } from "@/features/local/globalSettings/globalSettingsModels";
|
||||||
import { EditLayout } from "@/components/EditLayout";
|
import { EditLayout } from "@/components/EditLayout";
|
||||||
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
|
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
|
||||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||||
@@ -70,7 +73,29 @@ this is a matching question
|
|||||||
^ left answer - right dropdown
|
^ left answer - right dropdown
|
||||||
^ other thing - another option
|
^ other thing - another option
|
||||||
^ - distractor
|
^ - distractor
|
||||||
^ - other distractor`;
|
^ - other distractor
|
||||||
|
---
|
||||||
|
Points: 3
|
||||||
|
FEEDBACK EXAMPLE
|
||||||
|
What is 2+3?
|
||||||
|
+ Correct! Good job
|
||||||
|
- Incorrect, try again
|
||||||
|
... This is general feedback shown regardless
|
||||||
|
*a) 4
|
||||||
|
*b) 5
|
||||||
|
c) 6
|
||||||
|
---
|
||||||
|
Points: 2
|
||||||
|
FEEDBACK EXAMPLE
|
||||||
|
Multiline feedback example
|
||||||
|
+
|
||||||
|
Great work!
|
||||||
|
You understand the concept.
|
||||||
|
-
|
||||||
|
Not quite right.
|
||||||
|
Review the material and try again.
|
||||||
|
*a) correct answer
|
||||||
|
b) wrong answer`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EditQuiz({
|
export default function EditQuiz({
|
||||||
@@ -89,10 +114,15 @@ export default function EditQuiz({
|
|||||||
isFetching,
|
isFetching,
|
||||||
} = useQuizQuery(moduleName, quizName);
|
} = useQuizQuery(moduleName, quizName);
|
||||||
const updateQuizMutation = useUpdateQuizMutation();
|
const updateQuizMutation = useUpdateQuizMutation();
|
||||||
|
const { data: globalSettings } = useGlobalSettingsQuery();
|
||||||
|
const feedbackDelimiters = getFeedbackDelimitersFromSettings(
|
||||||
|
(globalSettings ?? ({} as GlobalSettings)) as GlobalSettings,
|
||||||
|
);
|
||||||
|
|
||||||
const { clientIsAuthoritative, text, textUpdate, monacoKey } =
|
const { clientIsAuthoritative, text, textUpdate, monacoKey } =
|
||||||
useAuthoritativeUpdates({
|
useAuthoritativeUpdates({
|
||||||
serverUpdatedAt: serverDataUpdatedAt,
|
serverUpdatedAt: serverDataUpdatedAt,
|
||||||
startingText: quizMarkdownUtils.toMarkdown(quiz),
|
startingText: quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -108,13 +138,18 @@ export default function EditQuiz({
|
|||||||
try {
|
try {
|
||||||
const name = extractLabelValue(text, "Name");
|
const name = extractLabelValue(text, "Name");
|
||||||
if (
|
if (
|
||||||
quizMarkdownUtils.toMarkdown(quiz) !==
|
quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters) !==
|
||||||
quizMarkdownUtils.toMarkdown(
|
quizMarkdownUtils.toMarkdown(
|
||||||
quizMarkdownUtils.parseMarkdown(text, name)
|
quizMarkdownUtils.parseMarkdown(text, name, feedbackDelimiters),
|
||||||
|
feedbackDelimiters,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (clientIsAuthoritative) {
|
if (clientIsAuthoritative) {
|
||||||
const updatedQuiz = quizMarkdownUtils.parseMarkdown(text, quizName);
|
const updatedQuiz = quizMarkdownUtils.parseMarkdown(
|
||||||
|
text,
|
||||||
|
quizName,
|
||||||
|
feedbackDelimiters,
|
||||||
|
);
|
||||||
await updateQuizMutation.mutateAsync({
|
await updateQuizMutation.mutateAsync({
|
||||||
quiz: updatedQuiz,
|
quiz: updatedQuiz,
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -125,7 +160,7 @@ export default function EditQuiz({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
"client not authoritative, updating client with server quiz"
|
"client not authoritative, updating client with server quiz",
|
||||||
);
|
);
|
||||||
textUpdate(quizMarkdownUtils.toMarkdown(quiz), true);
|
textUpdate(quizMarkdownUtils.toMarkdown(quiz), true);
|
||||||
}
|
}
|
||||||
@@ -143,6 +178,7 @@ export default function EditQuiz({
|
|||||||
}, [
|
}, [
|
||||||
clientIsAuthoritative,
|
clientIsAuthoritative,
|
||||||
courseName,
|
courseName,
|
||||||
|
feedbackDelimiters,
|
||||||
isFetching,
|
isFetching,
|
||||||
moduleName,
|
moduleName,
|
||||||
quiz,
|
quiz,
|
||||||
@@ -159,7 +195,7 @@ export default function EditQuiz({
|
|||||||
Body={
|
Body={
|
||||||
<>
|
<>
|
||||||
{showHelp && (
|
{showHelp && (
|
||||||
<pre className=" max-w-96">
|
<pre className=" max-w-96 h-full overflow-y-auto">
|
||||||
<code>{helpString(settings)}</code>
|
<code>{helpString(settings)}</code>
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
|
||||||
import { getCourseUrl } from "@/services/urlUtils";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { UpdateQuizName } from "./UpdateQuizName";
|
import { UpdateQuizName } from "./UpdateQuizName";
|
||||||
|
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||||
|
|
||||||
export default function EditQuizHeader({
|
export default function EditQuizHeader({
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -10,19 +9,18 @@ export default function EditQuizHeader({
|
|||||||
quizName: string;
|
quizName: string;
|
||||||
moduleName: string;
|
moduleName: string;
|
||||||
}) {
|
}) {
|
||||||
const { courseName } = useCourseContext();
|
|
||||||
return (
|
return (
|
||||||
<div className="py-1 flex flex-row justify-start gap-3">
|
<div className="py-1 flex flex-row justify-between">
|
||||||
<Link
|
<div className="flex flex-row">
|
||||||
className="btn"
|
<BreadCrumbs />
|
||||||
href={getCourseUrl(courseName)}
|
<span className="text-slate-500 cursor-default select-none my-auto">
|
||||||
shallow={true}
|
<RightSingleChevron />
|
||||||
prefetch={true}
|
</span>
|
||||||
>
|
<div className="my-auto px-3">{quizName}</div>
|
||||||
{courseName}
|
</div>
|
||||||
</Link>
|
<div className="px-1">
|
||||||
<UpdateQuizName quizName={quizName} moduleName={moduleName} />
|
<UpdateQuizName quizName={quizName} moduleName={moduleName} />
|
||||||
<div>{quizName}</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
import { getCourseUrl } from "@/services/urlUtils";
|
import { getCourseUrl } from "@/services/urlUtils";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||||
|
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||||
|
|
||||||
export function QuizButtons({
|
export function QuizButtons({
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -35,6 +37,11 @@ export function QuizButtons({
|
|||||||
const deleteFromCanvas = useDeleteQuizFromCanvasMutation();
|
const deleteFromCanvas = useDeleteQuizFromCanvasMutation();
|
||||||
const deleteLocal = useDeleteQuizMutation();
|
const deleteLocal = useDeleteQuizMutation();
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
|
const { previousUrl, nextUrl } = useItemNavigation(
|
||||||
|
"quiz",
|
||||||
|
quizName,
|
||||||
|
moduleName
|
||||||
|
);
|
||||||
|
|
||||||
const quizInCanvas = canvasQuizzes?.find((c) => c.title === quizName);
|
const quizInCanvas = canvasQuizzes?.find((c) => c.title === quizName);
|
||||||
|
|
||||||
@@ -111,6 +118,7 @@ export function QuizButtons({
|
|||||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||||
Go Back
|
Go Back
|
||||||
</Link>
|
</Link>
|
||||||
|
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,6 +80,45 @@ function QuizQuestionPreview({ question }: { question: LocalQuizQuestion }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MarkdownDisplay markdown={question.text} className="ms-4 mb-2" />
|
<MarkdownDisplay markdown={question.text} className="ms-4 mb-2" />
|
||||||
|
|
||||||
|
{/* Feedback Section */}
|
||||||
|
{(question.correctComments ||
|
||||||
|
question.incorrectComments ||
|
||||||
|
question.neutralComments) && (
|
||||||
|
<div className=" m-2 ps-2 py-1 rounded flex bg-slate-950/50">
|
||||||
|
<div>Feedback</div>
|
||||||
|
<div className="mx-4 space-y-1">
|
||||||
|
{question.correctComments && (
|
||||||
|
<div className="border-l-2 border-green-700 pl-2 py-1 flex">
|
||||||
|
<span className="text-green-500">+ </span>
|
||||||
|
<MarkdownDisplay
|
||||||
|
markdown={question.correctComments}
|
||||||
|
className="ms-4 mb-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{question.incorrectComments && (
|
||||||
|
<div className="border-l-2 border-red-700 pl-2 py-1 flex">
|
||||||
|
<span className="text-red-500">- </span>
|
||||||
|
<MarkdownDisplay
|
||||||
|
markdown={question.incorrectComments}
|
||||||
|
className="ms-4 mb-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{question.neutralComments && (
|
||||||
|
<div className="border-l-2 border-blue-800 pl-2 py-1 flex">
|
||||||
|
<span className="text-blue-500">... </span>
|
||||||
|
<MarkdownDisplay
|
||||||
|
markdown={question.neutralComments}
|
||||||
|
className="ms-4 mb-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{question.questionType === QuestionType.MATCHING && (
|
{question.questionType === QuestionType.MATCHING && (
|
||||||
<div>
|
<div>
|
||||||
{question.answers.map((answer) => (
|
{question.answers.map((answer) => (
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ export function UpdateQuizName({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
text-yellow-300
|
||||||
|
bg-yellow-950/30
|
||||||
|
border-2
|
||||||
|
rounded-lg
|
||||||
|
border-yellow-800
|
||||||
|
p-1 text-sm mb-2"
|
||||||
|
>
|
||||||
|
Warning: does not rename in Canvas
|
||||||
|
</div>
|
||||||
<TextInput value={name} setValue={setName} label={"Rename Quiz"} />
|
<TextInput value={name} setValue={setName} label={"Rename Quiz"} />
|
||||||
<button className="w-full my-3">Save New Name</button>
|
<button className="w-full my-3">Save New Name</button>
|
||||||
{isLoading && <Spinner />}
|
{isLoading && <Spinner />}
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import { Spinner } from "@/components/Spinner";
|
|||||||
import MeatballIcon from "./MeatballIcon";
|
import MeatballIcon from "./MeatballIcon";
|
||||||
import { useSetAssignmentGroupsMutation } from "@/features/canvas/hooks/canvasCourseHooks";
|
import { useSetAssignmentGroupsMutation } from "@/features/canvas/hooks/canvasCourseHooks";
|
||||||
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
|
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
|
||||||
|
import Modal, { useModal } from "@/components/Modal";
|
||||||
|
|
||||||
export default function AssignmentGroupManagement() {
|
export default function AssignmentGroupManagement() {
|
||||||
const { data: settings, isPending } = useLocalCourseSettingsQuery();
|
const { data: settings, isPending } = useLocalCourseSettingsQuery();
|
||||||
const updateSettings = useUpdateLocalCourseSettingsMutation();
|
const updateSettings = useUpdateLocalCourseSettingsMutation();
|
||||||
const applyInCanvas = useSetAssignmentGroupsMutation(settings.canvasId);
|
const applyInCanvas = useSetAssignmentGroupsMutation(settings.canvasId);
|
||||||
|
const modal = useModal();
|
||||||
|
|
||||||
const [assignmentGroups, setAssignmentGroups] = useState<
|
const [assignmentGroups, setAssignmentGroups] = useState<
|
||||||
LocalAssignmentGroup[]
|
LocalAssignmentGroup[]
|
||||||
@@ -104,17 +106,46 @@ export default function AssignmentGroupManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<Modal
|
||||||
onClick={async () => {
|
modalControl={modal}
|
||||||
const newSettings = await applyInCanvas.mutateAsync(settings);
|
buttonText="Update Assignment Groups In Canvas"
|
||||||
|
buttonClass="btn-"
|
||||||
// prevent debounce from resetting
|
modalWidth="w-1/5"
|
||||||
if (newSettings) setAssignmentGroups(newSettings.assignmentGroups);
|
|
||||||
}}
|
|
||||||
disabled={applyInCanvas.isPending}
|
|
||||||
>
|
>
|
||||||
Update Assignment Groups In Canvas
|
{({ closeModal }) => (
|
||||||
</button>
|
<div>
|
||||||
|
<div className="text-center font-bold">
|
||||||
|
DANGER: updating assignment groups can delete assignments and grades from canvas.
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
This is only recommended to do at the beginning of a semester. Are you sure you want to continue?
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div className="flex justify-around gap-3">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const newSettings = await applyInCanvas.mutateAsync(
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
|
||||||
|
// prevent debounce from resetting
|
||||||
|
if (newSettings)
|
||||||
|
setAssignmentGroups(newSettings.assignmentGroups);
|
||||||
|
}}
|
||||||
|
disabled={applyInCanvas.isPending}
|
||||||
|
className="btn-danger"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button onClick={closeModal} disabled={applyInCanvas.isPending}>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{applyInCanvas.isPending && <Spinner />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{applyInCanvas.isPending && <Spinner />}
|
{applyInCanvas.isPending && <Spinner />}
|
||||||
{applyInCanvas.isSuccess && (
|
{applyInCanvas.isSuccess && (
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default async function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head></head>
|
<head></head>
|
||||||
<body className="flex justify-center">
|
<body className="flex justify-center h-screen" suppressHydrationWarning>
|
||||||
<div className="bg-gray-950 h-screen text-slate-300 w-screen sm:p-1">
|
<div className="bg-gray-950 h-screen text-slate-300 w-screen sm:p-1">
|
||||||
<MyToaster />
|
<MyToaster />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|||||||
@@ -20,35 +20,30 @@ export default async function Home() {
|
|||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<AddCourseToGlobalSettings />
|
<AddCourseToGlobalSettings />
|
||||||
<AddExistingCourseToGlobalSettings />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<br />
|
<br />
|
||||||
|
<div className="mb-96">
|
||||||
|
<AddExistingCourseToGlobalSettings />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function makeQueryClient() {
|
|||||||
// refetchOnMount: false,
|
// refetchOnMount: false,
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = getAxiosErrorMessage(error as AxiosError);
|
const message = getAxiosErrorMessage(error as AxiosError);
|
||||||
console.error("Mutation error:", message);
|
console.error("Mutation error:", message);
|
||||||
|
|||||||
106
src/components/BreadCrumbs.tsx
Normal file
106
src/components/BreadCrumbs.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import HomeIcon from "./icons/HomeIcon";
|
||||||
|
import { RightSingleChevron } from "./icons/RightSingleChevron";
|
||||||
|
|
||||||
|
export const BreadCrumbs = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const pathSegments = pathname?.split("/").filter(Boolean) || [];
|
||||||
|
const isCourseRoute = pathSegments[0] === "course";
|
||||||
|
|
||||||
|
const courseName =
|
||||||
|
isCourseRoute && pathSegments[1]
|
||||||
|
? decodeURIComponent(pathSegments[1])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isLectureRoute = isCourseRoute && pathSegments[2] === "lecture";
|
||||||
|
const lectureDate =
|
||||||
|
isLectureRoute && pathSegments[3]
|
||||||
|
? decodeURIComponent(pathSegments[3])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const lectureDateOnly = lectureDate
|
||||||
|
? (() => {
|
||||||
|
const dateStr = lectureDate.split(" ")[0];
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const month = date.toLocaleDateString("en-US", { month: "short" });
|
||||||
|
const day = date.getDate();
|
||||||
|
return `${month} ${day}`;
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sharedBackgroundClassNames = `
|
||||||
|
group
|
||||||
|
hover:bg-blue-900/30
|
||||||
|
rounded-lg
|
||||||
|
h-full
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
transition
|
||||||
|
`;
|
||||||
|
const sharedLinkClassNames = `
|
||||||
|
text-slate-300
|
||||||
|
transition
|
||||||
|
group-hover:text-slate-100
|
||||||
|
rounded-lg
|
||||||
|
h-full
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
px-3
|
||||||
|
`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex flex-row font-bold text-sm items-center">
|
||||||
|
<span className={sharedBackgroundClassNames}>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
shallow={true}
|
||||||
|
className="flex items-center gap-1 rounded-lg h-full "
|
||||||
|
>
|
||||||
|
<span className={sharedLinkClassNames}>
|
||||||
|
<HomeIcon />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{courseName && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500 cursor-default select-none">
|
||||||
|
<RightSingleChevron />
|
||||||
|
</span>
|
||||||
|
<span className={sharedBackgroundClassNames}>
|
||||||
|
<Link
|
||||||
|
href={`/course/${encodeURIComponent(courseName)}`}
|
||||||
|
shallow={true}
|
||||||
|
className={sharedLinkClassNames}
|
||||||
|
>
|
||||||
|
{courseName}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLectureRoute && lectureDate && courseName && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500 cursor-default select-none">
|
||||||
|
<RightSingleChevron />
|
||||||
|
</span>
|
||||||
|
<span className={sharedBackgroundClassNames}>
|
||||||
|
<Link
|
||||||
|
href={`/course/${encodeURIComponent(
|
||||||
|
courseName
|
||||||
|
)}/lecture/${encodeURIComponent(lectureDate)}`}
|
||||||
|
shallow={true}
|
||||||
|
className={sharedLinkClassNames}
|
||||||
|
>
|
||||||
|
{lectureDateOnly}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ export default function MarkdownDisplay({
|
|||||||
markdown,
|
markdown,
|
||||||
className = "",
|
className = "",
|
||||||
replaceText = [],
|
replaceText = [],
|
||||||
|
convertImages,
|
||||||
}: {
|
}: {
|
||||||
markdown: string;
|
markdown: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -14,6 +15,7 @@ export default function MarkdownDisplay({
|
|||||||
source: string;
|
source: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
}[];
|
}[];
|
||||||
|
convertImages?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { data: settings } = useLocalCourseSettingsQuery();
|
const { data: settings } = useLocalCourseSettingsQuery();
|
||||||
return (
|
return (
|
||||||
@@ -23,6 +25,7 @@ export default function MarkdownDisplay({
|
|||||||
settings={settings}
|
settings={settings}
|
||||||
className={className}
|
className={className}
|
||||||
replaceText={replaceText}
|
replaceText={replaceText}
|
||||||
|
convertImages={convertImages}
|
||||||
/>
|
/>
|
||||||
</SuspenseAndErrorHandling>
|
</SuspenseAndErrorHandling>
|
||||||
);
|
);
|
||||||
@@ -33,6 +36,7 @@ function DangerousInnerMarkdown({
|
|||||||
settings,
|
settings,
|
||||||
className,
|
className,
|
||||||
replaceText,
|
replaceText,
|
||||||
|
convertImages,
|
||||||
}: {
|
}: {
|
||||||
markdown: string;
|
markdown: string;
|
||||||
settings: LocalCourseSettings;
|
settings: LocalCourseSettings;
|
||||||
@@ -41,6 +45,7 @@ function DangerousInnerMarkdown({
|
|||||||
source: string;
|
source: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
}[];
|
}[];
|
||||||
|
convertImages?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -48,6 +53,7 @@ function DangerousInnerMarkdown({
|
|||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: markdownToHTMLSafe({
|
__html: markdownToHTMLSafe({
|
||||||
markdownString: markdown,
|
markdownString: markdown,
|
||||||
|
convertImages,
|
||||||
settings,
|
settings,
|
||||||
replaceText,
|
replaceText,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,66 +3,89 @@ import React, { ReactNode, useCallback, useMemo, useState } from "react";
|
|||||||
|
|
||||||
export interface ModalControl {
|
export interface ModalControl {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
openModal: () => void;
|
openModal: (position?: { x: number; y: number }) => void;
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
|
position?: { x: number; y: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useModal() {
|
export function useModal() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [position, setPosition] = useState<
|
||||||
|
{ x: number; y: number } | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
const openModal = useCallback(() => setIsOpen(true), []);
|
const openModal = useCallback((pos?: { x: number; y: number }) => {
|
||||||
const closeModal = useCallback(() => setIsOpen(false), []);
|
setPosition(pos);
|
||||||
|
setIsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeModal = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setPosition(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isOpen,
|
isOpen,
|
||||||
openModal,
|
openModal,
|
||||||
closeModal,
|
closeModal,
|
||||||
|
position,
|
||||||
}),
|
}),
|
||||||
[closeModal, isOpen, openModal]
|
[closeModal, isOpen, openModal, position],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Modal({
|
export default function Modal({
|
||||||
children,
|
children,
|
||||||
buttonText,
|
buttonText = "",
|
||||||
buttonClass = "",
|
buttonClass = "",
|
||||||
modalWidth = "w-1/3",
|
modalWidth = "w-1/3",
|
||||||
modalControl,
|
modalControl,
|
||||||
|
buttonComponent,
|
||||||
|
backgroundCoverColor = "bg-black/80",
|
||||||
}: {
|
}: {
|
||||||
children: (props: { closeModal: () => void }) => ReactNode;
|
children: (props: { closeModal: () => void }) => ReactNode;
|
||||||
buttonText: string;
|
buttonText?: string;
|
||||||
buttonClass?: string;
|
buttonClass?: string;
|
||||||
modalWidth?: string;
|
modalWidth?: string;
|
||||||
modalControl: ModalControl;
|
modalControl: ModalControl;
|
||||||
|
buttonComponent?: (props: { openModal: () => void }) => ReactNode;
|
||||||
|
backgroundCoverColor?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button onClick={modalControl.openModal} className={buttonClass}>
|
{buttonComponent
|
||||||
{buttonText}
|
? buttonComponent({ openModal: () => modalControl.openModal() })
|
||||||
</button>
|
: buttonText && (
|
||||||
|
<button
|
||||||
|
onClick={() => modalControl.openModal()}
|
||||||
|
className={buttonClass}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
" fixed inset-0 flex items-center justify-center transition-all duration-400 h-screen w-screen " +
|
modalControl.isOpen
|
||||||
" bg-black" +
|
? `transition-all duration-400 fixed inset-0 ${modalControl.position ? "" : "flex items-center justify-center"} h-screen ${backgroundCoverColor} z-50 w-screen`
|
||||||
(modalControl.isOpen
|
: "hidden h-0 w-0 p-1 -z-50"
|
||||||
? " bg-opacity-50 z-50 "
|
|
||||||
: " bg-opacity-0 -z-50 ")
|
|
||||||
}
|
}
|
||||||
onClick={modalControl.closeModal}
|
onClick={modalControl.closeModal}
|
||||||
// if mouse up here, do not, if mouse down then still do
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// e.preventDefault();
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
className={
|
className={`bg-slate-800 ${modalControl.position ? "" : "p-6"} rounded-lg shadow-lg ${modalControl.position ? "" : modalWidth} transition-all duration-400 ${modalControl.isOpen ? "opacity-100" : "opacity-0"}`}
|
||||||
` bg-slate-800 p-6 rounded-lg shadow-lg ` +
|
style={
|
||||||
modalWidth +
|
modalControl.position
|
||||||
` transition-all duration-400 ` +
|
? {
|
||||||
` ${modalControl.isOpen ? "opacity-100" : "opacity-0"}`
|
position: "fixed",
|
||||||
|
left: modalControl.position.x,
|
||||||
|
top: modalControl.position.y,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{modalControl.isOpen &&
|
{modalControl.isOpen &&
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export const Tooltip: React.FC<{
|
|||||||
" absolute -translate-x-1/2 " +
|
" absolute -translate-x-1/2 " +
|
||||||
" bg-gray-900 text-slate-200 text-sm " +
|
" bg-gray-900 text-slate-200 text-sm " +
|
||||||
" rounded-md py-1 px-2 " +
|
" rounded-md py-1 px-2 " +
|
||||||
" transition-all duration-400 " +
|
" transition-opacity duration-150 " +
|
||||||
" border border-slate-700 shadow-[0px_0px_10px_5px] shadow-slate-500/20 " +
|
" border border-slate-700 shadow-[0px_0px_10px_5px] shadow-slate-500/20 " +
|
||||||
" max-w-sm max-h-64 overflow-hidden " +
|
" max-w-sm max-h-64 overflow-hidden " +
|
||||||
(visible ? " " : " hidden -z-50 ")
|
(visible ? " opacity-100 " : " opacity-0 pointer-events-none hidden ")
|
||||||
}
|
}
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
background-color: #030712 !important;
|
background-color: #030712 !important;
|
||||||
/* background-color: #101828 !important; */
|
/* background-color: #101828 !important; */
|
||||||
}
|
}
|
||||||
|
.sticky-widget {
|
||||||
|
background-color: #0C0F17 !important;
|
||||||
|
}
|
||||||
.monaco-editor {
|
.monaco-editor {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export function StoragePathSelector({
|
|||||||
setArrowUsed(false);
|
setArrowUsed(false);
|
||||||
setHighlightedIndex(-1);
|
setHighlightedIndex(-1);
|
||||||
if (shouldFocus) {
|
if (shouldFocus) {
|
||||||
|
// Keep the dropdown open by maintaining focus state
|
||||||
|
setIsFocused(true);
|
||||||
setTimeout(() => inputRef.current?.focus(), 0);
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -154,7 +156,10 @@ export function StoragePathSelector({
|
|||||||
className={`dropdown-option w-full px-2 py-1 cursor-pointer ${
|
className={`dropdown-option w-full px-2 py-1 cursor-pointer ${
|
||||||
highlightedIndex === idx ? "bg-blue-700 text-white" : ""
|
highlightedIndex === idx ? "bg-blue-700 text-white" : ""
|
||||||
}`}
|
}`}
|
||||||
onMouseDown={() => handleSelectFolder(option)}
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // Prevent input blur
|
||||||
|
handleSelectFolder(option, true);
|
||||||
|
}}
|
||||||
onMouseEnter={() => setHighlightedIndex(idx)}
|
onMouseEnter={() => setHighlightedIndex(idx)}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
|
|||||||
37
src/components/form/Toggle.tsx
Normal file
37
src/components/form/Toggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
export const Toggle: FC<{
|
||||||
|
label: string;
|
||||||
|
value: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}> = ({ label, value, onChange }) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className="
|
||||||
|
flex align-middle p-2 cursor-pointer
|
||||||
|
text-gray-300
|
||||||
|
hover:text-blue-400
|
||||||
|
transition-colors duration-200 ease-in-out
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="appearance-none peer"
|
||||||
|
checked={value}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
w-12 h-6 flex items-center flex-shrink-0 mx-3 p-1
|
||||||
|
bg-gray-600 rounded-full
|
||||||
|
duration-300 ease-in-out
|
||||||
|
peer-checked:bg-blue-600
|
||||||
|
after:w-4 after:h-4 after:bg-white after:rounded-full after:shadow-md
|
||||||
|
after:duration-300 peer-checked:after:translate-x-6
|
||||||
|
group-hover:after:translate-x-1
|
||||||
|
`}
|
||||||
|
></span>
|
||||||
|
<span className="">{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,26 +1,34 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export default function ExpandIcon({style}: {
|
export default function ExpandIcon({
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
style?: React.CSSProperties | undefined;
|
style?: React.CSSProperties | undefined;
|
||||||
}) {
|
}) {
|
||||||
const size = "24px";
|
const size = "24px";
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
style={style}
|
style={style}
|
||||||
|
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 17 17"
|
className="transition-all"
|
||||||
version="1.1"
|
viewBox="0 0 24 24"
|
||||||
className="si-glyph si-glyph-triangle-left transition-all ms-1"
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||||
|
<g
|
||||||
|
id="SVGRepo_tracerCarrier"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
></g>
|
||||||
|
<g id="SVGRepo_iconCarrier">
|
||||||
<path
|
<path
|
||||||
d="M3.446,10.052 C2.866,9.471 2.866,8.53 3.446,7.948 L9.89,1.506 C10.471,0.924 11.993,0.667 11.993,2.506 L11.993,15.494 C11.993,17.395 10.472,17.076 9.89,16.495 L3.446,10.052 L3.446,10.052 Z"
|
className="stroke-slate-300"
|
||||||
className="si-glyph-fill"
|
d="M9 6L15 12L9 18"
|
||||||
style={{
|
strokeWidth="2"
|
||||||
fill: "rgb(148 163 184 / var(--tw-text-opacity))",
|
strokeLinecap="round"
|
||||||
}}
|
strokeLinejoin="round"
|
||||||
></path>
|
></path>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
23
src/components/icons/HomeIcon.tsx
Normal file
23
src/components/icons/HomeIcon.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// https://www.svgrepo.com/collection/wolf-kit-solid-glyph-icons/?search=home
|
||||||
|
export default function HomeIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
>
|
||||||
|
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||||
|
<g
|
||||||
|
id="SVGRepo_tracerCarrier"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
></g>
|
||||||
|
<g id="SVGRepo_iconCarrier">
|
||||||
|
<path d="M11.3861 1.21065C11.7472 0.929784 12.2528 0.929784 12.6139 1.21065L21.6139 8.21065C21.8575 8.4001 22 8.69141 22 9V20.5C22 21.3284 21.3284 22 20.5 22H15V14C15 13.4477 14.5523 13 14 13H10C9.44772 13 9 13.4477 9 14V22H3.5C2.67157 22 2 21.3284 2 20.5V9C2 8.69141 2.14247 8.4001 2.38606 8.21065L11.3861 1.21065Z"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/icons/RightSingleChevron.tsx
Normal file
27
src/components/icons/RightSingleChevron.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// https://www.svgrepo.com/svg/491374/chevron-small-right
|
||||||
|
export const RightSingleChevron = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="7 4 11 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-3 w-3 fill-slate-600"
|
||||||
|
>
|
||||||
|
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||||
|
<g
|
||||||
|
id="SVGRepo_tracerCarrier"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
></g>
|
||||||
|
<g id="SVGRepo_iconCarrier">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M8.08586 5.41412C7.69534 5.80465 7.69534 6.43781 8.08586 6.82834L13.3788 12.1212L8.08586 17.4141C7.69534 17.8046 7.69534 18.4378 8.08586 18.8283L8.79297 19.5354C9.18349 19.926 9.81666 19.926 10.2072 19.5354L16.5607 13.1819C17.1465 12.5961 17.1465 11.6464 16.5607 11.0606L10.2072 4.70702C9.81666 4.31649 9.18349 4.31649 8.79297 4.70702L8.08586 5.41412Z"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,8 @@ import { useTRPC } from "@/services/serverFunctions/trpcClient";
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { io, Socket } from "socket.io-client";
|
import { io, Socket } from "socket.io-client";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useGlobalSettingsQuery } from "@/features/local/globalSettings/globalSettingsHooks";
|
||||||
|
import { GlobalSettings } from "@/features/local/globalSettings/globalSettingsModels";
|
||||||
|
|
||||||
interface ServerToClientEvents {
|
interface ServerToClientEvents {
|
||||||
message: (data: string) => void;
|
message: (data: string) => void;
|
||||||
@@ -24,6 +26,22 @@ function removeFileExtension(fileName: string): string {
|
|||||||
return fileName;
|
return fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCourseNameByPath(
|
||||||
|
filePath: string,
|
||||||
|
settings: GlobalSettings
|
||||||
|
) {
|
||||||
|
const courseSettings = settings.courses.find((c) => {
|
||||||
|
const normalizedFilePath = filePath.startsWith("./")
|
||||||
|
? filePath.substring(2)
|
||||||
|
: filePath;
|
||||||
|
const normalizedCoursePath = c.path.startsWith("./")
|
||||||
|
? c.path.substring(2)
|
||||||
|
: c.path;
|
||||||
|
return normalizedFilePath.startsWith(normalizedCoursePath);
|
||||||
|
});
|
||||||
|
return courseSettings?.name;
|
||||||
|
}
|
||||||
|
|
||||||
export function ClientCacheInvalidation() {
|
export function ClientCacheInvalidation() {
|
||||||
const invalidateCache = useFilePathInvalidation();
|
const invalidateCache = useFilePathInvalidation();
|
||||||
const [connectionAttempted, setConnectionAttempted] = useState(false);
|
const [connectionAttempted, setConnectionAttempted] = useState(false);
|
||||||
@@ -62,13 +80,32 @@ export function ClientCacheInvalidation() {
|
|||||||
const useFilePathInvalidation = () => {
|
const useFilePathInvalidation = () => {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { data: settings } = useGlobalSettingsQuery();
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(filePath: string) => {
|
(filePath: string) => {
|
||||||
const [courseName, moduleOrLectures, itemType, itemFile] =
|
const courseName = getCourseNameByPath(filePath, settings);
|
||||||
filePath.split("/");
|
// console.log(filePath, settings, courseName);
|
||||||
|
if (!courseName) {
|
||||||
|
console.log(
|
||||||
|
"no course settings found for file path, not invalidating cache",
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitPath = filePath.split("/");
|
||||||
|
const [moduleOrLectures, itemType, itemFile] = splitPath.slice(-3);
|
||||||
|
|
||||||
const itemName = itemFile ? removeFileExtension(itemFile) : undefined;
|
const itemName = itemFile ? removeFileExtension(itemFile) : undefined;
|
||||||
const allParts = [courseName, moduleOrLectures, itemType, itemName];
|
const allParts = { courseName, moduleOrLectures, itemType, itemName };
|
||||||
|
// console.log(
|
||||||
|
// "received file to invalidate",
|
||||||
|
// filePath,
|
||||||
|
// allParts,
|
||||||
|
// itemName,
|
||||||
|
// itemType
|
||||||
|
// );
|
||||||
|
|
||||||
if (moduleOrLectures === "settings.yml") {
|
if (moduleOrLectures === "settings.yml") {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -141,6 +178,8 @@ const useFilePathInvalidation = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("no cache invalidation match for file ", allParts);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -153,6 +192,7 @@ const useFilePathInvalidation = () => {
|
|||||||
trpc.quiz.getQuiz,
|
trpc.quiz.getQuiz,
|
||||||
trpc.settings.allCoursesSettings,
|
trpc.settings.allCoursesSettings,
|
||||||
trpc.settings.courseSettings,
|
trpc.settings.courseSettings,
|
||||||
|
settings,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
28
src/components/useTooltip.ts
Normal file
28
src/components/useTooltip.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
export const useTooltip = (delayMs: number = 150) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const targetRef = useRef<HTMLAnchorElement>(null);
|
||||||
|
|
||||||
|
const showTooltip = useCallback(() => {
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setVisible(true);
|
||||||
|
}, delayMs);
|
||||||
|
}, [delayMs]);
|
||||||
|
|
||||||
|
const hideTooltip = useCallback(() => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
visible,
|
||||||
|
targetRef,
|
||||||
|
showTooltip,
|
||||||
|
hideTooltip,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||||
import { CanvasAssignmentGroup } from "@/features/canvas/models/assignments/canvasAssignmentGroup";
|
import { CanvasAssignmentGroup } from "@/features/canvas/models/assignments/canvasAssignmentGroup";
|
||||||
import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
|
import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
|
||||||
import { rateLimitAwareDelete } from "./canvasWebRequestor";
|
import { rateLimitAwareDelete, rateLimitAwarePost } from "./canvasWebRequestUtils";
|
||||||
import { axiosClient } from "@/services/axiosUtils";
|
import { axiosClient } from "@/services/axiosUtils";
|
||||||
|
|
||||||
export const canvasAssignmentGroupService = {
|
export const canvasAssignmentGroupService = {
|
||||||
@@ -26,7 +26,7 @@ export const canvasAssignmentGroupService = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data: canvasAssignmentGroup } =
|
const { data: canvasAssignmentGroup } =
|
||||||
await axiosClient.post<CanvasAssignmentGroup>(url, body);
|
await rateLimitAwarePost<CanvasAssignmentGroup>(url, body);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...localAssignmentGroup,
|
...localAssignmentGroup,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getRubricCriterion } from "./canvasRubricUtils";
|
|||||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||||
import { axiosClient } from "@/services/axiosUtils";
|
import { axiosClient } from "@/services/axiosUtils";
|
||||||
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
||||||
|
import { rateLimitAwarePost } from "./canvasWebRequestUtils";
|
||||||
|
|
||||||
export const canvasAssignmentService = {
|
export const canvasAssignmentService = {
|
||||||
async getAll(courseId: number): Promise<CanvasAssignment[]> {
|
async getAll(courseId: number): Promise<CanvasAssignment[]> {
|
||||||
@@ -60,7 +61,7 @@ export const canvasAssignmentService = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await axiosClient.post<CanvasAssignment>(url, body);
|
const response = await rateLimitAwarePost<CanvasAssignment>(url, body);
|
||||||
const canvasAssignment = response.data;
|
const canvasAssignment = response.data;
|
||||||
|
|
||||||
await createRubric(canvasCourseId, canvasAssignment.id, localAssignment);
|
await createRubric(canvasCourseId, canvasAssignment.id, localAssignment);
|
||||||
@@ -152,7 +153,7 @@ const createRubric = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rubricUrl = `${canvasApi}/courses/${courseId}/rubrics`;
|
const rubricUrl = `${canvasApi}/courses/${courseId}/rubrics`;
|
||||||
const rubricResponse = await axiosClient.post<CanvasRubricCreationResponse>(
|
const rubricResponse = await rateLimitAwarePost<CanvasRubricCreationResponse>(
|
||||||
rubricUrl,
|
rubricUrl,
|
||||||
rubricBody
|
rubricBody
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
|
|||||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||||
import { CanvasModule } from "@/features/canvas/models/modules/canvasModule";
|
import { CanvasModule } from "@/features/canvas/models/modules/canvasModule";
|
||||||
import { axiosClient } from "@/services/axiosUtils";
|
import { axiosClient } from "@/services/axiosUtils";
|
||||||
|
import { rateLimitAwarePost } from "./canvasWebRequestUtils";
|
||||||
|
|
||||||
export const canvasModuleService = {
|
export const canvasModuleService = {
|
||||||
async updateModuleItem(
|
async updateModuleItem(
|
||||||
@@ -37,7 +38,7 @@ export const canvasModuleService = {
|
|||||||
console.log(`Creating new module item ${title}`);
|
console.log(`Creating new module item ${title}`);
|
||||||
const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
|
const url = `${canvasApi}/courses/${canvasCourseId}/modules/${canvasModuleId}/items`;
|
||||||
const body = { module_item: { title, type, content_id: contentId } };
|
const body = { module_item: { title, type, content_id: contentId } };
|
||||||
await axiosClient.post(url, body);
|
await rateLimitAwarePost(url, body);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createPageModuleItem(
|
async createPageModuleItem(
|
||||||
@@ -51,7 +52,7 @@ export const canvasModuleService = {
|
|||||||
const body = {
|
const body = {
|
||||||
module_item: { title, type: "Page", page_url: canvasPage.url },
|
module_item: { title, type: "Page", page_url: canvasPage.url },
|
||||||
};
|
};
|
||||||
await axiosClient.post<CanvasModuleItem>(url, body);
|
await rateLimitAwarePost<CanvasModuleItem>(url, body);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getCourseModules(canvasCourseId: number) {
|
async getCourseModules(canvasCourseId: number) {
|
||||||
@@ -67,7 +68,7 @@ export const canvasModuleService = {
|
|||||||
name: moduleName,
|
name: moduleName,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const response = await axiosClient.post<CanvasModule>(url, body);
|
const response = await rateLimitAwarePost<CanvasModule>(url, body);
|
||||||
return response.data.id;
|
return response.data.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
|
import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
|
||||||
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
|
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
|
||||||
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
import { canvasApi, paginatedRequest } from "./canvasServiceUtils";
|
||||||
import { rateLimitAwareDelete } from "./canvasWebRequestor";
|
import { rateLimitAwareDelete, rateLimitAwarePost } from "./canvasWebRequestUtils";
|
||||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||||
import { axiosClient } from "@/services/axiosUtils";
|
import { axiosClient } from "@/services/axiosUtils";
|
||||||
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
||||||
@@ -41,7 +41,7 @@ export const canvasPageService = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: canvasPage } = await axiosClient.post<CanvasPage>(url, body);
|
const { data: canvasPage } = await rateLimitAwarePost<CanvasPage>(url, body);
|
||||||
if (!canvasPage) {
|
if (!canvasPage) {
|
||||||
throw new Error("Created canvas course page was null");
|
throw new Error("Created canvas course page was null");
|
||||||
}
|
}
|
||||||
|
|||||||
225
src/features/canvas/services/canvasQuizService.test.ts
Normal file
225
src/features/canvas/services/canvasQuizService.test.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { canvasQuizService } from "./canvasQuizService";
|
||||||
|
import { CanvasQuizQuestion } from "@/features/canvas/models/quizzes/canvasQuizQuestionModel";
|
||||||
|
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
|
||||||
|
import { QuestionType } from "@/features/local/quizzes/models/localQuizQuestion";
|
||||||
|
|
||||||
|
// Mock the dependencies
|
||||||
|
vi.mock("@/services/axiosUtils", () => ({
|
||||||
|
axiosClient: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./canvasServiceUtils", () => ({
|
||||||
|
canvasApi: "https://test.instructure.com/api/v1",
|
||||||
|
paginatedRequest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./canvasAssignmentService", () => ({
|
||||||
|
canvasAssignmentService: {
|
||||||
|
getAll: vi.fn(() => Promise.resolve([])),
|
||||||
|
delete: vi.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/services/htmlMarkdownUtils", () => ({
|
||||||
|
markdownToHTMLSafe: vi.fn(({ markdownString }) => `<p>${markdownString}</p>`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/local/utils/timeUtils", () => ({
|
||||||
|
getDateFromStringOrThrow: vi.fn((dateString) => new Date(dateString)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/services/utils/questionHtmlUtils", () => ({
|
||||||
|
escapeMatchingText: vi.fn((text) => text),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("canvasQuizService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getQuizQuestions", () => {
|
||||||
|
it("should fetch and sort quiz questions by position", async () => {
|
||||||
|
const mockQuestions: CanvasQuizQuestion[] = [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
quiz_id: 1,
|
||||||
|
position: 3,
|
||||||
|
question_name: "Question 3",
|
||||||
|
question_type: "multiple_choice_question",
|
||||||
|
question_text: "What is 2+2?",
|
||||||
|
correct_comments: "",
|
||||||
|
incorrect_comments: "",
|
||||||
|
neutral_comments: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
quiz_id: 1,
|
||||||
|
position: 1,
|
||||||
|
question_name: "Question 1",
|
||||||
|
question_type: "multiple_choice_question",
|
||||||
|
question_text: "What is your name?",
|
||||||
|
correct_comments: "",
|
||||||
|
incorrect_comments: "",
|
||||||
|
neutral_comments: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
quiz_id: 1,
|
||||||
|
position: 2,
|
||||||
|
question_name: "Question 2",
|
||||||
|
question_type: "essay_question",
|
||||||
|
question_text: "Describe yourself",
|
||||||
|
correct_comments: "",
|
||||||
|
incorrect_comments: "",
|
||||||
|
neutral_comments: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { paginatedRequest } = await import("./canvasServiceUtils");
|
||||||
|
vi.mocked(paginatedRequest).mockResolvedValue(mockQuestions);
|
||||||
|
|
||||||
|
const result = await canvasQuizService.getQuizQuestions(1, 1);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0].position).toBe(1);
|
||||||
|
expect(result[1].position).toBe(2);
|
||||||
|
expect(result[2].position).toBe(3);
|
||||||
|
expect(result[0].question_text).toBe("What is your name?");
|
||||||
|
expect(result[1].question_text).toBe("Describe yourself");
|
||||||
|
expect(result[2].question_text).toBe("What is 2+2?");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle questions without position", async () => {
|
||||||
|
const mockQuestions: CanvasQuizQuestion[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
quiz_id: 1,
|
||||||
|
question_name: "Question 1",
|
||||||
|
question_type: "multiple_choice_question",
|
||||||
|
question_text: "What is your name?",
|
||||||
|
correct_comments: "",
|
||||||
|
incorrect_comments: "",
|
||||||
|
neutral_comments: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
quiz_id: 1,
|
||||||
|
question_name: "Question 2",
|
||||||
|
question_type: "essay_question",
|
||||||
|
question_text: "Describe yourself",
|
||||||
|
correct_comments: "",
|
||||||
|
incorrect_comments: "",
|
||||||
|
neutral_comments: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { paginatedRequest } = await import("./canvasServiceUtils");
|
||||||
|
vi.mocked(paginatedRequest).mockResolvedValue(mockQuestions);
|
||||||
|
|
||||||
|
const result = await canvasQuizService.getQuizQuestions(1, 1);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
// Should maintain original order when no position is specified
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Question order verification (integration test concept)", () => {
|
||||||
|
it("should detect correct question order", async () => {
|
||||||
|
// This is a conceptual test showing what the verification should validate
|
||||||
|
const _localQuiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: "A test quiz",
|
||||||
|
dueAt: "2023-12-01T23:59:00Z",
|
||||||
|
shuffleAnswers: false,
|
||||||
|
showCorrectAnswers: true,
|
||||||
|
oneQuestionAtATime: false,
|
||||||
|
allowedAttempts: 1,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: "What is your name?",
|
||||||
|
questionType: QuestionType.SHORT_ANSWER,
|
||||||
|
points: 5,
|
||||||
|
answers: [],
|
||||||
|
matchDistractors: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Describe yourself",
|
||||||
|
questionType: QuestionType.ESSAY,
|
||||||
|
points: 10,
|
||||||
|
answers: [],
|
||||||
|
matchDistractors: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "What is 2+2?",
|
||||||
|
questionType: QuestionType.MULTIPLE_CHOICE,
|
||||||
|
points: 5,
|
||||||
|
answers: [
|
||||||
|
{ text: "3", correct: false },
|
||||||
|
{ text: "4", correct: true },
|
||||||
|
{ text: "5", correct: false },
|
||||||
|
],
|
||||||
|
matchDistractors: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const canvasQuestions: CanvasQuizQuestion[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
quiz_id: 1,
|
||||||
|
position: 1,
|
||||||
|
question_name: "Question 1",
|
||||||
|
question_type: "short_answer_question",
|
||||||
|
question_text: "<p>What is your name?</p>",
|
||||||
|
correct_comments: "",
|
||||||
|
incorrect_comments: "",
|
||||||
|
neutral_comments: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
quiz_id: 1,
|
||||||
|
position: 2,
|
||||||
|
question_name: "Question 2",
|
||||||
|
question_type: "essay_question",
|
||||||
|
question_text: "<p>Describe yourself</p>",
|
||||||
|
correct_comments: "",
|
||||||
|
incorrect_comments: "",
|
||||||
|
neutral_comments: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
quiz_id: 1,
|
||||||
|
position: 3,
|
||||||
|
question_name: "Question 3",
|
||||||
|
question_type: "multiple_choice_question",
|
||||||
|
question_text: "<p>What is 2+2?</p>",
|
||||||
|
correct_comments: "",
|
||||||
|
incorrect_comments: "",
|
||||||
|
neutral_comments: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock the getQuizQuestions to return our test data
|
||||||
|
const { paginatedRequest } = await import("./canvasServiceUtils");
|
||||||
|
vi.mocked(paginatedRequest).mockResolvedValue(canvasQuestions);
|
||||||
|
|
||||||
|
const result = await canvasQuizService.getQuizQuestions(1, 1);
|
||||||
|
|
||||||
|
// Verify the questions are in the expected order
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0].question_text).toContain("What is your name?");
|
||||||
|
expect(result[1].question_text).toContain("Describe yourself");
|
||||||
|
expect(result[2].question_text).toContain("What is 2+2?");
|
||||||
|
|
||||||
|
// Verify positions are sequential
|
||||||
|
expect(result[0].position).toBe(1);
|
||||||
|
expect(result[1].position).toBe(2);
|
||||||
|
expect(result[2].position).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,11 +9,14 @@ import {
|
|||||||
QuestionType,
|
QuestionType,
|
||||||
} from "@/features/local/quizzes/models/localQuizQuestion";
|
} from "@/features/local/quizzes/models/localQuizQuestion";
|
||||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||||
import { axiosClient } from "@/services/axiosUtils";
|
|
||||||
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
||||||
import { escapeMatchingText } from "@/services/utils/questionHtmlUtils";
|
import { escapeMatchingText } from "@/services/utils/questionHtmlUtils";
|
||||||
|
import {
|
||||||
|
rateLimitAwareDelete,
|
||||||
|
rateLimitAwarePost,
|
||||||
|
} from "./canvasWebRequestUtils";
|
||||||
|
|
||||||
export const getAnswers = (
|
export const getAnswersForCanvas = (
|
||||||
question: LocalQuizQuestion,
|
question: LocalQuizQuestion,
|
||||||
settings: LocalCourseSettings
|
settings: LocalCourseSettings
|
||||||
) => {
|
) => {
|
||||||
@@ -29,6 +32,36 @@ export const getAnswers = (
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (question.questionType === QuestionType.NUMERICAL) {
|
||||||
|
// if (question.answers[0].numericalAnswerType === "range_answer") {
|
||||||
|
// console.log(
|
||||||
|
// "answer range",
|
||||||
|
// question.answers.map((answer) => ({
|
||||||
|
// numerical_answer_type: answer.numericalAnswerType,
|
||||||
|
// start: answer.numericAnswerRangeMin,
|
||||||
|
// end: answer.numericAnswerRangeMax,
|
||||||
|
// }))
|
||||||
|
// );
|
||||||
|
// return question.answers.map((answer) => ({
|
||||||
|
// numerical_answer_type: answer.numericalAnswerType,
|
||||||
|
// start: answer.numericAnswerRangeMin + "",
|
||||||
|
// end: answer.numericAnswerRangeMax + "",
|
||||||
|
// }));
|
||||||
|
// }
|
||||||
|
return question.answers.map((answer) => {
|
||||||
|
if (answer.numericalAnswerType === "range_answer")
|
||||||
|
return {
|
||||||
|
numerical_answer_type: answer.numericalAnswerType,
|
||||||
|
answer_range_start: answer.numericAnswerRangeMin,
|
||||||
|
answer_range_end: answer.numericAnswerRangeMax,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
numerical_answer_type: answer.numericalAnswerType,
|
||||||
|
exact: answer.numericAnswer,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return question.answers.map((answer) => ({
|
return question.answers.map((answer) => ({
|
||||||
answer_html: markdownToHTMLSafe({ markdownString: answer.text, settings }),
|
answer_html: markdownToHTMLSafe({ markdownString: answer.text, settings }),
|
||||||
answer_weight: answer.correct ? 100 : 0,
|
answer_weight: answer.correct ? 100 : 0,
|
||||||
@@ -36,7 +69,7 @@ export const getAnswers = (
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getQuestionType = (question: LocalQuizQuestion) => {
|
export const getQuestionTypeForCanvas = (question: LocalQuizQuestion) => {
|
||||||
return `${question.questionType.replace("=", "")}_question`;
|
return `${question.questionType.replace("=", "")}_question`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,20 +84,24 @@ const createQuestionOnly = async (
|
|||||||
|
|
||||||
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/questions`;
|
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/questions`;
|
||||||
|
|
||||||
|
console.log(question);
|
||||||
const body = {
|
const body = {
|
||||||
question: {
|
question: {
|
||||||
question_text: markdownToHTMLSafe({
|
question_text: markdownToHTMLSafe({
|
||||||
markdownString: question.text,
|
markdownString: question.text,
|
||||||
settings,
|
settings,
|
||||||
}),
|
}),
|
||||||
question_type: getQuestionType(question),
|
question_type: getQuestionTypeForCanvas(question),
|
||||||
points_possible: question.points,
|
points_possible: question.points,
|
||||||
position,
|
position,
|
||||||
answers: getAnswers(question, settings),
|
answers: getAnswersForCanvas(question, settings),
|
||||||
|
correct_comments: question.incorrectComments,
|
||||||
|
incorrect_comments: question.incorrectComments,
|
||||||
|
neutral_comments: question.neutralComments,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await axiosClient.post<CanvasQuizQuestion>(url, body);
|
const response = await rateLimitAwarePost<CanvasQuizQuestion>(url, body);
|
||||||
const newQuestion = response.data;
|
const newQuestion = response.data;
|
||||||
|
|
||||||
if (!newQuestion) throw new Error("Created question is null");
|
if (!newQuestion) throw new Error("Created question is null");
|
||||||
@@ -86,7 +123,76 @@ const hackFixQuestionOrdering = async (
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/reorder`;
|
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/reorder`;
|
||||||
await axiosClient.post(url, { order });
|
await rateLimitAwarePost(url, { order });
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyQuestionOrder = async (
|
||||||
|
canvasCourseId: number,
|
||||||
|
canvasQuizId: number,
|
||||||
|
localQuiz: LocalQuiz
|
||||||
|
): Promise<boolean> => {
|
||||||
|
console.log("Verifying question order in Canvas quiz");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const canvasQuestions = await canvasQuizService.getQuizQuestions(
|
||||||
|
canvasCourseId,
|
||||||
|
canvasQuizId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the number of questions matches
|
||||||
|
if (canvasQuestions.length !== localQuiz.questions.length) {
|
||||||
|
console.error(
|
||||||
|
`Question count mismatch: Canvas has ${canvasQuestions.length}, local quiz has ${localQuiz.questions.length}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that questions are in the correct order by comparing text content
|
||||||
|
// We'll use a simple approach: strip HTML tags and compare the core text content
|
||||||
|
const stripHtml = (html: string): string => {
|
||||||
|
return html.replace(/<[^>]*>/g, "").trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < localQuiz.questions.length; i++) {
|
||||||
|
const localQuestion = localQuiz.questions[i];
|
||||||
|
const canvasQuestion = canvasQuestions[i];
|
||||||
|
|
||||||
|
const localQuestionText = localQuestion.text.trim();
|
||||||
|
const canvasQuestionText = stripHtml(canvasQuestion.question_text).trim();
|
||||||
|
|
||||||
|
// Check if the question text content matches (allowing for HTML conversion differences)
|
||||||
|
if (
|
||||||
|
!canvasQuestionText.includes(localQuestionText) &&
|
||||||
|
!localQuestionText.includes(canvasQuestionText)
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
`Question order mismatch at position ${i}:`,
|
||||||
|
`Local: "${localQuestionText}"`,
|
||||||
|
`Canvas: "${canvasQuestionText}"`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify position is correct
|
||||||
|
if (
|
||||||
|
canvasQuestion.position !== undefined &&
|
||||||
|
canvasQuestion.position !== i + 1
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
`Question position mismatch at index ${i}: Canvas position is ${
|
||||||
|
canvasQuestion.position
|
||||||
|
}, expected ${i + 1}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Question order verification successful");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during question order verification:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hackFixRedundantAssignments = async (canvasCourseId: number) => {
|
const hackFixRedundantAssignments = async (canvasCourseId: number) => {
|
||||||
@@ -137,6 +243,19 @@ const createQuizQuestions = async (
|
|||||||
questionAndPositions
|
questionAndPositions
|
||||||
);
|
);
|
||||||
await hackFixRedundantAssignments(canvasCourseId);
|
await hackFixRedundantAssignments(canvasCourseId);
|
||||||
|
|
||||||
|
// Verify that the question order in Canvas matches the local quiz order
|
||||||
|
const orderVerified = await verifyQuestionOrder(
|
||||||
|
canvasCourseId,
|
||||||
|
canvasQuizId,
|
||||||
|
localQuiz
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!orderVerified) {
|
||||||
|
console.warn(
|
||||||
|
"Question order verification failed! The quiz was created but the question order may not match the intended order."
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const canvasQuizService = {
|
export const canvasQuizService = {
|
||||||
@@ -165,6 +284,21 @@ export const canvasQuizService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getQuizQuestions(
|
||||||
|
canvasCourseId: number,
|
||||||
|
canvasQuizId: number
|
||||||
|
): Promise<CanvasQuizQuestion[]> {
|
||||||
|
try {
|
||||||
|
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/questions`;
|
||||||
|
const questions = await paginatedRequest<CanvasQuizQuestion[]>({ url });
|
||||||
|
// Sort by position to ensure correct order
|
||||||
|
return questions.sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching quiz questions from Canvas:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
canvasCourseId: number,
|
canvasCourseId: number,
|
||||||
localQuiz: LocalQuiz,
|
localQuiz: LocalQuiz,
|
||||||
@@ -204,7 +338,10 @@ export const canvasQuizService = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: canvasQuiz } = await axiosClient.post<CanvasQuiz>(url, body);
|
const { data: canvasQuiz } = await rateLimitAwarePost<CanvasQuiz>(
|
||||||
|
url,
|
||||||
|
body
|
||||||
|
);
|
||||||
await createQuizQuestions(
|
await createQuizQuestions(
|
||||||
canvasCourseId,
|
canvasCourseId,
|
||||||
canvasQuiz.id,
|
canvasQuiz.id,
|
||||||
@@ -215,6 +352,6 @@ export const canvasQuizService = {
|
|||||||
},
|
},
|
||||||
async delete(canvasCourseId: number, canvasQuizId: number) {
|
async delete(canvasCourseId: number, canvasQuizId: number) {
|
||||||
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}`;
|
const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}`;
|
||||||
await axiosClient.delete(url);
|
await rateLimitAwareDelete(url);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { axiosClient } from "@/services/axiosUtils";
|
import { axiosClient } from "@/services/axiosUtils";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse, AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
const rateLimitRetryCount = 6;
|
const rateLimitRetryCount = 6;
|
||||||
const rateLimitSleepInterval = 1000;
|
const rateLimitSleepInterval = 1000;
|
||||||
@@ -16,21 +16,26 @@ export const isRateLimited = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// const rateLimitAwarePost = async (url: string, body: unknown, retryCount = 0) => {
|
export const rateLimitAwarePost = async <T>(
|
||||||
// const response = await axiosClient.post(url, body);
|
url: string,
|
||||||
|
body: unknown,
|
||||||
|
config?: AxiosRequestConfig,
|
||||||
|
retryCount = 0
|
||||||
|
): Promise<AxiosResponse<T>> => {
|
||||||
|
const response = await axiosClient.post<T>(url, body, config);
|
||||||
|
|
||||||
// if (await isRateLimited(response)) {
|
if (await isRateLimited(response)) {
|
||||||
// if (retryCount < rateLimitRetryCount) {
|
if (retryCount < rateLimitRetryCount) {
|
||||||
// console.info(
|
console.info(
|
||||||
// `Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying`
|
`Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying`
|
||||||
// );
|
);
|
||||||
// await sleep(rateLimitSleepInterval);
|
await sleep(rateLimitSleepInterval);
|
||||||
// return await rateLimitAwarePost(url, body, retryCount + 1);
|
return await rateLimitAwarePost<T>(url, body, config, retryCount + 1);
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
// return response;
|
return response;
|
||||||
// };
|
};
|
||||||
|
|
||||||
export const rateLimitAwareDelete = async (
|
export const rateLimitAwareDelete = async (
|
||||||
url: string,
|
url: string,
|
||||||
@@ -4,10 +4,11 @@ import axios from "axios";
|
|||||||
import { canvasApi } from "../canvasServiceUtils";
|
import { canvasApi } from "../canvasServiceUtils";
|
||||||
import { axiosClient } from "@/services/axiosUtils";
|
import { axiosClient } from "@/services/axiosUtils";
|
||||||
import FormData from "form-data";
|
import FormData from "form-data";
|
||||||
|
import { rateLimitAwarePost } from "../canvasWebRequestUtils";
|
||||||
|
|
||||||
export const downloadUrlToTempDirectory = async (
|
export const downloadUrlToTempDirectory = async (
|
||||||
sourceUrl: string
|
sourceUrl: string
|
||||||
): Promise<{fileName: string, success: boolean}> => {
|
): Promise<{ fileName: string; success: boolean }> => {
|
||||||
try {
|
try {
|
||||||
const fileName =
|
const fileName =
|
||||||
path.basename(new URL(sourceUrl).pathname) || `tempfile-${Date.now()}`;
|
path.basename(new URL(sourceUrl).pathname) || `tempfile-${Date.now()}`;
|
||||||
@@ -16,10 +17,10 @@ export const downloadUrlToTempDirectory = async (
|
|||||||
responseType: "arraybuffer",
|
responseType: "arraybuffer",
|
||||||
});
|
});
|
||||||
await fs.writeFile(tempFilePath, response.data);
|
await fs.writeFile(tempFilePath, response.data);
|
||||||
return {fileName: tempFilePath, success: true};
|
return { fileName: tempFilePath, success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error downloading or saving the file:", sourceUrl, error);
|
console.log("Error downloading or saving the file:", sourceUrl, error);
|
||||||
return {fileName: sourceUrl, success: false};
|
return { fileName: sourceUrl, success: false };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,7 +46,10 @@ export const uploadToCanvasPart1 = async (
|
|||||||
formData.append("name", path.basename(pathToUpload));
|
formData.append("name", path.basename(pathToUpload));
|
||||||
formData.append("size", (await getFileSize(pathToUpload)).toString());
|
formData.append("size", (await getFileSize(pathToUpload)).toString());
|
||||||
|
|
||||||
const response = await axiosClient.post(url, formData);
|
const response = await rateLimitAwarePost<{
|
||||||
|
upload_url: string;
|
||||||
|
upload_params: { [key: string]: string };
|
||||||
|
}>(url, formData);
|
||||||
|
|
||||||
const upload_url = response.data.upload_url;
|
const upload_url = response.data.upload_url;
|
||||||
const upload_params = response.data.upload_params;
|
const upload_params = response.data.upload_params;
|
||||||
@@ -77,10 +81,14 @@ export const uploadToCanvasPart2 = async ({
|
|||||||
const fileName = path.basename(pathToUpload);
|
const fileName = path.basename(pathToUpload);
|
||||||
formData.append("file", fileBuffer, fileName);
|
formData.append("file", fileBuffer, fileName);
|
||||||
|
|
||||||
const response = await axiosClient.post(upload_url, formData, {
|
const response = await rateLimitAwarePost<{ url: string }>(
|
||||||
headers: formData.getHeaders(),
|
upload_url,
|
||||||
validateStatus: (status) => status < 500,
|
formData,
|
||||||
});
|
{
|
||||||
|
headers: formData.getHeaders(),
|
||||||
|
validateStatus: (status) => status < 500,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (response.status === 301) {
|
if (response.status === 301) {
|
||||||
const redirectUrl = response.headers.location;
|
const redirectUrl = response.headers.location;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorage
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
|
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
|
||||||
import { assignmentMarkdownSerializer } from "./models/utils/assignmentMarkdownSerializer";
|
import { assignmentMarkdownSerializer } from "./models/utils/assignmentMarkdownSerializer";
|
||||||
|
import { assertValidFileName } from "@/services/fileNameValidation";
|
||||||
|
|
||||||
export const assignmentRouter = router({
|
export const assignmentRouter = router({
|
||||||
getAssignment: publicProcedure
|
getAssignment: publicProcedure
|
||||||
@@ -133,6 +134,8 @@ export async function updateOrCreateAssignmentFile({
|
|||||||
assignmentName: string;
|
assignmentName: string;
|
||||||
assignment: LocalAssignment;
|
assignment: LocalAssignment;
|
||||||
}) {
|
}) {
|
||||||
|
assertValidFileName(assignmentName);
|
||||||
|
|
||||||
const courseDirectory = await getCoursePathByName(courseName);
|
const courseDirectory = await getCoursePathByName(courseName);
|
||||||
const folder = path.join(courseDirectory, moduleName, "assignments");
|
const folder = path.join(courseDirectory, moduleName, "assignments");
|
||||||
await fs.mkdir(folder, { recursive: true });
|
await fs.mkdir(folder, { recursive: true });
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ import {
|
|||||||
CourseItemType,
|
CourseItemType,
|
||||||
typeToFolder,
|
typeToFolder,
|
||||||
} from "@/features/local/course/courseItemTypes";
|
} from "@/features/local/course/courseItemTypes";
|
||||||
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
|
import {
|
||||||
|
getCoursePathByName,
|
||||||
|
getGlobalSettings,
|
||||||
|
} from "../globalSettings/globalSettingsFileStorageService";
|
||||||
import {
|
import {
|
||||||
localPageMarkdownUtils,
|
localPageMarkdownUtils,
|
||||||
} from "@/features/local/pages/localCoursePageModels";
|
} from "@/features/local/pages/localCoursePageModels";
|
||||||
import {
|
import { quizMarkdownUtils } from "../quizzes/models/utils/quizMarkdownUtils";
|
||||||
localQuizMarkdownUtils,
|
import { getFeedbackDelimitersFromSettings } from "../globalSettings/globalSettingsUtils";
|
||||||
} from "@/features/local/quizzes/models/localQuiz";
|
|
||||||
|
|
||||||
const getItemFileNames = async ({
|
const getItemFileNames = async ({
|
||||||
courseName,
|
courseName,
|
||||||
@@ -61,9 +64,12 @@ const getItem = async <T extends CourseItemType>({
|
|||||||
name
|
name
|
||||||
) as CourseItemReturnType<T>;
|
) as CourseItemReturnType<T>;
|
||||||
} else if (type === "Quiz") {
|
} else if (type === "Quiz") {
|
||||||
return localQuizMarkdownUtils.parseMarkdown(
|
const globalSettings = await getGlobalSettings();
|
||||||
|
const delimiters = getFeedbackDelimitersFromSettings(globalSettings);
|
||||||
|
return quizMarkdownUtils.parseMarkdown(
|
||||||
rawFile,
|
rawFile,
|
||||||
name
|
name,
|
||||||
|
delimiters
|
||||||
) as CourseItemReturnType<T>;
|
) as CourseItemReturnType<T>;
|
||||||
} else if (type === "Page") {
|
} else if (type === "Page") {
|
||||||
return localPageMarkdownUtils.parseMarkdown(
|
return localPageMarkdownUtils.parseMarkdown(
|
||||||
@@ -93,7 +99,8 @@ export const courseItemFileStorageService = {
|
|||||||
try {
|
try {
|
||||||
const item = await getItem({ courseName, moduleName, name, type });
|
const item = await getItem({ courseName, moduleName, name, type });
|
||||||
return item;
|
return item;
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.log(`Error loading ${type} ${name} in module ${moduleName}:`, e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ async function migrateCourseContent(
|
|||||||
) {
|
) {
|
||||||
const oldCourseName = settingsFromCourseToImport.name;
|
const oldCourseName = settingsFromCourseToImport.name;
|
||||||
const newCourseName = settings.name;
|
const newCourseName = settings.name;
|
||||||
|
console.log(
|
||||||
|
"migrating content from ",
|
||||||
|
oldCourseName,
|
||||||
|
"to ",
|
||||||
|
newCourseName
|
||||||
|
);
|
||||||
|
|
||||||
const oldModules = await getModuleNamesFromFiles(oldCourseName);
|
const oldModules = await getModuleNamesFromFiles(oldCourseName);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
oldModules.map(async (moduleName) => {
|
oldModules.map(async (moduleName) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const zodGlobalSettingsCourse = z.object({
|
|||||||
|
|
||||||
export const zodGlobalSettings = z.object({
|
export const zodGlobalSettings = z.object({
|
||||||
courses: z.array(zodGlobalSettingsCourse),
|
courses: z.array(zodGlobalSettingsCourse),
|
||||||
|
feedbackDelims: z.record(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { getFeedbackDelimitersFromSettings, overriddenDefaults } from "./globalSettingsUtils";
|
||||||
|
import { defaultFeedbackDelimiters } from "../quizzes/models/utils/quizFeedbackMarkdownUtils";
|
||||||
|
import { GlobalSettings } from "./globalSettingsModels";
|
||||||
|
|
||||||
|
describe("overriddenDefaults", () => {
|
||||||
|
it("uses defaults when overrides are missing", () => {
|
||||||
|
const defaults = { a: 1, b: 2 };
|
||||||
|
const overrides = {};
|
||||||
|
expect(overriddenDefaults(defaults, overrides)).toEqual({ a: 1, b: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses overrides when present", () => {
|
||||||
|
const defaults = { a: 1, b: 2 };
|
||||||
|
const overrides = { a: 3 };
|
||||||
|
expect(overriddenDefaults(defaults, overrides)).toEqual({ a: 3, b: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores extra keys in overrides", () => {
|
||||||
|
const defaults = { a: 1 };
|
||||||
|
const overrides = { a: 2, c: 3 };
|
||||||
|
expect(overriddenDefaults(defaults, overrides)).toEqual({ a: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFeedbackDelimitersFromSettings", () => {
|
||||||
|
it("returns default delimiters if options are missing", () => {
|
||||||
|
const settings: GlobalSettings = {
|
||||||
|
courses: [],
|
||||||
|
};
|
||||||
|
expect(getFeedbackDelimitersFromSettings(settings)).toEqual(
|
||||||
|
defaultFeedbackDelimiters
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns custom delimiters if options are present", () => {
|
||||||
|
const settings: GlobalSettings = {
|
||||||
|
courses: [],
|
||||||
|
feedbackDelims: {
|
||||||
|
neutral: ":|",
|
||||||
|
correct: ":)",
|
||||||
|
incorrect: ":(",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const expected = {
|
||||||
|
correct: ":)",
|
||||||
|
incorrect: ":(",
|
||||||
|
neutral: ":|",
|
||||||
|
};
|
||||||
|
expect(getFeedbackDelimitersFromSettings(settings)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns mixed delimiters if some options are missing", () => {
|
||||||
|
const settings: GlobalSettings = {
|
||||||
|
courses: [],
|
||||||
|
feedbackDelims: {
|
||||||
|
correct: ":)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const expected = {
|
||||||
|
...defaultFeedbackDelimiters,
|
||||||
|
correct: ":)",
|
||||||
|
};
|
||||||
|
expect(getFeedbackDelimitersFromSettings(settings)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { GlobalSettings, zodGlobalSettings } from "./globalSettingsModels";
|
import { GlobalSettings, zodGlobalSettings } from "./globalSettingsModels";
|
||||||
import { parse, stringify } from "yaml";
|
import { parse, stringify } from "yaml";
|
||||||
|
import {
|
||||||
|
FeedbackDelimiters,
|
||||||
|
defaultFeedbackDelimiters,
|
||||||
|
} from "../quizzes/models/utils/quizFeedbackMarkdownUtils";
|
||||||
|
|
||||||
export const globalSettingsToYaml = (settings: GlobalSettings) => {
|
export const globalSettingsToYaml = (settings: GlobalSettings) => {
|
||||||
return stringify(settings);
|
return stringify(settings);
|
||||||
@@ -14,3 +18,22 @@ export const parseGlobalSettingsYaml = (yaml: string): GlobalSettings => {
|
|||||||
throw new Error(`Error parsing global settings, got ${yaml}, ${e}`);
|
throw new Error(`Error parsing global settings, got ${yaml}, ${e}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function overriddenDefaults<T>(
|
||||||
|
defaults: T,
|
||||||
|
overrides: Record<string, unknown>
|
||||||
|
): T {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(defaults as Record<string, unknown>).map(([k, v]) => [k, overrides[k] ?? v])
|
||||||
|
) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFeedbackDelimitersFromSettings = (
|
||||||
|
settings: GlobalSettings
|
||||||
|
): FeedbackDelimiters => {
|
||||||
|
return overriddenDefaults(
|
||||||
|
defaultFeedbackDelimiters,
|
||||||
|
settings.feedbackDelims ?? ({} as Record<string, unknown>)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export async function getModuleNamesFromFiles(courseName: string) {
|
|||||||
.map((dirent) => dirent.name);
|
.map((dirent) => dirent.name);
|
||||||
|
|
||||||
const modules = await Promise.all(modulePromises);
|
const modules = await Promise.all(modulePromises);
|
||||||
const modulesWithoutLectures = modules.filter((m) => m !== lectureFolderName);
|
const modulesWithoutLectures = modules.filter(
|
||||||
|
(m) => m !== lectureFolderName && !m.startsWith(".")
|
||||||
|
);
|
||||||
return modulesWithoutLectures.sort((a, b) => a.localeCompare(b));
|
return modulesWithoutLectures.sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import publicProcedure from "../../../services/serverFunctions/publicProcedure";
|
import publicProcedure from "../../../services/serverFunctions/publicProcedure";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { router } from "../../../services/serverFunctions/trpcSetup";
|
import { router } from "../../../services/serverFunctions/trpcSetup";
|
||||||
import { LocalCoursePage, localPageMarkdownUtils, zodLocalCoursePage } from "@/features/local/pages/localCoursePageModels";
|
import {
|
||||||
|
LocalCoursePage,
|
||||||
|
localPageMarkdownUtils,
|
||||||
|
zodLocalCoursePage,
|
||||||
|
} from "@/features/local/pages/localCoursePageModels";
|
||||||
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
|
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
|
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
|
||||||
|
import { assertValidFileName } from "@/services/fileNameValidation";
|
||||||
|
|
||||||
export const pageRouter = router({
|
export const pageRouter = router({
|
||||||
getPage: publicProcedure
|
getPage: publicProcedure
|
||||||
@@ -115,31 +120,32 @@ export const pageRouter = router({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function updatePageFile({
|
export async function updatePageFile({
|
||||||
courseName,
|
courseName,
|
||||||
|
moduleName,
|
||||||
|
pageName,
|
||||||
|
page,
|
||||||
|
}: {
|
||||||
|
courseName: string;
|
||||||
|
moduleName: string;
|
||||||
|
pageName: string;
|
||||||
|
page: LocalCoursePage;
|
||||||
|
}) {
|
||||||
|
assertValidFileName(pageName);
|
||||||
|
const courseDirectory = await getCoursePathByName(courseName);
|
||||||
|
const folder = path.join(courseDirectory, moduleName, "pages");
|
||||||
|
await fs.mkdir(folder, { recursive: true });
|
||||||
|
|
||||||
|
const filePath = path.join(
|
||||||
|
courseDirectory,
|
||||||
moduleName,
|
moduleName,
|
||||||
pageName,
|
"pages",
|
||||||
page,
|
pageName + ".md"
|
||||||
}: {
|
);
|
||||||
courseName: string;
|
|
||||||
moduleName: string;
|
|
||||||
pageName: string;
|
|
||||||
page: LocalCoursePage;
|
|
||||||
}) {
|
|
||||||
const courseDirectory = await getCoursePathByName(courseName);
|
|
||||||
const folder = path.join(courseDirectory, moduleName, "pages");
|
|
||||||
await fs.mkdir(folder, { recursive: true });
|
|
||||||
|
|
||||||
const filePath = path.join(
|
const pageMarkdown = localPageMarkdownUtils.toMarkdown(page);
|
||||||
courseDirectory,
|
console.log(`Saving page ${filePath}`);
|
||||||
moduleName,
|
await fs.writeFile(filePath, pageMarkdown);
|
||||||
"pages",
|
}
|
||||||
pageName + ".md"
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageMarkdown = localPageMarkdownUtils.toMarkdown(page);
|
|
||||||
console.log(`Saving page ${filePath}`);
|
|
||||||
await fs.writeFile(filePath, pageMarkdown);
|
|
||||||
}
|
|
||||||
async function deletePageFile({
|
async function deletePageFile({
|
||||||
courseName,
|
courseName,
|
||||||
moduleName,
|
moduleName,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { quizQuestionMarkdownUtils } from "../../quizzes/models/utils/quizQuestionMarkdownUtils";
|
||||||
|
import { FeedbackDelimiters } from "../../quizzes/models/utils/quizFeedbackMarkdownUtils";
|
||||||
|
import { QuestionType } from "../../quizzes/models/localQuizQuestion";
|
||||||
|
|
||||||
|
describe("Custom Feedback Delimiters", () => {
|
||||||
|
const customDelimiters: FeedbackDelimiters = {
|
||||||
|
correct: ":)",
|
||||||
|
incorrect: ":(",
|
||||||
|
neutral: ":|",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("can parse question with custom feedback delimiters", () => {
|
||||||
|
const input = `Points: 1
|
||||||
|
Question text
|
||||||
|
:) Correct feedback
|
||||||
|
:( Incorrect feedback
|
||||||
|
:| Neutral feedback
|
||||||
|
*a) Answer 1
|
||||||
|
b) Answer 2`;
|
||||||
|
|
||||||
|
const question = quizQuestionMarkdownUtils.parseMarkdown(input, 0, customDelimiters);
|
||||||
|
|
||||||
|
expect(question.correctComments).toBe("Correct feedback");
|
||||||
|
expect(question.incorrectComments).toBe("Incorrect feedback");
|
||||||
|
expect(question.neutralComments).toBe("Neutral feedback");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can serialize question with custom feedback delimiters", () => {
|
||||||
|
const question = {
|
||||||
|
points: 1,
|
||||||
|
text: "Question text",
|
||||||
|
questionType: "multiple_choice_question" as QuestionType,
|
||||||
|
answers: [
|
||||||
|
{ text: "Answer 1", correct: true, weight: 100 },
|
||||||
|
{ text: "Answer 2", correct: false, weight: 0 },
|
||||||
|
],
|
||||||
|
correctComments: "Correct feedback",
|
||||||
|
incorrectComments: "Incorrect feedback",
|
||||||
|
neutralComments: "Neutral feedback",
|
||||||
|
matchDistractors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdown = quizQuestionMarkdownUtils.toMarkdown(question, customDelimiters);
|
||||||
|
|
||||||
|
expect(markdown).toContain(":) Correct feedback");
|
||||||
|
expect(markdown).toContain(":( Incorrect feedback");
|
||||||
|
expect(markdown).toContain(":| Neutral feedback");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { quizQuestionMarkdownUtils } from "@/features/local/quizzes/models/utils/quizQuestionMarkdownUtils";
|
||||||
|
import { QuestionType, LocalQuizQuestion } from "@/features/local/quizzes/models/localQuizQuestion";
|
||||||
|
|
||||||
|
describe("feedback spacing", () => {
|
||||||
|
it("adds a blank line after feedback before answers", () => {
|
||||||
|
const question = {
|
||||||
|
text: "What is 2+2?",
|
||||||
|
questionType: QuestionType.MULTIPLE_CHOICE,
|
||||||
|
points: 1,
|
||||||
|
answers: [
|
||||||
|
{ correct: true, text: "4" },
|
||||||
|
],
|
||||||
|
matchDistractors: [],
|
||||||
|
correctComments: "Good",
|
||||||
|
incorrectComments: "No",
|
||||||
|
neutralComments: "Note",
|
||||||
|
} as LocalQuizQuestion;
|
||||||
|
|
||||||
|
const md = quizQuestionMarkdownUtils.toMarkdown(question);
|
||||||
|
|
||||||
|
// look for double newline separating feedback block and answer marker
|
||||||
|
expect(md).toMatch(/\n\n\*?a\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { quizMarkdownUtils } from "../../quizzes/models/utils/quizMarkdownUtils";
|
||||||
|
import { QuestionType } from "../../quizzes/models/localQuizQuestion";
|
||||||
|
|
||||||
|
describe("numerical answer questions", () => {
|
||||||
|
it("can parse question with numerical answers", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: quiz description
|
||||||
|
---
|
||||||
|
What is 2+3?
|
||||||
|
= 5
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
|
const question = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(question.text).toBe("What is 2+3?");
|
||||||
|
expect(question.questionType).toBe(QuestionType.NUMERICAL);
|
||||||
|
expect(question.answers[0].numericAnswer).toBe(5);
|
||||||
|
});
|
||||||
|
it("can parse question with range answers", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: quiz description
|
||||||
|
---
|
||||||
|
What is the cube root of 2?
|
||||||
|
= [1.2598, 1.2600]
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
|
const question = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(question.text).toBe("What is the cube root of 2?");
|
||||||
|
expect(question.questionType).toBe(QuestionType.NUMERICAL);
|
||||||
|
expect(question.answers[0].numericalAnswerType).toBe("range_answer");
|
||||||
|
expect(question.answers[0].numericAnswerRangeMin).toBe(1.2598);
|
||||||
|
expect(question.answers[0].numericAnswerRangeMax).toBe(1.26);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { QuestionType } from "@/features/local/quizzes/models/localQuizQuestion";
|
||||||
|
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
|
||||||
|
import { LocalQuiz } from "../../quizzes/models/localQuiz";
|
||||||
|
|
||||||
|
describe("Question Feedback options", () => {
|
||||||
|
it("can parse question with correct feedback", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: quiz description
|
||||||
|
---
|
||||||
|
Points: 3
|
||||||
|
What is the purpose of a context switch?
|
||||||
|
+ Correct! The context switch is used to change the current process by swapping the registers and other state with a new process
|
||||||
|
*a) To change the current window you are on
|
||||||
|
b) To change the current process's status
|
||||||
|
*c) To swap the current process's registers for a new process's registers
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
|
const question = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(question.correctComments).toBe(
|
||||||
|
"Correct! The context switch is used to change the current process by swapping the registers and other state with a new process"
|
||||||
|
);
|
||||||
|
expect(question.incorrectComments).toBeUndefined();
|
||||||
|
expect(question.neutralComments).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse question with incorrect feedback", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: quiz description
|
||||||
|
---
|
||||||
|
Points: 3
|
||||||
|
What state does a process need to be in to be able to be scheduled?
|
||||||
|
- Incorrect! A process in ready state can be scheduled
|
||||||
|
*a) Ready
|
||||||
|
b) Running
|
||||||
|
c) Zombie
|
||||||
|
d) Embryo
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
|
const question = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(question.incorrectComments).toBe(
|
||||||
|
"Incorrect! A process in ready state can be scheduled"
|
||||||
|
);
|
||||||
|
expect(question.correctComments).toBeUndefined();
|
||||||
|
expect(question.neutralComments).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse question with correct and incorrect feedback", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: quiz description
|
||||||
|
---
|
||||||
|
Points: 3
|
||||||
|
What is the purpose of a context switch?
|
||||||
|
+ Correct! The context switch is used to change the current process
|
||||||
|
- Incorrect! The context switch is NOT used to change windows
|
||||||
|
*a) To change the current window you are on
|
||||||
|
b) To change the current process's status
|
||||||
|
*c) To swap the current process's registers for a new process's registers
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
|
const question = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(question.correctComments).toBe(
|
||||||
|
"Correct! The context switch is used to change the current process"
|
||||||
|
);
|
||||||
|
expect(question.incorrectComments).toBe(
|
||||||
|
"Incorrect! The context switch is NOT used to change windows"
|
||||||
|
);
|
||||||
|
expect(question.neutralComments).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse question with neutral feedback", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: quiz description
|
||||||
|
---
|
||||||
|
Points: 3
|
||||||
|
What is a prime number?
|
||||||
|
... This feedback will be shown regardless of the answer
|
||||||
|
*a) A number divisible only by 1 and itself
|
||||||
|
b) Any odd number
|
||||||
|
c) Any even number
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
|
const question = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(question.neutralComments).toBe(
|
||||||
|
"This feedback will be shown regardless of the answer"
|
||||||
|
);
|
||||||
|
expect(question.correctComments).toBeUndefined();
|
||||||
|
expect(question.incorrectComments).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse question with all three feedback types", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: quiz description
|
||||||
|
---
|
||||||
|
Points: 3
|
||||||
|
What is the purpose of a context switch?
|
||||||
|
+ Great job! You understand context switching
|
||||||
|
- Try reviewing the material on process management
|
||||||
|
... Context switches are a fundamental operating system concept
|
||||||
|
*a) To change the current window you are on
|
||||||
|
b) To change the current process's status
|
||||||
|
*c) To swap the current process's registers for a new process's registers
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
|
const question = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(question.correctComments).toBe(
|
||||||
|
"Great job! You understand context switching"
|
||||||
|
);
|
||||||
|
expect(question.incorrectComments).toBe(
|
||||||
|
"Try reviewing the material on process management"
|
||||||
|
);
|
||||||
|
expect(question.neutralComments).toBe(
|
||||||
|
"Context switches are a fundamental operating system concept"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse multiline feedback", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: quiz description
|
||||||
|
---
|
||||||
|
Points: 3
|
||||||
|
What is the purpose of a context switch?
|
||||||
|
+ Correct! The context switch is used to change the current process.
|
||||||
|
This is additional information on a new line.
|
||||||
|
- Incorrect! You should review the material.
|
||||||
|
Check your notes on process management.
|
||||||
|
*a) To change the current window you are on
|
||||||
|
b) To change the current process's status
|
||||||
|
*c) To swap the current process's registers for a new process's registers
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
|
const question = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(question.correctComments).toBe(
|
||||||
|
"Correct! The context switch is used to change the current process.\nThis is additional information on a new line."
|
||||||
|
);
|
||||||
|
expect(question.incorrectComments).toBe(
|
||||||
|
"Incorrect! You should review the material.\nCheck your notes on process management."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("feedback can serialize to markdown", () => {
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name: "Test Quiz",
|
||||||
|
description: "quiz description",
|
||||||
|
lockAt: new Date(8640000000000000).toISOString(),
|
||||||
|
dueAt: new Date(8640000000000000).toISOString(),
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: false,
|
||||||
|
localAssignmentGroupName: "Assignments",
|
||||||
|
allowedAttempts: -1,
|
||||||
|
showCorrectAnswers: false,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: "What is the purpose of a context switch?",
|
||||||
|
questionType: QuestionType.MULTIPLE_CHOICE,
|
||||||
|
points: 3,
|
||||||
|
correctComments: "Correct! Good job",
|
||||||
|
incorrectComments: "Incorrect! Try again",
|
||||||
|
neutralComments: "Context switches are important",
|
||||||
|
answers: [
|
||||||
|
{ correct: false, text: "To change the current window you are on" },
|
||||||
|
{ correct: true, text: "To swap registers" },
|
||||||
|
],
|
||||||
|
matchDistractors: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
|
||||||
|
expect(markdown).toContain("+ Correct! Good job");
|
||||||
|
expect(markdown).toContain("- Incorrect! Try again");
|
||||||
|
expect(markdown).toContain("... Context switches are important");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse question with alternative format using ellipsis for general feedback", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: An addition question
|
||||||
|
---
|
||||||
|
Points: 2
|
||||||
|
What is 2+3?
|
||||||
|
... General question feedback.
|
||||||
|
+ Feedback for correct answer.
|
||||||
|
- Feedback for incorrect answer.
|
||||||
|
a) 6
|
||||||
|
b) 1
|
||||||
|
*c) 5
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
|
const question = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(question.text).toBe("What is 2+3?");
|
||||||
|
expect(question.points).toBe(2);
|
||||||
|
expect(question.neutralComments).toBe("General question feedback.");
|
||||||
|
expect(question.correctComments).toBe("Feedback for correct answer.");
|
||||||
|
expect(question.incorrectComments).toBe("Feedback for incorrect answer.");
|
||||||
|
expect(question.answers).toHaveLength(3);
|
||||||
|
expect(question.answers[0].text).toBe("6");
|
||||||
|
expect(question.answers[0].correct).toBe(false);
|
||||||
|
expect(question.answers[1].text).toBe("1");
|
||||||
|
expect(question.answers[1].correct).toBe(false);
|
||||||
|
expect(question.answers[2].text).toBe("5");
|
||||||
|
expect(question.answers[2].correct).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can parse multiline general feedback with ellipsis", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description: quiz description
|
||||||
|
---
|
||||||
|
Points: 2
|
||||||
|
What is 2+3?
|
||||||
|
...
|
||||||
|
General question feedback.
|
||||||
|
This continues on multiple lines.
|
||||||
|
+ Feedback for correct answer.
|
||||||
|
- Feedback for incorrect answer.
|
||||||
|
a) 6
|
||||||
|
b) 1
|
||||||
|
*c) 5
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
|
const question = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(question.neutralComments).toBe(
|
||||||
|
"General question feedback.\nThis continues on multiple lines."
|
||||||
|
);
|
||||||
|
expect(question.correctComments).toBe("Feedback for correct answer.");
|
||||||
|
expect(question.incorrectComments).toBe("Feedback for incorrect answer.");
|
||||||
|
});
|
||||||
|
it("essay questions can have feedback", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const rawMarkdownQuiz = `
|
||||||
|
ShuffleAnswers: true
|
||||||
|
OneQuestionAtATime: false
|
||||||
|
DueAt: 08/21/2023 23:59:00
|
||||||
|
LockAt: 08/21/2023 23:59:00
|
||||||
|
AssignmentGroup: Assignments
|
||||||
|
AllowedAttempts: -1
|
||||||
|
Description:
|
||||||
|
---
|
||||||
|
this is the description
|
||||||
|
... this is general feedback
|
||||||
|
essay
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
|
const firstQuestion = quiz.questions[0];
|
||||||
|
|
||||||
|
expect(firstQuestion.questionType).toBe(QuestionType.ESSAY);
|
||||||
|
expect(firstQuestion.text).not.toContain("this is general feedback");
|
||||||
|
expect(firstQuestion.neutralComments).toBe("this is general feedback");
|
||||||
|
expect(firstQuestion.neutralComments).not.toContain("...");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -200,6 +200,79 @@ describe("QuizDeterministicChecks", () => {
|
|||||||
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown, name);
|
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown, name);
|
||||||
|
|
||||||
|
expect(parsedQuiz).toEqual(quiz);
|
||||||
|
});
|
||||||
|
it("SerializationIsDeterministic Numeric with exact answer", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name,
|
||||||
|
description: "quiz description",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: true,
|
||||||
|
password: undefined,
|
||||||
|
localAssignmentGroupName: "Assignments",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: "test numeric",
|
||||||
|
questionType: QuestionType.NUMERICAL,
|
||||||
|
points: 1,
|
||||||
|
matchDistractors: [],
|
||||||
|
answers: [
|
||||||
|
{
|
||||||
|
text: "= 42",
|
||||||
|
correct: true,
|
||||||
|
numericalAnswerType: "exact_answer",
|
||||||
|
numericAnswer: 42,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowedAttempts: -1,
|
||||||
|
showCorrectAnswers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown, name);
|
||||||
|
|
||||||
|
expect(parsedQuiz).toEqual(quiz);
|
||||||
|
});
|
||||||
|
it("SerializationIsDeterministic Numeric with range answer", () => {
|
||||||
|
const name = "Test Quiz";
|
||||||
|
const quiz: LocalQuiz = {
|
||||||
|
name,
|
||||||
|
description: "quiz description",
|
||||||
|
lockAt: "08/21/2023 23:59:00",
|
||||||
|
dueAt: "08/21/2023 23:59:00",
|
||||||
|
shuffleAnswers: true,
|
||||||
|
oneQuestionAtATime: true,
|
||||||
|
password: undefined,
|
||||||
|
localAssignmentGroupName: "Assignments",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
text: "test numeric",
|
||||||
|
questionType: QuestionType.NUMERICAL,
|
||||||
|
points: 1,
|
||||||
|
matchDistractors: [],
|
||||||
|
answers: [
|
||||||
|
{
|
||||||
|
text: "= [2, 5]",
|
||||||
|
correct: true,
|
||||||
|
numericalAnswerType: "range_answer",
|
||||||
|
numericAnswerRangeMin: 2,
|
||||||
|
numericAnswerRangeMax: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowedAttempts: -1,
|
||||||
|
showCorrectAnswers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
||||||
|
const parsedQuiz = quizMarkdownUtils.parseMarkdown(quizMarkdown, name);
|
||||||
|
|
||||||
expect(parsedQuiz).toEqual(quiz);
|
expect(parsedQuiz).toEqual(quiz);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { QuestionType } from "@/features/local/quizzes/models/localQuizQuestion"
|
|||||||
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
|
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
|
||||||
import { quizQuestionMarkdownUtils } from "@/features/local/quizzes/models/utils/quizQuestionMarkdownUtils";
|
import { quizQuestionMarkdownUtils } from "@/features/local/quizzes/models/utils/quizQuestionMarkdownUtils";
|
||||||
|
|
||||||
// Test suite for QuizMarkdown
|
|
||||||
describe("QuizMarkdownTests", () => {
|
describe("QuizMarkdownTests", () => {
|
||||||
it("can serialize quiz to markdown", () => {
|
it("can serialize quiz to markdown", () => {
|
||||||
const quiz: LocalQuiz = {
|
const quiz: LocalQuiz = {
|
||||||
@@ -281,4 +280,5 @@ b) false
|
|||||||
expect(quizHtml).toContain("<mi>x</mi>");
|
expect(quizHtml).toContain("<mi>x</mi>");
|
||||||
expect(quizHtml).not.toContain("x_2");
|
expect(quizHtml).not.toContain("x_2");
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { getQuestionType, getAnswers } from "@/features/canvas/services/canvasQuizService";
|
import {
|
||||||
|
getQuestionTypeForCanvas,
|
||||||
|
getAnswersForCanvas,
|
||||||
|
} from "@/features/canvas/services/canvasQuizService";
|
||||||
import {
|
import {
|
||||||
QuestionType,
|
QuestionType,
|
||||||
zodQuestionType,
|
|
||||||
} from "@/features/local/quizzes/models/localQuizQuestion";
|
} from "@/features/local/quizzes/models/localQuizQuestion";
|
||||||
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
|
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
|
||||||
import { quizQuestionMarkdownUtils } from "@/features/local/quizzes/models/utils/quizQuestionMarkdownUtils";
|
import { quizQuestionMarkdownUtils } from "@/features/local/quizzes/models/utils/quizQuestionMarkdownUtils";
|
||||||
@@ -205,12 +207,6 @@ short_answer=
|
|||||||
expect(firstQuestion.answers[1].text).toBe("other");
|
expect(firstQuestion.answers[1].text).toBe("other");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Has short_answer= type at the same position in types and zod types", () => {
|
|
||||||
expect(Object.values(zodQuestionType.Enum)).toEqual(
|
|
||||||
Object.values(QuestionType)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Associates short_answer= questions with short_answer_question canvas question type", () => {
|
it("Associates short_answer= questions with short_answer_question canvas question type", () => {
|
||||||
const name = "Test Quiz";
|
const name = "Test Quiz";
|
||||||
const rawMarkdownQuiz = `
|
const rawMarkdownQuiz = `
|
||||||
@@ -232,7 +228,9 @@ short_answer=
|
|||||||
|
|
||||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
const firstQuestion = quiz.questions[0];
|
const firstQuestion = quiz.questions[0];
|
||||||
expect(getQuestionType(firstQuestion)).toBe("short_answer_question");
|
expect(getQuestionTypeForCanvas(firstQuestion)).toBe(
|
||||||
|
"short_answer_question"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Includes answer_text in answers sent to canvas", () => {
|
it("Includes answer_text in answers sent to canvas", () => {
|
||||||
@@ -256,7 +254,7 @@ short_answer=
|
|||||||
|
|
||||||
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name);
|
||||||
const firstQuestion = quiz.questions[0];
|
const firstQuestion = quiz.questions[0];
|
||||||
const answers = getAnswers(firstQuestion, {
|
const answers = getAnswersForCanvas(firstQuestion, {
|
||||||
name: "",
|
name: "",
|
||||||
assignmentGroups: [],
|
assignmentGroups: [],
|
||||||
daysOfWeek: [],
|
daysOfWeek: [],
|
||||||
|
|||||||
@@ -1,22 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { LocalQuizQuestion, zodLocalQuizQuestion } from "./localQuizQuestion";
|
import { zodLocalQuizQuestion } from "./localQuizQuestion";
|
||||||
import { quizMarkdownUtils } from "./utils/quizMarkdownUtils";
|
|
||||||
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
||||||
|
|
||||||
export interface LocalQuiz extends IModuleItem {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
password?: string;
|
|
||||||
lockAt?: string; // ISO 8601 date string
|
|
||||||
dueAt: string; // ISO 8601 date string
|
|
||||||
shuffleAnswers: boolean;
|
|
||||||
showCorrectAnswers: boolean;
|
|
||||||
oneQuestionAtATime: boolean;
|
|
||||||
localAssignmentGroupName?: string;
|
|
||||||
allowedAttempts: number;
|
|
||||||
questions: LocalQuizQuestion[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const zodLocalQuiz = z.object({
|
export const zodLocalQuiz = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
@@ -31,7 +16,4 @@ export const zodLocalQuiz = z.object({
|
|||||||
questions: zodLocalQuizQuestion.array(),
|
questions: zodLocalQuizQuestion.array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const localQuizMarkdownUtils = {
|
export interface LocalQuiz extends IModuleItem, z.infer<typeof zodLocalQuiz> {}
|
||||||
parseMarkdown: quizMarkdownUtils.parseMarkdown,
|
|
||||||
toMarkdown: quizMarkdownUtils.toMarkdown,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,40 +1,38 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { zodLocalQuizQuestionAnswer } from "./localQuizQuestionAnswer";
|
||||||
LocalQuizQuestionAnswer,
|
|
||||||
zodLocalQuizQuestionAnswer,
|
|
||||||
} from "./localQuizQuestionAnswer";
|
|
||||||
|
|
||||||
export enum QuestionType {
|
|
||||||
MULTIPLE_ANSWERS = "multiple_answers",
|
|
||||||
MULTIPLE_CHOICE = "multiple_choice",
|
|
||||||
ESSAY = "essay",
|
|
||||||
SHORT_ANSWER = "short_answer",
|
|
||||||
MATCHING = "matching",
|
|
||||||
NONE = "",
|
|
||||||
SHORT_ANSWER_WITH_ANSWERS = "short_answer=",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const zodQuestionType = z.enum([
|
export const zodQuestionType = z.enum([
|
||||||
QuestionType.MULTIPLE_ANSWERS,
|
"multiple_answers",
|
||||||
QuestionType.MULTIPLE_CHOICE,
|
"multiple_choice",
|
||||||
QuestionType.ESSAY,
|
"essay",
|
||||||
QuestionType.SHORT_ANSWER,
|
"short_answer",
|
||||||
QuestionType.MATCHING,
|
"matching",
|
||||||
QuestionType.NONE,
|
"",
|
||||||
QuestionType.SHORT_ANSWER_WITH_ANSWERS,
|
"short_answer=",
|
||||||
|
"numerical",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export interface LocalQuizQuestion {
|
export const QuestionType = {
|
||||||
text: string;
|
MULTIPLE_ANSWERS: "multiple_answers",
|
||||||
questionType: QuestionType;
|
MULTIPLE_CHOICE: "multiple_choice",
|
||||||
points: number;
|
ESSAY: "essay",
|
||||||
answers: LocalQuizQuestionAnswer[];
|
SHORT_ANSWER: "short_answer",
|
||||||
matchDistractors: string[];
|
MATCHING: "matching",
|
||||||
}
|
NONE: "",
|
||||||
|
SHORT_ANSWER_WITH_ANSWERS: "short_answer=",
|
||||||
|
NUMERICAL: "numerical",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type QuestionType = z.infer<typeof zodQuestionType>;
|
||||||
|
|
||||||
export const zodLocalQuizQuestion = z.object({
|
export const zodLocalQuizQuestion = z.object({
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
questionType: zodQuestionType,
|
questionType: zodQuestionType,
|
||||||
points: z.number(),
|
points: z.number(),
|
||||||
answers: zodLocalQuizQuestionAnswer.array(),
|
answers: zodLocalQuizQuestionAnswer.array(),
|
||||||
matchDistractors: z.array(z.string()),
|
matchDistractors: z.array(z.string()),
|
||||||
|
correctComments: z.string().optional(),
|
||||||
|
incorrectComments: z.string().optional(),
|
||||||
|
neutralComments: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
export type LocalQuizQuestion = z.infer<typeof zodLocalQuizQuestion>;
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export interface LocalQuizQuestionAnswer {
|
|
||||||
correct: boolean;
|
|
||||||
text: string;
|
|
||||||
matchedText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const zodLocalQuizQuestionAnswer = z.object({
|
export const zodLocalQuizQuestionAnswer = z.object({
|
||||||
correct: z.boolean(),
|
correct: z.boolean(),
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
matchedText: z.string().optional(),
|
matchedText: z.string().optional(),
|
||||||
|
numericalAnswerType: z
|
||||||
|
.enum(["exact_answer", "range_answer", "precision_answer"])
|
||||||
|
.optional(),
|
||||||
|
numericAnswer: z.number().optional(),
|
||||||
|
numericAnswerRangeMin: z.number().optional(),
|
||||||
|
numericAnswerRangeMax: z.number().optional(),
|
||||||
|
numericAnswerMargin: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type LocalQuizQuestionAnswer = z.infer<
|
||||||
|
typeof zodLocalQuizQuestionAnswer
|
||||||
|
>;
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
export interface FeedbackDelimiters {
|
||||||
|
correct: string;
|
||||||
|
incorrect: string;
|
||||||
|
neutral: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultFeedbackDelimiters: FeedbackDelimiters = {
|
||||||
|
correct: "+",
|
||||||
|
incorrect: "-",
|
||||||
|
neutral: "...",
|
||||||
|
};
|
||||||
|
|
||||||
|
type feedbackTypeOptions = "correct" | "incorrect" | "neutral" | "none";
|
||||||
|
|
||||||
|
export const quizFeedbackMarkdownUtils = {
|
||||||
|
extractFeedback(
|
||||||
|
lines: string[],
|
||||||
|
delimiters: FeedbackDelimiters = defaultFeedbackDelimiters
|
||||||
|
): {
|
||||||
|
correctComments?: string;
|
||||||
|
incorrectComments?: string;
|
||||||
|
neutralComments?: string;
|
||||||
|
otherLines: string[];
|
||||||
|
} {
|
||||||
|
const comments = {
|
||||||
|
correct: [] as string[],
|
||||||
|
incorrect: [] as string[],
|
||||||
|
neutral: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const otherLines: string[] = [];
|
||||||
|
|
||||||
|
const feedbackIndicators = delimiters;
|
||||||
|
|
||||||
|
let currentFeedbackType: feedbackTypeOptions = "none";
|
||||||
|
|
||||||
|
for (const line of lines.map((l) => l)) {
|
||||||
|
const lineFeedbackType: feedbackTypeOptions = line.startsWith(
|
||||||
|
feedbackIndicators.correct
|
||||||
|
)
|
||||||
|
? "correct"
|
||||||
|
: line.startsWith(feedbackIndicators.incorrect)
|
||||||
|
? "incorrect"
|
||||||
|
: line.startsWith(feedbackIndicators.neutral)
|
||||||
|
? "neutral"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
if (lineFeedbackType === "none" && currentFeedbackType !== "none") {
|
||||||
|
const lineWithoutIndicator = line
|
||||||
|
.replace(feedbackIndicators[currentFeedbackType], "")
|
||||||
|
.trim();
|
||||||
|
comments[currentFeedbackType].push(lineWithoutIndicator);
|
||||||
|
} else if (lineFeedbackType !== "none") {
|
||||||
|
const lineWithoutIndicator = line
|
||||||
|
.replace(feedbackIndicators[lineFeedbackType], "")
|
||||||
|
.trim();
|
||||||
|
currentFeedbackType = lineFeedbackType;
|
||||||
|
comments[lineFeedbackType].push(lineWithoutIndicator);
|
||||||
|
} else {
|
||||||
|
otherLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const correctComments = comments.correct.filter((l) => l).join("\n");
|
||||||
|
const incorrectComments = comments.incorrect.filter((l) => l).join("\n");
|
||||||
|
const neutralComments = comments.neutral.filter((l) => l).join("\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
correctComments: correctComments || undefined,
|
||||||
|
incorrectComments: incorrectComments || undefined,
|
||||||
|
neutralComments: neutralComments || undefined,
|
||||||
|
otherLines,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
formatFeedback(
|
||||||
|
correctComments?: string,
|
||||||
|
incorrectComments?: string,
|
||||||
|
neutralComments?: string,
|
||||||
|
delimiters: FeedbackDelimiters = defaultFeedbackDelimiters
|
||||||
|
): string {
|
||||||
|
let feedbackText = "";
|
||||||
|
if (correctComments) {
|
||||||
|
feedbackText += `${delimiters.correct} ${correctComments}\n`;
|
||||||
|
}
|
||||||
|
if (incorrectComments) {
|
||||||
|
feedbackText += `${delimiters.incorrect} ${incorrectComments}\n`;
|
||||||
|
}
|
||||||
|
if (neutralComments) {
|
||||||
|
feedbackText += `${delimiters.neutral} ${neutralComments}\n`;
|
||||||
|
}
|
||||||
|
// Ensure there's a blank line after feedback block so answers are separated
|
||||||
|
if (feedbackText) feedbackText += "\n";
|
||||||
|
return feedbackText;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
} from "@/features/local/utils/timeUtils";
|
} from "@/features/local/utils/timeUtils";
|
||||||
import { LocalQuiz } from "../localQuiz";
|
import { LocalQuiz } from "../localQuiz";
|
||||||
import { quizQuestionMarkdownUtils } from "./quizQuestionMarkdownUtils";
|
import { quizQuestionMarkdownUtils } from "./quizQuestionMarkdownUtils";
|
||||||
|
import { FeedbackDelimiters } from "./quizFeedbackMarkdownUtils";
|
||||||
|
|
||||||
const extractLabelValue = (input: string, label: string): string => {
|
const extractLabelValue = (input: string, label: string): string => {
|
||||||
const pattern = new RegExp(`${label}: (.*?)\n`);
|
const pattern = new RegExp(`${label}: (.*?)\n`);
|
||||||
@@ -103,7 +104,7 @@ const getQuizWithOnlySettings = (settings: string, name: string): LocalQuiz => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const quizMarkdownUtils = {
|
export const quizMarkdownUtils = {
|
||||||
toMarkdown(quiz: LocalQuiz): string {
|
toMarkdown(quiz: LocalQuiz, delimiters?: FeedbackDelimiters): string {
|
||||||
if (!quiz) {
|
if (!quiz) {
|
||||||
throw Error(`quiz was undefined, cannot parse markdown`);
|
throw Error(`quiz was undefined, cannot parse markdown`);
|
||||||
}
|
}
|
||||||
@@ -115,7 +116,7 @@ export const quizMarkdownUtils = {
|
|||||||
throw Error(`quiz ${quiz.name} is probably not a quiz`);
|
throw Error(`quiz ${quiz.name} is probably not a quiz`);
|
||||||
}
|
}
|
||||||
const questionMarkdownArray = quiz.questions.map((q) =>
|
const questionMarkdownArray = quiz.questions.map((q) =>
|
||||||
quizQuestionMarkdownUtils.toMarkdown(q)
|
quizQuestionMarkdownUtils.toMarkdown(q, delimiters)
|
||||||
);
|
);
|
||||||
const questionDelimiter = "\n\n---\n\n";
|
const questionDelimiter = "\n\n---\n\n";
|
||||||
const questionMarkdown = questionMarkdownArray.join(questionDelimiter);
|
const questionMarkdown = questionMarkdownArray.join(questionDelimiter);
|
||||||
@@ -133,7 +134,11 @@ Description: ${quiz.description}
|
|||||||
${questionMarkdown}`;
|
${questionMarkdown}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
parseMarkdown(input: string, name: string): LocalQuiz {
|
parseMarkdown(
|
||||||
|
input: string,
|
||||||
|
name: string,
|
||||||
|
delimiters?: FeedbackDelimiters
|
||||||
|
): LocalQuiz {
|
||||||
const splitInput = input.split("---\n");
|
const splitInput = input.split("---\n");
|
||||||
const settings = splitInput[0];
|
const settings = splitInput[0];
|
||||||
const quizWithoutQuestions = getQuizWithOnlySettings(settings, name);
|
const quizWithoutQuestions = getQuizWithOnlySettings(settings, name);
|
||||||
@@ -141,7 +146,7 @@ ${questionMarkdown}`;
|
|||||||
const rawQuestions = splitInput.slice(1);
|
const rawQuestions = splitInput.slice(1);
|
||||||
const questions = rawQuestions
|
const questions = rawQuestions
|
||||||
.filter((str) => str.trim().length > 0)
|
.filter((str) => str.trim().length > 0)
|
||||||
.map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i));
|
.map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i, delimiters));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...quizWithoutQuestions,
|
...quizWithoutQuestions,
|
||||||
|
|||||||
@@ -1,5 +1,49 @@
|
|||||||
import { QuestionType } from "../localQuizQuestion";
|
import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion";
|
||||||
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
|
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
|
||||||
|
const _validFirstAnswerDelimiters = [
|
||||||
|
"*a)",
|
||||||
|
"a)",
|
||||||
|
"*)",
|
||||||
|
")",
|
||||||
|
"[ ]",
|
||||||
|
"[]",
|
||||||
|
"[*]",
|
||||||
|
"^",
|
||||||
|
"=",
|
||||||
|
];
|
||||||
|
const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"];
|
||||||
|
const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"];
|
||||||
|
|
||||||
|
const parseNumericalAnswer = (input: string): LocalQuizQuestionAnswer => {
|
||||||
|
const trimmedInput = input.replace(/^=\s*/, "").trim();
|
||||||
|
|
||||||
|
// Check if it's a range answer: = [min, max]
|
||||||
|
const minMaxPattern = /^\[([^,]+),\s*([^\]]+)\]$/;
|
||||||
|
const rangeNumbericAnswerMatch = trimmedInput.match(minMaxPattern);
|
||||||
|
|
||||||
|
if (rangeNumbericAnswerMatch) {
|
||||||
|
const minValue = parseFloat(rangeNumbericAnswerMatch[1].trim());
|
||||||
|
const maxValue = parseFloat(rangeNumbericAnswerMatch[2].trim());
|
||||||
|
const answer: LocalQuizQuestionAnswer = {
|
||||||
|
correct: true,
|
||||||
|
text: input.trim(),
|
||||||
|
numericalAnswerType: "range_answer",
|
||||||
|
numericAnswerRangeMin: minValue,
|
||||||
|
numericAnswerRangeMax: maxValue,
|
||||||
|
};
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, it's an exact answer
|
||||||
|
const numericValue = parseFloat(trimmedInput);
|
||||||
|
const answer: LocalQuizQuestionAnswer = {
|
||||||
|
correct: true,
|
||||||
|
text: input.trim(),
|
||||||
|
numericalAnswerType: "exact_answer",
|
||||||
|
numericAnswer: numericValue,
|
||||||
|
};
|
||||||
|
return answer;
|
||||||
|
};
|
||||||
|
|
||||||
const parseMatchingAnswer = (input: string) => {
|
const parseMatchingAnswer = (input: string) => {
|
||||||
const matchingPattern = /^\^?/;
|
const matchingPattern = /^\^?/;
|
||||||
@@ -13,14 +57,51 @@ const parseMatchingAnswer = (input: string) => {
|
|||||||
return answer;
|
return answer;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getAnswerStringsWithMultilineSupport = (
|
||||||
|
linesWithoutPoints: string[],
|
||||||
|
questionIndex: number
|
||||||
|
) => {
|
||||||
|
const indexOfAnswerStart = linesWithoutPoints.findIndex((l) =>
|
||||||
|
_validFirstAnswerDelimiters.some((prefix) =>
|
||||||
|
l.trimStart().startsWith(prefix)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (indexOfAnswerStart === -1) {
|
||||||
|
const debugLine = linesWithoutPoints.find((l) => l.trim().length > 0);
|
||||||
|
throw Error(
|
||||||
|
`question ${
|
||||||
|
questionIndex + 1
|
||||||
|
}: no answers when detecting question type on ${debugLine}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerLinesRaw = linesWithoutPoints.slice(indexOfAnswerStart);
|
||||||
|
|
||||||
|
const answerStartPattern = /^(\*?[a-z]?\))|(?<!\S)\[\s*\]|\[\*\]|\^/;
|
||||||
|
const answerLines = answerLinesRaw.reduce((acc: string[], line: string) => {
|
||||||
|
const isNewAnswer = answerStartPattern.test(line);
|
||||||
|
if (isNewAnswer) {
|
||||||
|
acc.push(line);
|
||||||
|
} else if (acc.length !== 0) {
|
||||||
|
acc[acc.length - 1] += "\n" + line;
|
||||||
|
} else {
|
||||||
|
acc.push(line);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
return answerLines;
|
||||||
|
};
|
||||||
|
|
||||||
export const quizQuestionAnswerMarkdownUtils = {
|
export const quizQuestionAnswerMarkdownUtils = {
|
||||||
// getHtmlText(): string {
|
parseMarkdown(
|
||||||
// return MarkdownService.render(this.text);
|
input: string,
|
||||||
// }
|
questionType: QuestionType
|
||||||
|
): LocalQuizQuestionAnswer {
|
||||||
|
if (questionType === QuestionType.NUMERICAL) {
|
||||||
|
return parseNumericalAnswer(input);
|
||||||
|
}
|
||||||
|
|
||||||
parseMarkdown(input: string, questionType: string): LocalQuizQuestionAnswer {
|
|
||||||
const isCorrect = input.startsWith("*") || input[1] === "*";
|
const isCorrect = input.startsWith("*") || input[1] === "*";
|
||||||
|
|
||||||
if (questionType === QuestionType.MATCHING) {
|
if (questionType === QuestionType.MATCHING) {
|
||||||
return parseMatchingAnswer(input);
|
return parseMatchingAnswer(input);
|
||||||
}
|
}
|
||||||
@@ -38,4 +119,117 @@ export const quizQuestionAnswerMarkdownUtils = {
|
|||||||
};
|
};
|
||||||
return answer;
|
return answer;
|
||||||
},
|
},
|
||||||
|
isAnswerLine: (trimmedLine: string): boolean => {
|
||||||
|
return _validFirstAnswerDelimiters.some((prefix) =>
|
||||||
|
trimmedLine.startsWith(prefix)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getQuestionType: (
|
||||||
|
linesWithoutPoints: string[],
|
||||||
|
questionIndex: number // needed for debug logging
|
||||||
|
): QuestionType => {
|
||||||
|
const lastLine = linesWithoutPoints[linesWithoutPoints.length - 1]
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
if (linesWithoutPoints.length === 0) return QuestionType.NONE;
|
||||||
|
if (lastLine === "essay") return QuestionType.ESSAY;
|
||||||
|
if (lastLine === "short answer") return QuestionType.SHORT_ANSWER;
|
||||||
|
if (lastLine === "short_answer") return QuestionType.SHORT_ANSWER;
|
||||||
|
if (lastLine === "short_answer=")
|
||||||
|
return QuestionType.SHORT_ANSWER_WITH_ANSWERS;
|
||||||
|
if (lastLine.startsWith("=")) return QuestionType.NUMERICAL;
|
||||||
|
|
||||||
|
const answerLines = getAnswerStringsWithMultilineSupport(
|
||||||
|
linesWithoutPoints,
|
||||||
|
questionIndex
|
||||||
|
);
|
||||||
|
const firstAnswerLine = answerLines[0];
|
||||||
|
const isMultipleChoice = _multipleChoicePrefix.some((prefix) =>
|
||||||
|
firstAnswerLine.startsWith(prefix)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMultipleChoice) return QuestionType.MULTIPLE_CHOICE;
|
||||||
|
|
||||||
|
const isMultipleAnswer = _multipleAnswerPrefix.some((prefix) =>
|
||||||
|
firstAnswerLine.startsWith(prefix)
|
||||||
|
);
|
||||||
|
if (isMultipleAnswer) return QuestionType.MULTIPLE_ANSWERS;
|
||||||
|
|
||||||
|
const isMatching = firstAnswerLine.startsWith("^");
|
||||||
|
if (isMatching) return QuestionType.MATCHING;
|
||||||
|
|
||||||
|
return QuestionType.NONE;
|
||||||
|
},
|
||||||
|
getAnswers: (
|
||||||
|
linesWithoutPoints: string[],
|
||||||
|
questionIndex: number,
|
||||||
|
questionType: QuestionType
|
||||||
|
): { answers: LocalQuizQuestionAnswer[]; distractors: string[] } => {
|
||||||
|
const typesWithAnswers: QuestionType[] = [
|
||||||
|
QuestionType.MULTIPLE_CHOICE,
|
||||||
|
QuestionType.MULTIPLE_ANSWERS,
|
||||||
|
QuestionType.MATCHING,
|
||||||
|
QuestionType.SHORT_ANSWER_WITH_ANSWERS,
|
||||||
|
QuestionType.NUMERICAL,
|
||||||
|
];
|
||||||
|
if (!typesWithAnswers.includes(questionType)) {
|
||||||
|
return { answers: [], distractors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (questionType == QuestionType.SHORT_ANSWER_WITH_ANSWERS)
|
||||||
|
linesWithoutPoints = linesWithoutPoints.slice(
|
||||||
|
0,
|
||||||
|
linesWithoutPoints.length - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const answerLines = getAnswerStringsWithMultilineSupport(
|
||||||
|
linesWithoutPoints,
|
||||||
|
questionIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
const allAnswers = answerLines.map((a) =>
|
||||||
|
quizQuestionAnswerMarkdownUtils.parseMarkdown(a, questionType)
|
||||||
|
);
|
||||||
|
|
||||||
|
// For matching questions, separate answers from distractors
|
||||||
|
if (questionType === QuestionType.MATCHING) {
|
||||||
|
const answers = allAnswers.filter((a) => a.text);
|
||||||
|
const distractors = allAnswers
|
||||||
|
.filter((a) => !a.text)
|
||||||
|
.map((a) => a.matchedText ?? "");
|
||||||
|
return { answers, distractors };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { answers: allAnswers, distractors: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
getAnswerMarkdown: (
|
||||||
|
question: LocalQuizQuestion,
|
||||||
|
answer: LocalQuizQuestionAnswer,
|
||||||
|
index: number
|
||||||
|
): string => {
|
||||||
|
const multilineMarkdownCompatibleText = answer.text.startsWith("```")
|
||||||
|
? "\n" + answer.text
|
||||||
|
: answer.text;
|
||||||
|
|
||||||
|
if (question.questionType === "multiple_answers") {
|
||||||
|
const correctIndicator = answer.correct ? "*" : " ";
|
||||||
|
const questionTypeIndicator = `[${correctIndicator}] `;
|
||||||
|
|
||||||
|
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
|
||||||
|
} else if (question.questionType === "matching") {
|
||||||
|
return `^ ${answer.text} - ${answer.matchedText}`;
|
||||||
|
} else if (question.questionType === "numerical") {
|
||||||
|
if (answer.numericalAnswerType === "range_answer") {
|
||||||
|
return `= [${answer.numericAnswerRangeMin}, ${answer.numericAnswerRangeMax}]`;
|
||||||
|
}
|
||||||
|
return `= ${answer.numericAnswer}`;
|
||||||
|
} else {
|
||||||
|
const questionLetter = String.fromCharCode(97 + index);
|
||||||
|
const correctIndicator = answer.correct ? "*" : "";
|
||||||
|
const questionTypeIndicator = `${correctIndicator}${questionLetter}) `;
|
||||||
|
|
||||||
|
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,151 +1,72 @@
|
|||||||
import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion";
|
import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion";
|
||||||
import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer";
|
import {
|
||||||
|
quizFeedbackMarkdownUtils,
|
||||||
|
FeedbackDelimiters,
|
||||||
|
} from "./quizFeedbackMarkdownUtils";
|
||||||
import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils";
|
import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils";
|
||||||
|
|
||||||
const _validFirstAnswerDelimiters = [
|
const splitLinesAndPoints = (input: string[]) => {
|
||||||
"*a)",
|
const firstLineIsPoints = input[0].toLowerCase().includes("points: ");
|
||||||
"a)",
|
|
||||||
"*)",
|
|
||||||
")",
|
|
||||||
"[ ]",
|
|
||||||
"[]",
|
|
||||||
"[*]",
|
|
||||||
"^",
|
|
||||||
];
|
|
||||||
const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"];
|
|
||||||
const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"];
|
|
||||||
|
|
||||||
const getAnswerStringsWithMultilineSupport = (
|
const textHasPointsLine =
|
||||||
linesWithoutPoints: string[],
|
input.length > 0 &&
|
||||||
questionIndex: number
|
input[0].includes(": ") &&
|
||||||
) => {
|
input[0].split(": ").length > 1 &&
|
||||||
const indexOfAnswerStart = linesWithoutPoints.findIndex((l) =>
|
!isNaN(parseFloat(input[0].split(": ")[1]));
|
||||||
_validFirstAnswerDelimiters.some((prefix) =>
|
|
||||||
l.trimStart().startsWith(prefix)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (indexOfAnswerStart === -1) {
|
|
||||||
const debugLine = linesWithoutPoints.find((l) => l.trim().length > 0);
|
|
||||||
throw Error(
|
|
||||||
`question ${
|
|
||||||
questionIndex + 1
|
|
||||||
}: no answers when detecting question type on ${debugLine}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const answerLinesRaw = linesWithoutPoints.slice(indexOfAnswerStart);
|
const points =
|
||||||
|
firstLineIsPoints && textHasPointsLine
|
||||||
|
? parseFloat(input[0].split(": ")[1])
|
||||||
|
: 1;
|
||||||
|
|
||||||
const answerStartPattern = /^(\*?[a-z]?\))|(?<!\S)\[\s*\]|\[\*\]|\^/;
|
const linesWithoutPoints = firstLineIsPoints ? input.slice(1) : input;
|
||||||
const answerLines = answerLinesRaw.reduce((acc: string[], line: string) => {
|
|
||||||
const isNewAnswer = answerStartPattern.test(line);
|
|
||||||
if (isNewAnswer) {
|
|
||||||
acc.push(line);
|
|
||||||
} else if (acc.length !== 0) {
|
|
||||||
acc[acc.length - 1] += "\n" + line;
|
|
||||||
} else {
|
|
||||||
acc.push(line);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
return answerLines;
|
|
||||||
};
|
|
||||||
const getQuestionType = (
|
|
||||||
linesWithoutPoints: string[],
|
|
||||||
questionIndex: number
|
|
||||||
): QuestionType => {
|
|
||||||
if (linesWithoutPoints.length === 0) return QuestionType.NONE;
|
|
||||||
if (
|
|
||||||
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() === "essay"
|
|
||||||
)
|
|
||||||
return QuestionType.ESSAY;
|
|
||||||
if (
|
|
||||||
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() ===
|
|
||||||
"short answer"
|
|
||||||
)
|
|
||||||
return QuestionType.SHORT_ANSWER;
|
|
||||||
if (
|
|
||||||
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() ===
|
|
||||||
"short_answer"
|
|
||||||
)
|
|
||||||
return QuestionType.SHORT_ANSWER;
|
|
||||||
if (
|
|
||||||
linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase().trim() ===
|
|
||||||
"short_answer="
|
|
||||||
)
|
|
||||||
return QuestionType.SHORT_ANSWER_WITH_ANSWERS;
|
|
||||||
|
|
||||||
const answerLines = getAnswerStringsWithMultilineSupport(
|
return { points, lines: linesWithoutPoints };
|
||||||
linesWithoutPoints,
|
|
||||||
questionIndex
|
|
||||||
);
|
|
||||||
const firstAnswerLine = answerLines[0];
|
|
||||||
const isMultipleChoice = _multipleChoicePrefix.some((prefix) =>
|
|
||||||
firstAnswerLine.startsWith(prefix)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isMultipleChoice) return QuestionType.MULTIPLE_CHOICE;
|
|
||||||
|
|
||||||
const isMultipleAnswer = _multipleAnswerPrefix.some((prefix) =>
|
|
||||||
firstAnswerLine.startsWith(prefix)
|
|
||||||
);
|
|
||||||
if (isMultipleAnswer) return QuestionType.MULTIPLE_ANSWERS;
|
|
||||||
|
|
||||||
const isMatching = firstAnswerLine.startsWith("^");
|
|
||||||
if (isMatching) return QuestionType.MATCHING;
|
|
||||||
|
|
||||||
return QuestionType.NONE;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAnswers = (
|
const getLinesBeforeAnswerLines = (lines: string[]): string[] => {
|
||||||
linesWithoutPoints: string[],
|
const { linesWithoutAnswers } = lines.reduce(
|
||||||
questionIndex: number,
|
({ linesWithoutAnswers, taking }, currentLine) => {
|
||||||
questionType: string
|
if (!taking)
|
||||||
): LocalQuizQuestionAnswer[] => {
|
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
|
||||||
if (questionType == QuestionType.SHORT_ANSWER_WITH_ANSWERS)
|
|
||||||
linesWithoutPoints = linesWithoutPoints.slice(
|
|
||||||
0,
|
|
||||||
linesWithoutPoints.length - 1
|
|
||||||
);
|
|
||||||
const answerLines = getAnswerStringsWithMultilineSupport(
|
|
||||||
linesWithoutPoints,
|
|
||||||
questionIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
const answers = answerLines.map((a) =>
|
const lineIsAnswer =
|
||||||
quizQuestionAnswerMarkdownUtils.parseMarkdown(a, questionType)
|
quizQuestionAnswerMarkdownUtils.isAnswerLine(currentLine);
|
||||||
|
if (lineIsAnswer)
|
||||||
|
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
|
||||||
|
|
||||||
|
return {
|
||||||
|
linesWithoutAnswers: [...linesWithoutAnswers, currentLine],
|
||||||
|
taking: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ linesWithoutAnswers: [] as string[], taking: true }
|
||||||
);
|
);
|
||||||
return answers;
|
return linesWithoutAnswers;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAnswerMarkdown = (
|
const removeQuestionTypeFromDescriptionLines = (
|
||||||
question: LocalQuizQuestion,
|
linesWithoutAnswers: string[],
|
||||||
answer: LocalQuizQuestionAnswer,
|
questionType: QuestionType
|
||||||
index: number
|
): string[] => {
|
||||||
): string => {
|
const questionTypesWithoutAnswers = ["essay", "short answer", "short_answer"];
|
||||||
const multilineMarkdownCompatibleText = answer.text.startsWith("```")
|
|
||||||
? "\n" + answer.text
|
|
||||||
: answer.text;
|
|
||||||
|
|
||||||
if (question.questionType === "multiple_answers") {
|
const descriptionLines = questionTypesWithoutAnswers.includes(questionType)
|
||||||
const correctIndicator = answer.correct ? "*" : " ";
|
? linesWithoutAnswers.filter(
|
||||||
const questionTypeIndicator = `[${correctIndicator}] `;
|
(line) => !questionTypesWithoutAnswers.includes(line.toLowerCase())
|
||||||
|
)
|
||||||
|
: linesWithoutAnswers;
|
||||||
|
|
||||||
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
|
return descriptionLines;
|
||||||
} else if (question.questionType === "matching") {
|
|
||||||
return `^ ${answer.text} - ${answer.matchedText}`;
|
|
||||||
} else {
|
|
||||||
const questionLetter = String.fromCharCode(97 + index);
|
|
||||||
const correctIndicator = answer.correct ? "*" : "";
|
|
||||||
const questionTypeIndicator = `${correctIndicator}${questionLetter}) `;
|
|
||||||
|
|
||||||
return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const quizQuestionMarkdownUtils = {
|
export const quizQuestionMarkdownUtils = {
|
||||||
toMarkdown(question: LocalQuizQuestion): string {
|
toMarkdown(
|
||||||
|
question: LocalQuizQuestion,
|
||||||
|
delimiters?: FeedbackDelimiters
|
||||||
|
): string {
|
||||||
const answerArray = question.answers.map((a, i) =>
|
const answerArray = question.answers.map((a, i) =>
|
||||||
getAnswerMarkdown(question, a, i)
|
quizQuestionAnswerMarkdownUtils.getAnswerMarkdown(question, a, i)
|
||||||
);
|
);
|
||||||
|
|
||||||
const distractorText =
|
const distractorText =
|
||||||
@@ -153,6 +74,14 @@ export const quizQuestionMarkdownUtils = {
|
|||||||
? question.matchDistractors?.map((d) => `\n^ - ${d}`).join("") ?? ""
|
? question.matchDistractors?.map((d) => `\n^ - ${d}`).join("") ?? ""
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
// Build feedback lines
|
||||||
|
const feedbackText = quizFeedbackMarkdownUtils.formatFeedback(
|
||||||
|
question.correctComments,
|
||||||
|
question.incorrectComments,
|
||||||
|
question.neutralComments,
|
||||||
|
delimiters
|
||||||
|
);
|
||||||
|
|
||||||
const answersText = answerArray.join("\n");
|
const answersText = answerArray.join("\n");
|
||||||
const questionTypeIndicator =
|
const questionTypeIndicator =
|
||||||
question.questionType === "essay" ||
|
question.questionType === "essay" ||
|
||||||
@@ -162,91 +91,53 @@ export const quizQuestionMarkdownUtils = {
|
|||||||
? `\n${QuestionType.SHORT_ANSWER_WITH_ANSWERS}`
|
? `\n${QuestionType.SHORT_ANSWER_WITH_ANSWERS}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return `Points: ${question.points}\n${question.text}\n${answersText}${distractorText}${questionTypeIndicator}`;
|
return `Points: ${question.points}\n${question.text}\n${feedbackText}${answersText}${distractorText}${questionTypeIndicator}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion {
|
parseMarkdown(
|
||||||
const lines = input.trim().split("\n");
|
input: string,
|
||||||
const firstLineIsPoints = lines[0].toLowerCase().includes("points: ");
|
questionIndex: number,
|
||||||
|
delimiters?: FeedbackDelimiters
|
||||||
|
): LocalQuizQuestion {
|
||||||
|
const { points, lines } = splitLinesAndPoints(input.trim().split("\n"));
|
||||||
|
|
||||||
const textHasPoints =
|
const linesWithoutAnswers = getLinesBeforeAnswerLines(lines);
|
||||||
lines.length > 0 &&
|
|
||||||
lines[0].includes(": ") &&
|
|
||||||
lines[0].split(": ").length > 1 &&
|
|
||||||
!isNaN(parseFloat(lines[0].split(": ")[1]));
|
|
||||||
|
|
||||||
const points =
|
const questionType = quizQuestionAnswerMarkdownUtils.getQuestionType(
|
||||||
firstLineIsPoints && textHasPoints
|
lines,
|
||||||
? parseFloat(lines[0].split(": ")[1])
|
questionIndex
|
||||||
: 1;
|
|
||||||
|
|
||||||
const linesWithoutPoints = firstLineIsPoints ? lines.slice(1) : lines;
|
|
||||||
|
|
||||||
const { linesWithoutAnswers } = linesWithoutPoints.reduce(
|
|
||||||
({ linesWithoutAnswers, taking }, currentLine) => {
|
|
||||||
if (!taking)
|
|
||||||
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
|
|
||||||
|
|
||||||
const lineIsAnswer = _validFirstAnswerDelimiters.some((prefix) =>
|
|
||||||
currentLine.trimStart().startsWith(prefix)
|
|
||||||
);
|
|
||||||
if (lineIsAnswer)
|
|
||||||
return { linesWithoutAnswers: linesWithoutAnswers, taking: false };
|
|
||||||
|
|
||||||
return {
|
|
||||||
linesWithoutAnswers: [...linesWithoutAnswers, currentLine],
|
|
||||||
taking: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ linesWithoutAnswers: [] as string[], taking: true }
|
|
||||||
);
|
);
|
||||||
const questionType = getQuestionType(linesWithoutPoints, questionIndex);
|
|
||||||
|
|
||||||
const questionTypesWithoutAnswers = [
|
const linesWithoutAnswersAndTypes = removeQuestionTypeFromDescriptionLines(
|
||||||
"essay",
|
linesWithoutAnswers,
|
||||||
"short answer",
|
questionType
|
||||||
"short_answer",
|
);
|
||||||
];
|
|
||||||
|
|
||||||
const descriptionLines = questionTypesWithoutAnswers.includes(
|
const {
|
||||||
questionType.toLowerCase()
|
correctComments,
|
||||||
)
|
incorrectComments,
|
||||||
? linesWithoutAnswers
|
neutralComments,
|
||||||
.slice(0, linesWithoutPoints.length)
|
otherLines: descriptionLines,
|
||||||
.filter(
|
} = quizFeedbackMarkdownUtils.extractFeedback(
|
||||||
(line) =>
|
linesWithoutAnswersAndTypes,
|
||||||
!questionTypesWithoutAnswers.includes(line.toLowerCase())
|
delimiters
|
||||||
)
|
);
|
||||||
: linesWithoutAnswers;
|
|
||||||
|
|
||||||
const description = descriptionLines.join("\n");
|
const { answers, distractors } = quizQuestionAnswerMarkdownUtils.getAnswers(
|
||||||
|
lines,
|
||||||
const typesWithAnswers = [
|
questionIndex,
|
||||||
"multiple_choice",
|
questionType
|
||||||
"multiple_answers",
|
);
|
||||||
"matching",
|
|
||||||
"short_answer=",
|
|
||||||
];
|
|
||||||
const answers = typesWithAnswers.includes(questionType)
|
|
||||||
? getAnswers(linesWithoutPoints, questionIndex, questionType)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const answersWithoutDistractors =
|
|
||||||
questionType === QuestionType.MATCHING
|
|
||||||
? answers.filter((a) => a.text)
|
|
||||||
: answers;
|
|
||||||
|
|
||||||
const distractors =
|
|
||||||
questionType === QuestionType.MATCHING
|
|
||||||
? answers.filter((a) => !a.text).map((a) => a.matchedText ?? "")
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const question: LocalQuizQuestion = {
|
const question: LocalQuizQuestion = {
|
||||||
text: description,
|
text: descriptionLines.join("\n"),
|
||||||
questionType,
|
questionType,
|
||||||
points,
|
points,
|
||||||
answers: answersWithoutDistractors,
|
answers,
|
||||||
matchDistractors: distractors,
|
matchDistractors: distractors,
|
||||||
|
correctComments,
|
||||||
|
incorrectComments,
|
||||||
|
neutralComments,
|
||||||
};
|
};
|
||||||
return question;
|
return question;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ import {
|
|||||||
LocalQuiz,
|
LocalQuiz,
|
||||||
zodLocalQuiz,
|
zodLocalQuiz,
|
||||||
} from "@/features/local/quizzes/models/localQuiz";
|
} from "@/features/local/quizzes/models/localQuiz";
|
||||||
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
|
import {
|
||||||
|
getCoursePathByName,
|
||||||
|
getGlobalSettings,
|
||||||
|
} from "../globalSettings/globalSettingsFileStorageService";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import { quizMarkdownUtils } from "./models/utils/quizMarkdownUtils";
|
import { quizMarkdownUtils } from "./models/utils/quizMarkdownUtils";
|
||||||
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
|
import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
|
||||||
|
import { getFeedbackDelimitersFromSettings } from "../globalSettings/globalSettingsUtils";
|
||||||
|
import { assertValidFileName } from "@/services/fileNameValidation";
|
||||||
|
|
||||||
export const quizRouter = router({
|
export const quizRouter = router({
|
||||||
getQuiz: publicProcedure
|
getQuiz: publicProcedure
|
||||||
@@ -149,6 +154,7 @@ export async function updateQuizFile({
|
|||||||
quizName: string;
|
quizName: string;
|
||||||
quiz: LocalQuiz;
|
quiz: LocalQuiz;
|
||||||
}) {
|
}) {
|
||||||
|
assertValidFileName(quizName);
|
||||||
const courseDirectory = await getCoursePathByName(courseName);
|
const courseDirectory = await getCoursePathByName(courseName);
|
||||||
const folder = path.join(courseDirectory, moduleName, "quizzes");
|
const folder = path.join(courseDirectory, moduleName, "quizzes");
|
||||||
await fs.mkdir(folder, { recursive: true });
|
await fs.mkdir(folder, { recursive: true });
|
||||||
@@ -159,7 +165,9 @@ export async function updateQuizFile({
|
|||||||
quizName + ".md"
|
quizName + ".md"
|
||||||
);
|
);
|
||||||
|
|
||||||
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);
|
const globalSettings = await getGlobalSettings();
|
||||||
|
const delimiters = getFeedbackDelimitersFromSettings(globalSettings);
|
||||||
|
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz, delimiters);
|
||||||
console.log(`Saving quiz ${filePath}`);
|
console.log(`Saving quiz ${filePath}`);
|
||||||
await fs.writeFile(filePath, quizMarkdown);
|
await fs.writeFile(filePath, quizMarkdown);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,4 +25,13 @@ export const directoriesRouter = router({
|
|||||||
.query(async ({ input: { folderPath } }) => {
|
.query(async ({ input: { folderPath } }) => {
|
||||||
return await fileStorageService.settings.folderIsCourse(folderPath);
|
return await fileStorageService.settings.folderIsCourse(folderPath);
|
||||||
}),
|
}),
|
||||||
|
directoryExists: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
relativePath: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input: { relativePath } }) => {
|
||||||
|
return await fileStorageService.directoryExists(relativePath);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,4 +72,15 @@ export const fileStorageService = {
|
|||||||
}
|
}
|
||||||
return { files, folders };
|
return { files, folders };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async directoryExists(relativePath: string): Promise<boolean> {
|
||||||
|
const fullPath = path.join(basePath, relativePath);
|
||||||
|
// Security: ensure fullPath is inside basePath
|
||||||
|
const resolvedBase = path.resolve(basePath);
|
||||||
|
const resolvedFull = path.resolve(fullPath);
|
||||||
|
if (!resolvedFull.startsWith(resolvedBase)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await directoryOrFileExists(fullPath);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,3 +23,10 @@ export const useDirectoryIsCourseQuery = (folderPath: string) => {
|
|||||||
trpc.directories.directoryIsCourse.queryOptions({ folderPath })
|
trpc.directories.directoryIsCourse.queryOptions({ folderPath })
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useDirectoryExistsQuery = (relativePath: string) => {
|
||||||
|
const trpc = useTRPC();
|
||||||
|
return useQuery(
|
||||||
|
trpc.directories.directoryExists.queryOptions({ relativePath })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
28
src/services/fileNameValidation.ts
Normal file
28
src/services/fileNameValidation.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export function validateFileName(fileName: string): string {
|
||||||
|
if (!fileName || fileName.trim() === "") {
|
||||||
|
return "Name cannot be empty";
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidChars = [":", "/", "\\", "*", "?", '"', "<", ">", "|"];
|
||||||
|
|
||||||
|
for (const char of fileName) {
|
||||||
|
if (invalidChars.includes(char)) {
|
||||||
|
return `Name contains invalid character: "${char}". Please avoid: ${invalidChars.join(
|
||||||
|
" "
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileName !== fileName.trimEnd()) {
|
||||||
|
return "Name cannot end with whitespace";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertValidFileName(fileName: string): void {
|
||||||
|
const error = validateFileName(fileName);
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/services/htmlMarkdownUtils.mermaid.test.ts
Normal file
22
src/services/htmlMarkdownUtils.mermaid.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { markdownToHtmlNoImages } from './htmlMarkdownUtils';
|
||||||
|
|
||||||
|
describe('markdownToHtmlNoImages', () => {
|
||||||
|
it('renders mermaid diagrams correctly using pako compression', () => {
|
||||||
|
const markdown = `
|
||||||
|
\`\`\`mermaid
|
||||||
|
graph TD;
|
||||||
|
A-->B;
|
||||||
|
A-->C;
|
||||||
|
B-->D;
|
||||||
|
C-->D;
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
const html = markdownToHtmlNoImages(markdown);
|
||||||
|
|
||||||
|
// The expected URL part for the graph above when compressed with pako
|
||||||
|
const expectedUrlPart = "pako:eNqrVkrOT0lVslJKL0osyFAIcbGOyVMAAkddXTsnJLYzlO0EZMPUOIPZSjpKualFuYmZKUpW1UolGam5IONSUtMSS3NKlGprAQJ0Gx4";
|
||||||
|
|
||||||
|
expect(html).toContain(`https://mermaid.ink/img/${expectedUrlPart}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user