From cad8656565ae8b6701706f31927eb7eebbe0f717 Mon Sep 17 00:00:00 2001 From: nadjajovancevic Date: Wed, 5 Aug 2026 11:51:47 +0200 Subject: [PATCH 01/59] Check if lectures have been fetched Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../labextension/src/app/pages/instructor-view/dashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/labextension/src/app/pages/instructor-view/dashboard.tsx b/packages/labextension/src/app/pages/instructor-view/dashboard.tsx index 186726c4..d50291fe 100644 --- a/packages/labextension/src/app/pages/instructor-view/dashboard.tsx +++ b/packages/labextension/src/app/pages/instructor-view/dashboard.tsx @@ -194,7 +194,7 @@ export const Dashboard = () => {
- Deadline is in{' '} - { - (dayDiff = calculateDaysDifference({ - startDate: new Date(field.state.value), - endDate: new Date() - })) - }{' '} - {determineDisplayText({ text: 'day', data: dayDiff })} + Deadline is in {timeDiffText()}.
{filteredLectures.length}{' '} - {determineDisplayText({ + {pluralize({ text: 'result', data: filteredLectures })}{' '} diff --git a/packages/labextension/src/app/shadcn-components/ui/date-time-picker.tsx b/packages/labextension/src/app/shadcn-components/ui/date-time-picker.tsx index 49187a2f..351c9929 100644 --- a/packages/labextension/src/app/shadcn-components/ui/date-time-picker.tsx +++ b/packages/labextension/src/app/shadcn-components/ui/date-time-picker.tsx @@ -10,15 +10,27 @@ import { Field, FieldGroup, FieldLabel } from './field'; import { Popover, PopoverContent, PopoverTrigger } from './popover'; import { AnyFieldApi } from '@tanstack/react-form'; import { InputGroup, InputGroupAddon, InputGroupInput } from './input-group'; +import { calculateDaysDifference } from '../../components/utils/utils'; interface IDatePickerTime { open: boolean; setOpen: React.Dispatch>; field: AnyFieldApi; + setTimeDiff?: + | React.Dispatch>> + | undefined; } export function DatePickerTime(props: IDatePickerTime) { const date = props.field.state.value as Date | undefined; + const setTimeDiff = (selectedDate: Date) => { + props.setTimeDiff( + calculateDaysDifference({ + endDate: new Date(selectedDate), + startDate: new Date() + }) + ); + }; const handleDateSelect = (selected: Date | undefined) => { if (!selected) { @@ -27,13 +39,15 @@ export function DatePickerTime(props: IDatePickerTime) { props.field.handleChange(selected.toISOString()); props.setOpen(false); + setTimeDiff(selected); }; const handleTimeChange = (e: React.ChangeEvent) => { - const [hours, minutes, seconds] = e.target.value.split(':').map(Number); + const [hours, minutes] = e.target.value.split(':').map(Number); const updated = date ? new Date(date) : new Date(); - updated.setHours(hours, minutes, seconds ?? 0); + updated.setHours(hours, minutes ?? 0); props.field.handleChange(updated.toISOString()); + setTimeDiff(updated); }; const deleteDate = () => { @@ -92,8 +106,7 @@ export function DatePickerTime(props: IDatePickerTime) { Date: Thu, 6 Aug 2026 12:45:28 +0200 Subject: [PATCH 09/59] fix: truncate overflowing text --- .../assignments/assignment-create-edit-dialog.tsx | 4 +--- .../src/app/pages/instructor-view/lecture.tsx | 8 +++++--- .../labextension/src/app/shadcn-components/ui/dialog.tsx | 2 +- .../labextension/src/app/shadcn-components/ui/field.tsx | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/labextension/src/app/components/grader-service/assignments/assignment-create-edit-dialog.tsx b/packages/labextension/src/app/components/grader-service/assignments/assignment-create-edit-dialog.tsx index cd566018..646c808a 100644 --- a/packages/labextension/src/app/components/grader-service/assignments/assignment-create-edit-dialog.tsx +++ b/packages/labextension/src/app/components/grader-service/assignments/assignment-create-edit-dialog.tsx @@ -190,9 +190,7 @@ export const AssignmentCreateEditDialog = (props: IAssignmentSettingsForm) => { - {props.assignment - ? `Edit ${props.assignment.name}` - : 'New Assignment'} + {props.assignment ? `Edit assignment` : 'New assignment'} diff --git a/packages/labextension/src/app/pages/instructor-view/lecture.tsx b/packages/labextension/src/app/pages/instructor-view/lecture.tsx index dcb660f5..2b4d55f5 100644 --- a/packages/labextension/src/app/pages/instructor-view/lecture.tsx +++ b/packages/labextension/src/app/pages/instructor-view/lecture.tsx @@ -307,12 +307,14 @@ export const Lecture = () => { > {!isPendingLecture && ( - - {lecture.name} + + + {lecture.name} + {lecture.code} )} diff --git a/packages/labextension/src/app/shadcn-components/ui/dialog.tsx b/packages/labextension/src/app/shadcn-components/ui/dialog.tsx index 74eccfe0..8a21af02 100644 --- a/packages/labextension/src/app/shadcn-components/ui/dialog.tsx +++ b/packages/labextension/src/app/shadcn-components/ui/dialog.tsx @@ -84,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { ) { } const fieldVariants = cva( - 'group/field flex w-full gap-1.5 rounded-xs data-[invalid=true]:text-destructive', + 'group/field flex min-w-0 w-full gap-1.5 rounded-xs data-[invalid=true]:text-destructive', { variants: { orientation: { From 432f3293ec7bcd637414abd27b4260c9ba480b6a Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Thu, 6 Aug 2026 12:46:18 +0200 Subject: [PATCH 10/59] fix: remove width constraint on tooltip's content container --- packages/labextension/src/app/shadcn-components/ui/tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/labextension/src/app/shadcn-components/ui/tooltip.tsx b/packages/labextension/src/app/shadcn-components/ui/tooltip.tsx index 4713c0a0..48a14e0c 100644 --- a/packages/labextension/src/app/shadcn-components/ui/tooltip.tsx +++ b/packages/labextension/src/app/shadcn-components/ui/tooltip.tsx @@ -51,7 +51,7 @@ function TooltipContent({ Date: Thu, 6 Aug 2026 13:11:52 +0200 Subject: [PATCH 11/59] fix: center global sidebar buttons when collapsed --- .../labextension/src/app/components/ui/global-sidebar.tsx | 6 +++++- packages/labextension/style/css/output.css | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/labextension/src/app/components/ui/global-sidebar.tsx b/packages/labextension/src/app/components/ui/global-sidebar.tsx index 713e30a7..2c17b019 100644 --- a/packages/labextension/src/app/components/ui/global-sidebar.tsx +++ b/packages/labextension/src/app/components/ui/global-sidebar.tsx @@ -94,7 +94,11 @@ export const GlobalSidebar = () => { } diff --git a/packages/labextension/style/css/output.css b/packages/labextension/style/css/output.css index 25424841..001a19b6 100644 --- a/packages/labextension/style/css/output.css +++ b/packages/labextension/style/css/output.css @@ -565,6 +565,10 @@ width: calc(var(--spacing) * 9); height: calc(var(--spacing) * 9); } + .size-15 { + width: calc(var(--spacing) * 15); + height: calc(var(--spacing) * 15); + } .size-20 { width: calc(var(--spacing) * 20); height: calc(var(--spacing) * 20); From 0c19942c6e172cde0b4d9fde0a30a48e4dcb729e Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Thu, 6 Aug 2026 14:45:20 +0200 Subject: [PATCH 12/59] fix: adjust icons' styling --- .../grader-service/assignments/assignment-card.tsx | 2 +- .../grader-service/lectures/edit-lecture-dialog.tsx | 2 +- .../components/grader-service/lectures/lecture-card.tsx | 4 ++-- packages/labextension/style/css/output.css | 9 +++++++++ 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/labextension/src/app/components/grader-service/assignments/assignment-card.tsx b/packages/labextension/src/app/components/grader-service/assignments/assignment-card.tsx index 8a1e1ea9..76db2cf8 100644 --- a/packages/labextension/src/app/components/grader-service/assignments/assignment-card.tsx +++ b/packages/labextension/src/app/components/grader-service/assignments/assignment-card.tsx @@ -169,7 +169,7 @@ export const AssignmentCard = (props: IAssignment) => { Go to notebooks & files diff --git a/packages/labextension/src/app/components/grader-service/lectures/edit-lecture-dialog.tsx b/packages/labextension/src/app/components/grader-service/lectures/edit-lecture-dialog.tsx index f8f89697..494d3614 100644 --- a/packages/labextension/src/app/components/grader-service/lectures/edit-lecture-dialog.tsx +++ b/packages/labextension/src/app/components/grader-service/lectures/edit-lecture-dialog.tsx @@ -67,7 +67,7 @@ export const EditLectureDialog = (props: IEditLectureDialog) => { }> - + Edit course diff --git a/packages/labextension/src/app/components/grader-service/lectures/lecture-card.tsx b/packages/labextension/src/app/components/grader-service/lectures/lecture-card.tsx index ce12ce42..a16827d3 100644 --- a/packages/labextension/src/app/components/grader-service/lectures/lecture-card.tsx +++ b/packages/labextension/src/app/components/grader-service/lectures/lecture-card.tsx @@ -80,7 +80,7 @@ export const LectureCard = (props: ILectureCard) => { } text={`${assignments.length.toString()} ${pluralize({ @@ -92,7 +92,7 @@ export const LectureCard = (props: ILectureCard) => { } text={`${students.length.toString()} ${pluralize({ diff --git a/packages/labextension/style/css/output.css b/packages/labextension/style/css/output.css index 001a19b6..fed36489 100644 --- a/packages/labextension/style/css/output.css +++ b/packages/labextension/style/css/output.css @@ -1587,6 +1587,12 @@ .text-border { color: var(--border); } + .text-card { + color: var(--card); + } + .text-card\! { + color: var(--card) !important; + } .text-card-foreground { color: var(--card-foreground); } @@ -3640,6 +3646,9 @@ .dark\:border-input:is(.dark *) { border-color: var(--input); } + .dark\:bg-\[\#2B2B2B\]:is(.dark *) { + background-color: #2b2b2b; + } .dark\:bg-\[\#E46E2E\]:is(.dark *) { background-color: #e46e2e; } From 6206047b6f52307749449b34846fae5d0fdc986e Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Fri, 7 Aug 2026 12:12:06 +0200 Subject: [PATCH 13/59] refactor: separate git and assignment request functions --- .../src/services/assignments.service.ts | 103 +++++------------- .../labextension/src/services/git.service.ts | 52 +++++++++ 2 files changed, 81 insertions(+), 74 deletions(-) create mode 100644 packages/labextension/src/services/git.service.ts diff --git a/packages/labextension/src/services/assignments.service.ts b/packages/labextension/src/services/assignments.service.ts index 24c60c53..d4579bc1 100644 --- a/packages/labextension/src/services/assignments.service.ts +++ b/packages/labextension/src/services/assignments.service.ts @@ -6,27 +6,18 @@ import { Assignment } from '../model/assignment'; import { AssignmentDetail } from '../model/assignmentDetail'; -import { Lecture } from '../model/lecture'; import { HTTPMethod, request } from './request.service'; -import { RepoType } from '../app/components/utils/repo-type'; -export function createAssignment( - lectureId: number, - assignment: Assignment -): Promise { - return request( - HTTPMethod.POST, - `/api/lectures/${lectureId}/assignments`, - assignment - ); -} +export const buildBaseUrl = (lectureId: number) => { + return `/api/lectures/${lectureId}/assignments`; +}; export function getAllAssignments( lectureId: number, reload = false, includeSubmissions = false ): Promise { - let url = `/api/lectures/${lectureId}/assignments`; + let url = buildBaseUrl(lectureId); if (includeSubmissions) { const searchParams = new URLSearchParams({ 'include-submissions': String(includeSubmissions) @@ -43,22 +34,20 @@ export function getAssignment( ): Promise { return request( HTTPMethod.GET, - `/api/lectures/${lectureId}/assignments/${assignmentId}`, + `${buildBaseUrl(lectureId)}/${assignmentId}`, null, reload ); } -export function getAssignmentProperties( +export function createAssignment( lectureId: number, - assignmentId: number, - reload: boolean = false -): Promise { - return request( - HTTPMethod.GET, - `/api/lectures/${lectureId}/assignments/${assignmentId}/properties`, - null, - reload + assignment: Assignment +): Promise { + return request( + HTTPMethod.POST, + buildBaseUrl(lectureId), + assignment ); } @@ -70,19 +59,32 @@ export function updateAssignment( const searchParams = new URLSearchParams({ 'recalc-scores': String(recalcScores) }); - let url = `/api/lectures/${lectureId}/assignments/${assignment.id}`; + let url = `${buildBaseUrl(lectureId)}/${assignment.id}`; url += '?' + searchParams; return request(HTTPMethod.PUT, url, assignment); } +export function getAssignmentProperties( + lectureId: number, + assignmentId: number, + reload: boolean = false +): Promise { + return request( + HTTPMethod.GET, + `${buildBaseUrl(lectureId)}/${assignmentId}/properties`, + null, + reload + ); +} + export function generateAssignment( lectureId: number, assignment: Assignment ): Promise { return request( HTTPMethod.PUT, - `/api/lectures/${lectureId}/assignments/${assignment.id}/generate`, + `${buildBaseUrl(lectureId)}/${assignment.id}/generate`, null ); } @@ -94,7 +96,7 @@ export function fetchAssignment( metadataOnly: boolean = false, reload: boolean = false ): Promise { - let url = `/api/lectures/${lectureId}/assignments/${assignmentId}`; + let url = `${buildBaseUrl(lectureId)}/${assignmentId}`; if (instructor || metadataOnly) { const searchParams = new URLSearchParams({ 'instructor-version': String(instructor), @@ -112,54 +114,7 @@ export function deleteAssignment( ): Promise { return request( HTTPMethod.DELETE, - `/api/lectures/${lectureId}/assignments/${assignmentId}`, - null - ); -} - -export function pushAssignment( - lectureId: number, - assignmentId: number, - repoType: RepoType, - commitMessage?: string, - selectedFiles?: string[] -): Promise { - let url = `/api/lectures/${lectureId}/assignments/${assignmentId}/push/${repoType}`; - if (commitMessage) { - const searchParams = new URLSearchParams({ - 'commit-message': commitMessage - }); - url += '?' + searchParams; - } - - if (selectedFiles && selectedFiles.length > 0) { - selectedFiles.forEach(file => { - url += `&selected-files=${encodeURIComponent(file)}`; - }); - } - - return request(HTTPMethod.PUT, url, null); -} - -export function pullAssignment( - lectureId: number, - assignmentId: number, - repoType: RepoType -): Promise { - return request( - HTTPMethod.GET, - `/api/lectures/${lectureId}/assignments/${assignmentId}/pull/${repoType}`, - null - ); -} - -export function resetAssignment( - lecture: Lecture, - assignment: Assignment -): Promise { - return request( - HTTPMethod.GET, - `/api/lectures/${lecture.id}/assignments/${assignment.id}/reset`, + `${buildBaseUrl(lectureId)}/${assignmentId}`, null ); } diff --git a/packages/labextension/src/services/git.service.ts b/packages/labextension/src/services/git.service.ts new file mode 100644 index 00000000..c930c14a --- /dev/null +++ b/packages/labextension/src/services/git.service.ts @@ -0,0 +1,52 @@ +import { RepoType } from '../app/components/utils/repo-type'; +import { HTTPMethod, request } from './request.service'; +import { Lecture } from '../model/lecture'; +import { Assignment } from '../model/assignment'; +import { buildBaseUrl } from './assignments.service'; + +export function pushAssignment( + lectureId: number, + assignmentId: number, + repoType: RepoType, + commitMessage?: string, + selectedFiles?: string[] +): Promise { + let url = `${buildBaseUrl(lectureId)}/${assignmentId}/push/${repoType}`; + if (commitMessage) { + const searchParams = new URLSearchParams({ + 'commit-message': commitMessage + }); + url += '?' + searchParams; + } + + if (selectedFiles && selectedFiles.length > 0) { + selectedFiles.forEach(file => { + url += `&selected-files=${encodeURIComponent(file)}`; + }); + } + + return request(HTTPMethod.PUT, url, null); +} + +export function pullAssignment( + lectureId: number, + assignmentId: number, + repoType: RepoType +): Promise { + return request( + HTTPMethod.GET, + `${buildBaseUrl(lectureId)}/${assignmentId}/pull/${repoType}`, + null + ); +} + +export function resetAssignment( + lecture: Lecture, + assignment: Assignment +): Promise { + return request( + HTTPMethod.GET, + `${buildBaseUrl(lecture.id)}/${assignment.id}/reset`, + null + ); +} From 89f7d677164867bc9a3cfadb8aec0bdadaa0bb2b Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Fri, 7 Aug 2026 12:14:40 +0200 Subject: [PATCH 14/59] refactor: remove error handling from functions in file services --- .../assignments/export-grades-dialog.tsx | 11 +++++----- .../src/services/file-browser.service.ts | 11 +++------- .../labextension/src/services/file.service.ts | 21 +++++++------------ .../src/services/submissions.service.ts | 10 +++++++++ 4 files changed, 26 insertions(+), 27 deletions(-) create mode 100644 packages/labextension/src/services/submissions.service.ts diff --git a/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx b/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx index 97dbb8b3..1ad7713b 100644 --- a/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx +++ b/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx @@ -15,11 +15,11 @@ import { SelectValue } from '../../../shadcn-components/ui/select'; import { Button } from '../../../shadcn-components/ui/button'; -import { exportGrades } from '../../../../services/lectures.service'; +import { exportGrades } from '../../../../services/submissions.service'; import { lectureBasePath, openFile } from '../../../../services/file.service'; import { Lecture } from '../../../../model/lecture'; -import { enqueueSnackbar } from 'notistack'; import { goToPath } from '../../../../services/file-browser.service'; +import { useMutationStatus } from '../../../../widget'; interface IExportGradesDialogProps { lecture: Lecture; @@ -30,6 +30,7 @@ interface IExportGradesDialogProps { export const ExportGradesDialog = (props: IExportGradesDialogProps) => { const [format, setFormat] = React.useState<'csv' | 'json'>('csv'); const [filter, setFilter] = React.useState<'best' | 'latest'>('best'); + const { setStatus } = useMutationStatus(); const handleExport = async () => { try { @@ -41,9 +42,9 @@ export const ExportGradesDialog = (props: IExportGradesDialogProps) => { // go into correct directory await goToPath(`${lectureBasePath}${props.lecture.code}`); } catch (error: any) { - console.error('Error exporting grades:', error); - enqueueSnackbar(error.message || 'Failed to export grades', { - variant: 'error' + setStatus({ + status: 'error', + message: error?.message || 'Failed to export grades' }); } finally { props.setIsOpen(false); diff --git a/packages/labextension/src/services/file-browser.service.ts b/packages/labextension/src/services/file-browser.service.ts index e7662f6d..6a2b12b5 100644 --- a/packages/labextension/src/services/file-browser.service.ts +++ b/packages/labextension/src/services/file-browser.service.ts @@ -1,12 +1,7 @@ import { GlobalObjects } from '../index'; -import { enqueueSnackbar } from 'notistack'; export const goToPath = async (path: string) => { - GlobalObjects.commands - .execute('filebrowser:go-to-path', { - path: path - }) - .catch(error => { - enqueueSnackbar(error.message, { variant: 'error' }); - }); + await GlobalObjects.commands.execute('filebrowser:go-to-path', { + path: path + }); }; diff --git a/packages/labextension/src/services/file.service.ts b/packages/labextension/src/services/file.service.ts index 6bf7f16d..bc7292b7 100644 --- a/packages/labextension/src/services/file.service.ts +++ b/packages/labextension/src/services/file.service.ts @@ -1,5 +1,4 @@ import { GlobalObjects } from '../index'; -import { enqueueSnackbar } from 'notistack'; import { PageConfig } from '@jupyterlab/coreutils'; const raw = PageConfig.getOption('lectures_base_path').replace( @@ -8,21 +7,15 @@ const raw = PageConfig.getOption('lectures_base_path').replace( ); export const lectureBasePath = raw && `${raw}/`; -export const lectureSubPaths: number = (lectureBasePath as string) +export const lectureSubPaths = (lectureBasePath as string) .split('/') .reduce((acc, v) => (v.length > 0 ? acc + 1 : acc), 0); export const openFile = async (path: string) => { - GlobalObjects.commands - .execute('docmanager:open', { - path: path, - options: { - mode: 'tab-after' // tab-after tab-before split-bottom split-right split-left split-top - } - }) - .catch(error => { - enqueueSnackbar(error.message, { - variant: 'error' - }); - }); + await GlobalObjects.commands.execute('docmanager:open', { + path: path, + options: { + mode: 'tab-after' // tab-after tab-before split-bottom split-right split-left split-top + } + }); }; diff --git a/packages/labextension/src/services/submissions.service.ts b/packages/labextension/src/services/submissions.service.ts new file mode 100644 index 00000000..dde97426 --- /dev/null +++ b/packages/labextension/src/services/submissions.service.ts @@ -0,0 +1,10 @@ +import { HTTPMethod, request } from './request.service'; + +export async function exportGrades( + lectureId: number, + filter: 'latest' | 'best' = 'best', + format: 'json' | 'csv' = 'csv' +): Promise { + const url = `/api/lectures/${lectureId}/submissions?filter=${filter}&format=${format}`; + return request(HTTPMethod.GET, url, null); +} From 9f1a082c86abc1ac4c572172f2fd836655f8fb96 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Fri, 7 Aug 2026 12:16:46 +0200 Subject: [PATCH 15/59] refactor: add common `baseUrl` variable --- .../grader-service/lectures/lecture-card.tsx | 4 +- .../grader-service/lectures/lecture-row.tsx | 4 +- .../app/pages/instructor-view/dashboard.tsx | 4 +- .../student-plugin/deadline-wrapper.tsx | 4 +- .../src/services/lectures.service.ts | 39 ++++++++----------- .../src/services/queries/lectures.queries.ts | 8 ++-- 6 files changed, 28 insertions(+), 35 deletions(-) diff --git a/packages/labextension/src/app/components/grader-service/lectures/lecture-card.tsx b/packages/labextension/src/app/components/grader-service/lectures/lecture-card.tsx index a16827d3..c27f30d6 100644 --- a/packages/labextension/src/app/components/grader-service/lectures/lecture-card.tsx +++ b/packages/labextension/src/app/components/grader-service/lectures/lecture-card.tsx @@ -11,7 +11,7 @@ import { Link } from 'react-router'; import React from 'react'; import { Lecture } from '../../../../model/lecture'; import { useQuery } from '@tanstack/react-query'; -import { getUsers } from '../../../../services/lectures.service'; +import { getLectureUsers } from '../../../../services/lectures.service'; import { getAllAssignments } from '../../../../services/assignments.service'; import { IconTextInfo } from '../../ui/icon-text-info'; import { pluralize, highlightText } from '../../utils/utils'; @@ -32,7 +32,7 @@ export const LectureCard = (props: ILectureCard) => { const lecture = props.lecture; const { data: users, isPending: isPendingUsers } = useQuery({ queryKey: ['users', lecture.id], - queryFn: async () => getUsers(lecture.id, false) + queryFn: async () => getLectureUsers(lecture.id, false) }); const { data: assignments, isPending: isPendingAssignments } = useQuery({ diff --git a/packages/labextension/src/app/components/grader-service/lectures/lecture-row.tsx b/packages/labextension/src/app/components/grader-service/lectures/lecture-row.tsx index 3487c0b4..7e930431 100644 --- a/packages/labextension/src/app/components/grader-service/lectures/lecture-row.tsx +++ b/packages/labextension/src/app/components/grader-service/lectures/lecture-row.tsx @@ -5,7 +5,7 @@ import { FileText, UserIcon } from 'lucide-react'; import { Link } from 'react-router'; import React from 'react'; import { useQuery } from '@tanstack/react-query'; -import { getUsers } from '../../../../services/lectures.service'; +import { getLectureUsers } from '../../../../services/lectures.service'; import { getAllAssignments } from '../../../../services/assignments.service'; import { IconTextInfo } from '../../ui/icon-text-info'; import { User } from '../../../../model/user'; @@ -27,7 +27,7 @@ export const LectureRow = (props: ILectureRow) => { const { data: users, isPending: isPendingUsers } = useQuery({ queryKey: ['users', lecture.id], - queryFn: async () => getUsers(lecture.id, false) + queryFn: async () => getLectureUsers(lecture.id, false) }); const { data: assignments, isPending: isPendingAssignments } = useQuery({ diff --git a/packages/labextension/src/app/pages/instructor-view/dashboard.tsx b/packages/labextension/src/app/pages/instructor-view/dashboard.tsx index 86447ced..5f926480 100644 --- a/packages/labextension/src/app/pages/instructor-view/dashboard.tsx +++ b/packages/labextension/src/app/pages/instructor-view/dashboard.tsx @@ -3,7 +3,7 @@ import { FilterLecturesButton } from '../../components/ui/filter-button'; import { SortButton } from '../../components/ui/sort-button'; import React, { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { getAllLectures } from '../../../services/lectures.service'; +import { getLectures } from '../../../services/lectures.service'; import { LayoutGrid, List } from 'lucide-react'; import { Table, @@ -66,7 +66,7 @@ export const Dashboard = () => { } = useQuery({ queryKey: ['completedLectures'], queryFn: async () => - getAllLectures({ instructor: true, complete: true }, false), + getLectures({ instructor: true, complete: true }, false), // only fetch completed lectures if the "completed" filter option has been chosen enabled: filterBy === 'completed' }); diff --git a/packages/labextension/src/components/notebook/student-plugin/deadline-wrapper.tsx b/packages/labextension/src/components/notebook/student-plugin/deadline-wrapper.tsx index 13a4619c..41904ab5 100644 --- a/packages/labextension/src/components/notebook/student-plugin/deadline-wrapper.tsx +++ b/packages/labextension/src/components/notebook/student-plugin/deadline-wrapper.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { DeadlineComponent } from '../../util/deadline'; -import { getAllLectures } from '../../../services/lectures.service'; +import { getLectures } from '../../../services/lectures.service'; import { getAssignment } from '../../../services/assignments.service'; import { lectureSubPaths } from '../../../services/file.service'; import { Assignment } from '../../../model/assignment'; @@ -22,7 +22,7 @@ export const DeadlineWrapper = (props: IDeadlineWrapperProps) => { React.useEffect(() => { if (lecture === null) { - getAllLectures({ complete: false }).then(response => { + getLectures({ complete: false }).then(response => { const l = response.find( l => l.code === props.notebookPaths[lectureSubPaths] ); diff --git a/packages/labextension/src/services/lectures.service.ts b/packages/labextension/src/services/lectures.service.ts index 4e70ed09..dfdeb4ae 100644 --- a/packages/labextension/src/services/lectures.service.ts +++ b/packages/labextension/src/services/lectures.service.ts @@ -8,11 +8,13 @@ import { Lecture } from '../model/lecture'; import { request, HTTPMethod } from './request.service'; import { User } from '../model/user'; -export function getAllLectures( +const baseUrl = '/api/lectures'; + +export function getLectures( filters: { [key: string]: boolean }, reload = false ): Promise { - let url = 'api/lectures'; + let url = baseUrl; const params = new URLSearchParams(); for (const key in filters) { @@ -24,31 +26,31 @@ export function getAllLectures( return request(HTTPMethod.GET, url, null, reload); } -export function updateLecture(lecture: Lecture): Promise { - return request( - HTTPMethod.PUT, - `/api/lectures/${lecture.id}`, - lecture - ); -} - export function getLecture( lectureId: number, reload = false ): Promise { return request( HTTPMethod.GET, - `/api/lectures/${lectureId}`, + `${baseUrl}/${lectureId}`, null, reload ); } +export function updateLecture(lecture: Lecture): Promise { + return request( + HTTPMethod.PUT, + `${baseUrl}/${lecture.id}`, + lecture + ); +} + export function deleteLecture(lectureId: number): Promise { - return request(HTTPMethod.DELETE, `/api/lectures/${lectureId}`, null); + return request(HTTPMethod.DELETE, `${baseUrl}/${lectureId}`, null); } -export function getUsers( +export function getLectureUsers( lectureId: number, reload: boolean = false ): Promise<{ instructors: User[]; tutors: User[]; students: User[] }> { @@ -56,14 +58,5 @@ export function getUsers( instructors: User[]; tutors: User[]; students: User[]; - }>(HTTPMethod.GET, `/api/lectures/${lectureId}/users`, null, reload); -} - -export async function exportGrades( - lectureId: number, - filter: 'latest' | 'best' = 'best', - format: 'json' | 'csv' = 'csv' -): Promise { - const url = `/api/lectures/${lectureId}/submissions?filter=${filter}&format=${format}`; - return request(HTTPMethod.GET, url, null); + }>(HTTPMethod.GET, `${baseUrl}/${lectureId}/users`, null, reload); } diff --git a/packages/labextension/src/services/queries/lectures.queries.ts b/packages/labextension/src/services/queries/lectures.queries.ts index 6a7159cb..1678cd69 100644 --- a/packages/labextension/src/services/queries/lectures.queries.ts +++ b/packages/labextension/src/services/queries/lectures.queries.ts @@ -1,25 +1,25 @@ -import { getAllLectures, getLecture } from '../lectures.service'; +import { getLectures, getLecture } from '../lectures.service'; import { queryOptions } from '@tanstack/react-query'; export const activeInstructorLecturesQuery = () => queryOptions({ queryKey: ['activeLectures'], queryFn: async () => - getAllLectures({ instructor: true, complete: false }, false) + getLectures({ instructor: true, complete: false }, false) }); export const completedLecturesQuery = () => queryOptions({ queryKey: ['completedLectures'], queryFn: async () => - getAllLectures({ instructor: true, complete: true }, false) + getLectures({ instructor: true, complete: true }, false) }); export const activeLecturesQuery = () => queryOptions({ queryKey: ['lectures'], queryFn: async () => - getAllLectures({ instructor: false, complete: false }, false) + getLectures({ instructor: false, complete: false }, false) }); export const lectureQuery = (lectureId: number) => From 6cdeaf5018ebae72e3332d33067993b352e1e64a Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Fri, 7 Aug 2026 13:38:05 +0200 Subject: [PATCH 16/59] refactor: create separate directory for enums --- .../components/notebook/switch-factory.tsx | 3 ++- .../src/services/assignments.service.ts | 3 ++- .../src/services/enums/http-methods.enum.ts | 6 +++++ .../services/enums/permissions-scope.enum.ts | 6 +++++ .../labextension/src/services/git.service.ts | 3 ++- .../src/services/lectures.service.ts | 3 ++- .../src/services/permission.service.ts | 22 ++++++------------- .../src/services/submissions.service.ts | 3 ++- .../labextension/src/services/user.service.ts | 3 ++- 9 files changed, 31 insertions(+), 21 deletions(-) create mode 100644 packages/labextension/src/services/enums/http-methods.enum.ts create mode 100644 packages/labextension/src/services/enums/permissions-scope.enum.ts diff --git a/packages/labextension/src/components/notebook/switch-factory.tsx b/packages/labextension/src/components/notebook/switch-factory.tsx index cca2d07f..d246322c 100644 --- a/packages/labextension/src/components/notebook/switch-factory.tsx +++ b/packages/labextension/src/components/notebook/switch-factory.tsx @@ -5,10 +5,11 @@ // LICENSE file in the root directory of this source tree. import React from 'react'; -import { Scope, UserPermissions } from '../../services/permission.service'; +import { UserPermissions } from '../../services/permission.service'; import { CreationModeSwitch } from './create-assignment/creation-switch'; import { IModeSwitchProps } from './slider'; import { lectureSubPaths } from '../../services/file.service'; +import { Scope } from '../../services/enums/permissions-scope.enum'; export class SwitchModeFactory { public static getSwitch(props: IModeSwitchProps): JSX.Element { diff --git a/packages/labextension/src/services/assignments.service.ts b/packages/labextension/src/services/assignments.service.ts index d4579bc1..b27052a9 100644 --- a/packages/labextension/src/services/assignments.service.ts +++ b/packages/labextension/src/services/assignments.service.ts @@ -6,7 +6,8 @@ import { Assignment } from '../model/assignment'; import { AssignmentDetail } from '../model/assignmentDetail'; -import { HTTPMethod, request } from './request.service'; +import { request } from './request.service'; +import { HTTPMethod } from './enums/http-methods.enum'; export const buildBaseUrl = (lectureId: number) => { return `/api/lectures/${lectureId}/assignments`; diff --git a/packages/labextension/src/services/enums/http-methods.enum.ts b/packages/labextension/src/services/enums/http-methods.enum.ts new file mode 100644 index 00000000..cbc79961 --- /dev/null +++ b/packages/labextension/src/services/enums/http-methods.enum.ts @@ -0,0 +1,6 @@ +export enum HTTPMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE' +} diff --git a/packages/labextension/src/services/enums/permissions-scope.enum.ts b/packages/labextension/src/services/enums/permissions-scope.enum.ts new file mode 100644 index 00000000..dcb27f18 --- /dev/null +++ b/packages/labextension/src/services/enums/permissions-scope.enum.ts @@ -0,0 +1,6 @@ +export enum Scope { + student = 0, + tutor = 1, + instructor = 2, + admin = 3 +} diff --git a/packages/labextension/src/services/git.service.ts b/packages/labextension/src/services/git.service.ts index c930c14a..f5cbe958 100644 --- a/packages/labextension/src/services/git.service.ts +++ b/packages/labextension/src/services/git.service.ts @@ -1,8 +1,9 @@ import { RepoType } from '../app/components/utils/repo-type'; -import { HTTPMethod, request } from './request.service'; +import { request } from './request.service'; import { Lecture } from '../model/lecture'; import { Assignment } from '../model/assignment'; import { buildBaseUrl } from './assignments.service'; +import { HTTPMethod } from './enums/http-methods.enum'; export function pushAssignment( lectureId: number, diff --git a/packages/labextension/src/services/lectures.service.ts b/packages/labextension/src/services/lectures.service.ts index dfdeb4ae..7fd78bb9 100644 --- a/packages/labextension/src/services/lectures.service.ts +++ b/packages/labextension/src/services/lectures.service.ts @@ -5,8 +5,9 @@ // LICENSE file in the root directory of this source tree. import { Lecture } from '../model/lecture'; -import { request, HTTPMethod } from './request.service'; +import { request } from './request.service'; import { User } from '../model/user'; +import { HTTPMethod } from './enums/http-methods.enum'; const baseUrl = '/api/lectures'; diff --git a/packages/labextension/src/services/permission.service.ts b/packages/labextension/src/services/permission.service.ts index 39a9f736..74a396b2 100644 --- a/packages/labextension/src/services/permission.service.ts +++ b/packages/labextension/src/services/permission.service.ts @@ -7,21 +7,16 @@ // token: 0b79bab50daca910b000d4f1a2b675d604257e42 import { Lecture } from '../model/lecture'; -import { HTTPMethod, request } from './request.service'; +import { HTTPMethod } from './enums/http-methods.enum'; +import { Scope } from './enums/permissions-scope.enum'; +import { request } from './request.service'; -export enum Scope { - student = 0, - tutor = 1, - instructor = 2, - admin = 3 -} - -interface PermissionScopes { +interface IPermissionScopes { [lecture_code: string]: Scope; } export namespace UserPermissions { - let permissions: PermissionScopes; + let permissions: IPermissionScopes; export async function loadPermissions(): Promise { permissions = {}; @@ -35,14 +30,11 @@ export namespace UserPermissions { }); } - export function getPermissions(): PermissionScopes { + export function getPermissions(): IPermissionScopes { return permissions; } export function getScope(lecture: Lecture) { - if (permissions === null) { - return null; - } - return permissions[lecture.code]; + return permissions?.[lecture.code] ?? null; } } diff --git a/packages/labextension/src/services/submissions.service.ts b/packages/labextension/src/services/submissions.service.ts index dde97426..762a4a72 100644 --- a/packages/labextension/src/services/submissions.service.ts +++ b/packages/labextension/src/services/submissions.service.ts @@ -1,4 +1,5 @@ -import { HTTPMethod, request } from './request.service'; +import { HTTPMethod } from './enums/http-methods.enum'; +import { request } from './request.service'; export async function exportGrades( lectureId: number, diff --git a/packages/labextension/src/services/user.service.ts b/packages/labextension/src/services/user.service.ts index 6c807f43..9233d120 100644 --- a/packages/labextension/src/services/user.service.ts +++ b/packages/labextension/src/services/user.service.ts @@ -1,4 +1,5 @@ -import { HTTPMethod, request } from './request.service'; +import { HTTPMethod } from './enums/http-methods.enum'; +import { request } from './request.service'; export function getCurrentUser(): Promise { const url = 'api/user'; From d3e3113c3e3da589cb2b0efd36e65e0bbe05fde8 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Fri, 7 Aug 2026 14:00:55 +0200 Subject: [PATCH 17/59] refactor: change HTTP response validation --- .../src/services/request.service.ts | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/labextension/src/services/request.service.ts b/packages/labextension/src/services/request.service.ts index 945d4b2c..0b0747f8 100644 --- a/packages/labextension/src/services/request.service.ts +++ b/packages/labextension/src/services/request.service.ts @@ -6,13 +6,7 @@ import { URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; - -export enum HTTPMethod { - GET = 'GET', - POST = 'POST', - PUT = 'PUT', - DELETE = 'DELETE' -} +import { HTTPMethod } from './enums/http-methods.enum'; export class HTTPError extends Error { statusCode: number; @@ -23,7 +17,7 @@ export class HTTPError extends Error { } } -export function request( +export async function request( method: HTTPMethod, endPoint: string, body: B, @@ -53,23 +47,19 @@ export function request( return ServerConnection.makeRequest(requestUrl, options, settings).then( async response => { - const method = options.method || 'GET'; // assuming `method` is part of options. - // handle non-OK responses - let responseData: T | string = await response.text(); if (!response.ok) { // default error message let errorMessage = 'Unknown error'; - try { - const errorData = JSON.parse(responseData); + const errorData = await response.json(); errorMessage = errorData['message'] || errorData['reason'] || errorData['error'] || errorMessage; } catch (e) { - errorMessage = responseData; // fallback to raw error text if not JSON + errorMessage = await response.text(); // fallback to raw error text if not JSON } // throw custom HTTPError with status code and message @@ -77,19 +67,15 @@ export function request( } // validate response body - if (responseData.length > 0) { - try { - responseData = JSON.parse(responseData); - } catch (e) { - console.log( - 'Not a JSON response body, handling as plain text.', - response - ); - } + let responseData: T | string = null; + try { + responseData = await response.json(); + } catch (e) { + console.log( + 'Not a JSON response body, handling as plain text.', + responseData + ); } - - console.log(`Request ${method} URL: ${requestUrl}`); - console.log(responseData); return responseData as T; } ); From 8f388cca20d32acbd9201e25bd355dc1014126f7 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Fri, 7 Aug 2026 14:18:03 +0200 Subject: [PATCH 18/59] refactor: simplify if-else statements in storage.service.ts --- .../src/services/storage.service.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/labextension/src/services/storage.service.ts b/packages/labextension/src/services/storage.service.ts index f641bc5b..596c1953 100644 --- a/packages/labextension/src/services/storage.service.ts +++ b/packages/labextension/src/services/storage.service.ts @@ -15,9 +15,12 @@ function getKey( return 'grader:' + key; } -export function deleteKey(key: string) { - key = getKey(key); - localStorage.removeItem(key); +export function deleteKey( + key: string, + lecture?: Lecture, + assignment?: Assignment +) { + localStorage.removeItem(getKey(key, lecture, assignment)); } export function storeString( @@ -54,11 +57,7 @@ export function loadBoolean( assignment?: Assignment ): boolean | null { const v = loadString(key, lecture, assignment); - if (v === null) { - return null; - } else { - return v === 'true'; - } + return v === null ? null : v === 'true'; } export function storeNumber( @@ -76,11 +75,7 @@ export function loadNumber( assignment?: Assignment ): number | null { const v = loadString(key, lecture, assignment); - if (v === null) { - return null; - } else { - return +v; - } + return v === null ? null : Number(v); } export function storeObject( @@ -98,6 +93,5 @@ export function loadObject( assignment?: Assignment ): T | null { const jsonString = loadString(key, lecture, assignment); - if (!jsonString) return null; // Return null if there's nothing to parse - return JSON.parse(jsonString); + return !jsonString ? null : JSON.parse(jsonString); } From 9003cb602dbb129797057f40005e7974c1787fdc Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 10 Aug 2026 10:11:09 +0200 Subject: [PATCH 19/59] chore: remove theme connector that included styles of non-existent `.css` file --- packages/labextension/src/index.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/labextension/src/index.ts b/packages/labextension/src/index.ts index e6a811a4..d5458320 100644 --- a/packages/labextension/src/index.ts +++ b/packages/labextension/src/index.ts @@ -343,17 +343,6 @@ const extension: JupyterFrontEndPlugin = { GlobalObjects.tracker = tracker; GlobalObjects.themeManager = themeManager; - // this connects the color-scheme CSS of base.css to the Jupyterlab themeManager - // the MUI theme provider is set in the corresponding widgets - // the CSS color-scheme property only applies to native input elements automatically so this does only apply to those (i.e. notebook grading mode and creation mode) - themeManager.themeChanged.connect(() => { - document.documentElement.dataset.theme = themeManager.isLight( - themeManager.theme ?? 'light' - ) - ? 'light' - : 'dark'; - }, this); - const courseManageTracker = new WidgetTracker< MainAreaWidget >({ From 312a5074975a00389d9340d24d2c6947aececaca Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 10 Aug 2026 11:08:58 +0200 Subject: [PATCH 20/59] fix: handle non-JSON response bodies correctly --- packages/labextension/src/services/request.service.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/labextension/src/services/request.service.ts b/packages/labextension/src/services/request.service.ts index 0b0747f8..e9144d06 100644 --- a/packages/labextension/src/services/request.service.ts +++ b/packages/labextension/src/services/request.service.ts @@ -47,19 +47,20 @@ export async function request( return ServerConnection.makeRequest(requestUrl, options, settings).then( async response => { + const bodyText = await response.text(); // handle non-OK responses if (!response.ok) { // default error message let errorMessage = 'Unknown error'; try { - const errorData = await response.json(); + const errorData = JSON.parse(bodyText); errorMessage = errorData['message'] || errorData['reason'] || errorData['error'] || errorMessage; } catch (e) { - errorMessage = await response.text(); // fallback to raw error text if not JSON + errorMessage = bodyText || errorMessage; // fallback to raw error text if not JSON } // throw custom HTTPError with status code and message @@ -67,9 +68,9 @@ export async function request( } // validate response body - let responseData: T | string = null; + let responseData: T | string = bodyText; try { - responseData = await response.json(); + responseData = JSON.parse(bodyText); } catch (e) { console.log( 'Not a JSON response body, handling as plain text.', From 4727b070097e0ad3f3f56a3551f6f97dbd30f052 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 10 Aug 2026 13:00:52 +0200 Subject: [PATCH 21/59] refactor: remove dead code and rename components to match new design --- packages/labextension/src/index.ts | 134 ++++++++++++--------------- packages/labextension/src/menu.ts | 51 ++-------- packages/labextension/src/widget.tsx | 22 ++--- 3 files changed, 78 insertions(+), 129 deletions(-) diff --git a/packages/labextension/src/index.ts b/packages/labextension/src/index.ts index d5458320..17553e04 100644 --- a/packages/labextension/src/index.ts +++ b/packages/labextension/src/index.ts @@ -34,7 +34,7 @@ import { import { IMainMenu } from '@jupyterlab/mainmenu'; -import { GraderServiceView } from './widget'; +import { GraderServiceWidget } from './widget'; import { Cell } from '@jupyterlab/cells'; @@ -42,7 +42,7 @@ import { Menu, PanelLayout } from '@lumino/widgets'; import { NotebookModeSwitch } from './components/notebook/slider'; -import { checkIcon, runIcon } from '@jupyterlab/ui-components'; +import { homeIcon, runIcon } from '@jupyterlab/ui-components'; import { CommandRegistry } from '@lumino/commands'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { Contents, ServiceManager } from '@jupyterlab/services'; @@ -57,20 +57,14 @@ import { import { HintWidget } from './components/notebook/student-plugin/hint-widget'; import { DeadlineWidget } from './components/notebook/student-plugin/deadline-widget'; import { lectureSubPaths } from './services/file.service'; -import IModel = Contents.IModel; import { updateMenus } from './menu'; import { loadString } from './services/storage.service'; +import IModel = Contents.IModel; -export namespace AssignmentsCommandIDs { - export const create = 'assignments:create'; - - export const open = 'assignments:open'; -} - -export namespace CourseManageCommandIDs { - export const create = 'coursemanage:create'; +export namespace GraderServiceCommandIDs { + export const create = 'graderservice:create'; - export const open = 'coursemanage:open'; + export const open = 'graderservice:open'; } namespace NotebookExecuteIDs { @@ -85,6 +79,8 @@ namespace ShowHintIDs { export const show = 'notebookplugin:show-hint'; } +export const hasElevatedPermissions = false; + export class GlobalObjects { static commands: CommandRegistry; static docRegistry: DocumentRegistry; @@ -93,52 +89,66 @@ export class GlobalObjects { static browserFactory: IFileBrowserFactory; static tracker: INotebookTracker; static themeManager: IThemeManager; - static assignmentMenu: Menu; - static courseManageMenu: Menu; + static graderServiceMenu: Menu; } -const createCourseManagementOpenCommand = ( +const createGraderServiceCommands = ( app: JupyterFrontEnd, launcher: ILauncher, - courseManageTracker: WidgetTracker> + courseManageTracker: WidgetTracker> ) => { - const command = CourseManageCommandIDs.open; - app.commands.addCommand(command, { + // add create widget command + app.commands.addCommand(GraderServiceCommandIDs.create, { + execute: () => { + // Create a blank content widget inside of a MainAreaWidget + const graderServiceWidget = new MainAreaWidget({ + content: new GraderServiceWidget() + }); + graderServiceWidget.id = 'grader-service'; + graderServiceWidget.title.label = 'Grader Service'; + graderServiceWidget.title.closable = true; + + courseManageTracker.add(graderServiceWidget); + + return graderServiceWidget; + } + }); + // add open widget command + app.commands.addCommand(GraderServiceCommandIDs.open, { label: args => - args['label'] ? (args['label'] as string) : 'Course Management', + args['label'] ? (args['label'] as string) : 'Grader Service', execute: async args => { - let gradingWidget = courseManageTracker.currentWidget; - if (!gradingWidget) { - gradingWidget = await app.commands.execute( - CourseManageCommandIDs.create + let graderServiceWidget = courseManageTracker.currentWidget; + if (!graderServiceWidget) { + graderServiceWidget = await app.commands.execute( + GraderServiceCommandIDs.create ); } let path = args?.path as string; if (args?.path === undefined) { - const savedPath = loadString('course-manage-react-router-path'); + const savedPath = loadString('grader-service-router-path'); if (savedPath !== null && savedPath !== '') { path = savedPath; } else { path = '/'; } } - await gradingWidget.content.router.navigate(path); + await graderServiceWidget.content.router.navigate(path); - if (!gradingWidget.isAttached) { + if (!graderServiceWidget.isAttached) { // Attach the widget to the main work area if it's not there - app.shell.add(gradingWidget, 'main'); + app.shell.add(graderServiceWidget, 'main'); } // Activate the widget - app.shell.activateById(gradingWidget.id); + app.shell.activateById(graderServiceWidget.id); }, - icon: args => (args['path'] ? undefined : checkIcon) + icon: args => (args['path'] ? undefined : homeIcon) }); // Add the command to the launcher - console.log('Add course management launcher'); launcher.add({ - command: command, - category: 'Assignments', + command: GraderServiceCommandIDs.open, + category: 'Grader Service', rank: 0 }); }; @@ -274,7 +284,9 @@ const createNotebookCommands = ( }, execute: () => { // check if there is an active cell - if (!tracker.activeCell) return; + if (!tracker.activeCell) { + return; + } let hintWidget: HintWidget | undefined; @@ -331,9 +343,6 @@ const extension: JupyterFrontEndPlugin = { mainMenu: IMainMenu ) => { console.log('JupyterLab extension grader-labextension is activated!'); - console.log('JupyterFrontEnd:', app); - console.log('ICommandPalette:', palette); - console.log('Tracker', tracker); GlobalObjects.commands = app.commands; GlobalObjects.docRegistry = app.docRegistry; @@ -342,36 +351,17 @@ const extension: JupyterFrontEndPlugin = { GlobalObjects.browserFactory = browserFactory; GlobalObjects.tracker = tracker; GlobalObjects.themeManager = themeManager; - - const courseManageTracker = new WidgetTracker< - MainAreaWidget + const graderServiceTracker = new WidgetTracker< + MainAreaWidget >({ - namespace: 'grader-coursemanage' + namespace: 'grader-service' }); - restorer.restore(courseManageTracker, { - command: CourseManageCommandIDs.open, - name: () => 'grader-coursemanage' - }); - - /* ##### Course Manage View Widget ##### */ - const command: string = CourseManageCommandIDs.create; - app.commands.addCommand(command, { - execute: () => { - // Create a blank content widget inside of a MainAreaWidget - const gradingView = new GraderServiceView(); - const gradingWidget = new MainAreaWidget({ - content: gradingView - }); - gradingWidget.id = 'coursemanage-jupyterlab'; - gradingWidget.title.label = 'Grader Service'; - gradingWidget.title.closable = true; - - courseManageTracker.add(gradingWidget); - - return gradingWidget; - } + restorer.restore(graderServiceTracker, { + command: GraderServiceCommandIDs.open, + name: () => 'grader-service' }); + /* ##### Grader Service View Widget ##### */ // If the user has no instructor roles in any lecture we do not display the course management UserPermissions.loadPermissions() @@ -384,27 +374,17 @@ const extension: JupyterFrontEndPlugin = { } } - // if tutor or instructor permissions were found add course management menu if (sum !== 0) { - console.log( - 'Non-student permissions found! Adding coursemanage launcher and connecting creation mode' - ); connectTrackerSignals(tracker); - - // add menu to JupyterLab main menu - const cmMenu = new Menu({ commands: app.commands }); - cmMenu.title.label = 'Grader Service'; - mainMenu.addMenu(cmMenu, false, { rank: 210 }); - createCourseManagementOpenCommand(app, launcher, courseManageTracker); - GlobalObjects.courseManageMenu = cmMenu; } + createGraderServiceCommands(app, launcher, graderServiceTracker); // add Menu to JupyterLab main menu - const aMenu = new Menu({ commands: app.commands }); - aMenu.title.label = 'Assignments'; - mainMenu.addMenu(aMenu, false, { rank: 200 }); + const menu = new Menu({ commands: app.commands }); + menu.title.label = 'Grader Service'; + mainMenu.addMenu(menu, false, { rank: 200 }); - GlobalObjects.assignmentMenu = aMenu; + GlobalObjects.graderServiceMenu = menu; updateMenus(); }) diff --git a/packages/labextension/src/menu.ts b/packages/labextension/src/menu.ts index 8cfcceea..591e7777 100644 --- a/packages/labextension/src/menu.ts +++ b/packages/labextension/src/menu.ts @@ -1,15 +1,8 @@ -import { - AssignmentsCommandIDs, - CourseManageCommandIDs, - GlobalObjects -} from './index'; +import { GlobalObjects, GraderServiceCommandIDs } from './index'; import { Lecture } from './model/lecture'; import { AssignmentDetail } from './model/assignmentDetail'; import { Menu } from '@lumino/widgets'; -import { - activeInstructorLecturesQuery, - activeLecturesQuery -} from './services/queries/lectures.queries'; +import { activeLecturesQuery } from './services/queries/lectures.queries'; import { queryClient } from './widget'; export const getLabel = (assignment: AssignmentDetail | null) => { @@ -17,18 +10,16 @@ export const getLabel = (assignment: AssignmentDetail | null) => { }; const getPath = (lecture: Lecture, assignment: AssignmentDetail | null) => { - return `/lecture/${lecture.id}`; + return `lectures/${lecture.id}`; }; export const updateMenus = async (reload: boolean = false) => { - const aMenu = GlobalObjects.assignmentMenu; - const cmMenu = GlobalObjects.courseManageMenu; - const [lectures, instructorLectures] = await Promise.all([ - queryClient.ensureQueryData(activeLecturesQuery()), - queryClient.ensureQueryData(activeInstructorLecturesQuery()) + const menu = GlobalObjects.graderServiceMenu; + const [lectures] = await Promise.all([ + queryClient.ensureQueryData(activeLecturesQuery()) ]); - aMenu.clearItems(); + menu.clearItems(); lectures.forEach(v => { const subMenu = new Menu({ commands: GlobalObjects.commands }); subMenu.title.label = v.name; @@ -37,36 +28,14 @@ export const updateMenus = async (reload: boolean = false) => { const label = getLabel(null); subMenu.addItem({ type: 'command', - command: AssignmentsCommandIDs.open, + command: GraderServiceCommandIDs.open, args: { path, label } }); - aMenu.addItem({ + menu.addItem({ type: 'submenu', submenu: subMenu }); }); - aMenu.update(); - - if (cmMenu) { - cmMenu.clearItems(); - instructorLectures.forEach(v => { - const subMenu = new Menu({ commands: GlobalObjects.commands }); - subMenu.title.label = v.name; - - const path = getPath(v, null); - const label = getLabel(null); - subMenu.addItem({ - type: 'command', - command: CourseManageCommandIDs.open, - args: { path, label } - }); - - cmMenu.addItem({ - type: 'submenu', - submenu: subMenu - }); - }); - cmMenu.update(); - } + menu.update(); }; diff --git a/packages/labextension/src/widget.tsx b/packages/labextension/src/widget.tsx index ef1d5073..01f703f8 100644 --- a/packages/labextension/src/widget.tsx +++ b/packages/labextension/src/widget.tsx @@ -12,7 +12,8 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10 * 60 * 1000, - cacheTime: 15 * 60 * 1000 + cacheTime: 15 * 60 * 1000, + refetchOnReconnect: true } as any } }); @@ -27,7 +28,6 @@ type MutationStatusContextType = { setStatus: (status: StatusState) => void; }; -const defaultStatus: StatusState = { status: null, message: null }; const MutationContext = React.createContext( null ); @@ -51,7 +51,10 @@ function GraderServiceViewComponent({ router: DataRouter; parentNode: HTMLElement | null; }) { - const [status, setStatus] = useState(defaultStatus); + const [status, setStatus] = useState({ + status: null, + message: null + }); useEffect(() => { if (status.status !== null) { @@ -75,26 +78,23 @@ function GraderServiceViewComponent({ ); } -export class GraderServiceView extends ReactWidget { +export class GraderServiceWidget extends ReactWidget { theme: 'dark' | 'light'; router: DataRouter; - constructor(options: CourseManageView.IOptions = {}) { + constructor(options: GraderServiceView.IOptions = {}) { super(); - this.id = options.id || 'course-manage-view'; + this.id = options.id || 'grader-service-view'; this.addClass('GradingWidget'); this.router = createMemoryRouter(getRoutes(), { initialEntries: ['/'] }); const themeManager = GlobalObjects.themeManager; - this.theme = themeManager.isLight(themeManager.theme ?? 'light') - ? 'light' - : 'dark'; themeManager.themeChanged.connect(() => { this.theme = themeManager.isLight(themeManager.theme ?? 'light') ? 'light' : 'dark'; - this.update(); // <- tells Lumino to re-invoke render() + this.update(); // tells Lumino to re-invoke render() }, this); } @@ -109,7 +109,7 @@ export class GraderServiceView extends ReactWidget { } } -export namespace CourseManageView { +export namespace GraderServiceView { export interface IOptions { id?: string; } From adb36598cb69c037c848ddc7f0cd80036f9f86dc Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 10 Aug 2026 13:29:38 +0200 Subject: [PATCH 22/59] refactor: create a function that checks if current user has elevated permissions --- packages/labextension/src/index.ts | 12 +----------- .../labextension/src/services/permission.service.ts | 4 ++++ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/labextension/src/index.ts b/packages/labextension/src/index.ts index 17553e04..dbc3b2ea 100644 --- a/packages/labextension/src/index.ts +++ b/packages/labextension/src/index.ts @@ -79,8 +79,6 @@ namespace ShowHintIDs { export const show = 'notebookplugin:show-hint'; } -export const hasElevatedPermissions = false; - export class GlobalObjects { static commands: CommandRegistry; static docRegistry: DocumentRegistry; @@ -366,15 +364,7 @@ const extension: JupyterFrontEndPlugin = { // If the user has no instructor roles in any lecture we do not display the course management UserPermissions.loadPermissions() .then(() => { - const permissions = UserPermissions.getPermissions(); - let sum = 0; - for (const el in permissions) { - if (permissions.hasOwnProperty(el)) { - sum += permissions[el]; - } - } - - if (sum !== 0) { + if (UserPermissions.hasElevatedPermissions) { connectTrackerSignals(tracker); } createGraderServiceCommands(app, launcher, graderServiceTracker); diff --git a/packages/labextension/src/services/permission.service.ts b/packages/labextension/src/services/permission.service.ts index 74a396b2..576131c7 100644 --- a/packages/labextension/src/services/permission.service.ts +++ b/packages/labextension/src/services/permission.service.ts @@ -37,4 +37,8 @@ export namespace UserPermissions { export function getScope(lecture: Lecture) { return permissions?.[lecture.code] ?? null; } + export function hasElevatedPermissions() { + const sum = Object.values(permissions).reduce((acc, v) => acc + v, 0); + return sum > 0; + } } From 7c70602a696b523403e31eabd749e7d3d37e21e6 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 10 Aug 2026 16:26:24 +0200 Subject: [PATCH 23/59] fix: display student/instructor view toggle button only when user has elevated permissions --- .../src/app/components/ui/global-sidebar.tsx | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/labextension/src/app/components/ui/global-sidebar.tsx b/packages/labextension/src/app/components/ui/global-sidebar.tsx index 2c17b019..94e01ea3 100644 --- a/packages/labextension/src/app/components/ui/global-sidebar.tsx +++ b/packages/labextension/src/app/components/ui/global-sidebar.tsx @@ -40,6 +40,7 @@ import { } from '../../shadcn-components/ui/toggle-group'; import { LogoWithText } from '../../../assets/logo-with-text'; import { Logo } from '../../../assets/logo'; +import { UserPermissions } from '../../../services/permission.service'; export const GlobalSidebar = () => { const { data: fetchedLectures } = useQuery(activeInstructorLecturesQuery()); @@ -146,27 +147,29 @@ export const GlobalSidebar = () => { - - {state === 'collapsed' ? ( - <> - - - - - - - > - ) : ( - <> - - Instructor View - - - Student View - - > - )} - + {UserPermissions.hasElevatedPermissions() && ( + + {state === 'collapsed' ? ( + <> + + + + + + + > + ) : ( + <> + + Instructor View + + + Student View + + > + )} + + )} Date: Wed, 19 Aug 2026 16:09:22 +0200 Subject: [PATCH 24/59] WIP: add functions from old labextension for file management --- .../assignments/export-grades-dialog.tsx | 10 +- .../student-plugin/deadline-wrapper.tsx | 6 +- .../components/notebook/switch-factory.tsx | 6 +- packages/labextension/src/index.ts | 4 +- .../src/services/file-browser.service.ts | 7 - .../labextension/src/services/file.service.ts | 85 +++++-- .../src/services/local-file.service.ts | 196 +++++++++++++++++ packages/labextension/style/css/output.css | 208 ++++++++++++++++++ 8 files changed, 486 insertions(+), 36 deletions(-) delete mode 100644 packages/labextension/src/services/file-browser.service.ts create mode 100644 packages/labextension/src/services/local-file.service.ts diff --git a/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx b/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx index 1ad7713b..6ad9420a 100644 --- a/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx +++ b/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx @@ -16,9 +16,11 @@ import { } from '../../../shadcn-components/ui/select'; import { Button } from '../../../shadcn-components/ui/button'; import { exportGrades } from '../../../../services/submissions.service'; -import { lectureBasePath, openFile } from '../../../../services/file.service'; +import { + lectureBasePath, + openInFileBrowser +} from '../../../../services/local-file.service'; import { Lecture } from '../../../../model/lecture'; -import { goToPath } from '../../../../services/file-browser.service'; import { useMutationStatus } from '../../../../widget'; interface IExportGradesDialogProps { @@ -36,11 +38,11 @@ export const ExportGradesDialog = (props: IExportGradesDialogProps) => { try { await exportGrades(props.lecture.id, filter, format); // open file in new tab - await openFile( + await openInFileBrowser( `${lectureBasePath}${props.lecture.code}/${props.lecture.name}_${filter}_submissions.${format}` ); // go into correct directory - await goToPath(`${lectureBasePath}${props.lecture.code}`); + await openInFileBrowser(`${lectureBasePath}${props.lecture.code}`); } catch (error: any) { setStatus({ status: 'error', diff --git a/packages/labextension/src/components/notebook/student-plugin/deadline-wrapper.tsx b/packages/labextension/src/components/notebook/student-plugin/deadline-wrapper.tsx index 41904ab5..3f369a70 100644 --- a/packages/labextension/src/components/notebook/student-plugin/deadline-wrapper.tsx +++ b/packages/labextension/src/components/notebook/student-plugin/deadline-wrapper.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { DeadlineComponent } from '../../util/deadline'; import { getLectures } from '../../../services/lectures.service'; import { getAssignment } from '../../../services/assignments.service'; -import { lectureSubPaths } from '../../../services/file.service'; +import { lectureSubPathsCount } from '../../../services/local-file.service'; import { Assignment } from '../../../model/assignment'; import { Lecture } from '../../../model/lecture'; @@ -24,7 +24,7 @@ export const DeadlineWrapper = (props: IDeadlineWrapperProps) => { if (lecture === null) { getLectures({ complete: false }).then(response => { const l = response.find( - l => l.code === props.notebookPaths[lectureSubPaths] + l => l.code === props.notebookPaths[lectureSubPathsCount] ); if (l === undefined) { return; @@ -33,7 +33,7 @@ export const DeadlineWrapper = (props: IDeadlineWrapperProps) => { if (l === null) { return; } - const assignmentIdIndex = lectureSubPaths + 2; + const assignmentIdIndex = lectureSubPathsCount + 2; getAssignment(l.id, +props.notebookPaths[assignmentIdIndex]).then( response => { setAssignment(response); diff --git a/packages/labextension/src/components/notebook/switch-factory.tsx b/packages/labextension/src/components/notebook/switch-factory.tsx index d246322c..e1986954 100644 --- a/packages/labextension/src/components/notebook/switch-factory.tsx +++ b/packages/labextension/src/components/notebook/switch-factory.tsx @@ -8,15 +8,15 @@ import React from 'react'; import { UserPermissions } from '../../services/permission.service'; import { CreationModeSwitch } from './create-assignment/creation-switch'; import { IModeSwitchProps } from './slider'; -import { lectureSubPaths } from '../../services/file.service'; +import { lectureSubPathsCount } from '../../services/local-file.service'; import { Scope } from '../../services/enums/permissions-scope.enum'; export class SwitchModeFactory { public static getSwitch(props: IModeSwitchProps): JSX.Element { const paths = props.notebookpanel.context.contentsModel.path.split('/'); - const path = paths[lectureSubPaths + 1]; + const path = paths[lectureSubPathsCount + 1]; const permissions = UserPermissions.getPermissions(); - const lecturecode = paths[lectureSubPaths]; + const lecturecode = paths[lectureSubPathsCount]; let hasPermission = false; if (permissions.hasOwnProperty(lecturecode)) { hasPermission = permissions[lecturecode] !== Scope.student; diff --git a/packages/labextension/src/index.ts b/packages/labextension/src/index.ts index dbc3b2ea..cdf6e990 100644 --- a/packages/labextension/src/index.ts +++ b/packages/labextension/src/index.ts @@ -56,7 +56,7 @@ import { } from '@jupyterlab/ui-components/lib/icon/iconimports'; import { HintWidget } from './components/notebook/student-plugin/hint-widget'; import { DeadlineWidget } from './components/notebook/student-plugin/deadline-widget'; -import { lectureSubPaths } from './services/file.service'; +import { lectureSubPathsCount } from './services/local-file.service'; import { updateMenus } from './menu'; import { loadString } from './services/storage.service'; import IModel = Contents.IModel; @@ -194,7 +194,7 @@ const connectTrackerSignals = (tracker: INotebookTracker) => { } const notebookPaths: string[] = contentsModel.path.split('/'); - if (notebookPaths[lectureSubPaths + 1] === 'manualgrade') { + if (notebookPaths[lectureSubPathsCount + 1] === 'manualgrade') { return; } diff --git a/packages/labextension/src/services/file-browser.service.ts b/packages/labextension/src/services/file-browser.service.ts deleted file mode 100644 index 6a2b12b5..00000000 --- a/packages/labextension/src/services/file-browser.service.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { GlobalObjects } from '../index'; - -export const goToPath = async (path: string) => { - await GlobalObjects.commands.execute('filebrowser:go-to-path', { - path: path - }); -}; diff --git a/packages/labextension/src/services/file.service.ts b/packages/labextension/src/services/file.service.ts index bc7292b7..23bfbf32 100644 --- a/packages/labextension/src/services/file.service.ts +++ b/packages/labextension/src/services/file.service.ts @@ -1,21 +1,72 @@ -import { GlobalObjects } from '../index'; -import { PageConfig } from '@jupyterlab/coreutils'; +import { Lecture } from '../model/lecture'; +import { Assignment } from '../model/assignment'; +import { RepoType } from '../app/components/utils/repo-type'; +import { request } from './request.service'; +import { HTTPMethod } from './enums/http-methods.enum'; +import { RemoteFileStatus } from '../model/remoteFileStatus'; -const raw = PageConfig.getOption('lectures_base_path').replace( - /^\/+|\/+$/g, - '' -); -export const lectureBasePath = raw && `${raw}/`; +const baseUrl = (props: { lectureId: number; assignmentId: number }) => { + return `/api/lectures/${props.lectureId}/assignments/${props.assignmentId}/`; +}; -export const lectureSubPaths = (lectureBasePath as string) - .split('/') - .reduce((acc, v) => (v.length > 0 ? acc + 1 : acc), 0); +interface IGitLogObject { + commit: string; + author: string; + date: string; + ref: string; + commit_msg: string; + pre_commit: string; +} -export const openFile = async (path: string) => { - await GlobalObjects.commands.execute('docmanager:open', { - path: path, - options: { - mode: 'tab-after' // tab-after tab-before split-bottom split-right split-left split-top - } +/** + * Returns the last n commits of the given repo + */ +export function getGitLog( + lecture: Lecture, + assignment: Assignment, + repo: RepoType, + nCommits: number +): Promise { + let url = `${baseUrl({ + lectureId: lecture.id, + assignmentId: assignment.id + })}log/${repo}/`; + const searchParams = new URLSearchParams({ + n: String(nCommits) }); -}; + url += '?' + searchParams; + return request(HTTPMethod.GET, url, null, true); +} + +/** + * Returns the status of the given repo + */ +export function getRemoteStatus( + lecture: Lecture, + assignment: Assignment, + repo: RepoType, + reload = false +): Promise { + const url = `${baseUrl({ + lectureId: lecture.id, + assignmentId: assignment.id + })}remote-status/${repo}/`; + return request(HTTPMethod.GET, url, null, reload); +} + +/** + * Returns the status of the given file in the given repo + */ +export function getRemoteFileStatus( + lecture: Lecture, + assignment: Assignment, + repo: RepoType, + filePath: string, + reload = false +): Promise { + const url = `${baseUrl({ + lectureId: lecture.id, + assignmentId: assignment.id + })}/remote-file-status/${repo}/?file=${encodeURIComponent(filePath)}`; + return request(HTTPMethod.GET, url, null, reload); +} diff --git a/packages/labextension/src/services/local-file.service.ts b/packages/labextension/src/services/local-file.service.ts new file mode 100644 index 00000000..3092955c --- /dev/null +++ b/packages/labextension/src/services/local-file.service.ts @@ -0,0 +1,196 @@ +/** + * Local File Service - a class that manipulates the local file system in JupyterLab + */ +import { GlobalObjects } from '../index'; +import { PageConfig, PathExt } from '@jupyterlab/coreutils'; +import { FileBrowserModel } from '@jupyterlab/filebrowser'; + +// extract lecture base path from jupyterlab's app config (set in grader_labextension/handlers/base_handler.py) +const raw = PageConfig.getOption('lectures_base_path').replace( + /^\/+|\/+$/g, + '' +); + +// append / so that lectureBasePath can be prepended to any string as a valid path +export const lectureBasePath = raw && `${raw}/`; + +// the number of sub paths in lecture base path e.g. grader/Lectures -> 2 +export const lectureSubPathsCount = (lectureBasePath as string) + .split('/') + .reduce((acc, v) => (v.length > 0 ? acc + 1 : acc), 0); + +// builds the base path for a given assignment in the file browser, e.g. lectures/lec1/assignments/1/ +const buildFileBasePath = ( + lectureCode: string, + type: string, + assignmentId: number +) => { + return `${lectureBasePath}${lectureCode}/${type}/${assignmentId}/`; +}; +/** + * Route to the given path in the file browser (Jupyterlab's sidebar) + * + * @param path path to route to + */ +export const openInFileBrowser = async (path: string) => { + await GlobalObjects.commands.execute('filebrowser:go-to-path', { + path: path + }); +}; + +/** + * Opens a file in a new tab + * + * @param path file path to open + */ +export const openInNewTab = async (path: string) => { + await GlobalObjects.commands.execute('docmanager:open', { + path: path, + options: { + mode: 'tab-after' // tab-after tab-before split-bottom split-right split-left split-top + } + }); +}; + +export interface IFile { + name: string; + path: string; + type: string; + content: IFile[]; +} + +/** + * Get all files in a given path (for displaying in the UI) + * + * @param path path to get files from + */ +export const getFiles = async (path: string): Promise => { + if (path === null) { + return []; + } + + const model = new FileBrowserModel({ + auto: false, + manager: GlobalObjects.docManager, + refreshInterval: 1000000 + }); + + try { + await model.cd(path); + await model.refresh(); + } catch (_) { + return []; + } + + if (model.path !== path) { + return []; + } + + const items = model.items(); + const files: IFile[] = []; + + let f = items.next(); + while (f.value !== undefined) { + if (f.value.type === 'directory') { + const nestedFiles = await getFiles(f.value.path); + files.push({ + name: f.value.name, + path: f.value.path, + type: f.value.type, + content: nestedFiles + }); + } else { + files.push({ + name: f.value.name, + path: f.value.path, + type: f.value.type, + content: [] + }); + } + f = items.next(); + } + return files; +}; + +export const getRelativePath = ( + lectureCode: string, + type: 'assignments' | 'source' | 'release', + assignmentId: number, + path: string +) => { + return PathExt.relative( + buildFileBasePath(lectureCode, type, assignmentId), + path + ); +}; + +export const extractRelativePaths = ( + lectureCode: string, + type: 'assignments' | 'source' | 'release', + assignmentId: number, + file: IFile +) => { + if (file.type === 'directory') { + const nestedPaths: any[] = file.content.flatMap(nestedFile => + extractRelativePaths(lectureCode, type, assignmentId, nestedFile) + ); + return [ + getRelativePath(lectureCode, type, assignmentId, file.path), + ...nestedPaths + ]; + } else { + return [getRelativePath(lectureCode, type, assignmentId, file.path)]; + } +}; + +const makeDir = async (path: string, name: string) => { + const newPath = PathExt.join(path, name); + let exists = false; + const model = new FileBrowserModel({ + auto: false, + manager: GlobalObjects.docManager, + refreshInterval: 1000000 + }); + try { + await model.cd(path); + await model.refresh(); + } catch (_) { + exists = false; + } + const items = model.items(); + let f = items.next(); + while (f.value !== undefined) { + if (f.value.type === 'directory') { + if (f.value.name === name) { + exists = true; + } + } + f = items.next(); + } + + if (!exists) { + const model = await GlobalObjects.docManager.newUntitled({ + path, + type: 'directory' + }); + const oldPath = PathExt.join(path, model.name); + await GlobalObjects.docManager.rename(oldPath, newPath).catch(error => { + if (error.response.status !== 409) { + // if it's not caused by an already existing file, rethrow + throw error; + } + }); + } + return newPath; +}; + +/** + * Make a directory at the given path + */ +export const makeDirs = async (path: string, names: string[]) => { + let p = path; + names.map(async name => { + p = await makeDir(p, name); + }); + return p; +}; diff --git a/packages/labextension/style/css/output.css b/packages/labextension/style/css/output.css index fed36489..910d82df 100644 --- a/packages/labextension/style/css/output.css +++ b/packages/labextension/style/css/output.css @@ -18,6 +18,7 @@ --color-green-500: oklch(72.3% 0.219 149.579); --color-green-700: oklch(52.7% 0.154 150.069); --color-blue-500: oklch(62.3% 0.214 259.815); + --color-slate-600: oklch(44.6% 0.043 257.281); --color-black: #000; --color-white: #fff; --spacing: 0.25rem; @@ -338,6 +339,9 @@ .top-1\/2 { top: calc(1 / 2 * 100%); } + .top-1\/3 { + top: calc(1 / 3 * 100%); + } .top-2 { top: calc(var(--spacing) * 2); } @@ -452,6 +456,12 @@ .mt-auto { margin-top: auto; } + .mr-2 { + margin-right: calc(var(--spacing) * 2); + } + .mr-auto { + margin-right: auto; + } .mb-1 { margin-bottom: var(--spacing); } @@ -605,6 +615,9 @@ .h-8 { height: calc(var(--spacing) * 8); } + .h-8\! { + height: calc(var(--spacing) * 8) !important; + } .h-9 { height: calc(var(--spacing) * 9); } @@ -653,6 +666,9 @@ .max-h-20 { max-height: calc(var(--spacing) * 20); } + .max-h-72 { + max-height: calc(var(--spacing) * 72); + } .max-h-80 { max-height: calc(var(--spacing) * 80); } @@ -815,6 +831,14 @@ --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); } + .translate-y-0 { + --tw-translate-y: 0px; + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .translate-y-0\.5 { + --tw-translate-y: calc(var(--spacing) * 0.5); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .translate-y-\[calc\(-50\%-2px\)\] { --tw-translate-y: calc(-50% - 2px); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -955,6 +979,13 @@ .gap-\[--spacing\(var\(--gap\)\)\] { gap: calc(var(--spacing) * var(--gap)); } + :where(.space-y-1 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(var(--spacing) * var(--tw-space-y-reverse)); + margin-block-end: calc( + var(--spacing) * calc(1 - var(--tw-space-y-reverse)) + ); + } :where(.space-y-4 > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc( @@ -1026,6 +1057,9 @@ .rounded-lg { border-radius: var(--radius); } + .rounded-lg\! { + border-radius: var(--radius) !important; + } .rounded-md { border-radius: calc(var(--radius) - 2px); } @@ -1035,6 +1069,9 @@ .rounded-sm { border-radius: calc(var(--radius) - 4px); } + .rounded-xl\! { + border-radius: calc(var(--radius) + 4px) !important; + } .rounded-xs { border-radius: var(--radius-xs); } @@ -1148,6 +1185,12 @@ .border-input { border-color: var(--input); } + .border-input\/30 { + border-color: var(--input); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--input) 30%, transparent); + } + } .border-muted-foreground { border-color: var(--muted-foreground); } @@ -1303,6 +1346,12 @@ .bg-input { background-color: var(--input); } + .bg-input\/30 { + background-color: var(--input); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--input) 30%, transparent); + } + } .bg-muted { background-color: var(--muted); } @@ -1473,6 +1522,9 @@ .pt-2 { padding-top: calc(var(--spacing) * 2); } + .pr-0 { + padding-right: 0px; + } .pr-2 { padding-right: calc(var(--spacing) * 2); } @@ -1482,18 +1534,30 @@ .pr-15 { padding-right: calc(var(--spacing) * 15); } + .pb-0 { + padding-bottom: 0px; + } .pb-2 { padding-bottom: calc(var(--spacing) * 2); } + .pb-4 { + padding-bottom: calc(var(--spacing) * 4); + } .pl-0 { padding-left: 0px; } + .pl-1 { + padding-left: var(--spacing); + } .pl-2 { padding-left: calc(var(--spacing) * 2); } .pl-2\.5 { padding-left: calc(var(--spacing) * 2.5); } + .pl-8 { + padding-left: calc(var(--spacing) * 8); + } .pl-9 { padding-left: calc(var(--spacing) * 9); } @@ -1572,9 +1636,15 @@ .text-wrap { text-wrap: wrap; } + .break-all { + word-break: break-all; + } .whitespace-nowrap { white-space: nowrap; } + .text-\[\#DC182C\] { + color: #dc182c; + } .text-\[\#FCE8EA\] { color: #fce8ea; } @@ -1653,6 +1723,9 @@ color: color-mix(in oklab, var(--sidebar-foreground) 70%, transparent); } } + .text-slate-600 { + color: var(--color-slate-600); + } .text-white { color: var(--color-white); } @@ -1714,6 +1787,11 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .shadow-none\! { + --tw-shadow: 0 0 #0000 !important; + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), + var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow) !important; + } .shadow-sm { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); @@ -1985,6 +2063,11 @@ ) { display: flex; } + .group-data-selected\/command-item\:text-foreground:is( + :where(.group\/command-item)[data-selected] * + ) { + color: var(--foreground); + } .group-data-\[collapsible\=icon\]\:-mt-8:is( :where(.group)[data-collapsible='icon'] * ) { @@ -2271,6 +2354,10 @@ .last\:mt-0:last-child { margin-top: 0px; } + .last\:border-b-0:last-child { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 0px; + } .empty\:m-0:empty { margin: 0px; } @@ -2382,18 +2469,28 @@ .focus\:z-10:focus { z-index: 10; } + .focus\:border-l-2:focus { + border-left-style: var(--tw-border-style); + border-left-width: 2px; + } .focus\:border-destructive\/40:focus { border-color: var(--destructive); @supports (color: color-mix(in lab, red, red)) { border-color: color-mix(in oklab, var(--destructive) 40%, transparent); } } + .focus\:border-l-primary:focus { + border-left-color: var(--primary); + } .focus\:bg-accent:focus { background-color: var(--accent); } .focus\:text-accent-foreground:focus { color: var(--accent-foreground); } + .focus\:text-primary:focus { + color: var(--primary); + } .focus\:ring-0:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); @@ -2596,6 +2693,10 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + :where([data-slot='dialog-content']) + .in-data-\[slot\=dialog-content\]\:rounded-lg\! { + border-radius: var(--radius) !important; + } :where([data-slot='popover-content']) .in-data-\[slot\=popover-content\]\:bg-transparent { background-color: transparent; @@ -3076,6 +3177,12 @@ .data-\[disabled\]\:opacity-50[data-disabled] { opacity: 50%; } + .data-\[disabled\=true\]\:pointer-events-none[data-disabled='true'] { + pointer-events: none; + } + .data-\[disabled\=true\]\:opacity-50[data-disabled='true'] { + opacity: 50%; + } .data-\[invalid\=true\]\:text-destructive[data-invalid='true'] { color: var(--destructive); } @@ -3135,6 +3242,21 @@ .data-\[selected\=true\]\:rounded-none[data-selected='true'] { border-radius: 0; } + .data-\[selected\=true\]\:ring-2[data-selected='true'] { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 + calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), + var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .data-\[selected\=true\]\:inset-ring[data-selected='true'] { + --tw-inset-ring-shadow: inset 0 0 0 1px + var(--tw-inset-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), + var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .data-\[selected\=true\]\:ring-primary[data-selected='true'] { + --tw-ring-color: var(--primary); + } .data-\[side\=bottom\]\:inset-x-0[data-side='bottom'] { inset-inline: 0px; } @@ -3363,6 +3485,11 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + :is( + .\*\:data-\[slot\=input-group-addon\]\:pl-2\! > * + )[data-slot='input-group-addon'] { + padding-left: calc(var(--spacing) * 2) !important; + } :is(.\*\*\:data-\[slot\=kbd\]\:relative *)[data-slot='kbd'] { position: relative; } @@ -3440,6 +3567,13 @@ .data-\[state\=checked\]\:active\:bg-primary-hover[data-state='checked']:active { background-color: var(--primary-hover); } + .data-\[state\=closed\]\:animate-accordion-up[data-state='closed'] { + animation: accordion-up + var(--tw-animation-duration, var(--tw-duration, 0.2s)) + var(--tw-ease, ease-out) var(--tw-animation-delay, 0s) + var(--tw-animation-iteration-count, 1) + var(--tw-animation-direction, normal) var(--tw-animation-fill-mode, none); + } .data-\[state\=delayed-open\]\:animate-in[data-state='delayed-open'] { animation: enter var(--tw-animation-duration, var(--tw-duration, 0.15s)) var(--tw-ease, ease) var(--tw-animation-delay, 0s) @@ -3468,6 +3602,13 @@ .data-\[state\=indeterminate\]\:active\:bg-primary-hover[data-state='indeterminate']:active { background-color: var(--primary-hover); } + .data-\[state\=open\]\:animate-accordion-down[data-state='open'] { + animation: accordion-down + var(--tw-animation-duration, var(--tw-duration, 0.2s)) + var(--tw-ease, ease-out) var(--tw-animation-delay, 0s) + var(--tw-animation-iteration-count, 1) + var(--tw-animation-direction, normal) var(--tw-animation-fill-mode, none); + } .data-\[state\=selected\]\:bg-muted[data-state='selected'] { background-color: var(--muted); } @@ -3900,6 +4041,27 @@ ):is(.rdp-button_previous > svg) { rotate: 180deg; } + :is(.\*\*\:\[\[cmdk-group-heading\]\]\:px-2 *):is([cmdk-group-heading]) { + padding-inline: calc(var(--spacing) * 2); + } + :is(.\*\*\:\[\[cmdk-group-heading\]\]\:py-1\.5 *):is([cmdk-group-heading]) { + padding-block: calc(var(--spacing) * 1.5); + } + :is(.\*\*\:\[\[cmdk-group-heading\]\]\:text-xs *):is([cmdk-group-heading]) { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + :is(.\*\*\:\[\[cmdk-group-heading\]\]\:font-medium *):is( + [cmdk-group-heading] + ) { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + :is(.\*\*\:\[\[cmdk-group-heading\]\]\:text-muted-foreground *):is( + [cmdk-group-heading] + ) { + color: var(--muted-foreground); + } .\[\[readonly\]\]\:cursor-not-allowed:is([readonly]) { cursor: not-allowed; } @@ -3935,6 +4097,9 @@ :is(.\*\:\[svg\]\:shrink-0 > *):is(svg) { flex-shrink: 0; } + :is(.data-selected\:\*\:\[svg\]\:text-foreground[data-selected] > *):is(svg) { + color: var(--foreground); + } :is( .data-\[variant\=destructive\]\:\*\:\[svg\]\:text-destructive[data-variant='destructive'] > * @@ -4138,6 +4303,9 @@ border-bottom-style: var(--tw-border-style); border-bottom-width: 0px; } + .\[\&\[data-state\=open\]\>svg\]\:rotate-180[data-state='open'] > svg { + rotate: 180deg; + } .\[\&\~\[data-slot\=autocomplete-clear\]\]\:right-1\.5 ~ [data-slot='autocomplete-clear'] { right: calc(var(--spacing) * 1.5); @@ -4699,6 +4867,46 @@ filter: blur(var(--tw-exit-blur, 0)); } } +@keyframes accordion-down { + from { + height: 0; + } + to { + height: var( + --radix-accordion-content-height, + var( + --bits-accordion-content-height, + var( + --reka-accordion-content-height, + var( + --kb-accordion-content-height, + var(--ngp-accordion-content-height, auto) + ) + ) + ) + ); + } +} +@keyframes accordion-up { + from { + height: var( + --radix-accordion-content-height, + var( + --bits-accordion-content-height, + var( + --reka-accordion-content-height, + var( + --kb-accordion-content-height, + var(--ngp-accordion-content-height, auto) + ) + ) + ) + ); + } + to { + height: 0; + } +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { From 9e93406136baaa344185b88e1a2a976410e675f1 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 24 Aug 2026 11:29:43 +0200 Subject: [PATCH 25/59] fix: use correct function for opening file after grade export --- .../grader-service/assignments/export-grades-dialog.tsx | 7 +++---- packages/labextension/src/services/local-file.service.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx b/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx index 6ad9420a..2ea7d95d 100644 --- a/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx +++ b/packages/labextension/src/app/components/grader-service/assignments/export-grades-dialog.tsx @@ -18,7 +18,8 @@ import { Button } from '../../../shadcn-components/ui/button'; import { exportGrades } from '../../../../services/submissions.service'; import { lectureBasePath, - openInFileBrowser + openInFileBrowser, + openInNewTab } from '../../../../services/local-file.service'; import { Lecture } from '../../../../model/lecture'; import { useMutationStatus } from '../../../../widget'; @@ -37,11 +38,9 @@ export const ExportGradesDialog = (props: IExportGradesDialogProps) => { const handleExport = async () => { try { await exportGrades(props.lecture.id, filter, format); - // open file in new tab - await openInFileBrowser( + await openInNewTab( `${lectureBasePath}${props.lecture.code}/${props.lecture.name}_${filter}_submissions.${format}` ); - // go into correct directory await openInFileBrowser(`${lectureBasePath}${props.lecture.code}`); } catch (error: any) { setStatus({ diff --git a/packages/labextension/src/services/local-file.service.ts b/packages/labextension/src/services/local-file.service.ts index 3092955c..a2e86e3c 100644 --- a/packages/labextension/src/services/local-file.service.ts +++ b/packages/labextension/src/services/local-file.service.ts @@ -42,12 +42,16 @@ export const openInFileBrowser = async (path: string) => { * Opens a file in a new tab * * @param path file path to open + * @param mode determines how to open a file. Default: tab-after */ -export const openInNewTab = async (path: string) => { +export const openInNewTab = async ( + path: string, + mode: string = 'tab-after' +) => { await GlobalObjects.commands.execute('docmanager:open', { path: path, options: { - mode: 'tab-after' // tab-after tab-before split-bottom split-right split-left split-top + mode: mode // tab-after tab-before split-bottom split-right split-left split-top } }); }; From 52af2ca2a993c7e18c47581c5fb2c5b5a9be1f87 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 24 Aug 2026 11:46:15 +0200 Subject: [PATCH 26/59] chore: rename const --- .../src/services/assignments.service.ts | 18 +++++++++--------- .../labextension/src/services/file.service.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/labextension/src/services/assignments.service.ts b/packages/labextension/src/services/assignments.service.ts index b27052a9..e9f66440 100644 --- a/packages/labextension/src/services/assignments.service.ts +++ b/packages/labextension/src/services/assignments.service.ts @@ -9,7 +9,7 @@ import { AssignmentDetail } from '../model/assignmentDetail'; import { request } from './request.service'; import { HTTPMethod } from './enums/http-methods.enum'; -export const buildBaseUrl = (lectureId: number) => { +export const baseUrl = (lectureId: number) => { return `/api/lectures/${lectureId}/assignments`; }; @@ -18,7 +18,7 @@ export function getAllAssignments( reload = false, includeSubmissions = false ): Promise { - let url = buildBaseUrl(lectureId); + let url = baseUrl(lectureId); if (includeSubmissions) { const searchParams = new URLSearchParams({ 'include-submissions': String(includeSubmissions) @@ -35,7 +35,7 @@ export function getAssignment( ): Promise { return request( HTTPMethod.GET, - `${buildBaseUrl(lectureId)}/${assignmentId}`, + `${baseUrl(lectureId)}/${assignmentId}`, null, reload ); @@ -47,7 +47,7 @@ export function createAssignment( ): Promise { return request( HTTPMethod.POST, - buildBaseUrl(lectureId), + baseUrl(lectureId), assignment ); } @@ -60,7 +60,7 @@ export function updateAssignment( const searchParams = new URLSearchParams({ 'recalc-scores': String(recalcScores) }); - let url = `${buildBaseUrl(lectureId)}/${assignment.id}`; + let url = `${baseUrl(lectureId)}/${assignment.id}`; url += '?' + searchParams; return request(HTTPMethod.PUT, url, assignment); @@ -73,7 +73,7 @@ export function getAssignmentProperties( ): Promise { return request( HTTPMethod.GET, - `${buildBaseUrl(lectureId)}/${assignmentId}/properties`, + `${baseUrl(lectureId)}/${assignmentId}/properties`, null, reload ); @@ -85,7 +85,7 @@ export function generateAssignment( ): Promise { return request( HTTPMethod.PUT, - `${buildBaseUrl(lectureId)}/${assignment.id}/generate`, + `${baseUrl(lectureId)}/${assignment.id}/generate`, null ); } @@ -97,7 +97,7 @@ export function fetchAssignment( metadataOnly: boolean = false, reload: boolean = false ): Promise { - let url = `${buildBaseUrl(lectureId)}/${assignmentId}`; + let url = `${baseUrl(lectureId)}/${assignmentId}`; if (instructor || metadataOnly) { const searchParams = new URLSearchParams({ 'instructor-version': String(instructor), @@ -115,7 +115,7 @@ export function deleteAssignment( ): Promise { return request( HTTPMethod.DELETE, - `${buildBaseUrl(lectureId)}/${assignmentId}`, + `${baseUrl(lectureId)}/${assignmentId}`, null ); } diff --git a/packages/labextension/src/services/file.service.ts b/packages/labextension/src/services/file.service.ts index 23bfbf32..ad1b9fa5 100644 --- a/packages/labextension/src/services/file.service.ts +++ b/packages/labextension/src/services/file.service.ts @@ -5,7 +5,7 @@ import { request } from './request.service'; import { HTTPMethod } from './enums/http-methods.enum'; import { RemoteFileStatus } from '../model/remoteFileStatus'; -const baseUrl = (props: { lectureId: number; assignmentId: number }) => { +export const baseUrl = (props: { lectureId: number; assignmentId: number }) => { return `/api/lectures/${props.lectureId}/assignments/${props.assignmentId}/`; }; From 354989bea0c4f6a62d732fec8263e550f08a5d04 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 24 Aug 2026 11:46:32 +0200 Subject: [PATCH 27/59] fix: use correct baseUrl --- packages/labextension/src/services/git.service.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/labextension/src/services/git.service.ts b/packages/labextension/src/services/git.service.ts index f5cbe958..e6b956c8 100644 --- a/packages/labextension/src/services/git.service.ts +++ b/packages/labextension/src/services/git.service.ts @@ -1,8 +1,6 @@ import { RepoType } from '../app/components/utils/repo-type'; import { request } from './request.service'; -import { Lecture } from '../model/lecture'; -import { Assignment } from '../model/assignment'; -import { buildBaseUrl } from './assignments.service'; +import { baseUrl } from './file.service'; import { HTTPMethod } from './enums/http-methods.enum'; export function pushAssignment( @@ -12,7 +10,7 @@ export function pushAssignment( commitMessage?: string, selectedFiles?: string[] ): Promise { - let url = `${buildBaseUrl(lectureId)}/${assignmentId}/push/${repoType}`; + let url = `${baseUrl({ lectureId, assignmentId })}push/${repoType}`; if (commitMessage) { const searchParams = new URLSearchParams({ 'commit-message': commitMessage @@ -36,18 +34,18 @@ export function pullAssignment( ): Promise { return request( HTTPMethod.GET, - `${buildBaseUrl(lectureId)}/${assignmentId}/pull/${repoType}`, + `${baseUrl({ lectureId, assignmentId })}pull/${repoType}`, null ); } export function resetAssignment( - lecture: Lecture, - assignment: Assignment + lectureId: number, + assignmentId: number ): Promise { return request( HTTPMethod.GET, - `${buildBaseUrl(lecture.id)}/${assignment.id}/reset`, + `${baseUrl({ lectureId, assignmentId })}reset`, null ); } From 8ece9cff743b342ca58db8236cf509ed90016643 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 24 Aug 2026 12:13:48 +0200 Subject: [PATCH 28/59] WIP: add submission-related endpoints --- .../src/app/components/utils/repo-type.tsx | 5 +- .../labextension/src/services/git.service.ts | 104 ++++++++++++- .../src/services/submissions.service.ts | 141 ++++++++++++++++++ 3 files changed, 248 insertions(+), 2 deletions(-) diff --git a/packages/labextension/src/app/components/utils/repo-type.tsx b/packages/labextension/src/app/components/utils/repo-type.tsx index beeb4ef3..919982ca 100644 --- a/packages/labextension/src/app/components/utils/repo-type.tsx +++ b/packages/labextension/src/app/components/utils/repo-type.tsx @@ -7,5 +7,8 @@ export enum RepoType { SOURCE = 'source', RELEASE = 'release', - ASSIGNMENT = 'assignment' + USER = 'user', + AUTOGRADE = 'autograde', + EDIT = 'edit', + FEEDBACK = 'feedback' } diff --git a/packages/labextension/src/services/git.service.ts b/packages/labextension/src/services/git.service.ts index e6b956c8..f2ec57e5 100644 --- a/packages/labextension/src/services/git.service.ts +++ b/packages/labextension/src/services/git.service.ts @@ -2,8 +2,9 @@ import { RepoType } from '../app/components/utils/repo-type'; import { request } from './request.service'; import { baseUrl } from './file.service'; import { HTTPMethod } from './enums/http-methods.enum'; +import { Submission } from '../model/submission'; -export function pushAssignment( +export function releaseAssignment( lectureId: number, assignmentId: number, repoType: RepoType, @@ -39,6 +40,16 @@ export function pullAssignment( ); } +export function submitAssignment(lectureId: number, assignmentId: number) { + let url = `${baseUrl({ lectureId, assignmentId })}push/${RepoType.USER}`; + const searchParams = new URLSearchParams({ + submit: 'true' + }); + url += '?' + searchParams; + + return request(HTTPMethod.PUT, url, null); +} + export function resetAssignment( lectureId: number, assignmentId: number @@ -49,3 +60,94 @@ export function resetAssignment( null ); } + +export function getGitLogs( + lectureId: number, + assignmentId: number, + submissionId: number, + reload = false +): Promise { + const url = `${baseUrl({ + lectureId, + assignmentId + })}submissions/${submissionId}/logs`; + return request(HTTPMethod.GET, url, null, reload); +} + +export function createOrOverrideEditRepository( + lectureId: number, + assignmentId: number, + submissionId: number +): Promise { + const url = `${baseUrl({ + lectureId, + assignmentId + })}submissions/${submissionId}/edit`; + return request(HTTPMethod.PUT, url, {}); +} + +export async function pullFeedback( + lectureId: number, + assignmentId: number, + submission: Submission +) { + return request( + HTTPMethod.GET, + `${baseUrl({ lectureId, assignmentId })}grading/${submission.id}/pull/${ + RepoType.FEEDBACK + }`, + null + ); +} + +export async function pullSubmissionFiles( + lectureId: number, + assignmentId: number, + submission: Submission +) { + let url = `${baseUrl({ lectureId, assignmentId })}pull/${RepoType.EDIT}`; + + const searchParams = new URLSearchParams({ + subid: String(submission.id) + }); + url += '?' + searchParams; + return request(HTTPMethod.GET, url, null); +} + +export async function createSubmissionFiles( + lectureId: number, + assignmentId: number, + username: string +) { + let url = `${baseUrl({ lectureId, assignmentId })}push/${RepoType.EDIT}`; + const searchParams = new URLSearchParams({ + for_user: username + }); + url += '?' + searchParams; + return request(HTTPMethod.PUT, url, null); +} + +export async function pushSubmissionFiles( + lectureId: number, + assignmentId: number, + submission: Submission +) { + let url = `${baseUrl({ lectureId, assignmentId })}push/${RepoType.EDIT}`; + const searchParams = new URLSearchParams({ + subid: String(submission.id) + }); + url += '?' + searchParams; + return request(HTTPMethod.PUT, url, null); +} + +export function restoreSubmission( + lectureId: number, + assignmentId: number, + commitHash: string +): Promise { + return request( + HTTPMethod.GET, + `${baseUrl({ lectureId, assignmentId })}restore/${commitHash}`, + null + ); +} diff --git a/packages/labextension/src/services/submissions.service.ts b/packages/labextension/src/services/submissions.service.ts index 762a4a72..3b0f1705 100644 --- a/packages/labextension/src/services/submissions.service.ts +++ b/packages/labextension/src/services/submissions.service.ts @@ -1,5 +1,146 @@ import { HTTPMethod } from './enums/http-methods.enum'; import { request } from './request.service'; +import { Submission } from '../model/submission'; +import { baseUrl } from './file.service'; + +export function getSubmissions( + lectureId: number, + assignmentId: number, + filter: 'none' | 'latest' | 'best' = 'none', + instructor = true, + reload = false +): Promise { + let url = `${baseUrl({ lectureId, assignmentId })}submissions`; + + if (filter || instructor) { + const searchParams = new URLSearchParams({ + 'instructor-version': String(instructor), + filter: filter + }); + url += '?' + searchParams; + } + return request(HTTPMethod.GET, url, null, reload); +} + +export function getSubmission( + lectureId: number, + assignmentId: number, + submissionId: number, + reload: boolean = false +): Promise { + const url = `${baseUrl({ + lectureId, + assignmentId + })}submissions/${submissionId}`; + return request(HTTPMethod.GET, url, null, reload); +} + +export function updateSubmission( + lectureId: number, + assignmentId: number, + submissionId: number, + updatedSubmission: Submission +): Promise { + const url = `${baseUrl({ + lectureId, + assignmentId + })}submissions/${submissionId}`; + return request(HTTPMethod.PUT, url, updatedSubmission); +} + +export function deleteSubmission( + lectureId: number, + assignmentId: number, + submissionId: number +): Promise { + return request( + HTTPMethod.DELETE, + `${baseUrl({ lectureId, assignmentId })}submissions/${submissionId}`, + null + ); +} + +export function getFeedback( + lectureId: number, + assignmentId: number, + latest = false, + instructor = false +): Promise { + let url = `${baseUrl({ lectureId, assignmentId })}feedback`; + if (latest || instructor) { + const searchParams = new URLSearchParams({ + 'instructor-version': String(instructor), + latest: String(latest) + }); + url += '?' + searchParams; + } + return request(HTTPMethod.GET, url, null); +} + +export function getProperties( + lectureId: number, + assignmentId: number, + submissionId: number, + reload = false +): Promise { + const url = `${baseUrl({ + lectureId, + assignmentId + })}submissions/${submissionId}/properties`; + return request(HTTPMethod.GET, url, null, reload); +} + +export function updateProperties( + lectureId: number, + assignmentId: number, + submissionId: number, + updatedProperties: any +): Promise { + const url = `${baseUrl({ + lectureId, + assignmentId + })}submissions/${submissionId}/properties`; + return request(HTTPMethod.PUT, url, updatedProperties); +} + +export function ltiSyncSubmissions( + lectureId: number, + assignmentId: number, + option: string, + submissionIds: number[] = [] +): Promise<{ + synced_platforms: Array<{ + platform: string; + syncable_users: number; + synced_user: number; + }>; +}> { + let url = `${baseUrl({ lectureId, assignmentId })}submissions/lti`; + const searchParams = new URLSearchParams({ + option: option + }); + url += '?' + searchParams; + return request<{ + synced_platforms: Array<{ + platform: string; + syncable_users: number; + synced_user: number; + }>; + }>(HTTPMethod.PUT, url, { submission_ids: submissionIds }); +} + +export async function getSubmissionCount( + lectureId: number, + assignmentId: number +): Promise<{ submission_count: number }> { + const url = `${baseUrl({ lectureId, assignmentId })}submissions/count`; + return request<{ submission_count: number }>( + HTTPMethod.GET, + url, + null, + false + ); +} export async function exportGrades( lectureId: number, From bdbda1fc69e29772a8fe43ca33d1358e4f852397 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 24 Aug 2026 13:32:29 +0200 Subject: [PATCH 29/59] WIP: add grading service --- .../src/services/grading.service.ts | 112 ++++++++++++++++++ .../src/services/submissions.service.ts | 18 +++ 2 files changed, 130 insertions(+) create mode 100644 packages/labextension/src/services/grading.service.ts diff --git a/packages/labextension/src/services/grading.service.ts b/packages/labextension/src/services/grading.service.ts new file mode 100644 index 00000000..2b07b3c0 --- /dev/null +++ b/packages/labextension/src/services/grading.service.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2022, TU Wien +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +import { Assignment } from '../model/assignment'; +import { User } from '../model/user'; +import { request } from './request.service'; +import { Submission } from '../model/submission'; +import { baseUrl } from './file.service'; +import { HTTPMethod } from './enums/http-methods.enum'; + +export function autogradeSubmission( + lectureId: number, + assignmentId: number, + submissionId: number +): Promise { + return request( + HTTPMethod.GET, + `${baseUrl({ lectureId, assignmentId })}grading/${submissionId}/auto`, + null + ); +} + +export function generateFeedback( + lectureId: number, + assignmentId: number, + submissionId: number +): Promise { + return request( + HTTPMethod.GET, + `${baseUrl({ lectureId, assignmentId })}grading/${submissionId}/feedback`, + null + ); +} + +export function getGrade( + lectureId: number, + assignmentId: number, + student: User +): Promise { + return request( + HTTPMethod.GET, + `${baseUrl({ lectureId, assignmentId })}grading/${student.name}/score`, + null + ); +} +//response is not a schema => any +//TODO: should prob be changed +export function getStudentSubmissions( + lectureId: number, + assignmentId: number, + reload = false +): Promise { + return request( + HTTPMethod.GET, + `${baseUrl({ lectureId, assignmentId })}grading`, + null, + reload + ); +} + +export function getManualFeedback( + lectureId: number, + assignmentId: number, + student: User +): Promise { + return request( + HTTPMethod.GET, + `${baseUrl({ lectureId, assignmentId })}grading/${student.name}/manual`, + null + ); +} + +export function createManualFeedback( + lectureId: number, + assignmentId: number, + submissionId: number +): Promise { + return request( + HTTPMethod.GET, + `${baseUrl({ lectureId, assignmentId })}grading/${submissionId}/manual`, + null + ); +} + +export function updateManualFeedback( + lectureId: number, + assignmentId: number, + student: User, + manual: any +): Promise { + return request( + HTTPMethod.PUT, + `${baseUrl({ lectureId, assignmentId })}grading/${student.name}/manual`, + manual + ); +} + +export function deleteManualFeedback( + lectureId: number, + assignmentId: number, + student: User, + manual: any +): Promise { + return request( + HTTPMethod.DELETE, + `${baseUrl({ lectureId, assignmentId })}grading/${student.name}/manual`, + manual + ); +} diff --git a/packages/labextension/src/services/submissions.service.ts b/packages/labextension/src/services/submissions.service.ts index 3b0f1705..311db987 100644 --- a/packages/labextension/src/services/submissions.service.ts +++ b/packages/labextension/src/services/submissions.service.ts @@ -22,6 +22,24 @@ export function getSubmissions( return request(HTTPMethod.GET, url, null, reload); } +export function saveSubmissions( + lectureId: number, + assignmentId: number, + filter: 'none' | 'latest' | 'best' = 'none' +): Promise { + let url = `${baseUrl({ + lectureId, + assignmentId + })}submissions/save`; + if (filter) { + const searchParams = new URLSearchParams({ + filter: filter + }); + url += '?' + searchParams; + } + return request(HTTPMethod.PUT, url, null); +} + export function getSubmission( lectureId: number, assignmentId: number, From 738b22004e9ca6e5cc82a513feca0eec91e91a02 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 24 Aug 2026 14:11:35 +0200 Subject: [PATCH 30/59] fix: use correct endpoint uri --- .../src/services/grading.service.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/labextension/src/services/grading.service.ts b/packages/labextension/src/services/grading.service.ts index 2b07b3c0..855cbe70 100644 --- a/packages/labextension/src/services/grading.service.ts +++ b/packages/labextension/src/services/grading.service.ts @@ -46,19 +46,18 @@ export function getGrade( null ); } -//response is not a schema => any -//TODO: should prob be changed + export function getStudentSubmissions( - lectureId: number, - assignmentId: number, + username: string, + format: 'json' | 'csv' = 'csv', reload = false ): Promise { - return request( - HTTPMethod.GET, - `${baseUrl({ lectureId, assignmentId })}grading`, - null, - reload - ); + let url = `/api/users/${username}/submissions`; + const params = new URLSearchParams({ + format: format + }); + url += '?' + params.toString(); + return request(HTTPMethod.GET, url, null, reload); } export function getManualFeedback( From 2890278d566b80e113e2a2b245472fcfc302d3d1 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 24 Aug 2026 14:11:53 +0200 Subject: [PATCH 31/59] fix: build endpoint uri with correct search params format --- .../src/services/submissions.service.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/labextension/src/services/submissions.service.ts b/packages/labextension/src/services/submissions.service.ts index 311db987..9b257bbd 100644 --- a/packages/labextension/src/services/submissions.service.ts +++ b/packages/labextension/src/services/submissions.service.ts @@ -17,7 +17,7 @@ export function getSubmissions( 'instructor-version': String(instructor), filter: filter }); - url += '?' + searchParams; + url += '?' + searchParams.toString(); } return request(HTTPMethod.GET, url, null, reload); } @@ -35,7 +35,7 @@ export function saveSubmissions( const searchParams = new URLSearchParams({ filter: filter }); - url += '?' + searchParams; + url += '?' + searchParams.toString(); } return request(HTTPMethod.PUT, url, null); } @@ -90,7 +90,7 @@ export function getFeedback( 'instructor-version': String(instructor), latest: String(latest) }); - url += '?' + searchParams; + url += '?' + searchParams.toString(); } return request(HTTPMethod.GET, url, null); } @@ -137,7 +137,7 @@ export function ltiSyncSubmissions( const searchParams = new URLSearchParams({ option: option }); - url += '?' + searchParams; + url += '?' + searchParams.toString(); return request<{ synced_platforms: Array<{ platform: string; @@ -165,6 +165,11 @@ export async function exportGrades( filter: 'latest' | 'best' = 'best', format: 'json' | 'csv' = 'csv' ): Promise { - const url = `/api/lectures/${lectureId}/submissions?filter=${filter}&format=${format}`; + let url = `/api/lectures/${lectureId}/submissions`; + const searchParams = new URLSearchParams({ + filter: filter, + format: format + }); + url += '?' + searchParams.toString(); return request(HTTPMethod.GET, url, null); } From e88bbb7091dfea7b0f6edf40dfc09a97dff26efb Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 24 Aug 2026 14:12:12 +0200 Subject: [PATCH 32/59] WIP: add gradebook service --- .../src/services/gradebook.service.ts | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 packages/labextension/src/services/gradebook.service.ts diff --git a/packages/labextension/src/services/gradebook.service.ts b/packages/labextension/src/services/gradebook.service.ts new file mode 100644 index 00000000..af6907a1 --- /dev/null +++ b/packages/labextension/src/services/gradebook.service.ts @@ -0,0 +1,248 @@ +// Copyright (c) 2022, TU Wien +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +export class GradeBook { + public properties: any; + + public constructor(properties: any) { + this.properties = properties; + } + + public getNotebooks(): string[] { + return Object.keys(this.properties['notebooks']); + } + + public getExtraFiles(): string[] { + return this.properties['extra_files']; + } + + public setComment(notebook: string, cellId: string, comment: string) { + this.properties['notebooks'][notebook]['comments_dict'][cellId][ + 'manual_comment' + ] = comment; + } + + public getComment(notebook: string, cellId: string): string { + return this.properties['notebooks'][notebook]['comments_dict'][cellId][ + 'manual_comment' + ]; + } + + public setManualScore(notebook: string, cellId: string, score: number) { + try { + this.properties['notebooks'][notebook]['grades_dict'][cellId][ + 'manual_score' + ] = score; + } catch (error) { + this.createTaskGrade(notebook, cellId, score); + } + } + + private createTaskGrade(notebook: string, cellId: string, score: number) { + const maxScore = + this.properties['notebooks'][notebook]['task_cells_dict'][cellId][ + 'max_score' + ]; + const grade: any = { + cell_id: cellId, + notebook_id: notebook, + id: cellId, + auto_score: null, + manual_score: score, + extra_credit: null, + needs_manual_grade: false, + max_score_gradecell: null, + max_score_taskcell: maxScore, + failed_tests: null + }; + this.properties['notebooks'][notebook]['grades_dict'][cellId] = grade; + } + + public getManualScore(notebook: string, cellId: string): number { + return this.properties['notebooks'][notebook]['grades_dict'][cellId][ + 'manual_score' + ]; + } + + public setExtraCredit(notebook: string, cellId: string, credit: number) { + this.properties['notebooks'][notebook]['grades_dict'][cellId][ + 'extra_credit' + ] = credit; + } + + public getExtraCredit(notebook: string, cellId: string): number { + const extraCredit = + this.properties['notebooks'][notebook]['grades_dict'][cellId][ + 'extra_credit' + ]; + if (extraCredit) { + return extraCredit; + } else { + return 0.0; + } + } + + public setNeedsManualGrading( + notebook: string, + cellId: string, + needsGrading: boolean + ) { + this.properties['notebooks'][notebook]['grades_dict'][cellId][ + 'needs_manual_grade' + ] = needsGrading; + } + + public getNeedsManualGrading(notebook: string, cellId: string): boolean { + return this.properties['notebooks'][notebook]['grades_dict'][cellId][ + 'needs_manual_grade' + ]; + } + + public getNotebookGradingInfo(notebook: string): boolean { + const grades_dict = this.properties['notebooks'][notebook]['grades_dict']; + return Object.keys(grades_dict) + .map(v => this.getNeedsManualGrading(notebook, v)) + .reduce((r, v) => r || v, false); + } + + public getGradingInfo(): Map { + const map: Map = new Map(); + for (const notebook of Object.keys(this.properties['notebooks'])) { + map.set(notebook, this.getNotebookGradingInfo(notebook)); + } + return map; + } + + public getGradeScore(notebook: string, cellId: string): number { + const grade = this.properties['notebooks'][notebook]['grades_dict'][cellId]; + if (grade['manual_score'] === null && grade['auto_score'] === null) { + return 0.0; + } else if (grade['manual_score'] === null) { + return grade['auto_score']; + } else if (grade['auto_score'] === null) { + return grade['manual_score']; + } else { + return grade['manual_score']; + } + } + + public getAutoGradeScore(notebook: string, cellId: string): number { + return this.properties['notebooks'][notebook]['grades_dict'][cellId][ + 'auto_score' + ]; + } + + public getGradeMaxScore(notebook: string, cellId: string): number { + const grade = this.properties['notebooks'][notebook]['grades_dict'][cellId]; + if (grade['max_score_taskcell'] !== null) { + return grade['max_score_taskcell']; + } else if (grade['max_score_gradecell'] !== null) { + return grade['max_score_gradecell']; + } else { + return 0.0; + } + } + + public getGradeCellMaxScore(notebook: string, cellId: string): number { + return this.properties['notebooks'][notebook]['grade_cells_dict'][cellId][ + 'max_score' + ]; + } + + public getTaskCellMaxScore(notebook: string, cellId: string): number { + return this.properties['notebooks'][notebook]['task_cells_dict'][cellId][ + 'max_score' + ]; + } + + public getNotebookPoints(notebook: string): number { + let sum = 0; + const grades_dict = this.properties['notebooks'][notebook]['grades_dict']; + for (const cellId of Object.keys(grades_dict)) { + sum += this.getGradeScore(notebook, cellId); + } + return sum; + } + + public getPoints(): number { + let sum = 0; + for (const notebook of Object.keys(this.properties['notebooks'])) { + sum += this.getNotebookPoints(notebook); + } + return sum; + } + + public addTaskCellsToGrades() { + for (const notebook of Object.keys(this.properties['notebooks'])) { + const task_cells_dict = + this.properties['notebooks'][notebook]['task_cells_dict']; + for (const cellId of Object.keys(task_cells_dict)) { + if ( + this.properties['notebooks'][notebook]['grades_dict'][cellId] === + undefined + ) { + console.log('Adding grade for task cell: ' + cellId); + this.createTaskGrade(notebook, cellId, null); + } + } + } + } + + public missingGradeCells(): string[] { + const missing = []; + for (const notebook of Object.keys(this.properties['notebooks'])) { + const grade_cells_dict = + this.properties['notebooks'][notebook]['grade_cells_dict']; + const grades_dict = this.properties['notebooks'][notebook]['grades_dict']; + for (const cellId of Object.keys(grade_cells_dict)) { + if (!Object.prototype.hasOwnProperty.call(grades_dict, cellId)) { + missing.push(cellId); + } + } + } + return missing; + } + + public getNotebookMaxPointsCells(notebook: string): number { + let sum = 0; + const grade_cells_dict = + this.properties['notebooks'][notebook]['grade_cells_dict']; + for (const cellId of Object.keys(grade_cells_dict)) { + sum += this.getGradeCellMaxScore(notebook, cellId); + } + const task_cells_dict = + this.properties['notebooks'][notebook]['task_cells_dict']; + for (const cellId of Object.keys(task_cells_dict)) { + sum += this.getTaskCellMaxScore(notebook, cellId); + } + return sum; + } + + public getMaxPoints(): number { + let sum = 0; + for (const notebook of Object.keys(this.properties['notebooks'])) { + sum += this.getNotebookMaxPointsCells(notebook); + } + return sum; + } + + public getNotebookExtraCredit(notebook: string): number { + let sum = 0; + const grades_dict = this.properties['notebooks'][notebook]['grades_dict']; + for (const cellId of Object.keys(grades_dict)) { + sum += this.getExtraCredit(notebook, cellId); + } + return sum; + } + + public getExtraCredits() { + let sum = 0; + for (const notebook of Object.keys(this.properties['notebooks'])) { + sum += this.getNotebookExtraCredit(notebook); + } + return sum; + } +} From f3b362f654fb6106f5bc94f047880c1dc0da578c Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 24 Aug 2026 16:49:25 +0200 Subject: [PATCH 33/59] WIP: move notebook components into correct dir --- .../create-assignment/creation-switch.tsx | 2 +- .../create-assignment/creation-widget.tsx | 0 .../create-assignment/error-component.tsx | 2 +- .../create-assignment/error-widget.tsx | 0 .../notebook/create-assignment/validator.tsx | 6 +- .../{ => app}/components/notebook/model.ts | 4 +- .../{ => app}/components/notebook/slider.tsx | 0 .../student-plugin/deadline-widget.tsx | 0 .../student-plugin/deadline-wrapper.tsx | 12 +- .../student-plugin/hint-component.tsx | 0 .../notebook/student-plugin/hint-widget.tsx | 0 .../components/notebook/switch-factory.tsx | 6 +- .../components/utils}/deadline.tsx | 2 +- .../src/app/shadcn-components/ui/switch.tsx | 2 +- packages/labextension/src/index.ts | 9 +- packages/labextension/style/css/output.css | 415 ++++-------------- 16 files changed, 102 insertions(+), 358 deletions(-) rename packages/labextension/src/{ => app}/components/notebook/create-assignment/creation-switch.tsx (96%) rename packages/labextension/src/{ => app}/components/notebook/create-assignment/creation-widget.tsx (100%) rename packages/labextension/src/{ => app}/components/notebook/create-assignment/error-component.tsx (87%) rename packages/labextension/src/{ => app}/components/notebook/create-assignment/error-widget.tsx (100%) rename packages/labextension/src/{ => app}/components/notebook/create-assignment/validator.tsx (97%) rename packages/labextension/src/{ => app}/components/notebook/model.ts (98%) rename packages/labextension/src/{ => app}/components/notebook/slider.tsx (100%) rename packages/labextension/src/{ => app}/components/notebook/student-plugin/deadline-widget.tsx (100%) rename packages/labextension/src/{ => app}/components/notebook/student-plugin/deadline-wrapper.tsx (79%) rename packages/labextension/src/{ => app}/components/notebook/student-plugin/hint-component.tsx (100%) rename packages/labextension/src/{ => app}/components/notebook/student-plugin/hint-widget.tsx (100%) rename packages/labextension/src/{ => app}/components/notebook/switch-factory.tsx (84%) rename packages/labextension/src/{components/util => app/components/utils}/deadline.tsx (98%) diff --git a/packages/labextension/src/components/notebook/create-assignment/creation-switch.tsx b/packages/labextension/src/app/components/notebook/create-assignment/creation-switch.tsx similarity index 96% rename from packages/labextension/src/components/notebook/create-assignment/creation-switch.tsx rename to packages/labextension/src/app/components/notebook/create-assignment/creation-switch.tsx index 4a23b438..885a5797 100644 --- a/packages/labextension/src/components/notebook/create-assignment/creation-switch.tsx +++ b/packages/labextension/src/app/components/notebook/create-assignment/creation-switch.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { IModeSwitchProps } from '../slider'; import { CreationWidget } from './creation-widget'; import { ErrorWidget } from './error-widget'; -import { Switch } from '../../../app/shadcn-components/ui/switch'; +import { Switch } from '../../../shadcn-components/ui/switch'; import { Validator } from './validator'; export class CreationModeSwitch extends React.Component { diff --git a/packages/labextension/src/components/notebook/create-assignment/creation-widget.tsx b/packages/labextension/src/app/components/notebook/create-assignment/creation-widget.tsx similarity index 100% rename from packages/labextension/src/components/notebook/create-assignment/creation-widget.tsx rename to packages/labextension/src/app/components/notebook/create-assignment/creation-widget.tsx diff --git a/packages/labextension/src/components/notebook/create-assignment/error-component.tsx b/packages/labextension/src/app/components/notebook/create-assignment/error-component.tsx similarity index 87% rename from packages/labextension/src/components/notebook/create-assignment/error-component.tsx rename to packages/labextension/src/app/components/notebook/create-assignment/error-component.tsx index e2045bf2..5d7105ec 100644 --- a/packages/labextension/src/components/notebook/create-assignment/error-component.tsx +++ b/packages/labextension/src/app/components/notebook/create-assignment/error-component.tsx @@ -12,7 +12,7 @@ export interface IErrorComponentProps { export const ErrorComponent = (props: IErrorComponentProps) => { return ( - + {props.err} ); diff --git a/packages/labextension/src/components/notebook/create-assignment/error-widget.tsx b/packages/labextension/src/app/components/notebook/create-assignment/error-widget.tsx similarity index 100% rename from packages/labextension/src/components/notebook/create-assignment/error-widget.tsx rename to packages/labextension/src/app/components/notebook/create-assignment/error-widget.tsx diff --git a/packages/labextension/src/components/notebook/create-assignment/validator.tsx b/packages/labextension/src/app/components/notebook/create-assignment/validator.tsx similarity index 97% rename from packages/labextension/src/components/notebook/create-assignment/validator.tsx rename to packages/labextension/src/app/components/notebook/create-assignment/validator.tsx index aee537c6..b9bddead 100644 --- a/packages/labextension/src/components/notebook/create-assignment/validator.tsx +++ b/packages/labextension/src/app/components/notebook/create-assignment/validator.tsx @@ -10,14 +10,14 @@ import * as React from 'react'; import { CellModel, NbgraderData, ToolData } from '../model'; import { PanelLayout, Widget } from '@lumino/widgets'; import { ErrorWidget } from './error-widget'; -import { Button } from '../../../app/shadcn-components/ui/button'; +import { Button } from '../../../shadcn-components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter -} from '../../../app/shadcn-components/ui/dialog'; +} from '../../../shadcn-components/ui/dialog'; export interface ValidatorProps { notebook: Notebook; @@ -199,7 +199,7 @@ export const Validator = (props: ValidatorProps) => { Validation Report - + {results.length === 0 && ( No errors found diff --git a/packages/labextension/src/components/notebook/model.ts b/packages/labextension/src/app/components/notebook/model.ts similarity index 98% rename from packages/labextension/src/components/notebook/model.ts rename to packages/labextension/src/app/components/notebook/model.ts index 7fb8689d..392bdfaa 100644 --- a/packages/labextension/src/components/notebook/model.ts +++ b/packages/labextension/src/app/components/notebook/model.ts @@ -66,7 +66,7 @@ export namespace CellModel { return null; } const nbgraderValue = cellMetadata[NBGRADER_KEY]; - if (nbgraderValue === null) { + if (nbgraderValue === null || nbgraderValue === undefined) { return null; } return nbgraderValue.valueOf() as NbgraderData; @@ -166,7 +166,7 @@ export namespace CellModel { } const currentDataJson = readOnlyMetadata[NBGRADER_KEY]; const currentData = - currentDataJson === null + currentDataJson === null || currentDataJson === undefined ? null : (currentDataJson.valueOf() as NbgraderData); if (currentData !== data) { diff --git a/packages/labextension/src/components/notebook/slider.tsx b/packages/labextension/src/app/components/notebook/slider.tsx similarity index 100% rename from packages/labextension/src/components/notebook/slider.tsx rename to packages/labextension/src/app/components/notebook/slider.tsx diff --git a/packages/labextension/src/components/notebook/student-plugin/deadline-widget.tsx b/packages/labextension/src/app/components/notebook/student-plugin/deadline-widget.tsx similarity index 100% rename from packages/labextension/src/components/notebook/student-plugin/deadline-widget.tsx rename to packages/labextension/src/app/components/notebook/student-plugin/deadline-widget.tsx diff --git a/packages/labextension/src/components/notebook/student-plugin/deadline-wrapper.tsx b/packages/labextension/src/app/components/notebook/student-plugin/deadline-wrapper.tsx similarity index 79% rename from packages/labextension/src/components/notebook/student-plugin/deadline-wrapper.tsx rename to packages/labextension/src/app/components/notebook/student-plugin/deadline-wrapper.tsx index 3f369a70..4917c0a8 100644 --- a/packages/labextension/src/components/notebook/student-plugin/deadline-wrapper.tsx +++ b/packages/labextension/src/app/components/notebook/student-plugin/deadline-wrapper.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; -import { DeadlineComponent } from '../../util/deadline'; -import { getLectures } from '../../../services/lectures.service'; -import { getAssignment } from '../../../services/assignments.service'; -import { lectureSubPathsCount } from '../../../services/local-file.service'; -import { Assignment } from '../../../model/assignment'; -import { Lecture } from '../../../model/lecture'; +import { DeadlineComponent } from '../../utils/deadline'; +import { getLectures } from '../../../../services/lectures.service'; +import { getAssignment } from '../../../../services/assignments.service'; +import { lectureSubPathsCount } from '../../../../services/local-file.service'; +import { Assignment } from '../../../../model/assignment'; +import { Lecture } from '../../../../model/lecture'; export interface IDeadlineWrapperProps { notebookPaths: string[]; diff --git a/packages/labextension/src/components/notebook/student-plugin/hint-component.tsx b/packages/labextension/src/app/components/notebook/student-plugin/hint-component.tsx similarity index 100% rename from packages/labextension/src/components/notebook/student-plugin/hint-component.tsx rename to packages/labextension/src/app/components/notebook/student-plugin/hint-component.tsx diff --git a/packages/labextension/src/components/notebook/student-plugin/hint-widget.tsx b/packages/labextension/src/app/components/notebook/student-plugin/hint-widget.tsx similarity index 100% rename from packages/labextension/src/components/notebook/student-plugin/hint-widget.tsx rename to packages/labextension/src/app/components/notebook/student-plugin/hint-widget.tsx diff --git a/packages/labextension/src/components/notebook/switch-factory.tsx b/packages/labextension/src/app/components/notebook/switch-factory.tsx similarity index 84% rename from packages/labextension/src/components/notebook/switch-factory.tsx rename to packages/labextension/src/app/components/notebook/switch-factory.tsx index e1986954..eda3cae1 100644 --- a/packages/labextension/src/components/notebook/switch-factory.tsx +++ b/packages/labextension/src/app/components/notebook/switch-factory.tsx @@ -5,11 +5,11 @@ // LICENSE file in the root directory of this source tree. import React from 'react'; -import { UserPermissions } from '../../services/permission.service'; +import { UserPermissions } from '../../../services/permission.service'; import { CreationModeSwitch } from './create-assignment/creation-switch'; import { IModeSwitchProps } from './slider'; -import { lectureSubPathsCount } from '../../services/local-file.service'; -import { Scope } from '../../services/enums/permissions-scope.enum'; +import { lectureSubPathsCount } from '../../../services/local-file.service'; +import { Scope } from '../../../services/enums/permissions-scope.enum'; export class SwitchModeFactory { public static getSwitch(props: IModeSwitchProps): JSX.Element { diff --git a/packages/labextension/src/components/util/deadline.tsx b/packages/labextension/src/app/components/utils/deadline.tsx similarity index 98% rename from packages/labextension/src/components/util/deadline.tsx rename to packages/labextension/src/app/components/utils/deadline.tsx index 7ee43a96..7eec9df0 100644 --- a/packages/labextension/src/components/util/deadline.tsx +++ b/packages/labextension/src/app/components/utils/deadline.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import moment from 'moment'; import { Clock } from 'lucide-react'; -import { Badge } from '../../app/shadcn-components/ui/badge'; +import { Badge } from '../../shadcn-components/ui/badge'; export interface IDeadlineProps { deadline: string | null; diff --git a/packages/labextension/src/app/shadcn-components/ui/switch.tsx b/packages/labextension/src/app/shadcn-components/ui/switch.tsx index b0c89da1..59030021 100644 --- a/packages/labextension/src/app/shadcn-components/ui/switch.tsx +++ b/packages/labextension/src/app/shadcn-components/ui/switch.tsx @@ -15,7 +15,7 @@ function Switch({ data-slot="switch" data-size={size} className={cn( - 'peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80', + 'peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-secondary-background/30 dark:data-[state=unchecked]:bg-input/80', className )} {...props} diff --git a/packages/labextension/src/index.ts b/packages/labextension/src/index.ts index cdf6e990..3c45a422 100644 --- a/packages/labextension/src/index.ts +++ b/packages/labextension/src/index.ts @@ -40,7 +40,7 @@ import { Cell } from '@jupyterlab/cells'; import { Menu, PanelLayout } from '@lumino/widgets'; -import { NotebookModeSwitch } from './components/notebook/slider'; +import { NotebookModeSwitch } from './app/components/notebook/slider'; import { homeIcon, runIcon } from '@jupyterlab/ui-components'; import { CommandRegistry } from '@lumino/commands'; @@ -49,13 +49,13 @@ import { Contents, ServiceManager } from '@jupyterlab/services'; import { IDocumentManager } from '@jupyterlab/docmanager'; import { IFileBrowserFactory } from '@jupyterlab/filebrowser'; import { UserPermissions } from './services/permission.service'; -import { CreationWidget } from './components/notebook/create-assignment/creation-widget'; +import { CreationWidget } from './app/components/notebook/create-assignment/creation-widget'; import { listIcon, undoIcon } from '@jupyterlab/ui-components/lib/icon/iconimports'; -import { HintWidget } from './components/notebook/student-plugin/hint-widget'; -import { DeadlineWidget } from './components/notebook/student-plugin/deadline-widget'; +import { HintWidget } from './app/components/notebook/student-plugin/hint-widget'; +import { DeadlineWidget } from './app/components/notebook/student-plugin/deadline-widget'; import { lectureSubPathsCount } from './services/local-file.service'; import { updateMenus } from './menu'; import { loadString } from './services/storage.service'; @@ -169,7 +169,6 @@ const connectTrackerSignals = (tracker: INotebookTracker) => { notebookPanel, notebook ); - tracker.currentWidget.toolbar.insertItem(10, 'Mode', switcher); //Creation of deadline widget diff --git a/packages/labextension/style/css/output.css b/packages/labextension/style/css/output.css index 910d82df..97ba2896 100644 --- a/packages/labextension/style/css/output.css +++ b/packages/labextension/style/css/output.css @@ -330,18 +330,12 @@ .top-0 { top: 0px; } - .top-1 { - top: var(--spacing); - } .top-1\.5 { top: calc(var(--spacing) * 1.5); } .top-1\/2 { top: calc(1 / 2 * 100%); } - .top-1\/3 { - top: calc(1 / 3 * 100%); - } .top-2 { top: calc(var(--spacing) * 2); } @@ -363,9 +357,6 @@ .bottom-0 { bottom: 0px; } - .left-1 { - left: var(--spacing); - } .left-1\/2 { left: calc(1 / 2 * 100%); } @@ -408,9 +399,6 @@ .row-start-1 { grid-row-start: 1; } - .m-0 { - margin: 0px; - } .m-0\! { margin: 0px !important; } @@ -423,9 +411,6 @@ .mx-2 { margin-inline: calc(var(--spacing) * 2); } - .mx-3 { - margin-inline: calc(var(--spacing) * 3); - } .mx-3\.5 { margin-inline: calc(var(--spacing) * 3.5); } @@ -456,12 +441,6 @@ .mt-auto { margin-top: auto; } - .mr-2 { - margin-right: calc(var(--spacing) * 2); - } - .mr-auto { - margin-right: auto; - } .mb-1 { margin-bottom: var(--spacing); } @@ -480,9 +459,18 @@ .-ml-1 { margin-left: calc(var(--spacing) * -1); } + .ml-1 { + margin-left: var(--spacing); + } + .ml-2 { + margin-left: calc(var(--spacing) * 2); + } .ml-4 { margin-left: calc(var(--spacing) * 4); } + .ml-6 { + margin-left: calc(var(--spacing) * 6); + } .ml-auto { margin-left: auto; } @@ -575,10 +563,6 @@ width: calc(var(--spacing) * 9); height: calc(var(--spacing) * 9); } - .size-15 { - width: calc(var(--spacing) * 15); - height: calc(var(--spacing) * 15); - } .size-20 { width: calc(var(--spacing) * 20); height: calc(var(--spacing) * 20); @@ -594,9 +578,6 @@ .h-\(--cell-size\) { height: var(--cell-size); } - .h-2 { - height: calc(var(--spacing) * 2); - } .h-2\.5 { height: calc(var(--spacing) * 2.5); } @@ -615,9 +596,6 @@ .h-8 { height: calc(var(--spacing) * 8); } - .h-8\! { - height: calc(var(--spacing) * 8) !important; - } .h-9 { height: calc(var(--spacing) * 9); } @@ -627,9 +605,6 @@ .h-12 { height: calc(var(--spacing) * 12); } - .h-13 { - height: calc(var(--spacing) * 13); - } .h-13\.5 { height: calc(var(--spacing) * 13.5); } @@ -666,9 +641,6 @@ .max-h-20 { max-height: calc(var(--spacing) * 20); } - .max-h-72 { - max-height: calc(var(--spacing) * 72); - } .max-h-80 { max-height: calc(var(--spacing) * 80); } @@ -702,9 +674,6 @@ .w-\(--sidebar-width\) { width: var(--sidebar-width); } - .w-2 { - width: calc(var(--spacing) * 2); - } .w-2\.5 { width: calc(var(--spacing) * 2.5); } @@ -747,9 +716,6 @@ .max-w-\[calc\(100\%-2rem\)\] { max-width: calc(100% - 2rem); } - .max-w-xs { - max-width: var(--container-xs); - } .min-w-\(--cell-size\) { min-width: var(--cell-size); } @@ -777,6 +743,9 @@ .min-w-36 { min-width: calc(var(--spacing) * 36); } + .min-w-37\.5 { + min-width: calc(var(--spacing) * 37.5); + } .min-w-\[calc\(var\(--anchor-width\)\+--spacing\(7\)\)\] { min-width: calc(var(--anchor-width) + calc(var(--spacing) * 7)); } @@ -786,15 +755,9 @@ .flex-1 { flex: 1; } - .flex-shrink { - flex-shrink: 1; - } .shrink-0 { flex-shrink: 0; } - .flex-grow { - flex-grow: 1; - } .grow { flex-grow: 1; } @@ -807,10 +770,6 @@ .origin-\(--transform-origin\) { transform-origin: var(--transform-origin); } - .-translate-x-1 { - --tw-translate-x: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-x-1\/2 { --tw-translate-x: calc(calc(1 / 2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -823,22 +782,10 @@ --tw-translate-x: 1px; translate: var(--tw-translate-x) var(--tw-translate-y); } - .-translate-y-1 { - --tw-translate-y: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); } - .translate-y-0 { - --tw-translate-y: 0px; - translate: var(--tw-translate-x) var(--tw-translate-y); - } - .translate-y-0\.5 { - --tw-translate-y: calc(var(--spacing) * 0.5); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .translate-y-\[calc\(-50\%-2px\)\] { --tw-translate-y: calc(-50% - 2px); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -871,9 +818,6 @@ .touch-none { touch-action: none; } - .resize { - resize: both; - } .resize-none { resize: none; } @@ -979,22 +923,6 @@ .gap-\[--spacing\(var\(--gap\)\)\] { gap: calc(var(--spacing) * var(--gap)); } - :where(.space-y-1 > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(var(--spacing) * var(--tw-space-y-reverse)); - margin-block-end: calc( - var(--spacing) * calc(1 - var(--tw-space-y-reverse)) - ); - } - :where(.space-y-4 > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc( - calc(var(--spacing) * 4) * var(--tw-space-y-reverse) - ); - margin-block-end: calc( - calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)) - ); - } .self-center { align-self: center; } @@ -1057,9 +985,6 @@ .rounded-lg { border-radius: var(--radius); } - .rounded-lg\! { - border-radius: var(--radius) !important; - } .rounded-md { border-radius: calc(var(--radius) - 2px); } @@ -1069,9 +994,6 @@ .rounded-sm { border-radius: calc(var(--radius) - 4px); } - .rounded-xl\! { - border-radius: calc(var(--radius) + 4px) !important; - } .rounded-xs { border-radius: var(--radius-xs); } @@ -1140,9 +1062,6 @@ .border-\[\#FF9D52\] { border-color: #ff9d52; } - .border-blue-500 { - border-color: var(--color-blue-500); - } .border-blue-500\/50 { border-color: color-mix( in srgb, @@ -1156,6 +1075,12 @@ .border-border { border-color: var(--border); } + .border-border\/50 { + border-color: var(--border); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--border) 50%, transparent); + } + } .border-destructive { border-color: var(--destructive); } @@ -1165,9 +1090,6 @@ border-color: color-mix(in oklab, var(--destructive) 50%, transparent); } } - .border-green-500 { - border-color: var(--color-green-500); - } .border-green-500\/50 { border-color: color-mix( in srgb, @@ -1185,12 +1107,6 @@ .border-input { border-color: var(--input); } - .border-input\/30 { - border-color: var(--input); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--input) 30%, transparent); - } - } .border-muted-foreground { border-color: var(--muted-foreground); } @@ -1206,9 +1122,6 @@ .border-transparent { border-color: transparent; } - .border-yellow-500 { - border-color: var(--color-yellow-500); - } .border-yellow-500\/50 { border-color: color-mix( in srgb, @@ -1271,9 +1184,6 @@ .bg-background { background-color: var(--background); } - .bg-black { - background-color: var(--color-black); - } .bg-black\/10 { background-color: color-mix(in srgb, #000 10%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -1284,9 +1194,6 @@ ); } } - .bg-blue-500 { - background-color: var(--color-blue-500); - } .bg-blue-500\/10 { background-color: color-mix( in srgb, @@ -1326,9 +1233,6 @@ .bg-dialog-background { background-color: var(--dialog-background); } - .bg-green-500 { - background-color: var(--color-green-500); - } .bg-green-500\/10 { background-color: color-mix( in srgb, @@ -1346,15 +1250,22 @@ .bg-input { background-color: var(--input); } - .bg-input\/30 { - background-color: var(--input); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--input) 30%, transparent); - } - } .bg-muted { background-color: var(--muted); } + .bg-muted-foreground { + background-color: var(--muted-foreground); + } + .bg-muted-foreground\/10 { + background-color: var(--muted-foreground); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix( + in oklab, + var(--muted-foreground) 10%, + transparent + ); + } + } .bg-muted\/50 { background-color: var(--muted); @supports (color: color-mix(in lab, red, red)) { @@ -1462,6 +1373,9 @@ .px-0 { padding-inline: 0px; } + .px-0\.5 { + padding-inline: calc(var(--spacing) * 0.5); + } .px-1 { padding-inline: var(--spacing); } @@ -1489,9 +1403,6 @@ .px-14 { padding-inline: calc(var(--spacing) * 14); } - .py-0 { - padding-block: 0px; - } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -1522,9 +1433,6 @@ .pt-2 { padding-top: calc(var(--spacing) * 2); } - .pr-0 { - padding-right: 0px; - } .pr-2 { padding-right: calc(var(--spacing) * 2); } @@ -1534,30 +1442,21 @@ .pr-15 { padding-right: calc(var(--spacing) * 15); } - .pb-0 { - padding-bottom: 0px; - } .pb-2 { padding-bottom: calc(var(--spacing) * 2); } - .pb-4 { - padding-bottom: calc(var(--spacing) * 4); + .pb-6 { + padding-bottom: calc(var(--spacing) * 6); } .pl-0 { padding-left: 0px; } - .pl-1 { - padding-left: var(--spacing); - } .pl-2 { padding-left: calc(var(--spacing) * 2); } .pl-2\.5 { padding-left: calc(var(--spacing) * 2.5); } - .pl-8 { - padding-left: calc(var(--spacing) * 8); - } .pl-9 { padding-left: calc(var(--spacing) * 9); } @@ -1633,18 +1532,9 @@ --tw-tracking: var(--tracking-widest); letter-spacing: var(--tracking-widest); } - .text-wrap { - text-wrap: wrap; - } - .break-all { - word-break: break-all; - } .whitespace-nowrap { white-space: nowrap; } - .text-\[\#DC182C\] { - color: #dc182c; - } .text-\[\#FCE8EA\] { color: #fce8ea; } @@ -1657,9 +1547,6 @@ .text-border { color: var(--border); } - .text-card { - color: var(--card); - } .text-card\! { color: var(--card) !important; } @@ -1723,9 +1610,6 @@ color: color-mix(in oklab, var(--sidebar-foreground) 70%, transparent); } } - .text-slate-600 { - color: var(--color-slate-600); - } .text-white { color: var(--color-white); } @@ -1787,11 +1671,6 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - .shadow-none\! { - --tw-shadow: 0 0 #0000 !important; - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), - var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow) !important; - } .shadow-sm { --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); @@ -1815,9 +1694,6 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - .ring-foreground { - --tw-ring-color: var(--foreground); - } .ring-foreground\/10 { --tw-ring-color: var(--foreground); @supports (color: color-mix(in lab, red, red)) { @@ -1847,18 +1723,6 @@ var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } - .backdrop-filter { - -webkit-backdrop-filter: var(--tw-backdrop-blur,) - var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) - var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) - var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) - var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) - var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) - var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) - var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) - var(--tw-backdrop-sepia,); - } .transition { transition-property: color, @@ -2063,11 +1927,6 @@ ) { display: flex; } - .group-data-selected\/command-item\:text-foreground:is( - :where(.group\/command-item)[data-selected] * - ) { - color: var(--foreground); - } .group-data-\[collapsible\=icon\]\:-mt-8:is( :where(.group)[data-collapsible='icon'] * ) { @@ -2354,10 +2213,6 @@ .last\:mt-0:last-child { margin-top: 0px; } - .last\:border-b-0:last-child { - border-bottom-style: var(--tw-border-style); - border-bottom-width: 0px; - } .empty\:m-0:empty { margin: 0px; } @@ -2469,28 +2324,18 @@ .focus\:z-10:focus { z-index: 10; } - .focus\:border-l-2:focus { - border-left-style: var(--tw-border-style); - border-left-width: 2px; - } .focus\:border-destructive\/40:focus { border-color: var(--destructive); @supports (color: color-mix(in lab, red, red)) { border-color: color-mix(in oklab, var(--destructive) 40%, transparent); } } - .focus\:border-l-primary:focus { - border-left-color: var(--primary); - } .focus\:bg-accent:focus { background-color: var(--accent); } .focus\:text-accent-foreground:focus { color: var(--accent-foreground); } - .focus\:text-primary:focus { - color: var(--primary); - } .focus\:ring-0:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); @@ -2693,10 +2538,6 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - :where([data-slot='dialog-content']) - .in-data-\[slot\=dialog-content\]\:rounded-lg\! { - border-radius: var(--radius) !important; - } :where([data-slot='popover-content']) .in-data-\[slot\=popover-content\]\:bg-transparent { background-color: transparent; @@ -3177,12 +3018,6 @@ .data-\[disabled\]\:opacity-50[data-disabled] { opacity: 50%; } - .data-\[disabled\=true\]\:pointer-events-none[data-disabled='true'] { - pointer-events: none; - } - .data-\[disabled\=true\]\:opacity-50[data-disabled='true'] { - opacity: 50%; - } .data-\[invalid\=true\]\:text-destructive[data-invalid='true'] { color: var(--destructive); } @@ -3242,21 +3077,6 @@ .data-\[selected\=true\]\:rounded-none[data-selected='true'] { border-radius: 0; } - .data-\[selected\=true\]\:ring-2[data-selected='true'] { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 - calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), - var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - .data-\[selected\=true\]\:inset-ring[data-selected='true'] { - --tw-inset-ring-shadow: inset 0 0 0 1px - var(--tw-inset-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), - var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - .data-\[selected\=true\]\:ring-primary[data-selected='true'] { - --tw-ring-color: var(--primary); - } .data-\[side\=bottom\]\:inset-x-0[data-side='bottom'] { inset-inline: 0px; } @@ -3485,11 +3305,6 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - :is( - .\*\:data-\[slot\=input-group-addon\]\:pl-2\! > * - )[data-slot='input-group-addon'] { - padding-left: calc(var(--spacing) * 2) !important; - } :is(.\*\*\:data-\[slot\=kbd\]\:relative *)[data-slot='kbd'] { position: relative; } @@ -3567,13 +3382,6 @@ .data-\[state\=checked\]\:active\:bg-primary-hover[data-state='checked']:active { background-color: var(--primary-hover); } - .data-\[state\=closed\]\:animate-accordion-up[data-state='closed'] { - animation: accordion-up - var(--tw-animation-duration, var(--tw-duration, 0.2s)) - var(--tw-ease, ease-out) var(--tw-animation-delay, 0s) - var(--tw-animation-iteration-count, 1) - var(--tw-animation-direction, normal) var(--tw-animation-fill-mode, none); - } .data-\[state\=delayed-open\]\:animate-in[data-state='delayed-open'] { animation: enter var(--tw-animation-duration, var(--tw-duration, 0.15s)) var(--tw-ease, ease) var(--tw-animation-delay, 0s) @@ -3602,13 +3410,6 @@ .data-\[state\=indeterminate\]\:active\:bg-primary-hover[data-state='indeterminate']:active { background-color: var(--primary-hover); } - .data-\[state\=open\]\:animate-accordion-down[data-state='open'] { - animation: accordion-down - var(--tw-animation-duration, var(--tw-duration, 0.2s)) - var(--tw-ease, ease-out) var(--tw-animation-delay, 0s) - var(--tw-animation-iteration-count, 1) - var(--tw-animation-direction, normal) var(--tw-animation-fill-mode, none); - } .data-\[state\=selected\]\:bg-muted[data-state='selected'] { background-color: var(--muted); } @@ -3616,8 +3417,25 @@ --tw-translate-x: 0px; translate: var(--tw-translate-x) var(--tw-translate-y); } - .data-\[state\=unchecked\]\:bg-input[data-state='unchecked'] { - background-color: var(--input); + .data-\[state\=unchecked\]\:bg-secondary-background\/30[data-state='unchecked'] { + background-color: var(--secondary-background); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix( + in oklab, + var(--secondary-background) 30%, + transparent + ); + } + } + .data-\[state\=unchecked\]\:bg-secondary-background\/50[data-state='unchecked'] { + background-color: var(--secondary-background); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix( + in oklab, + var(--secondary-background) 50%, + transparent + ); + } } .data-\[variant\=destructive\]\:text-destructive[data-variant='destructive'] { color: var(--destructive); @@ -4041,27 +3859,6 @@ ):is(.rdp-button_previous > svg) { rotate: 180deg; } - :is(.\*\*\:\[\[cmdk-group-heading\]\]\:px-2 *):is([cmdk-group-heading]) { - padding-inline: calc(var(--spacing) * 2); - } - :is(.\*\*\:\[\[cmdk-group-heading\]\]\:py-1\.5 *):is([cmdk-group-heading]) { - padding-block: calc(var(--spacing) * 1.5); - } - :is(.\*\*\:\[\[cmdk-group-heading\]\]\:text-xs *):is([cmdk-group-heading]) { - font-size: var(--text-xs); - line-height: var(--tw-leading, var(--text-xs--line-height)); - } - :is(.\*\*\:\[\[cmdk-group-heading\]\]\:font-medium *):is( - [cmdk-group-heading] - ) { - --tw-font-weight: var(--font-weight-medium); - font-weight: var(--font-weight-medium); - } - :is(.\*\*\:\[\[cmdk-group-heading\]\]\:text-muted-foreground *):is( - [cmdk-group-heading] - ) { - color: var(--muted-foreground); - } .\[\[readonly\]\]\:cursor-not-allowed:is([readonly]) { cursor: not-allowed; } @@ -4097,9 +3894,6 @@ :is(.\*\:\[svg\]\:shrink-0 > *):is(svg) { flex-shrink: 0; } - :is(.data-selected\:\*\:\[svg\]\:text-foreground[data-selected] > *):is(svg) { - color: var(--foreground); - } :is( .data-\[variant\=destructive\]\:\*\:\[svg\]\:text-destructive[data-variant='destructive'] > * @@ -4303,9 +4097,6 @@ border-bottom-style: var(--tw-border-style); border-bottom-width: 0px; } - .\[\&\[data-state\=open\]\>svg\]\:rotate-180[data-state='open'] > svg { - rotate: 180deg; - } .\[\&\~\[data-slot\=autocomplete-clear\]\]\:right-1\.5 ~ [data-slot='autocomplete-clear'] { right: calc(var(--spacing) * 1.5); @@ -4599,11 +4390,6 @@ syntax: '*'; inherits: false; } -@property --tw-space-y-reverse { - syntax: '*'; - inherits: false; - initial-value: 0; -} @property --tw-border-style { syntax: '*'; inherits: false; @@ -4764,69 +4550,69 @@ syntax: '*'; inherits: false; } -@property --tw-backdrop-blur { +@property --tw-duration { syntax: '*'; inherits: false; } -@property --tw-backdrop-brightness { +@property --tw-ease { syntax: '*'; inherits: false; } -@property --tw-backdrop-contrast { +@property --tw-content { syntax: '*'; + initial-value: ''; inherits: false; } -@property --tw-backdrop-grayscale { +@property --tw-scale-x { syntax: '*'; inherits: false; + initial-value: 1; } -@property --tw-backdrop-hue-rotate { +@property --tw-scale-y { syntax: '*'; inherits: false; + initial-value: 1; } -@property --tw-backdrop-invert { +@property --tw-scale-z { syntax: '*'; inherits: false; + initial-value: 1; } -@property --tw-backdrop-opacity { +@property --tw-backdrop-blur { syntax: '*'; inherits: false; } -@property --tw-backdrop-saturate { +@property --tw-backdrop-brightness { syntax: '*'; inherits: false; } -@property --tw-backdrop-sepia { +@property --tw-backdrop-contrast { syntax: '*'; inherits: false; } -@property --tw-duration { +@property --tw-backdrop-grayscale { syntax: '*'; inherits: false; } -@property --tw-ease { +@property --tw-backdrop-hue-rotate { syntax: '*'; inherits: false; } -@property --tw-content { +@property --tw-backdrop-invert { syntax: '*'; - initial-value: ''; inherits: false; } -@property --tw-scale-x { +@property --tw-backdrop-opacity { syntax: '*'; inherits: false; - initial-value: 1; } -@property --tw-scale-y { +@property --tw-backdrop-saturate { syntax: '*'; inherits: false; - initial-value: 1; } -@property --tw-scale-z { +@property --tw-backdrop-sepia { syntax: '*'; inherits: false; - initial-value: 1; } @keyframes pulse { 50% { @@ -4867,46 +4653,6 @@ filter: blur(var(--tw-exit-blur, 0)); } } -@keyframes accordion-down { - from { - height: 0; - } - to { - height: var( - --radix-accordion-content-height, - var( - --bits-accordion-content-height, - var( - --reka-accordion-content-height, - var( - --kb-accordion-content-height, - var(--ngp-accordion-content-height, auto) - ) - ) - ) - ); - } -} -@keyframes accordion-up { - from { - height: var( - --radix-accordion-content-height, - var( - --bits-accordion-content-height, - var( - --reka-accordion-content-height, - var( - --kb-accordion-content-height, - var(--ngp-accordion-content-height, auto) - ) - ) - ) - ); - } - to { - height: 0; - } -} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { @@ -4922,7 +4668,6 @@ --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; - --tw-space-y-reverse: 0; --tw-border-style: solid; --tw-leading: initial; --tw-font-weight: initial; @@ -4960,6 +4705,12 @@ --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-duration: initial; + --tw-ease: initial; + --tw-content: ''; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-scale-z: 1; --tw-backdrop-blur: initial; --tw-backdrop-brightness: initial; --tw-backdrop-contrast: initial; @@ -4969,12 +4720,6 @@ --tw-backdrop-opacity: initial; --tw-backdrop-saturate: initial; --tw-backdrop-sepia: initial; - --tw-duration: initial; - --tw-ease: initial; - --tw-content: ''; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-scale-z: 1; --tw-animation-delay: 0s; --tw-animation-direction: normal; --tw-animation-duration: initial; From bda605f1f7c971d730daa7aa23d4a3c960ebf147 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 24 Aug 2026 16:50:03 +0200 Subject: [PATCH 34/59] WIP: add manual grading components --- .../create-assignment/creation-component.tsx | 177 ++++++++++++++++ .../data-widget/data-component.tsx | 47 +++++ .../data-widget/data-widget.tsx | 38 ++++ .../grade-widget/comment-component.tsx | 44 ++++ .../grade-widget/grade-component.tsx | 74 +++++++ .../grade-widget/grade-widget.tsx | 56 +++++ .../grade-widget/points-component.tsx | 76 +++++++ .../manual-grading/grading-switch.tsx | 191 ++++++++++++++++++ .../create-assignment/creation-component.tsx | 171 ---------------- 9 files changed, 703 insertions(+), 171 deletions(-) create mode 100644 packages/labextension/src/app/components/notebook/create-assignment/creation-component.tsx create mode 100644 packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-component.tsx create mode 100644 packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-widget.tsx create mode 100644 packages/labextension/src/app/components/notebook/manual-grading/grade-widget/comment-component.tsx create mode 100644 packages/labextension/src/app/components/notebook/manual-grading/grade-widget/grade-component.tsx create mode 100644 packages/labextension/src/app/components/notebook/manual-grading/grade-widget/grade-widget.tsx create mode 100644 packages/labextension/src/app/components/notebook/manual-grading/grade-widget/points-component.tsx create mode 100644 packages/labextension/src/app/components/notebook/manual-grading/grading-switch.tsx delete mode 100644 packages/labextension/src/components/notebook/create-assignment/creation-component.tsx diff --git a/packages/labextension/src/app/components/notebook/create-assignment/creation-component.tsx b/packages/labextension/src/app/components/notebook/create-assignment/creation-component.tsx new file mode 100644 index 00000000..72b598cf --- /dev/null +++ b/packages/labextension/src/app/components/notebook/create-assignment/creation-component.tsx @@ -0,0 +1,177 @@ +// Copyright (c) 2022, TU Wien +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +import * as React from 'react'; +import { Cell } from '@jupyterlab/cells'; +import { CellModel, CellType } from '../model'; +import { Checkbox } from '../../../shadcn-components/ui/checkbox'; +import { TriangleAlert } from 'lucide-react'; + +export interface ICreationComponentProps { + cell: Cell; +} + +const randomString = (length: number) => { + let result = ''; + const chars = 'abcdef0123456789'; + for (let i = 0; i < length; i++) { + result += chars[Math.floor(Math.random() * chars.length)]; + } + return result; +}; + +export const CreationComponent = (props: ICreationComponentProps) => { + const nbgraderData = CellModel.getNbgraderData(props.cell.model.metadata); + const toolData = CellModel.newToolData(nbgraderData, props.cell.model.type); + const [type, setType] = React.useState(toolData.type); + const [id, setId] = React.useState(toolData.id); + const [points, setPoints] = React.useState(toolData.points); + const [hintChecked, setChecked] = React.useState( + props.cell.model.getMetadata('hint') !== undefined + ); + const [hint, setHint] = React.useState( + hintChecked ? props.cell.model.getMetadata('hint') : '' + ); + const updateMetadata = () => { + toolData.type = type as CellType; + if (id === undefined) { + setId('cell-' + randomString(16)); + } else { + toolData.id = id; + } + toolData.points = points; + const data = CellModel.newNbgraderData(toolData); + if (data === null) { + CellModel.deleteNbgraderData(props.cell.model); + } else { + CellModel.setNbgraderData(data, props.cell.model); + } + //TODO: Currently we set the optional hint differently than the grader data, but maybe we should do it like this + if (hintChecked) { + props.cell.model.setMetadata('hint', hint); + } else { + props.cell.model.deleteMetadata('hint'); + } + }; + + React.useEffect(() => { + updateMetadata(); + }); + + const gradableCell = + type !== ('readonly' as CellType) && + type !== ('solution' as CellType) && + type !== ''; + const solutionCell = type === 'solution' || type === 'manual'; + + return ( + + + + + { + setType(e.target.value as CellType); + }} + > + - + Readonly + {props.cell.model.type === 'code' && ( + Autograded answer + )} + {props.cell.model.type === 'code' && ( + Autograded tests + )} + Manual graded answer + {props.cell.model.type === 'markdown' && ( + Manual graded task + )} + + + + {type !== '' && ( + + setId(e.target.value)} + required + > + + )} + + {gradableCell && ( + + Points: + setPoints(parseFloat(e.target.value))} + required + /> + + )} + + {solutionCell && ( + + setChecked(!hintChecked)} + aria-label={'controlled'} + /> + + )} + {solutionCell && ( + + setHint(e.target.value)} + > + + )} + + {type === '' && ( + + + + Type not set + + + )} + {points === 0 && ( + + + + Gradable cell with zero points + + + )} + + ); +}; diff --git a/packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-component.tsx b/packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-component.tsx new file mode 100644 index 00000000..44e84c60 --- /dev/null +++ b/packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-component.tsx @@ -0,0 +1,47 @@ +// Copyright (c) 2022, TU Wien +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +import * as React from 'react'; +import { Cell } from '@jupyterlab/cells'; +import { CellModel } from '../../model'; +import { GradeBook } from '../../../../../services/gradebook.service'; + +export interface IDataComponentProps { + cell: Cell; + gradebook: GradeBook; + nbname: string; +} + +export const DataComponent = (props: IDataComponentProps) => { + const nbgraderData = CellModel.getNbgraderData(props.cell.model.metadata); + const toolData = CellModel.newToolData(nbgraderData, props.cell.model.type); + + const gradableCell = + toolData.type !== 'readonly' && + toolData.type !== 'solution' && + toolData.type !== ''; + + return ( + + Type: {toolData.type} + + ID: {toolData.id} + + {toolData.type === 'tests' && ( + + Autograded Points:{' '} + {props.gradebook.getAutoGradeScore(props.nbname, toolData.id)} + + )} + + {gradableCell && ( + + Max Points: {toolData.points} + + )} + + ); +}; diff --git a/packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-widget.tsx b/packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-widget.tsx new file mode 100644 index 00000000..0e5022ea --- /dev/null +++ b/packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-widget.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2022, TU Wien +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +import { ReactWidget } from '@jupyterlab/apputils'; +import * as React from 'react'; +import { Cell } from '@jupyterlab/cells'; + +import { ReactElement, JSXElementConstructor } from 'react'; +import { GradeBook } from '../../../../../services/gradebook.service'; +import { DataComponent } from './data-component'; + +export class DataWidget extends ReactWidget { + public cell: Cell; + public gradebook: GradeBook; + public nbname: string; + + constructor(cell: Cell, gradebook: GradeBook, nbname: string) { + super(); + this.cell = cell; + this.gradebook = gradebook; + this.nbname = nbname; + } + + protected render(): + | ReactElement>[] + | ReactElement> { + return ( + + ); + } +} diff --git a/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/comment-component.tsx b/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/comment-component.tsx new file mode 100644 index 00000000..c2cd1b55 --- /dev/null +++ b/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/comment-component.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2022, TU Wien +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +import * as React from 'react'; +import { NbgraderData, ToolData } from '../../model'; +import { GradeBook } from '../../../../../services/gradebook.service'; +import { INotebookModel } from '@jupyterlab/notebook'; + +export interface ICommentComponentProps { + model: INotebookModel; + gradebook: GradeBook; + nbname: string; + nbgraderData: NbgraderData; + toolData: ToolData; +} + +export const CommentComponent = (props: ICommentComponentProps) => { + const [comment, setComment] = React.useState( + props.gradebook.getComment(props.nbname, props.toolData.id) + ); + + return ( + + { + setComment(e.target.value); + props.model.setMetadata('updated', true); + props.gradebook.setComment( + props.nbname, + props.toolData.id, + e.target.value + ); + }} + /> + + ); +}; diff --git a/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/grade-component.tsx b/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/grade-component.tsx new file mode 100644 index 00000000..d553a86f --- /dev/null +++ b/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/grade-component.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2022, TU Wien +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +import * as React from 'react'; +import { NbgraderData, ToolData } from '../../model'; +import { GradeBook } from '../../../../../services/gradebook.service'; +import { ExtraCreditComponent, PointsComponent } from './points-component'; +import { CommentComponent } from './comment-component'; +import { Notebook } from '@jupyterlab/notebook'; + +export interface GradeComponentProps { + notebook: Notebook; + gradebook: GradeBook; + nbname: string; + nbgraderData: NbgraderData; + toolData: ToolData; +} + +export const GradeComponent = (props: GradeComponentProps) => { + const model = props.notebook.model; + if (model.getMetadata('updated') != undefined) { + model.setMetadata('updated', false); + } + const gradableCell = + props.toolData.type !== 'readonly' && + props.toolData.type !== 'solution' && + props.toolData.type !== ''; + const showCommment = + props.toolData.type === 'task' || + props.toolData.type === 'manual' || + props.toolData.type === 'solution'; + + return ( + + {props.toolData.type !== 'readonly' && props.toolData.type !== '' && ( + + {showCommment && ( + + )} + + {gradableCell && ( + + )} + + {gradableCell && ( + + )} + + + )} + + ); +}; diff --git a/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/grade-widget.tsx b/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/grade-widget.tsx new file mode 100644 index 00000000..75125109 --- /dev/null +++ b/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/grade-widget.tsx @@ -0,0 +1,56 @@ +// Copyright (c) 2022, TU Wien +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +import { ReactWidget } from '@jupyterlab/apputils'; +import * as React from 'react'; +import { Cell } from '@jupyterlab/cells'; +import { GradeBook } from '../../../../../services/gradebook.service'; +import { GradeComponent } from './grade-component'; +import { CellModel, NbgraderData, ToolData } from '../../model'; +import { Notebook } from '@jupyterlab/notebook'; + +export class GradeWidget extends ReactWidget { + public cell: Cell; + public notebook: Notebook; + public gradebook: GradeBook; + public nbname: string; + public nbgraderData: NbgraderData; + public toolData: ToolData; + + constructor( + cell: Cell, + notebook: Notebook, + gradebook: GradeBook, + nbname: string + ) { + super(); + this.cell = cell; + this.notebook = notebook; + this.gradebook = gradebook; + this.nbname = nbname; + this.nbgraderData = CellModel.getNbgraderData(this.cell.model.metadata); + this.toolData = CellModel.newToolData( + this.nbgraderData, + this.cell.model.type + ); + } + + protected render(): any { + if (this.toolData.type !== '' && this.toolData.type !== 'readonly') { + return ( + + ); + } else { + return null; + } + } +} diff --git a/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/points-component.tsx b/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/points-component.tsx new file mode 100644 index 00000000..3d951b8a --- /dev/null +++ b/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/points-component.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2022, TU Wien +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +import * as React from 'react'; +import { NbgraderData, ToolData } from '../../model'; +import { GradeBook } from '../../../../../services/gradebook.service'; +import { INotebookModel } from '@jupyterlab/notebook'; + +export interface IPointsComponentProps { + model: INotebookModel; + gradebook: GradeBook; + nbname: string; + nbgraderData: NbgraderData; + toolData: ToolData; +} + +export const PointsComponent = (props: IPointsComponentProps) => { + const [points, setPoints] = React.useState( + props.gradebook.getGradeScore(props.nbname, props.toolData.id) + ); + + return ( + + Points: + { + setPoints(parseFloat(e.target.value)); + props.model.setMetadata('updated', true); + props.gradebook.setManualScore( + props.nbname, + props.toolData.id, + parseFloat(e.target.value) + ); + }} + /> + + ); +}; + +export const ExtraCreditComponent = (props: IPointsComponentProps) => { + const [extraCredit, setExtraCredit] = React.useState( + props.gradebook.getExtraCredit(props.nbname, props.toolData.id) + ); + + return ( + + Extra Credit: + { + setExtraCredit(parseFloat(e.target.value)); + props.model.setMetadata('updated', true); + props.gradebook.setExtraCredit( + props.nbname, + props.toolData.id, + parseFloat(e.target.value) + ); + }} + /> + + ); +}; diff --git a/packages/labextension/src/app/components/notebook/manual-grading/grading-switch.tsx b/packages/labextension/src/app/components/notebook/manual-grading/grading-switch.tsx new file mode 100644 index 00000000..c561483b --- /dev/null +++ b/packages/labextension/src/app/components/notebook/manual-grading/grading-switch.tsx @@ -0,0 +1,191 @@ +// Copyright (c) 2022, TU Wien +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +import { Cell } from '@jupyterlab/cells'; +import { PanelLayout } from '@lumino/widgets'; +import { GradeBook } from '../../../../services/gradebook.service'; +import { + getProperties, + getSubmission, + updateProperties, + updateSubmission +} from '../../../../services/submissions.service'; +import { IModeSwitchProps } from '../slider'; +import { Button } from '../../../shadcn-components/ui/button'; +import { showErrorMessage } from '@jupyterlab/apputils'; +import * as React from 'react'; +import { Notebook, NotebookPanel } from '@jupyterlab/notebook'; +import { Lecture } from '../../../../model/lecture'; +import { Assignment } from '../../../../model/assignment'; +import { getAssignment } from '../../../../services/assignments.service'; +import { getLectures } from '../../../../services/lectures.service'; +import { DataWidget } from './data-widget/data-widget'; +import { GradeWidget } from './grade-widget/grade-widget'; +import { lectureSubPathsCount } from '../../../../services/local-file.service'; +import { ManualStatus } from '../../../../model/manualStatus'; +import { Switch } from '../../../shadcn-components/ui/switch'; + +export class GradingModeSwitch extends React.Component { + public state = { + mode: false, + saveButtonText: 'Save' + }; + protected notebook: Notebook; + protected notebookpanel: NotebookPanel; + public lecture: Lecture; + public assignment: Assignment; + public gradeBook: GradeBook; + public onChange: any; + public subID: number; + public notebookPaths: string[]; + + public constructor(props: IModeSwitchProps) { + super(props); + this.state.mode = props.mode || false; + this.notebook = props.notebook; + this.notebookpanel = props.notebookpanel; + this.notebookPaths = + this.notebookpanel.context.contentsModel.path.split('/'); + this.subID = +this.notebookPaths[lectureSubPathsCount + 3]; + this.onChange = this.props.onChange; + } + + public async componentDidMount() { + const lectures = await getLectures({ instructor: true }); + this.lecture = lectures.find( + l => l.code === this.notebookPaths[lectureSubPathsCount] + ); + this.assignment = await getAssignment( + this.lecture.id, + +this.notebookPaths[lectureSubPathsCount + 2] + ); + + const properties = await getProperties( + this.lecture.id, + this.assignment.id, + this.subID + ); + this.gradeBook = new GradeBook(properties); + this.notebookpanel.context.saveState.connect((sender, saveState) => { + if (saveState === 'started') { + this.saveProperties(); + } + }); + } + + private async saveProperties() { + const model = this.notebook.model; + //if there were no updates return + if (!model.getMetadata('updated')) { + return; + } + model.setMetadata('updated', false); + this.setState({ saveButtonText: 'Saving' }); + try { + await updateProperties( + this.lecture.id, + this.assignment.id, + this.subID, + this.gradeBook.properties + ); + this.setState({ saveButtonText: 'Saved' }); + setTimeout(() => this.setState({ saveButtonText: 'Save' }), 2000); + const submission = await getSubmission( + this.lecture.id, + this.assignment.id, + this.subID + ); + submission.manual_status = ManualStatus.BeingEdited; + updateSubmission( + this.lecture.id, + this.assignment.id, + this.subID, + submission + ); + } catch (err) { + this.setState({ saveButtonText: 'Save' }); + if (err instanceof Error) { + showErrorMessage('Error saving properties', err); + } else { + console.error( + 'Error while trying to interpret type unknown as error', + err + ); + } + } + } + + protected handleChange = async () => { + const properties = await getProperties( + this.lecture.id, + this.assignment.id, + this.subID + ); + this.gradeBook = new GradeBook(properties); + + // TODO This is a dirty bugfix which generates grade dict entries for task cells which should exist + this.gradeBook.addTaskCellsToGrades(); + + this.setState({ mode: !this.state.mode }, () => { + this.onChange(this.state.mode); + this.notebook.widgets.map((c: Cell) => { + const currentLayout = c.layout as PanelLayout; + if (this.state.mode) { + currentLayout.insertWidget( + 0, + new DataWidget( + c, + this.gradeBook, + this.notebookPaths[lectureSubPathsCount + 4] + .split('.') + .slice(0, -1) + .join('.') + ) + ); + currentLayout.addWidget( + new GradeWidget( + c, + this.notebook, + this.gradeBook, + this.notebookPaths[lectureSubPathsCount + 4] + .split('.') + .slice(0, -1) + .join('.') + ) + ); + } else { + currentLayout.widgets.map(w => { + if (w instanceof DataWidget || w instanceof GradeWidget) { + try { + currentLayout.removeWidget(w); + } catch (error: any) { + console.log('Could not remove widget of cell:' + w.cell.id); + console.log('Error: ' + error); + } + } + }); + } + }); + }); + }; + + public render() { + return ( + + + Grading Mode + this.saveProperties()} + variant="outline" + size="sm" + > + {this.state.saveButtonText} + + + ); + } +} diff --git a/packages/labextension/src/components/notebook/create-assignment/creation-component.tsx b/packages/labextension/src/components/notebook/create-assignment/creation-component.tsx deleted file mode 100644 index c8927391..00000000 --- a/packages/labextension/src/components/notebook/create-assignment/creation-component.tsx +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright (c) 2022, TU Wien -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -import * as React from 'react'; -import { Cell } from '@jupyterlab/cells'; -import { CellModel, CellType } from '../model'; - -export interface ICreationComponentProps { - cell: Cell; -} - -const randomString = (length: number) => { - let result = ''; - const chars = 'abcdef0123456789'; - for (let i = 0; i < length; i++) { - result += chars[Math.floor(Math.random() * chars.length)]; - } - return result; -}; - -export const CreationComponent = (props: ICreationComponentProps) => { - const nbgraderData = CellModel.getNbgraderData(props.cell.model.metadata); - const toolData = CellModel.newToolData(nbgraderData, props.cell.model.type); - const [type, setType] = React.useState(toolData.type); - const [id, setId] = React.useState(toolData.id); - const [points, setPoints] = React.useState(toolData.points); - const [hintChecked, setChecked] = React.useState( - props.cell.model.getMetadata('hint') !== null - ); - const [hint, setHint] = React.useState( - hintChecked ? props.cell.model.getMetadata('hint') : '' - ); - - const handleHintChange = (event: React.ChangeEvent) => { - setChecked(event.target.checked); - }; - const updateMetadata = () => { - toolData.type = type as CellType; - if (id === undefined) { - setId('cell-' + randomString(16)); - } else { - toolData.id = id; - } - toolData.points = points; - const data = CellModel.newNbgraderData(toolData); - if (data === null) { - CellModel.deleteNbgraderData(props.cell.model); - } else { - CellModel.setNbgraderData(data, props.cell.model); - } - //TODO: Currently we set the optional hint differently than the grader data, but maybe we should do it like this - if (hintChecked) { - props.cell.model.setMetadata('hint', hint); - } else { - props.cell.model.deleteMetadata('hint'); - } - }; - - React.useEffect(() => { - updateMetadata(); - }); - - const gradableCell = - type !== ('readonly' as CellType) && - type !== ('solution' as CellType) && - type !== ''; - const solutionCell = type === 'solution' || type === 'manual'; - - return ( - - - - { - setType(e.target.value as CellType); - }} - > - - - Readonly - {props.cell.model.type === 'code' && ( - Autograded answer - )} - {props.cell.model.type === 'code' && ( - Autograded tests - )} - Manual graded answer - {props.cell.model.type === 'markdown' && ( - Manual graded task - )} - - - - {type !== '' && ( - - setId(e.target.value)} - required - > - - )} - - {gradableCell && ( - - Points: - setPoints(parseFloat(e.target.value))} - required - /> - - )} - - {solutionCell && ( - - - - )} - {solutionCell && ( - - setHint(e.target.value)} - > - - )} - - {type === '' && ( - - - Type not set - - - )} - - {points === 0 && ( - - - Gradable cell with zero points - - - )} - - ); -}; From b346b2ee127d07b6ce8a112c16378e016e3bb7fc Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 24 Aug 2026 16:54:57 +0200 Subject: [PATCH 35/59] chore: add space to description text --- .../labextension/src/app/pages/instructor-view/dashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/labextension/src/app/pages/instructor-view/dashboard.tsx b/packages/labextension/src/app/pages/instructor-view/dashboard.tsx index 5f926480..1f98d9d1 100644 --- a/packages/labextension/src/app/pages/instructor-view/dashboard.tsx +++ b/packages/labextension/src/app/pages/instructor-view/dashboard.tsx @@ -288,7 +288,7 @@ export const Dashboard = () => { icon={} description={ 'There are no courses available for you right \n now. Please check' + - 'again later.' + ' again later.' } /> )} From b8f4fe2b9bf34e4fabbd453227bd730c895692ae Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Tue, 25 Aug 2026 10:31:27 +0200 Subject: [PATCH 36/59] chore: use className instead of style attribute --- .../manual-grading/data-widget/data-component.tsx | 10 ++++------ .../manual-grading/grade-widget/comment-component.tsx | 2 +- .../manual-grading/grade-widget/grade-component.tsx | 4 ++-- .../manual-grading/grade-widget/points-component.tsx | 8 ++++---- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-component.tsx b/packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-component.tsx index 44e84c60..6b897a2a 100644 --- a/packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-component.tsx +++ b/packages/labextension/src/app/components/notebook/manual-grading/data-widget/data-component.tsx @@ -26,21 +26,19 @@ export const DataComponent = (props: IDataComponentProps) => { return ( - Type: {toolData.type} + Type: {toolData.type} - ID: {toolData.id} + ID: {toolData.id} {toolData.type === 'tests' && ( - + Autograded Points:{' '} {props.gradebook.getAutoGradeScore(props.nbname, toolData.id)} )} {gradableCell && ( - - Max Points: {toolData.points} - + Max Points: {toolData.points} )} ); diff --git a/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/comment-component.tsx b/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/comment-component.tsx index c2cd1b55..bae14167 100644 --- a/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/comment-component.tsx +++ b/packages/labextension/src/app/components/notebook/manual-grading/grade-widget/comment-component.tsx @@ -23,7 +23,7 @@ export const CommentComponent = (props: ICommentComponentProps) => { ); return ( - + { props.toolData.type === 'solution'; return ( - + {props.toolData.type !== 'readonly' && props.toolData.type !== '' && ( - + {showCommment && ( { ); return ( - + Points: { ); return ( - + Extra Credit: Date: Mon, 28 Sep 2026 14:17:42 +0200 Subject: [PATCH 37/59] Refactor time difference text construction Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../assignment-create-edit-dialog.tsx | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/labextension/src/app/components/grader-service/assignments/assignment-create-edit-dialog.tsx b/packages/labextension/src/app/components/grader-service/assignments/assignment-create-edit-dialog.tsx index e4028038..78242321 100644 --- a/packages/labextension/src/app/components/grader-service/assignments/assignment-create-edit-dialog.tsx +++ b/packages/labextension/src/app/components/grader-service/assignments/assignment-create-edit-dialog.tsx @@ -176,26 +176,23 @@ export const AssignmentCreateEditDialog = (props: IAssignmentSettingsForm) => { const daysDiff = timeDiff.daysDiff; const hoursDiff = timeDiff.hoursDiff; const minutesDiff = timeDiff.minutesDiff; - let timeDiffText = ''; + const parts: string[] = []; if (daysDiff > 0) { - timeDiffText += `${daysDiff} ${pluralize({ - text: 'day', - data: daysDiff - })}, `; + parts.push( + `${daysDiff} ${pluralize({ text: 'day', data: daysDiff })}` + ); } if (hoursDiff > 0) { - timeDiffText += `${hoursDiff} ${pluralize({ - text: 'hour', - data: hoursDiff - })} and `; + parts.push( + `${hoursDiff} ${pluralize({ text: 'hour', data: hoursDiff })}` + ); } if (minutesDiff > 0) { - timeDiffText += `${minutesDiff} ${pluralize({ - text: 'minute', - data: minutesDiff - })}`; + parts.push( + `${minutesDiff} ${pluralize({ text: 'minute', data: minutesDiff })}` + ); } - return timeDiffText; + return parts.length > 0 ? parts.join(', ') : 'less than a minute'; }; return ( From eb6f1b1cf2b00666912d15473482c31df2257be4 Mon Sep 17 00:00:00 2001 From: nadjajovancevic Date: Mon, 28 Sep 2026 14:19:02 +0200 Subject: [PATCH 38/59] Fix URL construction for remote file status request Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/labextension/src/services/file.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/labextension/src/services/file.service.ts b/packages/labextension/src/services/file.service.ts index ad1b9fa5..5993d6f3 100644 --- a/packages/labextension/src/services/file.service.ts +++ b/packages/labextension/src/services/file.service.ts @@ -67,6 +67,6 @@ export function getRemoteFileStatus( const url = `${baseUrl({ lectureId: lecture.id, assignmentId: assignment.id - })}/remote-file-status/${repo}/?file=${encodeURIComponent(filePath)}`; + })}remote-file-status/${repo}/?file=${encodeURIComponent(filePath)}`; return request(HTTPMethod.GET, url, null, reload); } From 5f309e77d2d0b5cce0a6f466b041feaa3cfea0f3 Mon Sep 17 00:00:00 2001 From: nadjajovancevic Date: Mon, 28 Sep 2026 14:20:00 +0200 Subject: [PATCH 39/59] Simplify URL building with URLSearchParams Refactor URL construction to use URLSearchParams for query parameters. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../labextension/src/services/git.service.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/labextension/src/services/git.service.ts b/packages/labextension/src/services/git.service.ts index f2ec57e5..dfb4ccbf 100644 --- a/packages/labextension/src/services/git.service.ts +++ b/packages/labextension/src/services/git.service.ts @@ -11,19 +11,15 @@ export function releaseAssignment( commitMessage?: string, selectedFiles?: string[] ): Promise { - let url = `${baseUrl({ lectureId, assignmentId })}push/${repoType}`; + const searchParams = new URLSearchParams(); if (commitMessage) { - const searchParams = new URLSearchParams({ - 'commit-message': commitMessage - }); - url += '?' + searchParams; - } - - if (selectedFiles && selectedFiles.length > 0) { - selectedFiles.forEach(file => { - url += `&selected-files=${encodeURIComponent(file)}`; - }); + searchParams.set('commit-message', commitMessage); } + selectedFiles?.forEach(file => { + searchParams.append('selected-files', file); + }); + const query = searchParams.toString(); + const url = `${baseUrl({ lectureId, assignmentId })}push/${repoType}${query ? `?${query}` : ''}`; return request(HTTPMethod.PUT, url, null); } From 1fb7f7ed9fd6794b7f01a0d96cce6f0e9c24767d Mon Sep 17 00:00:00 2001 From: nadjajovancevic Date: Mon, 28 Sep 2026 14:21:27 +0200 Subject: [PATCH 40/59] Refactor makeDirs to use for..of loop Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/labextension/src/services/local-file.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/labextension/src/services/local-file.service.ts b/packages/labextension/src/services/local-file.service.ts index a2e86e3c..0e5a96ef 100644 --- a/packages/labextension/src/services/local-file.service.ts +++ b/packages/labextension/src/services/local-file.service.ts @@ -193,8 +193,8 @@ const makeDir = async (path: string, name: string) => { */ export const makeDirs = async (path: string, names: string[]) => { let p = path; - names.map(async name => { + for (const name of names) { p = await makeDir(p, name); - }); + } return p; }; From baa7bd36f228c99fdffc278d1d653ab2536bca08 Mon Sep 17 00:00:00 2001 From: nadjajovancevic Date: Mon, 28 Sep 2026 14:22:22 +0200 Subject: [PATCH 41/59] Fix condition check for elevated permissions Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/labextension/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/labextension/src/index.ts b/packages/labextension/src/index.ts index 3c45a422..06bbf76a 100644 --- a/packages/labextension/src/index.ts +++ b/packages/labextension/src/index.ts @@ -363,7 +363,7 @@ const extension: JupyterFrontEndPlugin = { // If the user has no instructor roles in any lecture we do not display the course management UserPermissions.loadPermissions() .then(() => { - if (UserPermissions.hasElevatedPermissions) { +if (UserPermissions.hasElevatedPermissions()) { connectTrackerSignals(tracker); } createGraderServiceCommands(app, launcher, graderServiceTracker); From bcb8a775bc9d5279b1be24dab74cdc0b25d57bb2 Mon Sep 17 00:00:00 2001 From: nadjajovancevic Date: Mon, 28 Sep 2026 14:23:24 +0200 Subject: [PATCH 42/59] Update TypeScript lib to include ES2019 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/labextension/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/labextension/tsconfig.json b/packages/labextension/tsconfig.json index 4d24d343..f210dbff 100644 --- a/packages/labextension/tsconfig.json +++ b/packages/labextension/tsconfig.json @@ -9,7 +9,7 @@ "lib": [ "DOM", "DOM.Iterable", - "ES2018", +"ES2019", "ES2020.BigInt", "ES2020.Intl", "ES2020.String" From 833eb876d30d85caa05ce825c9a30884cdf9fdeb Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Mon, 28 Sep 2026 15:31:19 +0200 Subject: [PATCH 43/59] fix: use reduce function instead of for loops --- .../src/services/gradebook.service.ts | 78 +++++++++---------- 1 file changed, 35 insertions(+), 43 deletions(-) diff --git a/packages/labextension/src/services/gradebook.service.ts b/packages/labextension/src/services/gradebook.service.ts index af6907a1..fb30303d 100644 --- a/packages/labextension/src/services/gradebook.service.ts +++ b/packages/labextension/src/services/gradebook.service.ts @@ -78,11 +78,7 @@ export class GradeBook { this.properties['notebooks'][notebook]['grades_dict'][cellId][ 'extra_credit' ]; - if (extraCredit) { - return extraCredit; - } else { - return 0.0; - } + return extraCredit || 0.0; } public setNeedsManualGrading( @@ -159,20 +155,17 @@ export class GradeBook { } public getNotebookPoints(notebook: string): number { - let sum = 0; const grades_dict = this.properties['notebooks'][notebook]['grades_dict']; - for (const cellId of Object.keys(grades_dict)) { - sum += this.getGradeScore(notebook, cellId); - } - return sum; + return Object.keys(grades_dict) + .map(cellId => this.getGradeScore(notebook, cellId)) + .reduce((sum, grade) => sum + grade, 0); } public getPoints(): number { - let sum = 0; - for (const notebook of Object.keys(this.properties['notebooks'])) { - sum += this.getNotebookPoints(notebook); - } - return sum; + return Object.keys(this.properties['notebooks']).reduce( + (sum, notebook) => sum + this.getNotebookPoints(notebook), + 0 + ); } public addTaskCellsToGrades() { @@ -207,42 +200,41 @@ export class GradeBook { } public getNotebookMaxPointsCells(notebook: string): number { - let sum = 0; - const grade_cells_dict = - this.properties['notebooks'][notebook]['grade_cells_dict']; - for (const cellId of Object.keys(grade_cells_dict)) { - sum += this.getGradeCellMaxScore(notebook, cellId); - } - const task_cells_dict = - this.properties['notebooks'][notebook]['task_cells_dict']; - for (const cellId of Object.keys(task_cells_dict)) { - sum += this.getTaskCellMaxScore(notebook, cellId); - } - return sum; + const { grade_cells_dict, task_cells_dict } = + this.properties['notebooks'][notebook]; + + const gradePoints = Object.keys(grade_cells_dict).reduce( + (sum, cellId) => sum + this.getGradeCellMaxScore(notebook, cellId), + 0 + ); + const taskPoints = Object.keys(task_cells_dict).reduce( + (sum, cellId) => sum + this.getTaskCellMaxScore(notebook, cellId), + 0 + ); + + return gradePoints + taskPoints; } public getMaxPoints(): number { - let sum = 0; - for (const notebook of Object.keys(this.properties['notebooks'])) { - sum += this.getNotebookMaxPointsCells(notebook); - } - return sum; + return Object.keys(this.properties['notebook']).reduce( + (sum, notebook) => sum + this.getNotebookMaxPointsCells(notebook), + 0 + ); } public getNotebookExtraCredit(notebook: string): number { - let sum = 0; - const grades_dict = this.properties['notebooks'][notebook]['grades_dict']; - for (const cellId of Object.keys(grades_dict)) { - sum += this.getExtraCredit(notebook, cellId); - } - return sum; + const { grades_dict } = this.properties['notebooks'][notebook]; + + return Object.keys(grades_dict).reduce( + (sum, cellId) => sum + this.getExtraCredit(notebook, cellId), + 0 + ); } public getExtraCredits() { - let sum = 0; - for (const notebook of Object.keys(this.properties['notebooks'])) { - sum += this.getNotebookExtraCredit(notebook); - } - return sum; + return Object.keys(this.properties['notebook']).reduce( + (sum, notebook) => sum + this.getNotebookExtraCredit(notebook), + 0 + ); } } From 65645ecad5cd744d76038cb46db6b63684dc0c52 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Tue, 29 Sep 2026 16:10:00 +0200 Subject: [PATCH 44/59] chore: move getSubmissionLogs to submissions.service.ts --- .../labextension/src/services/git.service.ts | 18 +++--------------- .../src/services/submissions.service.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/labextension/src/services/git.service.ts b/packages/labextension/src/services/git.service.ts index dfb4ccbf..9004c9e3 100644 --- a/packages/labextension/src/services/git.service.ts +++ b/packages/labextension/src/services/git.service.ts @@ -19,7 +19,9 @@ export function releaseAssignment( searchParams.append('selected-files', file); }); const query = searchParams.toString(); - const url = `${baseUrl({ lectureId, assignmentId })}push/${repoType}${query ? `?${query}` : ''}`; + const url = `${baseUrl({ lectureId, assignmentId })}push/${repoType}${ + query ? `?${query}` : '' + }`; return request(HTTPMethod.PUT, url, null); } @@ -56,20 +58,6 @@ export function resetAssignment( null ); } - -export function getGitLogs( - lectureId: number, - assignmentId: number, - submissionId: number, - reload = false -): Promise { - const url = `${baseUrl({ - lectureId, - assignmentId - })}submissions/${submissionId}/logs`; - return request(HTTPMethod.GET, url, null, reload); -} - export function createOrOverrideEditRepository( lectureId: number, assignmentId: number, diff --git a/packages/labextension/src/services/submissions.service.ts b/packages/labextension/src/services/submissions.service.ts index 9b257bbd..ef866e29 100644 --- a/packages/labextension/src/services/submissions.service.ts +++ b/packages/labextension/src/services/submissions.service.ts @@ -160,6 +160,19 @@ export async function getSubmissionCount( ); } +export function getSubmissionLogs( + lectureId: number, + assignmentId: number, + submissionId: number, + reload = false +): Promise { + const url = `${baseUrl({ + lectureId, + assignmentId + })}submissions/${submissionId}/logs`; + return request(HTTPMethod.GET, url, null, reload); +} + export async function exportGrades( lectureId: number, filter: 'latest' | 'best' = 'best', From 0e274d82ed5d9111fe2638a462ff327d55031423 Mon Sep 17 00:00:00 2001 From: Nadja Jovancevic Date: Tue, 29 Sep 2026 16:13:06 +0200 Subject: [PATCH 45/59] fix: move getStudentSubmissions to submissions.service.ts and implement the corresponding endpoint in proxy handler --- .../handlers/submissions.py | 27 +++++++++++++ .../src/services/grading.service.ts | 40 ------------------- .../src/services/submissions.service.ts | 13 ++++++ 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/packages/labextension/grader_labextension/handlers/submissions.py b/packages/labextension/grader_labextension/handlers/submissions.py index 30885330..4aaa5ff1 100644 --- a/packages/labextension/grader_labextension/handlers/submissions.py +++ b/packages/labextension/grader_labextension/handlers/submissions.py @@ -324,3 +324,30 @@ async def get(self, lecture_id: int, assignment_id: int): self.log.error(e) raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response)) + + +@register_handler(path=r"api\/users\/(?P\d*)\/submissions\/?") +class SubmissionUserHandler(ExtensionBaseHandler): + """ + Tornado Handler class for http requests to /users/{username}/submissions. + """ + + async def get(self, username: str): + """Return the submissions of a specific user. + + :param username: username of the user + :type username: str + """ + + try: + response = await self.request_service.request( + method="GET", + endpoint=f"{self.service_base_url}api/users/{username}/submissions", + header=self.grader_authentication_header, + response_callback=self.set_service_headers, + ) + self.log.info(f"{response}") + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) + self.write(json.dumps(response)) diff --git a/packages/labextension/src/services/grading.service.ts b/packages/labextension/src/services/grading.service.ts index 855cbe70..bdc5dfd3 100644 --- a/packages/labextension/src/services/grading.service.ts +++ b/packages/labextension/src/services/grading.service.ts @@ -5,7 +5,6 @@ // LICENSE file in the root directory of this source tree. import { Assignment } from '../model/assignment'; -import { User } from '../model/user'; import { request } from './request.service'; import { Submission } from '../model/submission'; import { baseUrl } from './file.service'; @@ -47,19 +46,6 @@ export function getGrade( ); } -export function getStudentSubmissions( - username: string, - format: 'json' | 'csv' = 'csv', - reload = false -): Promise { - let url = `/api/users/${username}/submissions`; - const params = new URLSearchParams({ - format: format - }); - url += '?' + params.toString(); - return request(HTTPMethod.GET, url, null, reload); -} - export function getManualFeedback( lectureId: number, assignmentId: number, @@ -83,29 +69,3 @@ export function createManualFeedback( null ); } - -export function updateManualFeedback( - lectureId: number, - assignmentId: number, - student: User, - manual: any -): Promise { - return request( - HTTPMethod.PUT, - `${baseUrl({ lectureId, assignmentId })}grading/${student.name}/manual`, - manual - ); -} - -export function deleteManualFeedback( - lectureId: number, - assignmentId: number, - student: User, - manual: any -): Promise
Edit course
No errors found