From ae4081e4f3fe8144f59909cbce645358d1a2cde3 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 20 Dec 2024 13:23:20 +0100 Subject: [PATCH 01/89] feat: integrate Django REST framework and add Experiment API endpoints --- backend/aml/base_settings.py | 1 + backend/aml/urls.py | 24 +++++++++---------- backend/experiment/serializers.py | 7 ++++++ backend/experiment/urls.py | 39 ++++++++++++++++++++----------- backend/experiment/views.py | 18 ++++++++------ backend/requirements.in/base.txt | 3 +++ backend/requirements/dev.txt | 3 +++ backend/requirements/prod.txt | 3 +++ 8 files changed, 65 insertions(+), 33 deletions(-) diff --git a/backend/aml/base_settings.py b/backend/aml/base_settings.py index 2a3014782..ded710df4 100644 --- a/backend/aml/base_settings.py +++ b/backend/aml/base_settings.py @@ -41,6 +41,7 @@ # Application definition INSTALLED_APPS = [ + "rest_framework", "admin_interface", "modeltranslation", # Must be before django.contrib.admin "django.contrib.admin", diff --git a/backend/aml/urls.py b/backend/aml/urls.py index 3ab41b565..5e3da33a5 100644 --- a/backend/aml/urls.py +++ b/backend/aml/urls.py @@ -13,6 +13,7 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import include, path from django.conf import settings @@ -26,19 +27,17 @@ # Urls patterns urlpatterns = [ - path('experiment/', include('experiment.urls')), - path('question/', include('question.urls')), - path('participant/', include('participant.urls')), - path('result/', include('result.urls')), - path('section/', include('section.urls')), - path('session/', include('session.urls')), - path('theme/', include('theme.urls')), - path('admin_interface/', include('admin_interface.urls')), - path('admin/', admin.site.urls), - + path("experiment/", include("experiment.urls")), + path("question/", include("question.urls")), + path("participant/", include("participant.urls")), + path("result/", include("result.urls")), + path("section/", include("section.urls")), + path("session/", include("session.urls")), + path("theme/", include("theme.urls")), + path("admin_interface/", include("admin_interface.urls")), + path("admin/", admin.site.urls), # Sentry debug (uncomment to test Sentry) # path('sentry-debug/', lambda request: 1 / 0), - ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) # ^ The static helper function only works in debug mode # (https://docs.djangoproject.com/en/3.0/howto/static-files/) @@ -46,11 +45,12 @@ # Prefix all URLS with /server if AML_SUBPATH is set if settings.SUBPATH: - urlpatterns = [path('server/', include(urlpatterns))] + urlpatterns = [path("server/", include(urlpatterns))] # Debug toolbar if settings.DEBUG and not settings.TESTING: import debug_toolbar + urlpatterns += [ path("__debug__/", include(debug_toolbar.urls)), ] diff --git a/backend/experiment/serializers.py b/backend/experiment/serializers.py index c81837ade..320c38cb9 100644 --- a/backend/experiment/serializers.py +++ b/backend/experiment/serializers.py @@ -3,6 +3,7 @@ from django_markup.markup import formatter from django.utils.translation import activate, get_language +from rest_framework import serializers from experiment.actions.consent import Consent from image.serializers import serialize_image @@ -13,6 +14,12 @@ from .models import Block, Experiment, Phase, SocialMediaConfig +class ExperimentSerializer(serializers.ModelSerializer): + class Meta: + model = Experiment + fields = ["id", "slug", "active"] + + def serialize_actions(actions): """Serialize an array of actions""" if isinstance(actions, list): diff --git a/backend/experiment/urls.py b/backend/experiment/urls.py index 60bbd92ee..2c744aa4c 100644 --- a/backend/experiment/urls.py +++ b/backend/experiment/urls.py @@ -1,23 +1,34 @@ -from django.urls import path +from django.urls import path, include +from rest_framework import routers from django.views.generic.base import TemplateView -from .views import get_block, get_experiment, post_feedback, render_markdown, add_default_question_series, validate_block_playlist +from .views import ( + ExperimentViewSet, + get_block, + get_experiment, + post_feedback, + render_markdown, + add_default_question_series, + validate_block_playlist, +) -app_name = 'experiment' +app_name = "experiment" + +router = routers.DefaultRouter() +router.register(r"experiments", ExperimentViewSet, basename="experiment") urlpatterns = [ - path('add_default_question_series//', add_default_question_series, name='add_default_question_series'), + # Experiment API + path("api/", include(router.urls)), + path("add_default_question_series//", add_default_question_series, name="add_default_question_series"), # Experiment - path('render_markdown/', render_markdown, name='render_markdown'), - path('validate_playlist/', validate_block_playlist, name='validate_block_playlist'), - path('block//', get_block, name='block'), - path('block//feedback/', post_feedback, name='feedback'), - path('/', get_experiment, - name='experiment'), - + path("render_markdown/", render_markdown, name="render_markdown"), + path("validate_playlist/", validate_block_playlist, name="validate_block_playlist"), + path("block//", get_block, name="block"), + path("block//feedback/", post_feedback, name="feedback"), + path("/", get_experiment, name="experiment"), # Robots.txt path( "robots.txt", - TemplateView.as_view(template_name="robots.txt", - content_type="text/plain"), - ) + TemplateView.as_view(template_name="robots.txt", content_type="text/plain"), + ), ] diff --git a/backend/experiment/views.py b/backend/experiment/views.py index 31e0bf4c3..c560a3231 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -4,6 +4,7 @@ from django.http import Http404, HttpRequest, JsonResponse from django.utils.translation import gettext_lazy as _, get_language from django_markup.markup import formatter +from rest_framework import viewsets, permissions from .models import Block, Experiment, Feedback, Session from section.models import Playlist @@ -14,6 +15,8 @@ ) from experiment.rules import BLOCK_RULES from experiment.actions.utils import EXPERIMENT_KEY +from experiment.serializers import ExperimentSerializer + from participant.models import Participant from participant.utils import get_participant from theme.serializers import serialize_theme @@ -21,6 +24,12 @@ logger = logging.getLogger(__name__) +class ExperimentViewSet(viewsets.ModelViewSet): + queryset = Experiment.objects.all() + serializer_class = ExperimentSerializer + permission_classes = [permissions.AllowAny] + + def get_block(request: HttpRequest, slug: str) -> JsonResponse: """Get block data from active block with given :slug DO NOT modify session data here, it will break participant_id system @@ -48,10 +57,7 @@ def get_block(request: HttpRequest, slug: str) -> JsonResponse: "class_name": class_name, # can be used to override style "rounds": block.rounds, "bonus_points": block.bonus_points, - "playlists": [ - {"id": playlist.id, "name": playlist.name} - for playlist in block.playlists.all() - ], + "playlists": [{"id": playlist.id, "name": playlist.name} for playlist in block.playlists.all()], "feedback_info": block.get_rules().feedback_info(), "loading_text": _("Loading"), "session_id": session.id, @@ -138,9 +144,7 @@ def _get_min_session_count(experiment: Experiment, participant: Participant) -> for phase in phases: session_counts.extend( [ - Session.objects.exclude(finished_at__isnull=True) - .filter(block=block, participant=participant) - .count() + Session.objects.exclude(finished_at__isnull=True).filter(block=block, participant=participant).count() for block in phase.blocks.all() ] ) diff --git a/backend/requirements.in/base.txt b/backend/requirements.in/base.txt index 8e201c92b..c4d5d09c6 100644 --- a/backend/requirements.in/base.txt +++ b/backend/requirements.in/base.txt @@ -47,3 +47,6 @@ django-modeltranslation # Django multiselect with search django-select2 + +# Django REST framework +djangorestframework diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt index abef4daa0..67152ae23 100644 --- a/backend/requirements/dev.txt +++ b/backend/requirements/dev.txt @@ -48,6 +48,7 @@ django==4.2.17 # django-markup # django-modeltranslation # django-select2 + # djangorestframework django-appconf==1.0.6 # via django-select2 django-cors-headers==3.10.0 @@ -64,6 +65,8 @@ django-nested-admin==4.1.1 # via -r requirements.in/base.txt django-select2==8.2.1 # via -r requirements.in/base.txt +djangorestframework==3.15.2 + # via -r requirements.in/base.txt docutils==0.20.1 # via # django-markup diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt index ed303bde5..df1cc3679 100644 --- a/backend/requirements/prod.txt +++ b/backend/requirements/prod.txt @@ -40,6 +40,7 @@ django==4.2.17 # django-markup # django-modeltranslation # django-select2 + # djangorestframework django-appconf==1.0.6 # via django-select2 django-cors-headers==4.0.0 @@ -54,6 +55,8 @@ django-nested-admin==4.1.1 # via -r requirements.in/base.txt django-select2==8.2.1 # via -r requirements.in/base.txt +djangorestframework==3.15.2 + # via -r requirements.in/base.txt docutils==0.20.1 # via # django-markup From 2427ea77a9124e2f074021cbbd7d10f3303916c9 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 20 Dec 2024 13:23:27 +0100 Subject: [PATCH 02/89] feat: add initial React + TypeScript + Vite setup with Tailwind CSS and ESLint configuration --- .../form/experiment-form/.eslintrc.cjs | 18 +++ .../templates/form/experiment-form/.gitignore | 24 ++++ .../templates/form/experiment-form/README.md | 30 +++++ .../templates/form/experiment-form/bun.lockb | Bin 0 -> 107939 bytes .../templates/form/experiment-form/index.html | 13 ++ .../form/experiment-form/package.json | 31 +++++ .../form/experiment-form/postcss.config.js | 6 + .../form/experiment-form/public/vite.svg | 1 + .../form/experiment-form/src/App.css | 42 ++++++ .../form/experiment-form/src/App.tsx | 39 ++++++ .../form/experiment-form/src/assets/react.svg | 1 + .../src/components/ExperimentForm.tsx | 122 ++++++++++++++++++ .../form/experiment-form/src/index.css | 3 + .../form/experiment-form/src/main.tsx | 10 ++ .../form/experiment-form/src/vite-env.d.ts | 1 + .../form/experiment-form/tailwind.config.js | 11 ++ .../form/experiment-form/tsconfig.json | 25 ++++ .../form/experiment-form/tsconfig.node.json | 11 ++ .../form/experiment-form/vite.config.ts | 7 + 19 files changed, 395 insertions(+) create mode 100644 backend/experiment/templates/form/experiment-form/.eslintrc.cjs create mode 100644 backend/experiment/templates/form/experiment-form/.gitignore create mode 100644 backend/experiment/templates/form/experiment-form/README.md create mode 100755 backend/experiment/templates/form/experiment-form/bun.lockb create mode 100644 backend/experiment/templates/form/experiment-form/index.html create mode 100644 backend/experiment/templates/form/experiment-form/package.json create mode 100644 backend/experiment/templates/form/experiment-form/postcss.config.js create mode 100644 backend/experiment/templates/form/experiment-form/public/vite.svg create mode 100644 backend/experiment/templates/form/experiment-form/src/App.css create mode 100644 backend/experiment/templates/form/experiment-form/src/App.tsx create mode 100644 backend/experiment/templates/form/experiment-form/src/assets/react.svg create mode 100644 backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx create mode 100644 backend/experiment/templates/form/experiment-form/src/index.css create mode 100644 backend/experiment/templates/form/experiment-form/src/main.tsx create mode 100644 backend/experiment/templates/form/experiment-form/src/vite-env.d.ts create mode 100644 backend/experiment/templates/form/experiment-form/tailwind.config.js create mode 100644 backend/experiment/templates/form/experiment-form/tsconfig.json create mode 100644 backend/experiment/templates/form/experiment-form/tsconfig.node.json create mode 100644 backend/experiment/templates/form/experiment-form/vite.config.ts diff --git a/backend/experiment/templates/form/experiment-form/.eslintrc.cjs b/backend/experiment/templates/form/experiment-form/.eslintrc.cjs new file mode 100644 index 000000000..d6c953795 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/backend/experiment/templates/form/experiment-form/.gitignore b/backend/experiment/templates/form/experiment-form/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/backend/experiment/templates/form/experiment-form/README.md b/backend/experiment/templates/form/experiment-form/README.md new file mode 100644 index 000000000..0d6babedd --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +} +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/backend/experiment/templates/form/experiment-form/bun.lockb b/backend/experiment/templates/form/experiment-form/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..929250d3905977bdb8d9257a3fbf50e57db6cb0d GIT binary patch literal 107939 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 KQa + + + + + + Vite + React + TS + + +
+ + + diff --git a/backend/experiment/templates/form/experiment-form/package.json b/backend/experiment/templates/form/experiment-form/package.json new file mode 100644 index 000000000..aaefeedb3 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/package.json @@ -0,0 +1,31 @@ +{ + "name": "experiment-form", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.2.2", + "vite": "^5.2.0" + } +} diff --git a/backend/experiment/templates/form/experiment-form/postcss.config.js b/backend/experiment/templates/form/experiment-form/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/backend/experiment/templates/form/experiment-form/public/vite.svg b/backend/experiment/templates/form/experiment-form/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/experiment/templates/form/experiment-form/src/App.css b/backend/experiment/templates/form/experiment-form/src/App.css new file mode 100644 index 000000000..b9d355df2 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/backend/experiment/templates/form/experiment-form/src/App.tsx b/backend/experiment/templates/form/experiment-form/src/App.tsx new file mode 100644 index 000000000..c69cf55d0 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/App.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react' +import reactLogo from './assets/react.svg' +import viteLogo from '/vite.svg' +import './App.css' +import ExperimentForm from './components/ExperimentForm' + +function App() { + const [count, setCount] = useState(0) + + return ( + <> + +

Vite + React

+
+ +
+
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

+ Click on the Vite and React logos to learn more +

+ + ) +} + +export default App diff --git a/backend/experiment/templates/form/experiment-form/src/assets/react.svg b/backend/experiment/templates/form/experiment-form/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx new file mode 100644 index 000000000..3df0c9380 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from 'react'; + +interface Experiment { + id?: number; + slug: string; + active: boolean; +} + +interface ExperimentFormProps { + experimentId?: number; // If provided, we edit an existing experiment, otherwise we create a new one +} + +const ExperimentForm: React.FC = ({ experimentId }) => { + const [experiment, setExperiment] = useState({ slug: '', active: true }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (experimentId) { + setLoading(true); + fetch(`/api/experiments/${experimentId}/`) + .then(res => res.json()) + .then(data => { + setExperiment(data); + setLoading(false); + }) + .catch(err => { + setError('Failed to load experiment'); + setLoading(false); + }); + } + }, [experimentId]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, type, checked, value } = e.target; + setExperiment(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value + })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + setSuccess(false); + + const method = experimentId ? 'PUT' : 'POST'; + const url = experimentId ? `/api/experiments/${experimentId}/` : '/api/experiments/'; + + fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(experiment), + }) + .then(res => { + if (!res.ok) { + return res.json().then(errData => { + throw new Error(JSON.stringify(errData)); + }); + } + return res.json(); + }) + .then(data => { + setSuccess(true); + setLoading(false); + setExperiment(data); // update state with returned data + }) + .catch(err => { + console.error(err); + setError('Failed to save experiment.'); + setLoading(false); + }); + }; + + if (loading && !success && !error && experimentId) { + return
Loading...
; + } + + return ( +
+

{experimentId ? 'Edit Experiment' : 'Create Experiment'}

+ + {error &&
{error}
} + {success &&
Saved successfully!
} + + + + + + +
+ ); +}; + +export default ExperimentForm; diff --git a/backend/experiment/templates/form/experiment-form/src/index.css b/backend/experiment/templates/form/experiment-form/src/index.css new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/backend/experiment/templates/form/experiment-form/src/main.tsx b/backend/experiment/templates/form/experiment-form/src/main.tsx new file mode 100644 index 000000000..3d7150da8 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/backend/experiment/templates/form/experiment-form/src/vite-env.d.ts b/backend/experiment/templates/form/experiment-form/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/backend/experiment/templates/form/experiment-form/tailwind.config.js b/backend/experiment/templates/form/experiment-form/tailwind.config.js new file mode 100644 index 000000000..dca8ba02d --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/backend/experiment/templates/form/experiment-form/tsconfig.json b/backend/experiment/templates/form/experiment-form/tsconfig.json new file mode 100644 index 000000000..a7fc6fbf2 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/backend/experiment/templates/form/experiment-form/tsconfig.node.json b/backend/experiment/templates/form/experiment-form/tsconfig.node.json new file mode 100644 index 000000000..97ede7ee6 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/backend/experiment/templates/form/experiment-form/vite.config.ts b/backend/experiment/templates/form/experiment-form/vite.config.ts new file mode 100644 index 000000000..861b04b35 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) From c1e45113ce906ad80fe32bef93217e0b8a0cd2da Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 20 Dec 2024 13:30:43 +0100 Subject: [PATCH 03/89] feat: add react-router-dom for routing and update ExperimentForm integration --- .../templates/form/experiment-form/bun.lockb | Bin 107939 -> 110226 bytes .../form/experiment-form/package.json | 3 +- .../form/experiment-form/src/App.tsx | 44 +++++++----------- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/backend/experiment/templates/form/experiment-form/bun.lockb b/backend/experiment/templates/form/experiment-form/bun.lockb index 929250d3905977bdb8d9257a3fbf50e57db6cb0d..23d4aa69a30256e10c8bf94409f152c574472b12 100755 GIT binary patch delta 20590 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%0AIn< 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 diff --git a/backend/experiment/templates/form/experiment-form/package.json b/backend/experiment/templates/form/experiment-form/package.json index aaefeedb3..4f6f82096 100644 --- a/backend/experiment/templates/form/experiment-form/package.json +++ b/backend/experiment/templates/form/experiment-form/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^7.0.2" }, "devDependencies": { "@types/react": "^18.2.66", diff --git a/backend/experiment/templates/form/experiment-form/src/App.tsx b/backend/experiment/templates/form/experiment-form/src/App.tsx index c69cf55d0..ac2e01da0 100644 --- a/backend/experiment/templates/form/experiment-form/src/App.tsx +++ b/backend/experiment/templates/form/experiment-form/src/App.tsx @@ -1,37 +1,29 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' import './App.css' import ExperimentForm from './components/ExperimentForm' +import { + BrowserRouter as Router, + Route, + Routes, +} from "react-router-dom"; function App() { - const [count, setCount] = useState(0) - return ( <> +

