From 148989904abccc153cfb8827c2cf711cd23a83f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janis=20Daniel=20Da=CC=88hne?= <janis.daehne@informatik.uni-halle.de> Date: Thu, 20 Mar 2025 17:12:23 +0100 Subject: [PATCH] - added function to create an exercise test with asset --- i18n/en.ts | 6 + i18n/i18nRoot.ts | 6 + src/communicationLayer/exerciseEditorLayer.ts | 9 +- .../exerciseEditorSite/headerBarContent.tsx | 161 +++++++++++++++++- src/constants.ts | 2 +- src/helpers/assetHelper.ts | 2 +- src/helpers/jsonTransformer.ts | 2 +- src/types/exerciseEditor.ts | 15 ++ src/types/exercisePreview.ts | 2 +- src/types/submissions.ts | 2 +- 10 files changed, 195 insertions(+), 12 deletions(-) diff --git a/i18n/en.ts b/i18n/en.ts index 5812a318..612e236c 100644 --- a/i18n/en.ts +++ b/i18n/en.ts @@ -462,6 +462,12 @@ export const lang_en: LangObj = { "Save project": "Save project", "Export exercise": "Export exercise", "Import exercise": "Import exercise", + "Export tests": "Export tests", + "Import tests": "Import tests", + "Importing tests ...": "Importing tests ...", + "Imports tests from a json file and saves the tests to the backend": "Imports tests from a json file and saves the tests to the backend", + "At least one test could not be created": "At least one test could not be created", + "... all tests were created and saved": "... all tests were created and saved", "Lock exercise permanently" : "Lock exercise permanently", "Locked permanently" : "Locked permanently", "Exercise is lock": "Exercise is lock", diff --git a/i18n/i18nRoot.ts b/i18n/i18nRoot.ts index 527153aa..c969c35a 100644 --- a/i18n/i18nRoot.ts +++ b/i18n/i18nRoot.ts @@ -473,6 +473,12 @@ export interface LangObj { "Save project": string "Export exercise": string "Import exercise": string + "Export tests": string + "Import tests": string + "Importing tests ...": string + "Imports tests from a json file and saves the tests to the backend": string + "At least one test could not be created": string + "... all tests were created and saved": string "Lock exercise permanently": string "Locked permanently": string "Exercise is lock": string diff --git a/src/communicationLayer/exerciseEditorLayer.ts b/src/communicationLayer/exerciseEditorLayer.ts index 430c6b01..dde2c780 100644 --- a/src/communicationLayer/exerciseEditorLayer.ts +++ b/src/communicationLayer/exerciseEditorLayer.ts @@ -6,7 +6,7 @@ import * as genericLayer from './genericLayer' import { EditorExerciseForBackend, EditorExerciseFromBackend, - ExerciseFromBackendWithData, + ExerciseFromBackendWithData, ExerciseTestFromBackend, ExerciseTestFromFrontendWithData, FilePreviewFromBackend, FileWithData } from "../types/exerciseEditor"; @@ -165,3 +165,10 @@ export async function detachFileFromExerciseTest(exerciseId: number, testId: num `${controllerPrefixExerciseEditor}/upload/detach/tests/${exerciseId}/${testId}/${fileReferenceId}` ) } + + +export async function createExerciseTestWithTestAsset(exerciseId: number, test: ExerciseTestFromFrontendWithData): Promise<ExerciseTestFromBackend> { + return genericLayer.update<ExerciseTestFromFrontendWithData, ExerciseTestFromBackend>(callingName, + `${controllerPrefixExerciseEditor}/create/test/${exerciseId}`, test + ) +} diff --git a/src/components/sites/exerciseEditorSite/headerBarContent.tsx b/src/components/sites/exerciseEditorSite/headerBarContent.tsx index 43f6153b..1359fcba 100644 --- a/src/components/sites/exerciseEditorSite/headerBarContent.tsx +++ b/src/components/sites/exerciseEditorSite/headerBarContent.tsx @@ -15,8 +15,8 @@ import {InputOnChangeEvent} from "../../../types/reactEvents"; import {readFileAsText} from "../../../helpers/fileReader"; import Logger from "../../../helpers/logger"; import { - EditorExerciseForBackend, EditorExerciseFromBackend, ExerciseFromBackendWithData, - ExerciseTestFromBackend, + EditorExerciseForBackend, EditorExerciseFromBackend, ExerciseFromBackendWithData, ExerciseTestForBackend, + ExerciseTestFromBackend, ExerciseTestFromBackendWithData, ExerciseTestFromFrontendWithData, TestAssetFromBackendWithData } from "../../../types/exerciseEditor"; import {setEditorExercise} from "../../../state/actions/exerciseEditorSite/exerciseEditorSiteActions"; @@ -34,12 +34,18 @@ import {notifyError, notifyInfo, notifySuccess} from "../../../helpers/notificat import * as mousetrap from "mousetrap"; -import {getEditorExercise, getEditorExerciseWithData} from "../../../communicationLayer/exerciseEditorLayer"; +import { + createExerciseTestWithTestAsset, + getEditorExercise, + getEditorExerciseWithData, +} from "../../../communicationLayer/exerciseEditorLayer"; import {askDialog, errorDialog} from "../../../helpers/dialogHelper"; import {mergeEditorExerciseTestAssetContents} from "../../../helpers/convertersAndTransformers"; import {getI18n, getRawI18n} from "../../../../i18n/i18nRoot"; import {ErrorHelper} from '../../../helpers/errorHelper' import {HelpPopup} from '../../helpers/helpPopup' +import {setEditorTests} from "../../../state/actions/exerciseEditorSite/subSets/editorExerciseTestsActions"; +import {MyPopup} from "../../helpers/myPopup"; declare var require: any const fileSaver = require('file-saver'); @@ -89,6 +95,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators({ createEditorExerciseAsync, updateEditorExerciseAsync, lockEditorExercisePermanentlyAsync, + setEditorTests, }, dispatch) @@ -98,6 +105,7 @@ type Props = typeof stateProps & typeof dispatchProps; const importJsonFileExerciseId = 'exerciseEditor-file-import' +const importJsonFileExerciseTestsId = 'exerciseEditor-tests-file-import' class HeaderBar extends React.Component<Props, any> { @@ -152,7 +160,7 @@ class HeaderBar extends React.Component<Props, any> { } if (this.props.isNoDialogDisplayed === false) { - return + return false } if (this.props.editorExercise.id <= 0) { @@ -166,7 +174,7 @@ class HeaderBar extends React.Component<Props, any> { if (!created) { notifyError(getI18n(this.props.langId, 'Could not save exercise')) - return + return false } //because we only get the new id back (intentionally) @@ -189,12 +197,13 @@ class HeaderBar extends React.Component<Props, any> { if (!success) { notifyError(getI18n(this.props.langId, 'Could not update exercise')) - return + return false } notifySuccess(getI18n(this.props.langId, 'Updated exercise')) } + return true } @@ -272,6 +281,146 @@ class HeaderBar extends React.Component<Props, any> { </div>) const rightArea = (<div> + + <div className="clickable mar-right" + onClick={async () => { + + + //when we create a new exercise (not copying) then the test asset contents are not loaded... + // noinspection TsLint + let exerciseToExport: ExerciseFromBackendWithData | EditorExerciseFromBackend = this.props.editorExercise + + if (this.props.editorExercise.id > 0) { + + notifyInfo(getI18n(this.props.langId, 'Now loading test assets (before exporting)...')) + + try { + //load the full exercise but only take the test asset contents + const exerciseWithTestContents = await getEditorExerciseWithData(this.props.editorExercise.id) + + + exerciseToExport = mergeEditorExerciseTestAssetContents({ + ...this.props.editorExercise, + numReleases: 0, // set to 0 because when we import we create a new exercise... and we can only create templates if we have no releases + tests: this.props.editorExercise.tests, + }, + exerciseWithTestContents.tests + ) + + + } catch (err) { + ErrorHelper.makeServerErrorDialog(err, this.props.langId, 'Server error', 'Could not load test assets') + + return + } + } else { + //when we create a new exercise the test assets are still stored at frontend (in memory) + } + + const value = toJSONAndReplaceIds(exerciseToExport.tests) + + // noinspection TsLint + let fileName = `${this.props.editorExercise.exerciseProperties.displayName.trim()}_tests.json` + + if (!fileName) { + fileName = "exercise_tests.json" + } + + const blob = new Blob([value], {type: "application/json;charset=utf-8"}); + fileSaver.saveAs(blob, fileName) + }} + > + <Icon name="upload"/> + <span> + {getI18n(this.props.langId, 'Export tests')} + </span> + </div> + + <div className={`${this.props.editorExercise.id <= 0 ? 'div-disabled' : 'clickable'}`} + onClick={() => { + + const inputEl = document.getElementById(importJsonFileExerciseTestsId) + inputEl.click()}}> + <Icon name="download"/> + <span className="mar-right"> + {getI18n(this.props.langId, 'Import tests')} + </span> + <input id={importJsonFileExerciseTestsId} type="file" className="collapsed" accept="application/json" + disabled={this.props.editorExercise.id <= 0} + onChange={async (e: InputOnChangeEvent) => { + const files: FileList = e.currentTarget.files + + //only allowed for already saved exercises + //because we need to save here and this would redirect/reload the page --> our data is gone + if (this.props.editorExercise.id <= 0) { + return + } + + // noinspection TsLint + let text: string = null + try { + text = await readFileAsText(files[0]) + } catch (err) { + Logger.error(`exercise editor: ${err.message}`) + } + + if (!text) { + Logger.warn(`exercise editor: something went wrong reading the file`) + return + } + + notifyInfo(getI18n(this.props.langId, "Importing tests ...")) + + //TODO must this is insecure.... + //check if the parsed has every needed props + // noinspection TsLint + let parsed = JSON.parse(text) as ExerciseTestFromBackendWithData[] + + let mapped: ReadonlyArray<ExerciseTestFromFrontendWithData> = parsed.map(p => ({ + id: p.id, + displayIndex: -1, //index in array matters in setEditorTests not value here + displayName: p.displayName, + isSubmitTest: p.isSubmitTest, + maxPoints: p.maxPoints, + testTypeId: p.testTypeId, + content: p.content, + testSettings: { + ...p.testSettings, + }, + files: p.files.map(k => ({ + id: k.id, + mimeType: k.mimeType, + size: k.size, + displayName: k.displayName, + content: k.content + })) + })) + + /* tslint:disable-next-line */ + const createdTests: ExerciseTestFromBackend[] = [] + + for (let i = 0; i < mapped.length; i++) { + let mappedTest = mapped[i] + try { + const result = await createExerciseTestWithTestAsset(this.props.editorExercise.id, mappedTest) + createdTests.push(result) + } catch(err) { + Logger.error(`exercise editor: ${err.message}`) + notifyError(getI18n(this.props.langId, 'At least one test could not be created')) + return + } + } + + this.props.setEditorTests([...this.props.editorExercise.tests, ...createdTests]) + + notifySuccess(getI18n(this.props.langId, "... all tests were created and saved")) + }} + /> + </div> + <HelpPopup defaultText={getI18n(this.props.langId,'Imports tests from a json file and saves the tests to the backend')}/> + + <SimpleVDivider/> + <div className="clickable" onClick={async () => { diff --git a/src/constants.ts b/src/constants.ts index 3c8c840c..f4bc357c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,7 +13,7 @@ import Logger from './helpers/logger' * y - breaking changes / new features * z - fixes, small changes */ -export const versionString = '2.19.5' +export const versionString = '2.20.0' export const supportMail = 'yapex@informatik.uni-halle.de' diff --git a/src/helpers/assetHelper.ts b/src/helpers/assetHelper.ts index 58f43027..353ef936 100644 --- a/src/helpers/assetHelper.ts +++ b/src/helpers/assetHelper.ts @@ -121,4 +121,4 @@ export function getCopyAssetUrlText(asset: FileWithData): string { //from https://stackoverflow.com/questions/1144783/how-to-replace-all-occurrences-of-a-string-in-javascript function replaceAll(value: string, search: string, replacement: string): string { return value.split(search).join(replacement); -}; +} diff --git a/src/helpers/jsonTransformer.ts b/src/helpers/jsonTransformer.ts index 676403c4..7376ad10 100644 --- a/src/helpers/jsonTransformer.ts +++ b/src/helpers/jsonTransformer.ts @@ -37,7 +37,7 @@ export function toJSONAndReplaceIds(obj: any): string { */ function replaceIdsTransformer(key: string, value: any): any { if (key === 'id') { - return getNextId().toString() + return getNextId() //.toString() } return value diff --git a/src/types/exerciseEditor.ts b/src/types/exerciseEditor.ts index 69d09be4..e2fde24f 100644 --- a/src/types/exerciseEditor.ts +++ b/src/types/exerciseEditor.ts @@ -115,6 +115,10 @@ export interface ExerciseFromBackendWithData extends EditorExerciseBase { readonly creatorId: number | null } +export interface ExerciseTestFromFrontendWithData extends ExerciseTestBase { + readonly files: ReadonlyArray<TestAssetFromFrontendWithData> +} + /** * the properties for an exercise (in the backend these are part of the exercise but not in the frontend (have own panel) * --> no id @@ -334,6 +338,17 @@ export interface ExerciseTestFromBackend extends ExerciseTestBase { readonly files: ReadonlyArray<FilePreviewFromBackend> } +//almost same as TestAssetBase but with content +export interface TestAssetFromFrontendWithData { + readonly id: number + readonly displayName: string + readonly mimeType: string + readonly size: number + + //bytes + readonly content: ReadonlyArray<number> +} + /** * a test from the frontend with asset data */ diff --git a/src/types/exercisePreview.ts b/src/types/exercisePreview.ts index 612fe0b1..b6db8167 100644 --- a/src/types/exercisePreview.ts +++ b/src/types/exercisePreview.ts @@ -117,7 +117,7 @@ export interface ExercisePreviewFromBackend { readonly isReleased: boolean /** - * will be zero if no test was ran yet + * will be zero if no test has been run */ readonly passedNormalTestsCount: number readonly maxNormalTestsCount: number diff --git a/src/types/submissions.ts b/src/types/submissions.ts index 2879855c..e308ddb4 100644 --- a/src/types/submissions.ts +++ b/src/types/submissions.ts @@ -62,7 +62,7 @@ export interface AssessmentFullBase { * the summed max reachable submit test points * this is even set for dummy assessments */ - readonly maxSubmitTestPoints: number + readonly maxSubmitTestPoints: number | null /** * the summed points from the normal test -- GitLab