Task Creation Workflow
Relevant source files
The following files were used as context for generating this wiki page: - [frontend/src/pages/task/create.tsx](https://github.com/crt25/collimator/blob/HEAD/frontend/src/pages/task/create.tsx) - [frontend/src/components/task/TaskForm.tsx](https://github.com/crt25/collimator/blob/HEAD/frontend/src/components/task/TaskForm.tsx) - [backend/src/api/tasks/tasks.controller.ts](https://github.com/crt25/collimator/blob/HEAD/backend/src/api/tasks/tasks.controller.ts) - [backend/src/api/tasks/tasks.service.ts](https://github.com/crt25/collimator/blob/HEAD/backend/src/api/tasks/tasks.service.ts) - [frontend/src/api/collimator/generated/models/createTaskDto.ts](https://github.com/crt25/collimator/blob/HEAD/frontend/src/api/collimator/generated/models/createTaskDto.ts) - [e2e/tests/task/task-management.ts](https://github.com/crt25/collimator/blob/HEAD/e2e/tests/task/task-management.ts) - [apps/scratch/src/pages/Edit.tsx](https://github.com/crt25/collimator/blob/HEAD/apps/scratch/src/pages/__tests__/Edit.spec.tsx) - [apps/scratch/src/components/TaskConfig.tsx](https://github.com/crt25/collimator/blob/HEAD/apps/scratch/src/components/TaskConfig.tsx)The Task Creation Workflow enables users, typically teachers or administrators, to define new tasks for students. This process involves providing metadata like a title and description, selecting a task type (e.g., Scratch), and using an embedded external application to create the task's content and an initial solution. The workflow spans from the frontend user interface, through an embedded Scratch editor, to the backend API which persists the task and its associated files in the database.
The complete end-to-end process is tested in the task-management.spec.ts E2E test, which covers creating, updating, viewing, and deleting a task.
Sources: e2e/tests/task/task-management.spec.ts:1-76
High-Level Overview
The following diagram illustrates the major steps involved in creating a new task, from user interaction in the browser to data persistence on the server.
Frontend Implementation
The frontend part of the workflow is handled by a combination of a Next.js page and several React components.
Sources: frontend/src/pages/task/create.tsx, frontend/src/components/task/TaskForm.tsx
UI Components and Pages
-
CreateTaskPage (/task/create.tsx): This is the main entry point for the user. It renders theTaskFormcomponent and provides theonSubmitcallback function, which calls theuseCreateTaskhook to send the data to the backend.
Sources: frontend/src/pages/task/create.tsx:21-27 -
TaskFormComponent (/components/task/TaskForm.tsx): This is the core component for both creating and editing tasks. It manages the form state usingreact-hook-formand defines the data structure and validation rules usingyup. It includes fields for title, description, and type, and manages the task file, initial solution, and reference solutions.
Sources: frontend/src/components/task/TaskForm.tsx:1-734 -
EditTaskModalComponent (/components/modals/EditTaskModal.tsx): This modal is launched from theTaskForm. It embeds an external application (e.g., Scratch) in aniframeto allow the user to create or edit the task content (taskFile) and its initial solution (initialSolutionFile).
Sources: frontend/src/components/task/TaskForm.tsx:501-537 -
Scratch Editor (
/apps/scratch/src/pages/Edit.tsx): When the task type isSCRATCH, this is the application loaded inside theEditTaskModal. It provides the Scratch interface for building the task project. It communicates with the parent window (TaskForm) viawindow.postMessageto send back the created task file.
Sources: apps/scratch/src/pages/tests/Edit.spec.tsx:29-35
Data Flow and State Management
The TaskForm component orchestrates the data collection process.
- The user fills in basic information (title, description, type).
- The user clicks the "Create task in external application" button, which opens the
EditTaskModal. - Inside the modal, the Scratch editor is loaded. The user creates the task.
- Upon saving in the modal, the Scratch application sends the task file and initial solution back to the
TaskForm. TheonSavecallback updates the form's state with the receivedBlobdata fortaskFileandinitialSolutionFile. - The user can also add multiple "Reference Solutions," each with its own file created via a similar modal (
SolveTaskModal). - When the user finally submits the
TaskForm, theonSubmitWrapperfunction gathers all data, formats it into aTaskFormSubmissionobject, and passes it to theonSubmitprop provided by theCreateTaskpage.
The sequence diagram below shows the interaction for creating the main task file.
Form Data Structure
The TaskForm prepares a TaskFormSubmission object for the API call.
| Field | Type | Description |
|---|---|---|
title | string | The title of the task. |
description | string | The description of the task. |
type | TaskType | The type of task, e.g., SCRATCH. |
taskFile | Blob | The main task content file (e.g., a .sb3 file). |
referenceSolutions | UpdateReferenceSolutionDto[] | An array of reference solution metadata, including tests. |
referenceSolutionsFiles | Blob[] | An array of Blob files corresponding to the reference solutions. |
Sources: frontend/src/components/task/TaskForm.tsx:165-173
Backend API
The backend exposes a RESTful endpoint to handle the creation of new tasks.
POST /api/tasks
This endpoint accepts multipart/form-data to handle file uploads alongside JSON data.
| Property | Description - |
| Path | POST /tasks - |
| Description | Creates a new task. - |
| Request Body | CreateTaskDto as multipart/form-data. - |
| Success Response | 201 Created with ExistingTaskDto. - |
| Authorization | Requires authenticated user. - |
Sources: backend/src/api/tasks/tasks.controller.ts:40-60
The TasksController uses two interceptors:
FileFieldsInterceptor: To handle thetaskFileandreferenceSolutionsFilesfrom the multipart request.JsonToObjectsInterceptor: To parse thereferenceSolutionsstring field back into a JavaScript object.
Sources: backend/src/api/tasks/tasks.controller.ts:54-59
The controller performs several validation checks before passing the data to the service layer:
- Ensures a
taskFileis present. - Verifies that there is exactly one initial solution.
- Checks that the number of
referenceSolutionsmatches the number ofreferenceSolutionsFiles.
Sources: backend/src/api/tasks/tasks.controller.ts:70-84
API Data Transfer Object (CreateTaskDto)
This interface defines the expected structure of the data sent to the POST /api/tasks endpoint.
| Field | Type | Description |
|---|---|---|
title | string | Task title. |
description | string | Task description. |
type | TaskType | The task's type (e.g., SCRATCH). |
taskFile | Blob | The main task file. |
referenceSolutionsFiles | Blob[] | Files for the reference solutions. |
referenceSolutions | CreateReferenceSolutionDto[] | Metadata for the reference solutions. |
Sources: frontend/src/api/collimator/generated/models/createTaskDto.ts:1-13
Backend Service Logic
The TasksService contains the core business logic for creating a task in the database.
Sources: [backend/src/api/tasks/tasks.service.ts]
create Method
The create method in TasksService is responsible for persisting the task and its related entities. It uses a Prisma transaction (prisma.$transaction) to ensure atomicity. If any part of the process fails, the entire operation is rolled back.
The process within the transaction is as follows:
- A new
Taskrecord is created with its basic information (title,description,type,data,mimeType). - For each reference solution provided:
a. A hash of the solution file buffer is computed using SHA256.
b. A newSolutionrecord is created, storing the file buffer, MIME type, and the computed hash.
c. A newReferenceSolutionrecord is created, linking to theTaskand the newSolution. It also stores metadata liketitle,description, andisInitial.
d. AssociatedSolutionTestrecords are created.
This diagram shows the database transaction flow.
The computeSolutionHash function is used to generate a unique identifier for each solution file based on its content.
// backend/src/api/tasks/tasks.service.ts:39-41
computeSolutionHash(data: Solution["data"]): Buffer {
return createHash("sha256").update(data).digest();
}
Sources: backend/src/api/tasks/tasks.service.ts:39-41
Summary
The task creation workflow is a robust, multi-step process that integrates frontend components with a backend API. It leverages a multipart/form-data endpoint to handle complex data including files and structured JSON. The use of an embedded Scratch editor provides a seamless experience for creating task content, and the transactional nature of the backend service ensures data integrity. The entire flow is well-defined, with clear data structures (TaskFormSubmission, CreateTaskDto) and responsibilities at each layer of the application.
Sources: frontend/src/pages/task/create.tsx, backend/src/api/tasks/tasks.controller.ts, backend/src/api/tasks/tasks.service.ts## FULL FILE CONTEXT SUMMARY
Query: What is the purpose of this repository?
You will be given a series of file summaries.
Your task is to synthesize these summaries into a coherent, concise, and accurate answer to the user's query.
You MUST follow these guidelines:
- Start your answer with a direct, concise, one-sentence summary of the repository's purpose.
- Use the file summaries as the sole basis for your answer. Do not add information not present in the summaries.
- Organize your answer into logical paragraphs, using markdown for formatting (e.g., lists, bolding).
- Your response MUST be in English.
- Do not start with a preamble like "This repository contains..." or "The purpose of this repository is...".
- Do not repeat the user's question.
- Do not include a list of the files you used.
- Do not use markdown headers (e.g., "##", "###").
Total Files Included: 20
Total Files Skipped: 0
Total Context Tokens: 47,104 / 800,000 (5.9%)
Remaining Token Budget: 752,896
Included Files:
- e2e/tests/task/task-management.spec.ts (711 tokens)
- frontend/src/pages/task/[taskId]/edit.tsx (639 tokens)
- e2e/tests/task/task-management.ts (324 tokens)
- frontend/src/components/task/TaskForm.tsx (4,165 tokens)
- frontend/src/pages/task/[taskId]/detail.tsx (792 tokens)
- frontend/src/pages/task/create.tsx (276 tokens)
- frontend/src/pages/class/[classId]/session/[sessionId]/task/[taskId]/solve.tsx (1,945 tokens)
- backend/src/api/tasks/tasks.controller.ts (1,650 tokens)
- apps/scratch/src/pages/tests/Solve.spec.tsx (3,750 tokens)
- frontend/content/compiled-locales/en.json (9,752 tokens)
- frontend/content/compiled-locales/fr.json (9,917 tokens)
- apps/scratch/src/pages/tests/Edit.spec.tsx (3,382 tokens)
- backend/src/api/tasks/tasks.service.ts (2,017 tokens)
- frontend/src/components/session/SessionForm.tsx (1,499 tokens)
- e2e/tests/sessions/session-analysis.spec.ts (1,123 tokens)
- frontend/src/api/collimator/generated/models/createTaskDto.ts (152 tokens)
- apps/scratch/src/components/TaskConfig.tsx (1,788 tokens)
- frontend/src/components/dashboard/DissimilarityAnalysis.tsx (1,251 tokens)
- e2e/tests/sessions/solve-task-page-model.ts (578 tokens)
- e2e/tests/users/user-management.spec.ts (1,393 tokens)
import { useAdminUser } from "../../authentication-helpers";
import { expect, test } from "../../helpers";
import { taskList } from "../../selectors";
import checkXPositionWithAssertion from "../sessions/tasks/check-x-position-with-assertion";
import { TaskListPageModel } from "./task-list-page-model";
import { TaskFormPageModel } from "./task-form-page-model";
import { createTask } from "./task-management";
import { TaskType } from "@/api/collimator/generated/models";
const newTaskTitle = "new task name";
const newTaskDecription = "new task description";
let newTaskId: number = -1;
const updatedTaskTitle = "updated task name";
const updatedTaskDecription = "updated task description";
const updatedTaskType = TaskType.SCRATCH;
test.describe("task management", () => {
test.beforeEach(async ({ context, page, baseURL }) => {
await useAdminUser(context);
await page.goto(`${baseURL}/task`);
});
test.describe("/task/[taskId]/edit", () => {
test("can create a task", async ({ page: pwPage, baseURL }) => {
newTaskId = await createTask(baseURL!, pwPage, {
title: newTaskTitle,
description: newTaskDecription,
template: checkXPositionWithAssertion,
}).then((r) => r.id);
});
});
test.describe("/task/[taskId]/edit", () => {
test.beforeEach(async ({ context, page }) => {
await useAdminUser(context);
const list = await TaskListPageModel.create(page);
await list.editItem(newTaskId);
});
test("can update a task", async ({ page: pwPage, baseURL }) => {
const page = await TaskFormPageModel.create(pwPage);
await page.inputs.title.fill(updatedTaskTitle);
await page.inputs.description.fill(updatedTaskDecription);
await page.inputs.type.selectOption(updatedTaskType);
await page.openEditTaskModal();
await page.saveTask();
await page.submitButton.click();
await pwPage.waitForURL(`${baseURL}/task`);
const list = await TaskListPageModel.create(pwPage);
await expect(list.getTitle(newTaskId)).toHaveText(updatedTaskTitle);
});
});
test.describe("/task/{id}/detail", () => {
test.beforeEach(async ({ baseURL, page }) => {
const list = await TaskListPageModel.create(page);
await list.viewItem(newTaskId);
await page.waitForURL(`${baseURL}/task/${newTaskId}/detail`);
});
test("renders the task", async ({ page }) => {
await expect(page.locator("iframe")).toHaveCount(1);
});
});
test.describe("/task", () => {
test.beforeEach(async ({ page }) => {
await TaskListPageModel.create(page);
});
test("renders the fetched items", async ({ page }) => {
await expect(page.locator(taskList).locator("tbody tr")).toHaveCount(1);
});
test("can delete listed items", async ({ page: pwPage }) => {
const page = await TaskListPageModel.create(pwPage);
await page.deleteItem(newTaskId);
// Wait for the deletion to be reflected in the UI
await expect(page.getItemActions(newTaskId)).toHaveCount(0);
});
});
});
================================================================================
import { useRouter } from "next/router";
import { useCallback } from "react";
import { Container } from "react-bootstrap";
import { defineMessages, FormattedMessage } from "react-intl";
import { useTaskFile } from "@/api/collimator/hooks/tasks/useTask";
import { useUpdateTask } from "@/api/collimator/hooks/tasks/useUpdateTask";
import CrtNavigation from "@/components/CrtNavigation";
import Header from "@/components/Header";
import MultiSwrContent from "@/components/MultiSwrContent";
import PageHeader from "@/components/PageHeader";
import TaskForm, { TaskFormSubmission } from "@/components/task/TaskForm";
import { useTaskWithReferenceSolutions } from "@/api/collimator/hooks/tasks/useTaskWithReferenceSolutions";
const messages = defineMessages({
title: {
id: "EditTask.title",
defaultMessage: "Edit Task - {title}",
},
submit: {
id: "EditTask.submit",
defaultMessage: "Save Task",
},
});
const EditTask = () => {
const router = useRouter();
const { taskId } = router.query as {
taskId?: string;
};
const task = useTaskWithReferenceSolutions(taskId);
const taskFile = useTaskFile(taskId);
const updateTask = useUpdateTask();
const onSubmit = useCallback(
async (taskSubmission: TaskFormSubmission) => {
if (task.data && taskFile.data) {
await updateTask(task.data.id, taskSubmission);
}
},
[task.data, taskFile.data, updateTask],
);
return (
<>
<Header
title={messages.title}
titleParameters={{
title: task.data?.title ?? "",
}}
/>
<MultiSwrContent
data={[task.data, taskFile.data]}
isLoading={[task.isLoading, taskFile.isLoading]}
errors={[task.error, taskFile.error]}
>
{([task, taskFile]) => {
const initialSolution = task.referenceSolutions.find(
(s) => s.isInitial,
);
return (
<TaskForm
initialValues={{
...task,
taskFile,
initialSolution: initialSolution ?? null,
initialSolutionFile: initialSolution?.solution ?? null,
referenceSolutions: task.referenceSolutions.filter(
(s) => !s.isInitial,
),
referenceSolutionFiles: task.referenceSolutions
.filter((s) => !s.isInitial)
.reduce(
(acc, solution) => {
acc[solution.id] = solution.solution;
return acc;
},
{} as Record<number, Blob>,
),
}}
submitMessage={messages.submit}
onSubmit={onSubmit}
/>
);
}}
</MultiSwrContent>
</Container>
</>
);
};
export default EditTask;
================================================================================
import { Page } from "@playwright/test";
import { expect } from "../../helpers";
import { TaskTemplateWithSolutions } from "../sessions/tasks/task-template-with-solutions";
import { TaskFormPageModel } from "./task-form-page-model";
import { TaskListPageModel } from "./task-list-page-model";
export const createTask = async (
baseUrl: string,
pwPage: Page,
task: {
title: string;
description: string;
template: TaskTemplateWithSolutions;
},
): Promise<{
id: number;
}> => {
await pwPage.goto(${baseUrl}/task);
const list = await TaskListPageModel.create(pwPage);
await list.createItem();
const page = await TaskFormPageModel.create(pwPage);
await page.inputs.title.fill(task.title);
await page.inputs.description.fill(task.description);
await page.inputs.type.selectOption(task.template.type);
await page.openEditTaskModal();
const fileChooserPromise = pwPage.waitForEvent("filechooser");
await page.importTask();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles({
name: "task template",
mimeType: task.template.mimeType.template,
buffer: await task.template.template(),
});
await page.saveTask();
await page.submitButton.click();
await pwPage.waitForURL(${baseUrl}/task);
await expect(list.getTitleElementByTitle(task.title)).toHaveCount(1);
const taskId = await list.getIdByName(task.title);
return {
id: taskId,
};
};
================================================================================
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
defineMessages,
FormattedMessage,
IntlShape,
MessageDescriptor,
useIntl,
} from "react-intl";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import * as yup from "yup";
import styled from "@emotion/styled";
import { useRouter } from "next/router";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Submission } from "iframe-rpc-react/src";
import { useYupSchema } from "@/hooks/useYupSchema";
import { useYupResolver } from "@/hooks/useYupResolver";
import {
CreateSolutionTestDto,
TaskType,
UpdateReferenceSolutionDto,
} from "@/api/collimator/generated/models";
import { useNavigationObserver } from "@/utilities/navigation-observer";
import { getTaskTypeMessage } from "@/i18n/task-type-messages";
import ConfirmationModal from "@/components/modals/ConfirmationModal";
import ValidationErrorMessage from "../form/ValidationErrorMessage";
import Input from "../form/Input";
import SubmitFormButton from "../form/SubmitFormButton";
import TextArea from "../form/TextArea";
import Select from "../form/Select";
import Button from "../Button";
import EditTaskModal from "../modals/EditTaskModal";
import SortableListInput from "../form/SortableList";
import SolveTaskModal from "../modals/SolveTaskModal";
const EditTaskButton = styled(Button) margin-top: 1rem;;
const AddReferenceSolutionButton = styled(Button) margin-bottom: 1rem;;
const ReferenceSolutionListElement = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 1rem;
& > div {
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label,
label > span {
display: block;
}
input,
textarea {
width: 100%;
resize: none;
padding: 0.25rem 0.5rem;
}
`;
const RemoveTask = styled.span`
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
`;
const logModule = "[TaskForm]";
const messages = defineMessages({
title: {
id: "TaskForm.title",
defaultMessage: "Title",
},
description: {
id: "TaskForm.description",
defaultMessage: "Description",
},
type: {
id: "TaskForm.type",
defaultMessage: "Type",
},
blobValidation: {
id: "TaskForm.blobValidation",
defaultMessage: "The provided file data is invalid",
},
referenceSolutionsLengthValidation: {
id: "TaskForm.referenceSolutionsLengthValidation",
defaultMessage:
"The number of reference solutions must match the number of reference solution files",
},
closeConfirmationTitle: {
id: "TaskForm.closeConfirmation.title",
defaultMessage: "Attention: you may lose your work!",
},
closeConfirmationBody: {
id: "TaskForm.closeConfirmation.body",
defaultMessage:
"You are about to leave the task editing interface without saving.\nAre you sure this is" +
" what you want?",
},
closeConfirmationButton: {
id: "TaskForm.closeConfirmation.button",
defaultMessage: "Yes, I don't need to save.",
},
});
type CreateReferenceSolutionDtoWithId = UpdateReferenceSolutionDto & {
// add synthetic id for sortable list
id: number;
isNew?: boolean;
};
type TaskFormValues = {
title: string;
description: string;
type: TaskType;
referenceSolutions: CreateReferenceSolutionDtoWithId[];
taskFile: Blob;
initialSolution: UpdateReferenceSolutionDto | null;
initialSolutionFile: Blob | null;
referenceSolutionFiles: { [key: number]: Blob };
};
export type TaskFormSubmission = {
title: string;
description: string;
type: TaskType;
taskFile: Blob;
referenceSolutions: UpdateReferenceSolutionDto[];
referenceSolutionsFiles: Blob[];
};
const getYupSchema = (intl: IntlShape) => {
const referenceSolutionBase = {
title: yup.string().defined(),
description: yup.string().defined(),
isInitial: yup.boolean().required(),
tests: yup
.array(
yup
.object({
identifier: yup.string().nullable().defined(),
name: yup.string().required(),
contextName: yup.string().nullable().defined(),
passed: yup.boolean().required(),
})
.required(),
)
.required(),
};
const referenceSolution = yup
.object({
id: yup.number().nullable().defined(),
...referenceSolutionBase,
})
.nullable()
.defined();
const referenceSolutionWithId = yup
.object({
id: yup.number().required(),
isNew: yup.boolean(),
...referenceSolutionBase,
})
.required();
return {
title: yup.string().required(),
description: yup.string().defined(),
type: yup.string().oneOf(Object.values(TaskType)).required(),
referenceSolutions: yup.array(referenceSolutionWithId).required(),
taskFile: yup
.mixed()
.test(
"is-blob",
intl.formatMessage(messages.blobValidation),
(v) => v instanceof Blob,
)
.required(),
initialSolutionFile: yup
.mixed()
.test(
"is-blob",
intl.formatMessage(messages.blobValidation),
(v) => v instanceof Blob || v === null,
)
.nullable()
.defined(),
initialSolution: referenceSolution,
referenceSolutionFiles: yup
.mixed<{ [key: number]: Blob }>()
.test(
"is-blob-map",
intl.formatMessage(messages.blobValidation),
(v) =>
typeof v === "object" &&
Object.values(v).every((v) => v instanceof Blob),
)
.required()
.test(
"length-match",
intl.formatMessage(messages.referenceSolutionsLengthValidation),
(blobMap, { parent: { referenceSolutions } }) =>
Array.isArray(referenceSolutions) &&
Object.values(blobMap).length === referenceSolutions.length,
)
.required(),
_fileChanged: yup.boolean().required(),
};
};
const createSubmissionTests = (
submission: Submission,
): CreateSolutionTestDto[] => [
...submission.failedTests.map((t) => ({ ...t, passed: false })),
...submission.passedTests.map((t) => ({ ...t, passed: true })),
];
const TaskForm = ({
submitMessage,
initialValues,
onSubmit,
}: {
submitMessage: MessageDescriptor;
initialValues?: Partial;
onSubmit: (data: TaskFormSubmission) => Promise;
}) => {
const intl = useIntl();
const [showEditTaskModal, setShowEditTaskModal] = useState(false);
const [showQuitNoSaveModal, setShowQuitNoSaveModal] = useState(false);
const [showSolveTaskModalForId, setShowSolveTaskModalForId] = useState<
null | number
(null);
const [solutionFile, setSolutionFile] = useState<Blob | null>(null);
const cannotNavigate = useRef(false);
const schema = useYupSchema(getYupSchema(intl)) satisfies yup.ObjectSchema<{
title: string;
description: string;
type: TaskType;
referenceSolutions: CreateReferenceSolutionDtoWithId[];
taskFile: Blob;
initialSolution: UpdateReferenceSolutionDto | null;
initialSolutionFile: Blob | null;
referenceSolutionFiles: { [key: number]: Blob };
_fileChanged: boolean;
}>;
const resolver = useYupResolver(schema);
const defaultValues = useMemo(
() => ({
type: TaskType.SCRATCH,
title: "",
description: "",
referenceSolutionFiles: [],
referenceSolutions: [],
...initialValues,
_fileChanged: false,
}),
[initialValues],
);
const {
register,
handleSubmit,
formState: { errors, isDirty, isValid },
watch,
reset,
setValue,
} = useForm<TaskFormValues & { _fileChanged: boolean }>({
resolver,
defaultValues,
});
const initialSolution = watch("initialSolution");
const referenceSolutions = watch("referenceSolutions");
// ensure that the selected tasks are always in sync with the form
const setReferenceSolutions = useCallback(
(referenceSolutions: CreateReferenceSolutionDtoWithId[]) => {
setValue("referenceSolutions", referenceSolutions, {
shouldDirty: true,
shouldValidate: true,
});
},
[setValue],
);
const updateReferenceSolution = useCallback(
(index: number, solution: CreateReferenceSolutionDtoWithId) => {
const newReferenceSolutions = [...referenceSolutions];
newReferenceSolutions[index] = solution;
setReferenceSolutions(newReferenceSolutions);
},
[referenceSolutions, setReferenceSolutions],
);
const onAddReferenceSolution = useCallback(
() =>
setReferenceSolutions([
...referenceSolutions,
{
// add unique synthetic id that will be removed later
id: Math.max(...referenceSolutions.map((s) => s.id), 0) + 1,
isNew: true,
isInitial: false,
title: "",
description: "",
tests: [],
},
]),
[referenceSolutions, setReferenceSolutions],
);
const shouldStopNavigation = useCallback(() => cannotNavigate.current, []);
const onNavigate = useCallback(() => {
setShowQuitNoSaveModal(true);
}, []);
const navigate = useNavigationObserver({
shouldStopNavigation,
onNavigate,
});
useEffect(() => {
// when the form becomes dirty, we do not allow navigation
cannotNavigate.current = isDirty;
}, [isDirty]);
const router = useRouter();
const onSubmitWrapper = useCallback(
(e: React.FormEvent) => {
let data: TaskFormValues;
handleSubmit((v: TaskFormValues) => {
data = v;
const referenceSolutions = data.referenceSolutions
.toSorted((a, b) => a.id - b.id)
.map((solution) => ({
...solution,
id: solution.isNew ? null : solution.id,
}));
const referenceSolutionsFiles = Object.entries(
data.referenceSolutionFiles,
)
.toSorted(([a, _], [b, __]) => parseInt(a) - parseInt(b))
.map(([_id, file]) => file);
if (data.initialSolution && data.initialSolutionFile) {
referenceSolutions.push({
...data.initialSolution,
id: null,
});
referenceSolutionsFiles.push(data.initialSolutionFile);
}
return onSubmit({
title: data.title,
description: data.description,
type: data.type,
taskFile: data.taskFile,
referenceSolutions,
referenceSolutionsFiles,
} satisfies TaskFormSubmission);
})(e)
.then(() => {
// allow navigation after the task has been saved
cannotNavigate.current = false;
// reset the form to the updated values
// and mark the blob as not changed
// so the user can navigate without confirmation
reset(data);
toast.success(
<FormattedMessage
id="TaskForm.SaveSuccess"
defaultMessage="The task was saved successfully."
/>,
{ position: "top-center" },
);
router.back();
})
.catch((err) => {
console.error(`${logModule} Error saving task`, err);
toast.error(
<FormattedMessage
id="TaskForm.SaveError"
defaultMessage="An error occurred while saving the task. Try again later."
/>,
{ position: "top-center" },
);
});
},
[handleSubmit, onSubmit, reset, router],
);
const taskFile: Blob | undefined | null = watch("taskFile");
const referenceSolutionFiles: { [key: number]: Blob } = watch(
"referenceSolutionFiles",
);
const taskType = watch("type");
const openSolutionModal = useCallback(
(solutionId: number, solution: Blob | null) => {
if (showSolveTaskModalForId !== null) {
throw new Error("Cannot open new modal while another is open");
}
setSolutionFile(solution);
setShowSolveTaskModalForId(solutionId);
},
[showSolveTaskModalForId],
);
return (
<>
<Input
label={messages.title}
{...register("title")}
data-testid="title"
>
{errors.title?.message}
<TextArea
label={messages.description}
{...register("description")}
data-testid="description"
>
{errors.description?.message}
<Select
alwaysShow
label={messages.type}
options={Object.values(TaskType).map((taskType) => ({
value: taskType,
label: getTaskTypeMessage(taskType as TaskType),
}))}
{...register("type")}
data-testid="type"
>
<EditTaskButton
data-testid="edit-task-button"
type="button"
onClick={() => setShowEditTaskModal(true)}
>
{taskFile ? (
) : (
)}
<ValidationErrorMessage>
{errors.type?.message}
</ValidationErrorMessage>
</Select>
<EditTaskModal
isShown={showEditTaskModal}
setIsShown={setShowEditTaskModal}
initialTask={taskFile}
taskType={taskType}
onSave={(task) => {
setValue("taskFile", task.file, {
shouldDirty: true,
shouldValidate: true,
});
setValue("initialSolutionFile", task.initialSolution.file, {
shouldDirty: true,
shouldValidate: true,
});
setValue(
"initialSolution",
{
// if possible, use the existing initial solution id,
// otherwise set it to null and create a new one
id: initialSolution?.id ?? null,
title: "",
description: "",
isInitial: true,
tests: createSubmissionTests(task.initialSolution),
},
{
shouldDirty: true,
shouldValidate: true,
},
);
}}
/>
<ValidationErrorMessage>
{errors.taskFile?.message}
</ValidationErrorMessage>
<ValidationErrorMessage>
{errors.initialSolution?.message}
</ValidationErrorMessage>
<ValidationErrorMessage>
{errors.initialSolutionFile?.message}
</ValidationErrorMessage>
<h2>
<FormattedMessage
id="TaskForm.referenceSolutions"
defaultMessage="Reference Solutions"
/>
</h2>
<SortableListInput
items={referenceSolutions}
updateItems={setReferenceSolutions}
testId="reference-solutions"
>
{(solution, index) => (
<ReferenceSolutionListElement>
<div>
<label>
<span>{intl.formatMessage(messages.title)}</span>
<input
value={solution.title}
onChange={(e) =>
updateReferenceSolution(index, {
...referenceSolutions[index],
title: e.target.value,
})
}
/>
</label>
<label>
<span>{intl.formatMessage(messages.description)}</span>
<textarea
rows={5}
value={solution.description}
onChange={(e) =>
updateReferenceSolution(index, {
...referenceSolutions[index],
description: e.target.value,
})
}
/>
</label>
<Button
data-testid={`edit-solution-button-${solution.id}`}
type="button"
onClick={() =>
openSolutionModal(
solution.id,
referenceSolutionFiles[solution.id] ?? null,
)
}
>
{referenceSolutionFiles[solution.id] ? (
<FormattedMessage
id="TaskForm.solution.edit"
defaultMessage="Edit solution in external application"
/>
) : (
<FormattedMessage
id="TaskForm.solution.create"
defaultMessage="Create solution in external application"
/>
)}
</Button>
</div>
<RemoveTask
data-testid="remove-task"
onClick={() => {
setReferenceSolutions(
referenceSolutions.filter((s) => s !== solution),
);
const newObject = { ...referenceSolutionFiles };
delete newObject[solution.id];
setValue("referenceSolutionFiles", newObject, {
shouldDirty: true,
shouldValidate: true,
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</RemoveTask>
</ReferenceSolutionListElement>
)}
</SortableListInput>
<AddReferenceSolutionButton
onClick={onAddReferenceSolution}
type="button"
>
<FormattedMessage
id="TaskForm.addReferenceSolution"
defaultMessage="Add"
/>
</AddReferenceSolutionButton>
<SolveTaskModal
isShown={showSolveTaskModalForId !== null}
setIsShown={(isShown) =>
setShowSolveTaskModalForId(isShown ? showSolveTaskModalForId : null)
}
taskType={taskType}
task={taskFile}
solution={solutionFile}
onSave={(solution) => {
if (showSolveTaskModalForId === null) {
throw new Error(
`Modal onSave was called while showSolveTaskModalForIndex is ${showSolveTaskModalForId}`,
);
}
const index = referenceSolutions.findIndex(
(s) => s.id === showSolveTaskModalForId,
);
updateReferenceSolution(index, {
...referenceSolutions[index],
tests: createSubmissionTests(solution),
});
setValue(
"referenceSolutionFiles",
{
...referenceSolutionFiles,
[showSolveTaskModalForId]: solution.file,
},
{ shouldDirty: true, shouldValidate: true },
);
// react hook form does not detect the file change, so we need to set it manually
setValue("_fileChanged", true, {
shouldDirty: true,
shouldValidate: true,
});
}}
/>
{taskFile && (
<SubmitFormButton
label={submitMessage}
disabled={!isDirty || !isValid}
/>
)}
</form>
<ConfirmationModal
isShown={showQuitNoSaveModal}
setIsShown={setShowQuitNoSaveModal}
onConfirm={navigate}
isDangerous
messages={{
title: messages.closeConfirmationTitle,
body: messages.closeConfirmationBody,
confirmButton: messages.closeConfirmationButton,
}}
/>
</>
);
};
export default TaskForm;
================================================================================
import { useRouter } from "next/router";
import { useCallback, useMemo, useRef } from "react";
import { defineMessages, FormattedMessage, useIntl } from "react-intl";
import { Container } from "react-bootstrap";
import { Language } from "iframe-rpc-react/src";
import { useTask, useTaskFile } from "@/api/collimator/hooks/tasks/useTask";
import { TaskType } from "@/api/collimator/generated/models";
import TaskNavigation from "@/components/task/TaskNavigation";
import MultiSwrContent from "@/components/MultiSwrContent";
import Breadcrumbs from "@/components/Breadcrumbs";
import Header from "@/components/Header";
import PageHeader from "@/components/PageHeader";
import CrtNavigation from "@/components/CrtNavigation";
import EmbeddedApp, { EmbeddedAppRef } from "@/components/EmbeddedApp";
import { useFileHash } from "@/hooks/useFileHash";
import { scratchAppHostName } from "@/utilities/constants";
const messages = defineMessages({
title: {
id: "TaskDetail.title",
defaultMessage: "Task - {title}",
},
});
const getDisplaySolveUrl = (taskType: TaskType) => {
switch (taskType) {
case TaskType.SCRATCH:
return ${scratchAppHostName}/solve;
default:
return null;
}
};
const TaskDetail = () => {
const router = useRouter();
const intl = useIntl();
const { taskId } = router.query as {
taskId: string;
};
const {
data: task,
error: taskError,
isLoading: isLoadingTask,
} = useTask(taskId);
const {
data: taskFile,
isLoading: isLoadingTaskFile,
error: taskFileError,
} = useTaskFile(taskId);
const iframeSrc = useMemo(
() => (task ? getDisplaySolveUrl(task.type) : null),
[task],
);
const taskFileHash = useFileHash(taskFile);
const embeddedApp = useRef<EmbeddedAppRef | null>(null);
const onAppAvailable = useCallback(() => {
if (embeddedApp.current && taskFile) {
embeddedApp.current.sendRequest("loadTask", {
task: taskFile,
language: intl.locale as Language,
});
}
// since taskFileHash is a blob, use its hash as a proxy for its content
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskFile, taskFileHash, intl.locale]);
return (
<>
<Header
title={messages.title}
titleParameters={{
title: task?.title ?? "",
}}
/>
<MultiSwrContent
data={[task, taskFile]}
errors={[task