diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ea40fd0188f79b0031dfe206bb7edf6e6e078e1a..69aaa94bafa7432c5492fbf1abced66885610912 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -366,6 +366,7 @@ class Study(BaseModel): chat_duration: int users: list[User] surveys: list[Survey] + typing_test: bool class StudyCreate(BaseModel): @@ -373,4 +374,5 @@ class StudyCreate(BaseModel): description: str start_date: NaiveDatetime end_date: NaiveDatetime - chat_duration: int = 30 * 60 + chat_duration: int = 30 + typing_test: bool = False diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json index efeb4f3b90bbda30c30b074d4a1dfd77a5fc7f42..8a87d44d5e4ab565f94591484fa4ab96d845f2b1 100644 --- a/frontend/src/lang/fr.json +++ b/frontend/src/lang/fr.json @@ -345,7 +345,7 @@ "deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette étude ? Cette action est irréversible.", "startDate": "Date de début", "endDate": "Date de fin", - "chatDuration": "Durée des sessions (en secondes)", + "chatDuration": "Durée des sessions (en minutes)", "updated": "Étude mise à jour avec succès", "noChanges": "Aucune modification", "updateError": "Erreur lors de la mise à jour de l'étude", @@ -364,7 +364,8 @@ "addUserError": "Erreur lors de l'ajout de l'utilisateur", "addUserSuccess": "Utilisateur ajouté à l'étude", "deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette étude ? Cette action est irréversible.", - "createTitle": "Créer une nouvelle étude" + "createTitle": "Créer une nouvelle étude", + "typingTest": "Activer le test de frappe" }, "button": { "create": "Créer", @@ -462,7 +463,9 @@ "users": "Utilisateurs", "description": "Description", "email": "E-mail", - "toggle": "Participants" + "toggle": "Participants", + "groups": "groupes", + "questions": "questions" } }, "inputs": { diff --git a/frontend/src/lib/api/survey.ts b/frontend/src/lib/api/survey.ts index def03af31e4412db56808402ff31e530fa0d5287..ea6934475d9274d568fae6d4abd535a944bb7331 100644 --- a/frontend/src/lib/api/survey.ts +++ b/frontend/src/lib/api/survey.ts @@ -1,5 +1,11 @@ import type { fetchType } from '$lib/utils/types'; +export async function getSurveysAPI(fetch: fetchType) { + const response = await fetch('/api/surveys'); + if (!response.ok) return null; + return await response.json(); +} + export async function getSurveyAPI(fetch: fetchType, survey_id: number) { const response = await fetch(`/api/surveys/${survey_id}`); if (!response.ok) return null; diff --git a/frontend/src/lib/types/study.ts b/frontend/src/lib/types/study.ts index dd4353d3eed56d2028e2d38af8c98d7d79a03866..c46818713c123020ba2404cabd5f601ddf59d3a8 100644 --- a/frontend/src/lib/types/study.ts +++ b/frontend/src/lib/types/study.ts @@ -18,6 +18,7 @@ export default class Study { private _endDate: Date; private _chatDuration: number; private _users: User[]; + private _typingTest: boolean; private constructor( id: number, @@ -26,7 +27,8 @@ export default class Study { startDate: Date, endDate: Date, chatDuration: number, - users: User[] + users: User[], + typingTest: boolean ) { this._id = id; this._title = title; @@ -35,6 +37,7 @@ export default class Study { this._endDate = endDate; this._chatDuration = chatDuration; this._users = users; + this._typingTest = typingTest; } get id(): number { @@ -69,18 +72,23 @@ export default class Study { return this._users.length; } + get typingTest(): boolean { + return this._typingTest; + } + static async create( title: string, description: string, startDate: Date, endDate: Date, chatDuration: number, + typingTest: boolean, f: fetchType = fetch ): Promise<Study | null> { const id = await createStudyAPI(f, title, description, startDate, endDate, chatDuration); if (id) { - return new Study(id, title, description, startDate, endDate, chatDuration, []); + return new Study(id, title, description, startDate, endDate, chatDuration, [], typingTest); } return null; } @@ -97,6 +105,7 @@ export default class Study { if (data.start_date) this._startDate = parseToLocalDate(data.start_date); if (data.end_date) this._endDate = parseToLocalDate(data.end_date); if (data.chat_duration) this._chatDuration = data.chat_duration; + if (data.typing_test) this._typingTest = data.typing_test; return true; } return false; @@ -133,7 +142,8 @@ export default class Study { parseToLocalDate(json.start_date), parseToLocalDate(json.end_date), json.chat_duration, - [] + [], + json.typing_test ); study._users = User.parseAll(json.users); diff --git a/frontend/src/lib/types/survey.ts b/frontend/src/lib/types/survey.ts index b1c28ed5dfc562e66628f9b8a4fc2f0c5819d39d..3408a8824a3acb70e9580577ba5e482c42fcd745 100644 --- a/frontend/src/lib/types/survey.ts +++ b/frontend/src/lib/types/survey.ts @@ -24,6 +24,10 @@ export default class Survey { return this._groups; } + get nQuestions(): number { + return this._groups.reduce((acc, group) => acc + group.questions.length, 0); + } + static parse(data: any): Survey | null { if (data === null) { toastAlert('Failed to parse survey data'); diff --git a/frontend/src/routes/admin/studies/+page.svelte b/frontend/src/routes/admin/studies/+page.svelte index 56920d9dfb3c47abf0c39f1b43d2c58585cf3e35..d56aeebafc6a0a4f66ad75e6e9d5dc5045f16873 100644 --- a/frontend/src/routes/admin/studies/+page.svelte +++ b/frontend/src/routes/admin/studies/+page.svelte @@ -9,6 +9,8 @@ import { Icon, MagnifyingGlass } from 'svelte-hero-icons'; import { getUserByEmailAPI } from '$lib/api/users'; import type { PageData } from './$types'; + import Draggable from './Draggable.svelte'; + import Survey from '$lib/types/survey'; const { data }: { data: PageData } = $props(); @@ -18,10 +20,14 @@ let description: string | null = $state(null); let startDate: Date | null = $state(null); let endDate: Date | null = $state(null); - let chatDuration: number | null = $state(null); - let typingTest: boolean = $state(true); + let chatDuration: number = $state(30); + let typingTest: boolean = $state(false); + let tests: (string | Survey)[] = $state([]); - let studyCreate: boolean = $state(false); + let studyCreate: boolean = $state(true); + + let possibleTests = ['Typing Test', ...data.surveys]; + let selectedTest: string | Survey | undefined = $state(); function selectStudy(study: Study | null) { selectedStudy = study; @@ -30,7 +36,8 @@ description = study?.description ?? null; startDate = study?.startDate ?? null; endDate = study?.endDate ?? null; - chatDuration = study?.chatDuration ?? null; + chatDuration = study?.chatDuration ?? 30; + typingTest = study?.typingTest ?? false; } async function studyUpdate() { @@ -51,7 +58,8 @@ endDate.getDay() === selectedStudy.endDate.getDay() && endDate.getMonth() === selectedStudy.endDate.getMonth() && endDate.getFullYear() === selectedStudy.endDate.getFullYear() && - chatDuration === selectedStudy.chatDuration) + chatDuration === selectedStudy.chatDuration && + typingTest === selectedStudy.typingTest) ) { selectStudy(null); toastSuccess($t('studies.noChanges')); @@ -63,7 +71,8 @@ description, start_date: formatToUTCDate(startDate), end_date: formatToUTCDate(endDate), - chatDuration + chat_duration: chatDuration, + typing_test: typingTest }); if (result) { @@ -88,7 +97,14 @@ return; } - const study = await Study.create(title, description, startDate, endDate, chatDuration); + const study = await Study.create( + title, + description, + startDate, + endDate, + chatDuration, + typingTest + ); if (study) { toastSuccess($t('studies.created')); @@ -212,6 +228,15 @@ bind:value={chatDuration} min="0" /> + <div class="flex items-center mt-2"> + <label class="label flex-grow" for="typingTest">{$t('studies.typingTest')} *</label> + <input + type="checkbox" + class="checkbox checkbox-primary size-8" + id="typingTest" + bind:checked={typingTest} + /> + </div> <label class="label" for="users">{$t('utils.words.users')}</label> <table class="table"> <thead> @@ -279,18 +304,49 @@ bind:value={chatDuration} min="0" /> - <label class="label" for="typingTest">{$t('studies.typingTest')} *</label> - <input type="checkbox" class="input" id="typingTest" bind:checked={typingTest} /> + <!-- + <div class="flex items-center mt-2"> + <label class="label flex-grow" for="typingTest">{$t('studies.typingTest')} *</label> + <input + type="checkbox" + class="checkbox checkbox-primary size-8" + id="typingTest" + bind:checked={typingTest} + /> + </div> + --> + <h3 class="my-2">{$t('Tests')} *</h3> + <Draggable bind:items={tests} /> + <div class="mt-2 flex"> + <select class="select select-bordered flex-grow" bind:value={selectedTest}> + {#each possibleTests as test} + {#if test instanceof Survey} + <option value={test}>{test.title}</option> + {:else} + <option value={test}>{test}</option> + {/if} + {/each} + </select> + <button + class="ml-2 button" + onclick={() => { + if (selectedTest === undefined) return; + tests = [...tests, selectedTest]; + }} + > + + + </button> + </div> + <div class="mt-4"> + <button class="button" onclick={createStudy}>{$t('button.create')}</button> + <button + class="btn btn-outline float-end ml-2" + onclick={() => (studyCreate = false && selectStudy(null))} + > + {$t('button.cancel')} + </button> + </div> </form> - <div class="mt-4"> - <button class="button" onclick={createStudy}>{$t('button.create')}</button> - <button - class="btn btn-outline float-end ml-2" - onclick={() => (studyCreate = false && selectStudy(null))} - > - {$t('button.cancel')} - </button> - </div> </div> </dialog> diff --git a/frontend/src/routes/admin/studies/+page.ts b/frontend/src/routes/admin/studies/+page.ts index f4043711bee65e230b7b3fee7fa244006d43629d..8760658e0353ac72f6e653a65a6f32396d4aa889 100644 --- a/frontend/src/routes/admin/studies/+page.ts +++ b/frontend/src/routes/admin/studies/+page.ts @@ -1,11 +1,15 @@ import { getStudiesAPI } from '$lib/api/studies'; +import { getSurveysAPI } from '$lib/api/survey'; import Study from '$lib/types/study'; +import Survey from '$lib/types/survey'; import { type Load } from '@sveltejs/kit'; export const load: Load = async ({ fetch }) => { const studies = Study.parseAll(await getStudiesAPI(fetch)); + const surveys = Survey.parseAll(await getSurveysAPI(fetch)); return { - studies + studies, + surveys }; }; diff --git a/frontend/src/routes/admin/studies/Draggable.svelte b/frontend/src/routes/admin/studies/Draggable.svelte new file mode 100644 index 0000000000000000000000000000000000000000..464c4c746b2fea25b5135f1c7b7611751c686480 --- /dev/null +++ b/frontend/src/routes/admin/studies/Draggable.svelte @@ -0,0 +1,102 @@ +<script lang="ts"> + import { t } from '$lib/services/i18n'; + import Survey from '$lib/types/survey'; + + let { items = $bindable([]) } = $props(); + + let draggedIndex: number | null = $state(null); + let overIndex: number | null = $state(null); + + const handleDragStart = (index: number) => { + draggedIndex = index; + }; + + const handleDragOver = (index: number, event: DragEvent) => { + event.preventDefault(); + overIndex = index; + }; + + const handleDrop = (index: number) => { + if (draggedIndex !== null && draggedIndex !== index) { + const reordered = [...items]; + const [removed] = reordered.splice(draggedIndex, 1); + reordered.splice(index, 0, removed); + items = reordered; + } + draggedIndex = null; + overIndex = null; + }; + + const handleDragEnd = () => { + draggedIndex = null; + overIndex = null; + }; + + const deleteItem = (index: number) => { + items = items.filter((_, i) => i !== index); + }; +</script> + +<ul class="space-y-2"> + {#each items as item, index} + <li + class="p-3 bg-gray-200 border rounded-md select-none + transition-transform ease-out duration-200 flex + {index === draggedIndex ? 'opacity-50 bg-gray-300' : ''} + {index === overIndex ? 'border-dashed border-2 border-blue-500' : ''}" + > + <div class="flex-grow"> + {#if item instanceof Survey} + {item.title} ({item.groups.length} + {$t('utils.words.groups')}, {item.nQuestions} + {$t('utils.words.questions')}) + {:else} + {item} + {/if} + </div> + <div + class="ml-4 flex flex-col gap-1 cursor-grab" + draggable="true" + ondragstart={() => handleDragStart(index)} + ondragover={(e) => handleDragOver(index, e)} + ondrop={() => handleDrop(index)} + ondragend={handleDragEnd} + role="button" + tabindex="0" + > + <div class="flex gap-1"> + <span class="w-2 h-2 bg-gray-400 rounded-full"></span> + <span class="w-2 h-2 bg-gray-400 rounded-full"></span> + </div> + <div class="flex gap-1"> + <span class="w-2 h-2 bg-gray-400 rounded-full"></span> + <span class="w-2 h-2 bg-gray-400 rounded-full"></span> + </div> + <div class="flex gap-1"> + <span class="w-2 h-2 bg-gray-400 rounded-full"></span> + <span class="w-2 h-2 bg-gray-400 rounded-full"></span> + </div> + </div> + <button + class="ml-4 p-2 bg-red-500 text-white rounded-md hover:bg-red-600" + onclick={() => deleteItem(index)} + aria-label="Delete" + > + <svg + xmlns="http://www.w3.org/2000/svg" + class="h-4 w-4" + fill="none" + viewBox="0 0 24 24" + stroke="currentColor" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="M6 18L18 6M6 6l12 12" + /> + </svg> + </button> + </li> + {/each} +</ul>