+ MUSCLE forms +

+
-

Vite + React

-
- + + + {/* Request reload for given participant */} + } /> + +
-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

+ ) } From ecb82fd333ffdfd9241898f5d902795721fdede4 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 20 Dec 2024 16:10:41 +0100 Subject: [PATCH 04/89] feat: implement experiments overview and fetch hook, update routing for experiment forms --- .../form/experiment-form/src/App.css | 42 ------- .../form/experiment-form/src/App.tsx | 13 ++- .../src/components/ExperimentForm.tsx | 91 ++++++++------- .../src/components/ExperimentsOverview.tsx | 105 ++++++++++++++++++ .../form/experiment-form/src/config.ts | 5 + .../experiment-form/src/hooks/useFetch.ts | 51 +++++++++ 6 files changed, 218 insertions(+), 89 deletions(-) create mode 100644 backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx create mode 100644 backend/experiment/templates/form/experiment-form/src/config.ts create mode 100644 backend/experiment/templates/form/experiment-form/src/hooks/useFetch.ts diff --git a/backend/experiment/templates/form/experiment-form/src/App.css b/backend/experiment/templates/form/experiment-form/src/App.css index b9d355df2..e69de29bb 100644 --- a/backend/experiment/templates/form/experiment-form/src/App.css +++ b/backend/experiment/templates/form/experiment-form/src/App.css @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/backend/experiment/templates/form/experiment-form/src/App.tsx b/backend/experiment/templates/form/experiment-form/src/App.tsx index ac2e01da0..54f2e611a 100644 --- a/backend/experiment/templates/form/experiment-form/src/App.tsx +++ b/backend/experiment/templates/form/experiment-form/src/App.tsx @@ -1,30 +1,31 @@ import './App.css' -import ExperimentForm from './components/ExperimentForm' import { BrowserRouter as Router, Route, Routes, } from "react-router-dom"; +import ExperimentsOverview from './components/ExperimentsOverview'; +import ExperimentForm from './components/ExperimentForm'; function App() { return ( - <> +

MUSCLE forms

- {/* Request reload for given participant */} - } /> + } /> + } />
- +
) } diff --git a/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx index 3df0c9380..c68c0195a 100644 --- a/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx +++ b/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx @@ -1,4 +1,6 @@ import React, { useEffect, useState } from 'react'; +import { createEntityUrl } from '../config'; +import { useParams } from 'react-router-dom'; interface Experiment { id?: number; @@ -7,19 +9,22 @@ interface Experiment { } interface ExperimentFormProps { - experimentId?: number; // If provided, we edit an existing experiment, otherwise we create a new one } -const ExperimentForm: React.FC = ({ experimentId }) => { + +const ExperimentForm: React.FC = () => { const [experiment, setExperiment] = useState({ slug: '', active: true }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); + + const { id: experimentId } = useParams<{ id: string }>(); + const url = createEntityUrl('experiments', experimentId); useEffect(() => { if (experimentId) { setLoading(true); - fetch(`/api/experiments/${experimentId}/`) + fetch(url) .then(res => res.json()) .then(data => { setExperiment(data); @@ -47,7 +52,6 @@ const ExperimentForm: React.FC = ({ experimentId }) => { setSuccess(false); const method = experimentId ? 'PUT' : 'POST'; - const url = experimentId ? `/api/experiments/${experimentId}/` : '/api/experiments/'; fetch(url, { method, @@ -79,43 +83,48 @@ const ExperimentForm: React.FC = ({ experimentId }) => { } return ( -
-

{experimentId ? 'Edit Experiment' : 'Create Experiment'}

- - {error &&
{error}
} - {success &&
Saved successfully!
} - - - - - - -
+ <> +
+ + < Back to Experiments + +

{experimentId ? 'Edit Experiment' : 'Create Experiment'}

+ + {error &&
{error}
} + {success &&
Saved successfully!
} + + + + + + +
+ ); }; diff --git a/backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx b/backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx new file mode 100644 index 000000000..7f7a1d446 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx @@ -0,0 +1,105 @@ +import { createEntityUrl } from "../config"; +import useFetch from "../hooks/useFetch"; +import { Link } from "react-router-dom"; + +type Experiment = { + id?: number; + slug: string; + active: boolean; +} + +const url = createEntityUrl('experiments'); + +const ExperimentsOverview = () => { + const [experiments, error, loading, fetchData] = useFetch(url); + const handleDelete = async (id: number) => { + if (window.confirm('Are you sure you want to delete this experiment?')) { + try { + await fetch(`${url}/${id}/`, { method: 'DELETE' }); + // Refresh the page or update the list + fetchData(); + } catch (error) { + console.error('Error deleting experiment:', error); + } + } + }; + + if (error) return
Error: {error}
; + const loadingClass = loading ? 'opacity-50 pointer-events-none' : ''; + + async function onCreateExperiment() { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ slug: 'new-experiment', active: false }), + }); + if (response.ok) { + fetchData(); + // navigate to the new experiment + const id = (await response.json()).id; + window.location.href = `/experiments/${id}`; + } else { + console.error('Failed to create experiment:', response); + } + } + + + return ( +
+
+

Experiments

+ +
+ +
+ + + + + + + + + + + {experiments?.map((experiment) => ( + + + + + + + ))} + +
IDSlugStatusActions
{experiment.id}{experiment.slug} + + {experiment.active ? 'Active' : 'Inactive'} + + + + Edit + + +
+
+
+ ); +}; + +export default ExperimentsOverview; diff --git a/backend/experiment/templates/form/experiment-form/src/config.ts b/backend/experiment/templates/form/experiment-form/src/config.ts new file mode 100644 index 000000000..f5a4432d3 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/config.ts @@ -0,0 +1,5 @@ +export const BASE_URL = 'http://localhost:8000/experiment/api'; + +export const createEntityUrl = (entity: string, id?: string) => { + return id ? `${BASE_URL}/${entity}/${id}/` : `${BASE_URL}/${entity}/`; +} diff --git a/backend/experiment/templates/form/experiment-form/src/hooks/useFetch.ts b/backend/experiment/templates/form/experiment-form/src/hooks/useFetch.ts new file mode 100644 index 000000000..b07374cbe --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/hooks/useFetch.ts @@ -0,0 +1,51 @@ +import { useState, useEffect, useCallback } from "react"; + +// useFetch is a react hook for getting data from a given url +export const useFetch = (url: string, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET', body: any = null, initialFetch = true): [T | null, string | null, boolean, () => void] => { + + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + setData(null); + setError(null); + setLoading(true); + try { + + let options: RequestInit = { + method + } + + if (body) { + options = { + ...options, + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' } + } + } + + const response = await fetch(url, options); + const jsonData = await response.json(); + setData(jsonData); + } catch (err) { + setData(null); + setError(err.toString()); + + } finally { + setLoading(false); + } + }, [url, method, body]); + + useEffect(() => { + const abortController = new AbortController(); + fetchData(); + return () => { + abortController.abort(); + }; + }, [url, fetchData]); + + return [data, error, loading, fetchData]; +}; + +export default useFetch; From c4a7dcedabaa3eed8b3b402dbf85b2e505e27405 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 20 Dec 2024 16:12:55 +0100 Subject: [PATCH 05/89] feat: add collapsible sidebar navigation to Experiment forms --- .../form/experiment-form/src/App.tsx | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/backend/experiment/templates/form/experiment-form/src/App.tsx b/backend/experiment/templates/form/experiment-form/src/App.tsx index 54f2e611a..14e8bd442 100644 --- a/backend/experiment/templates/form/experiment-form/src/App.tsx +++ b/backend/experiment/templates/form/experiment-form/src/App.tsx @@ -6,26 +6,38 @@ import { } from "react-router-dom"; import ExperimentsOverview from './components/ExperimentsOverview'; import ExperimentForm from './components/ExperimentForm'; +import { useState } from 'react'; function App() { + const [isCollapsed, setIsCollapsed] = useState(false); + return ( -
-

- MUSCLE forms -

- -
- + +
+ +
+

+ MUSCLE forms +

} /> } /> - +
- -
+ ) } From fcf0f134103d3a921190b3c18ba61a1ff1f98ac2 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 20 Dec 2024 16:53:24 +0100 Subject: [PATCH 06/89] feat: refactor ExperimentForm and ExperimentsOverview to use a new Page component for consistent layout --- .../src/components/ExperimentForm.tsx | 16 +-- .../src/components/ExperimentsOverview.tsx | 100 +++++++++--------- .../experiment-form/src/components/Page.tsx | 30 ++++++ 3 files changed, 89 insertions(+), 57 deletions(-) create mode 100644 backend/experiment/templates/form/experiment-form/src/components/Page.tsx diff --git a/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx index c68c0195a..0c2e200c9 100644 --- a/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx +++ b/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { createEntityUrl } from '../config'; import { useParams } from 'react-router-dom'; +import Page from './Page'; interface Experiment { id?: number; @@ -83,13 +84,12 @@ const ExperimentForm: React.FC = () => { } return ( - <> -
- - < Back to Experiments - -

{experimentId ? 'Edit Experiment' : 'Create Experiment'}

- + + {error &&
{error}
} {success &&
Saved successfully!
} @@ -124,7 +124,7 @@ const ExperimentForm: React.FC = () => { {loading ? 'Saving...' : (experimentId ? 'Update Experiment' : 'Create Experiment')} - +
); }; diff --git a/backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx b/backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx index 7f7a1d446..5db7b2273 100644 --- a/backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx +++ b/backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx @@ -1,6 +1,7 @@ import { createEntityUrl } from "../config"; import useFetch from "../hooks/useFetch"; import { Link } from "react-router-dom"; +import Page from "./Page"; type Experiment = { id?: number; @@ -47,58 +48,59 @@ const ExperimentsOverview = () => { return ( -
-
-

Experiments

- -
+ +
+
+ +
-
- - - - - - - - - - - {experiments?.map((experiment) => ( - - - - - +
+
IDSlugStatusActions
{experiment.id}{experiment.slug} - - {experiment.active ? 'Active' : 'Inactive'} - - - - Edit - - -
+ + + + + + - ))} - -
IDSlugStatusActions
+ + + {experiments?.map((experiment) => ( + + {experiment.id} + {experiment.slug} + + + {experiment.active ? 'Active' : 'Inactive'} + + + + + Edit + + + + + ))} + + +
-
+ ); }; diff --git a/backend/experiment/templates/form/experiment-form/src/components/Page.tsx b/backend/experiment/templates/form/experiment-form/src/components/Page.tsx new file mode 100644 index 000000000..262fc39a9 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/Page.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; + +interface PageProps { + title: string; + children: ReactNode; + backTo?: string; + backText?: string; +} + +const Page = ({ title, children, backTo, backText = 'Back' }: PageProps) => { + return ( +
+
+ {backTo && ( + + < {backText} + + )} +

{title}

+
+ {children} +
+ ); +}; + +export default Page; From 3cc25cab6fa22c8d29fa875555d3554cfb8a7126 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 20 Dec 2024 17:12:56 +0100 Subject: [PATCH 07/89] feat: add support for translated content in Experiment model and forms --- backend/experiment/serializers.py | 37 +++- .../src/components/ExperimentForm.tsx | 15 +- .../experiment-form/src/components/Tabs.tsx | 36 ++++ .../src/components/TranslatedContentForm.tsx | 170 ++++++++++++++++++ .../form/experiment-form/src/constants.ts | 6 + 5 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 backend/experiment/templates/form/experiment-form/src/components/Tabs.tsx create mode 100644 backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForm.tsx create mode 100644 backend/experiment/templates/form/experiment-form/src/constants.ts diff --git a/backend/experiment/serializers.py b/backend/experiment/serializers.py index 320c38cb9..58942c680 100644 --- a/backend/experiment/serializers.py +++ b/backend/experiment/serializers.py @@ -11,13 +11,46 @@ from result.models import Result from session.models import Session from theme.serializers import serialize_theme -from .models import Block, Experiment, Phase, SocialMediaConfig +from .models import Block, Experiment, Phase, SocialMediaConfig, ExperimentTranslatedContent + + +class ExperimentTranslatedContentSerializer(serializers.ModelSerializer): + class Meta: + model = ExperimentTranslatedContent + fields = ["id", "index", "language", "name", "description", "about_content", "social_media_message"] class ExperimentSerializer(serializers.ModelSerializer): + translated_content = ExperimentTranslatedContentSerializer(many=True, required=False) + class Meta: model = Experiment - fields = ["id", "slug", "active"] + fields = ["id", "slug", "active", "translated_content"] + + def create(self, validated_data): + translated_content_data = validated_data.pop("translated_content", []) + experiment = Experiment.objects.create(**validated_data) + + for content_data in translated_content_data: + ExperimentTranslatedContent.objects.create(experiment=experiment, **content_data) + + return experiment + + def update(self, instance, validated_data): + translated_content_data = validated_data.pop("translated_content", []) + + # Update experiment fields + instance.slug = validated_data.get("slug", instance.slug) + instance.active = validated_data.get("active", instance.active) + instance.save() + + # Update or create translated content + if translated_content_data: + instance.translated_content.all().delete() # Remove existing translations + for content_data in translated_content_data: + ExperimentTranslatedContent.objects.create(experiment=instance, **content_data) + + return instance def serialize_actions(actions): diff --git a/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx index 0c2e200c9..4fb3bafe4 100644 --- a/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx +++ b/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx @@ -2,11 +2,13 @@ import React, { useEffect, useState } from 'react'; import { createEntityUrl } from '../config'; import { useParams } from 'react-router-dom'; import Page from './Page'; +import { TranslatedContentForm } from './TranslatedContentForm'; interface Experiment { id?: number; slug: string; active: boolean; + translated_content: TranslatedContent[]; } interface ExperimentFormProps { @@ -14,7 +16,11 @@ interface ExperimentFormProps { const ExperimentForm: React.FC = () => { - const [experiment, setExperiment] = useState({ slug: '', active: true }); + const [experiment, setExperiment] = useState({ + slug: '', + active: true, + translated_content: [] + }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); @@ -116,10 +122,15 @@ const ExperimentForm: React.FC = () => { Active + setExperiment(prev => ({ ...prev, translated_content: newContents }))} + /> + diff --git a/backend/experiment/templates/form/experiment-form/src/components/Tabs.tsx b/backend/experiment/templates/form/experiment-form/src/components/Tabs.tsx new file mode 100644 index 000000000..66569de89 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/Tabs.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +interface Tab { + id: string | number; + label: string; +} + +interface TabsProps { + tabs: Tab[]; + activeTab: string | number; + onTabChange: (tabId: string | number) => void; +} + +export const Tabs: React.FC = ({ tabs, activeTab, onTabChange }) => { + return ( +
+ +
+ ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForm.tsx new file mode 100644 index 000000000..233b097ca --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForm.tsx @@ -0,0 +1,170 @@ +import React, { useState } from 'react'; +import { ISO_LANGUAGES } from '../constants'; +import { Tabs } from './Tabs'; + +interface TranslatedContent { + id?: number; + index: number; + language: string; + name: string; + description: string; + about_content: string; + social_media_message: string; +} + +interface TranslatedContentFormProps { + contents: TranslatedContent[]; + onChange: (contents: TranslatedContent[]) => void; +} + +const defaultContent: TranslatedContent = { + index: 0, + language: '', + name: '', + description: '', + about_content: '', + social_media_message: 'I scored {points} points in {experiment_name}!', +}; + +export const TranslatedContentForm: React.FC = ({ contents, onChange }) => { + const [activeTabIndex, setActiveTabIndex] = useState(0); + + const handleAdd = () => { + const newContent = { + ...defaultContent, + index: contents.length, + }; + onChange([...contents, newContent]); + setActiveTabIndex(contents.length); + }; + + const handleRemove = (index: number) => { + onChange(contents.filter((_, i) => i !== index)); + setActiveTabIndex(Math.max(0, activeTabIndex - 1)); + }; + + const handleChange = (index: number, field: keyof TranslatedContent, value: string) => { + const updatedContents = contents.map((content, i) => { + if (i === index) { + return { ...content, [field]: value }; + } + return content; + }); + onChange(updatedContents); + }; + + const getTabLabel = (content: TranslatedContent, index: number) => { + if (content.language && ISO_LANGUAGES[content.language]) { + return ISO_LANGUAGES[content.language]; + } + return `Translation ${index + 1}`; + }; + + return ( +
+
+

Translated Content

+ +
+ + {contents.length > 0 && ( + <> + ({ + id: index, + label: getTabLabel(content, index), + }))} + activeTab={activeTabIndex} + onTabChange={(tabId) => setActiveTabIndex(tabId as number)} + /> + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+