diff --git a/backend/aml/base_settings.py b/backend/aml/base_settings.py index f0087c1dc..5d2d7827d 100644 --- a/backend/aml/base_settings.py +++ b/backend/aml/base_settings.py @@ -15,6 +15,9 @@ from corsheaders.defaults import default_headers import sentry_sdk +# JWT Settings +from datetime import timedelta + # Workaround for deprecated ugettext_lazy in django-inline-actions import django from django.utils.translation import gettext_lazy @@ -41,6 +44,7 @@ # Application definition INSTALLED_APPS = [ + "rest_framework", "admin_interface", "modeltranslation", # Must be before django.contrib.admin "django.contrib.admin", @@ -207,3 +211,13 @@ MARKUP_SETTINGS = {"markdown": {"safe_mode": False}} SUBPATH = os.getenv("AML_SUBPATH", "") + +# REST framework settings +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",), +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), +} diff --git a/backend/aml/urls.py b/backend/aml/urls.py index 3ab41b565..3954a9424 100644 --- a/backend/aml/urls.py +++ b/backend/aml/urls.py @@ -13,11 +13,17 @@ 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 from django.conf.urls.static import static +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, +) + # Admin strings admin.site.site_header = "AML Admin" @@ -26,19 +32,19 @@ # 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), + path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), # 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 +52,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 f2227ca53..7f7e00b19 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 @@ -10,7 +11,189 @@ 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, BlockTranslatedContent +from section.models import Playlist + + +class ExperimentTranslatedContentSerializer(serializers.ModelSerializer): + class Meta: + model = ExperimentTranslatedContent + fields = ["id", "index", "language", "name", "description", "about_content", "social_media_message"] + + +class BlockTranslatedContentSerializer(serializers.ModelSerializer): + class Meta: + model = BlockTranslatedContent + fields = ["language", "name", "description"] + + +class PlaylistSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + + class Meta: + model = Playlist + fields = ["id", "name"] + + +class BlockSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + translated_contents = BlockTranslatedContentSerializer(many=True, required=False, read_only=False) + playlists = PlaylistSerializer(many=True, required=False) + + class Meta: + model = Block + fields = [ + "id", + "index", + "slug", + "rounds", + "bonus_points", + "rules", + "translated_contents", + "playlists", # many to many field + ] + extra_kwargs = { + "slug": {"validators": []}, + } + + def create(self, validated_data): + translated_contents_data = validated_data.pop("translated_contents", []) + playlists_data = validated_data.pop("playlists", []) + block = Block.objects.create(**validated_data) + + for content_data in translated_contents_data: + BlockTranslatedContent.objects.create(block=block, **content_data) + + for playlist_data in playlists_data: + playlist = Playlist.objects.get(pk=playlist_data["id"]) + block.playlists.add(playlist) + + return block + + def update(self, instance, validated_data): + translated_contents_data = validated_data.pop("translated_contents", []) + playlists_data = validated_data.pop("playlists", []) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + BlockTranslatedContent.objects.filter(block=instance).delete() + for content_data in translated_contents_data: + BlockTranslatedContent.objects.create(block=instance, **content_data) + + existing_playlist_ids = set() + for playlist_data in playlists_data: + playlist_id = playlist_data.get("id") + if playlist_id: + playlist = Playlist.objects.get(pk=playlist_id) + instance.playlists.add(playlist) + existing_playlist_ids.add(playlist.id) + + # Delete removed playlists + instance.playlists.exclude(id__in=existing_playlist_ids).delete() + + instance.save() + + return instance + + +class PhaseSerializer(serializers.ModelSerializer): + blocks = BlockSerializer(many=True, required=False) + + class Meta: + model = Phase + fields = ["id", "index", "dashboard", "randomize", "blocks"] + + +class ExperimentSerializer(serializers.ModelSerializer): + translated_content = ExperimentTranslatedContentSerializer(many=True, required=False) + phases = PhaseSerializer(many=True, required=False) + + class Meta: + model = Experiment + fields = ["id", "slug", "active", "translated_content", "phases"] + + def create(self, validated_data): + translated_content_data = validated_data.pop("translated_content", []) + phases_data = validated_data.pop("phases", []) + experiment = Experiment.objects.create(**validated_data) + + for content_data in translated_content_data: + ExperimentTranslatedContent.objects.create(experiment=experiment, **content_data) + + for phase_data in phases_data: + blocks_data = phase_data.pop("blocks", []) + phase = Phase.objects.create(experiment=experiment, **phase_data) + + for block_data in blocks_data: + Block.objects.create(phase=phase, **block_data) + + return experiment + + def update(self, instance, validated_data): + translated_content_data = validated_data.pop("translated_content", []) + phases_data = validated_data.pop("phases", []) + + # Update experiment fields + instance.slug = validated_data.get("slug", instance.slug) + instance.active = validated_data.get("active", instance.active) + instance.save() + + # Update translated content + if translated_content_data is not None: + existing_content_ids = set() + + # Update or create translated content + for content_data in translated_content_data: + content_id = content_data.get("id") + if content_id: + content, _ = ExperimentTranslatedContent.objects.update_or_create( + id=content_id, experiment=instance, defaults=content_data + ) + else: + content = ExperimentTranslatedContent.objects.create(experiment=instance, **content_data) + existing_content_ids.add(content.id) + + # Delete removed content + instance.translated_content.exclude(id__in=existing_content_ids).delete() + + # Update phases + if phases_data is not None: + existing_phase_ids = set() + + # Update or create phases + for phase_data in phases_data: + blocks_data = phase_data.pop("blocks", []) + phase_id = phase_data.get("id") + + if phase_id: + phase, _ = Phase.objects.update_or_create(id=phase_id, experiment=instance, defaults=phase_data) + else: + phase = Phase.objects.create(experiment=instance, **phase_data) + existing_phase_ids.add(phase.id) + + # Handle blocks for this phase + existing_block_ids = set() + + for block_data in blocks_data: + block_id = block_data.get("id") + + if block_id: + block_instance = Block.objects.get(pk=block_id) + block_serializer = BlockSerializer(block_instance, data=block_data, partial=True) + else: + block_serializer = BlockSerializer(data=block_data, partial=True) + block_serializer.is_valid(raise_exception=True) + block = block_serializer.save(phase=phase) + existing_block_ids.add(block.id) + + # Delete removed blocks + phase.blocks.exclude(id__in=existing_block_ids).delete() + + # Delete removed phases + instance.phases.exclude(id__in=existing_phase_ids).delete() + + return instance def serialize_actions(actions): 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 000000000..a785e61d0 Binary files /dev/null and b/backend/experiment/templates/form/experiment-form/bun.lockb differ diff --git a/backend/experiment/templates/form/experiment-form/eslint.config.mjs b/backend/experiment/templates/form/experiment-form/eslint.config.mjs new file mode 100644 index 000000000..2b5389cc6 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/eslint.config.mjs @@ -0,0 +1,42 @@ +import { fixupConfigRules } from "@eslint/compat"; +import reactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; +import tsParser from "@typescript-eslint/parser"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}); + +export default [{ + ignores: ["**/dist", "**/.eslintrc.cjs"], +}, ...fixupConfigRules(compat.extends( + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", +)), { + plugins: { + "react-refresh": reactRefresh, + }, + + languageOptions: { + globals: { + ...globals.browser, + }, + + parser: tsParser, + }, + + rules: { + "react-refresh/only-export-components": ["warn", { + allowConstantExport: true, + }], + }, +}]; \ No newline at end of file diff --git a/backend/experiment/templates/form/experiment-form/index.html b/backend/experiment/templates/form/experiment-form/index.html new file mode 100644 index 000000000..e4b78eae1 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/index.html @@ -0,0 +1,13 @@ + + + + + + + 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..6e49b48dd --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/package.json @@ -0,0 +1,43 @@ +{ + "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": { + "@uiw/react-md-editor": "^4.0.5", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-icons": "^5.4.0", + "react-markdown": "^9.0.3", + "react-router-dom": "^7.0.2", + "react-world-flags": "^1.6.0", + "remark-gfm": "^4.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@eslint/compat": "^1.2.4", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.17.0", + "@tailwindcss/typography": "^0.5.16", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@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": "^9.17.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "globals": "^15.14.0", + "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.tsx b/backend/experiment/templates/form/experiment-form/src/App.tsx new file mode 100644 index 000000000..6426c614f --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/App.tsx @@ -0,0 +1,92 @@ +import { + BrowserRouter as Router, + Route, + Routes, + Link, +} from "react-router-dom"; +import { AiTwotoneExperiment } from "react-icons/ai"; +import ExperimentsOverview from './components/ExperimentsOverview'; +import ExperimentForm from './components/ExperimentForm'; +import Login from './components/Login'; +import { useState } from 'react'; +import { FiLogOut } from 'react-icons/fi'; +import { Toasts } from './components/Toasts'; +import useBoundStore from "./utils/store"; + +function App() { + const [isCollapsed, setIsCollapsed] = useState(false); + const jwt = useBoundStore(state => state.jwt); + const setJwt = useBoundStore(state => state.setJwt); + + const handleLogin = (newJwt: string) => { + setJwt(newJwt); + }; + + const handleLogout = () => { + setJwt(null); + }; + + if (!jwt) { + return ; + } else { + try { + const payload = JSON.parse(atob(jwt.split('.')[1])); + if (payload.exp && Date.now() >= payload.exp * 1000) { + handleLogout(); + return ; + } + } catch (error) { + handleLogout(); + return ; + } + } + + return ( + <> + +
+ +
+

+ MUSCLE forms +

+ + } /> + } /> + } /> + +
+
+
+ + + ) +} + +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/Accordion.tsx b/backend/experiment/templates/form/experiment-form/src/components/Accordion.tsx new file mode 100644 index 000000000..b83cf3638 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/Accordion.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; +import { FiChevronDown, FiChevronRight } from 'react-icons/fi'; + +interface AccordionItemProps { + title: string; + children: React.ReactNode; + isOpen?: boolean; + onToggle?: () => void; +} + +export const AccordionItem: React.FC = ({ + title, + children, + isOpen = false, + onToggle, +}) => { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); // Prevent form submission + onToggle?.(); + }; + + return ( +
+ + {isOpen &&
{children}
} +
+ ); +}; + +interface AccordionProps { + items: { + id: number | string; + title: string; + content: React.ReactNode; + }[]; + singleOpen?: boolean; +} + +export const Accordion: React.FC = ({ items, singleOpen = false }) => { + const [openItems, setOpenItems] = useState>(new Set()); + + const toggleItem = (id: number | string) => { + setOpenItems((prev) => { + const newOpenItems = new Set(prev); + if (singleOpen) { + newOpenItems.clear(); + } + if (newOpenItems.has(id)) { + newOpenItems.delete(id); + } else { + newOpenItems.add(id); + } + return newOpenItems; + }); + }; + + return ( +
+ {items.map((item) => ( + toggleItem(item.id)} + > + {item.content} + + ))} +
+ ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/BlockForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/BlockForm.tsx new file mode 100644 index 000000000..fb984833c --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/BlockForm.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { FormField } from './form/FormField'; +import { Input } from './form/Input'; +import { Select } from './form/Select'; +import { Block, BlockTranslatedContent } from '../types/types'; +import { useBlockRules } from '../hooks/useBlockRules'; +import { FiTrash2 } from 'react-icons/fi'; +import { Button } from './Button'; +import { BlockTranslatedContentForms } from './BlockTranslatedContentForms'; +import { useBlockPlaylists } from '../hooks/useBlockPlaylists'; + +interface BlockFormProps { + block: Block; + onChange: (block: Block) => void; + onDelete: () => void; +} + +export const BlockForm: React.FC = ({ block, onChange, onDelete }) => { + const { rules, loading, error } = useBlockRules(); + const { playlists, loading: playlistsLoading, error: playlistsError } = useBlockPlaylists(); + + const handleChange = (field: keyof Block, value: string | number | BlockTranslatedContent[]) => { + onChange({ ...block, [field]: value }); + }; + + return ( +
+
+

Block Settings

+ +
+
+ + handleChange('slug', e.target.value)} + required + /> + + + + handleChange('index', parseInt(e.target.value))} + required + /> + +
+ +
+ + handleChange('rounds', parseInt(e.target.value))} + required + /> + + + + handleChange('bonus_points', parseInt(e.target.value))} + /> + +
+ +
+ + + {error &&

{error}

} + {loading &&

Loading rules...

} +
+ + + + {playlistsError &&

{playlistsError}

} + {playlistsLoading &&

Loading playlists...

} +
+
+ + handleChange('translated_contents', contents)} // Changed from translated_content + /> +
+ ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForm.tsx new file mode 100644 index 000000000..5a7d8c51a --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForm.tsx @@ -0,0 +1,53 @@ +import { ISO_LANGUAGES } from "../constants"; +import { BlockTranslatedContent } from "../types/types"; +import { FormField } from "./form/FormField"; +import { Input } from "./form/Input"; +import { Select } from "./form/Select"; +import { MarkdownEditor } from "./form/MarkdownEditor"; + +interface BlockTranslatedContentFormProps { + content: BlockTranslatedContent; + onChange: (content: BlockTranslatedContent) => void; +} + +export function BlockTranslatedContentForm({ content, onChange }: BlockTranslatedContentFormProps) { + return ( +
+
+
+ + + + + + onChange({ ...content, name: e.target.value })} + required + /> + +
+ + + onChange({ ...content, description: value })} + rows={3} + field="Description" + /> + +
+
+ ); +} diff --git a/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForms.tsx b/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForms.tsx new file mode 100644 index 000000000..e78a822b1 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForms.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { Tabs } from './Tabs'; +import { FiPlus, FiTrash } from 'react-icons/fi'; +import { BlockTranslatedContent } from '../types/types'; +import { Flag } from './Flag'; +import { BlockTranslatedContentForm } from './BlockTranslatedContentForm'; + +interface BlockTranslatedContentFormsProps { + contents: BlockTranslatedContent[]; + onChange: (contents: BlockTranslatedContent[]) => void; +} + +const defaultContent: BlockTranslatedContent = { + index: 0, + language: '', + name: '', + description: '', +}; + +export const BlockTranslatedContentForms: React.FC = ({ contents, onChange }) => { + const [activeTabIndex, setActiveTabIndex] = React.useState(0); + + const handleAdd = () => { + const newContent = { + ...defaultContent, + index: contents.length, + }; + const updatedContents = [...contents, newContent].map((content, index) => ({ ...content, index })); + onChange(updatedContents); + setActiveTabIndex(updatedContents.length - 1); + }; + + const handleRemove = (index: number) => { + if (confirm('Are you sure you want to remove this translation?')) { + onChange(contents.filter((_, i) => i !== index)); + setActiveTabIndex(Math.min(index, contents.length - 2)); + } + }; + + const handleChange = (index: number, newContent: BlockTranslatedContent) => { + const updatedContents = contents.map((content, i) => { + if (i === index) { + return { ...content, ...newContent }; + } + return content; + }); + onChange(updatedContents); + }; + + const getTabLabel = (content: BlockTranslatedContent, index: number) => { + if (content.language) { + return ; + } + return `Translation ${index + 1}`; + }; + + const handleTabChange = (tabIndex: string | number) => { + if (tabIndex === 'new') { + handleAdd(); + } else { + setActiveTabIndex(tabIndex as number); + } + }; + + return ( +
+

+ Translated Content +

+ + ({ + id: index, + label: getTabLabel(content, index), + })), + { + id: 'new', + label: ( +
+ + New Translation +
+ ), + } + ]} + wrap={true} + activeTab={activeTabIndex} + onTabChange={handleTabChange} + actions={[ + { + icon: , + title: 'Remove translation', + onClick: (tabId) => handleRemove(tabId as number), + }, + ]} + /> + + {contents.length > 0 && !!contents[activeTabIndex] && ( + handleChange(activeTabIndex, content)} + /> + )} +
+ ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/Button.tsx b/backend/experiment/templates/form/experiment-form/src/components/Button.tsx new file mode 100644 index 000000000..c0df998ff --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/Button.tsx @@ -0,0 +1,75 @@ +import React, { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; + +interface ButtonProps { + children: ReactNode; + onClick?: (e?: React.MouseEvent) => void; + type?: 'button' | 'submit' | 'reset'; + variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning'; + size?: 'sm' | 'md' | 'lg'; + icon?: ReactNode; + to?: string; + disabled?: boolean; + className?: string; +} + +const variantStyles = { + primary: 'bg-indigo-600 hover:bg-indigo-700 text-white', + secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-800', + danger: 'bg-red-500 hover:bg-red-600 text-white', + success: 'bg-emerald-500 hover:bg-emerald-600 text-white', + warning: 'bg-amber-500 hover:bg-amber-600 text-white', +}; + +const sizeStyles = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2', + lg: 'px-6 py-3 text-lg', +}; + +export const Button: React.FC = ({ + children, + onClick, + type = 'button', + variant = 'primary', + size = 'md', + icon, + to, + disabled = false, + className = '', +}) => { + const baseStyle = 'inline-flex items-center justify-center rounded-md font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed'; + const styles = `${baseStyle} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`; + + if (onClick) { + return ( + + ); + } else if (to) { + return ( + + {icon && {icon}} + {children} + + ); + } + + return ( + + ); +}; 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..ee66d4f09 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx @@ -0,0 +1,265 @@ +import React, { useEffect, useState } from 'react'; +import { createExperimentEntityUrl } from '../config'; +import { useNavigate, useParams, Routes, Route, useLocation } from 'react-router-dom'; +import Page from './Page'; +import { TranslatedContentForms } from './TranslatedContentForms'; +import { FiSave, FiArrowLeft, FiGlobe, FiLayers, FiLoader } from 'react-icons/fi'; +import { Button } from './Button'; +import { Tabs } from './Tabs'; +import { FormField } from './form/FormField'; +import { Input } from './form/Input'; +import { Experiment, TranslatedContent } from '../types/types'; +import { PhasesForm } from './PhasesForm'; +import useFetch from '../hooks/useFetch'; +import { useMutation } from '../hooks/useMutation'; +import useBoundStore from '../utils/store'; + +interface ExperimentFormProps { +} + +interface UnsavedChanges { + main: boolean; + translatedContent: boolean; + phases: boolean; +} + +const ExperimentForm: React.FC = () => { + const { id: experimentId } = useParams<{ id: string }>(); + const url = createExperimentEntityUrl('experiments', experimentId); + const [experimentResource, error, loading] = useFetch(url, 'GET', null); + const [saveExperiment, { loading: saveLoading }] = useMutation( + url, + experimentId ? 'PUT' : 'POST' + ); + + const experiment = useBoundStore(state => state.experiment); + const setExperiment = useBoundStore(state => state.setExperiment); + const patchExperiment = useBoundStore(state => state.patchExperiment); + const addToast = useBoundStore(state => state.addToast); + + const [success, setSuccess] = useState(false); + const [activeTab, setActiveTab] = useState<'translatedContent' | 'phases'>('translatedContent'); + const [unsavedChanges, setUnsavedChanges] = useState({ + main: false, + translatedContent: false, + phases: false, + }); + + const navigate = useNavigate(); + const location = useLocation(); + + const hasUnsavedChanges = unsavedChanges.main || unsavedChanges.translatedContent || unsavedChanges.phases; + + useEffect(() => { + if (experimentResource) { + setExperiment(experimentResource); + navigate(`/experiments/${experimentId}/phases`); + } else { + setExperiment({ + slug: '', + active: true, + translated_content: [], + phases: [], + }); + } + }, [experimentResource]); + + useEffect(() => { + // Set active tab based on URL + if (location.pathname.includes('/translated-content')) { + setActiveTab('translatedContent'); + } else if (location.pathname.includes('/phases')) { + setActiveTab('phases'); + } + }, [location]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === 's') { + event.preventDefault(); + handleSubmit(event as unknown as React.FormEvent); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [experiment]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, type, checked, value } = e.target; + patchExperiment({ + [name]: type === 'checkbox' ? checked : value + }); + setUnsavedChanges(prev => ({ ...prev, main: true })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSuccess(false); + + try { + const savedExperiment = await saveExperiment(experiment); + setSuccess(true); + setExperiment(savedExperiment); + addToast({ + message: "Experiment saved successfully!", + duration: 3000, + level: "success" + }); + } catch (err) { + addToast({ + message: "Failed to save experiment. Please try again.", + duration: 5000, + level: "error" + }); + console.error("Error submitting form:", err); + } + }; + + const handleTranslatedContentChange = (newContents: TranslatedContent[]) => { + patchExperiment({ translated_content: newContents }); + setUnsavedChanges(prev => ({ ...prev, translatedContent: true })); + }; + + const getTabLabel = (label: string, hasChanges: boolean) => { + return hasChanges ? `${label} *` : label; + }; + + const handleTabChange = (tabId: string) => { + const tab = tabId as 'translatedContent' | 'phases'; + setActiveTab(tab); + if (tab === 'translatedContent') { + navigate(`/experiments/${experimentId}/translated-content`); + } else { + navigate(`/experiments/${experimentId}/phases`); + } + }; + + useEffect(() => { + if (success) { + setUnsavedChanges({ main: false, translatedContent: false, phases: false }); + } + }, [success]); + + if (loading && !success && !error && experimentId) { + return
Loading...
; + } + + if (loading) { + return
Loading...
; + } + + if (!experiment) { + return
Experiment not found
; + } + + return ( + + + +
+
+ + + + + + + +
+ + + }, + { + id: 'translatedContent', + label: getTabLabel('Translated Content', unsavedChanges.translatedContent), + icon: + }, + ]} + activeTab={activeTab} + onTabChange={handleTabChange} + /> + +
+ + + } + /> + { + patchExperiment({ phases: newPhases }); + setUnsavedChanges(prev => ({ ...prev, phases: true })); + }} + /> + } + /> + +
+ +
+ + + +
+ ); +}; + +export default ExperimentForm; 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..1f36423ef --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx @@ -0,0 +1,159 @@ +import { createExperimentEntityUrl } from "../config"; +import useFetch from "../hooks/useFetch"; +import { Button } from "./Button"; +import Page from "./Page"; +import { FiPlus, FiEdit2, FiTrash2 } from "react-icons/fi"; +import { Experiment, Phase, TranslatedContent } from "../types/types"; +import React from "react"; +import { Flag } from "./Flag"; +import { Link } from "react-router-dom"; +import useBoundStore from "../utils/store"; + +const url = createExperimentEntityUrl('experiments'); + +const PhasesPills: React.FC<{ phases: Phase[] }> = ({ phases }) => { + return ( +
+ {phases.sort((a, b) => a.index - b.index).map((phase, idx) => ( + + {idx > 0 &&
} +
b.slug).join(', ') : 'No blocks'}`} + > + {phase.blocks.length} +
+ + ))} +
+ ); +}; + +const LanguagePills: React.FC<{ translations: TranslatedContent[] }> = ({ translations }) => { + const languages = [...new Set(translations.map(t => t.language))]; + return ( +
+ {languages.map(lang => ( + + ))} +
+ ); +}; + +const ExperimentsOverview = () => { + const jwt = useBoundStore(state => state.jwt); + 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(createExperimentEntityUrl('experiments', id.toString()), { + 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', + Authorization: `Bearer ${jwt}`, + }, + 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?.map((experiment) => ( + + + + + + + + + ))} + +
IDSlugStatusPhasesLanguagesActions
+ + {experiment.id} + + {experiment.slug} + + {experiment.active ? 'Active' : 'Inactive'} + + + + + + + + +
+
+
+
+ ); +}; + +export default ExperimentsOverview; diff --git a/backend/experiment/templates/form/experiment-form/src/components/Flag.tsx b/backend/experiment/templates/form/experiment-form/src/components/Flag.tsx new file mode 100644 index 000000000..4e9ce0008 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/Flag.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import WorldFlag from 'react-world-flags'; +import { getCountryCode, getLanguageName } from '../utils/languageUtils'; + +interface FlagProps { + languageCode: string; + className?: string; + showLabel?: boolean; +} + +export const Flag: React.FC = ({ languageCode, className = '', showLabel = false }) => { + const countryCode = getCountryCode(languageCode); + const languageName = getLanguageName(languageCode); + + return ( +
+ {countryCode}} + /> + {showLabel && {languageName}} +
+ ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/Login.tsx b/backend/experiment/templates/form/experiment-form/src/components/Login.tsx new file mode 100644 index 000000000..fbecfc329 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/Login.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import { TOKEN_URL } from '../config'; + + +interface LoginProps { + onLogin: (jwt: string) => void; +} + +const Login = ({ onLogin }: LoginProps) => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }); + + if (!response.ok) { + throw new Error('Invalid credentials'); + } + + const data = await response.json(); + onLogin(data.access); + } catch (err) { + setError('Login failed. Please check your credentials.'); + } + }; + + return ( +
+
+

Login

+ {error &&
{error}
} +
+
+ + setUsername(e.target.value)} + className="w-full p-2 border rounded" + required + /> +
+
+ + setPassword(e.target.value)} + className="w-full p-2 border rounded" + required + /> +
+ +
+
+
+ ); +}; + +export default Login; 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..627a7435c --- /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; diff --git a/backend/experiment/templates/form/experiment-form/src/components/PhaseForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/PhaseForm.tsx new file mode 100644 index 000000000..9315759e6 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/PhaseForm.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { FiTrash2 } from 'react-icons/fi'; +import { Phase } from '../types/types'; +import { FormField } from './form/FormField'; +import { Input } from './form/Input'; +import { Button } from './Button'; +import { useParams } from 'react-router-dom'; +import useBoundStore from '../utils/store'; + +interface PhaseFormProps { + onChange: (phase: Phase) => void; + onDelete: () => void; +} + +export const PhaseForm: React.FC = ({ onChange, onDelete }) => { + + const experiment = useBoundStore(state => state.experiment); + + const { phaseIndex } = useParams<{ id: string }>(); + + const phase = experiment?.phases[phaseIndex]; + + const handleChange = (field: keyof Phase, value: any) => { + onChange({ ...phase, [field]: value }); + }; + + if (!phase) { + return null; + } + + return ( +
+
+

Phase Settings

+ +
+ +
+ + handleChange('index', parseInt(e.target.value))} + required + /> + +
+ +
+ + + +
+
+ ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/PhasesForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/PhasesForm.tsx new file mode 100644 index 000000000..abe59401e --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/PhasesForm.tsx @@ -0,0 +1,315 @@ +import React, { useState, useEffect } from 'react'; +import { Phase, Selection } from '../types/types'; +import { PhaseForm } from './PhaseForm'; +import { Timeline } from './Timeline'; +import { BlockForm } from './BlockForm'; +import useBoundStore from '../utils/store'; +import { useNavigate, useParams, useLocation, Routes, Route } from 'react-router-dom'; +import { Button } from './Button'; +import { FiPlus } from 'react-icons/fi'; + +const defaultPhase: Phase = { + index: 0, + blocks: [], + dashboard: false, + randomize: false, +}; + +interface PhasesFormProps { + phases: Phase[]; + onChange: (phases: Phase[]) => void; +} + +export const PhasesForm: React.FC = ({ phases, onChange }) => { + const experiment = useBoundStore(state => state.experiment); + const [timelineSelection, setTimelineSelection] = useState(null); + const navigate = useNavigate(); + const params = useParams(); + const { id: experimentId } = params; + const location = useLocation(); + + + useEffect(() => { + // Parse URL to set initial selection + const match = location.pathname.match(/\/phases\/(\d+)(?:\/blocks\/(\d+))?/); + + if (match) { + const [_, phaseIndex, blockIndex] = match; + if (blockIndex !== undefined) { + // Set block selection + setTimelineSelection({ + type: 'block', + phaseIndex: parseInt(phaseIndex), + blockIndex: parseInt(blockIndex) + }); + } else { + // Set phase selection + setTimelineSelection({ + type: 'phase', + phaseIndex: parseInt(phaseIndex) + }); + } + } else if (phases.length > 0) { + + + // Default to first phase if no selection in URL + navigate(`/experiments/${experimentId}/phases/0`); + } + }, [location.pathname, phases]); + + const handleTimelineSelect = (selection: Selection) => { + + setTimelineSelection(selection); + const basePath = `/experiments/${experimentId}/phases`; + if (selection.type === 'block') { + navigate(`${basePath}/${selection.phaseIndex}/blocks/${selection.blockIndex}`); + } else { + navigate(`${basePath}/${selection.phaseIndex}`); + } + }; + + const addPhaseOrBlock = (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => { + if (type === 'phase') { + const newPhase: Phase = { + ...defaultPhase, + index: phaseIndex, + blocks: [], + }; + + let updatedPhases = [...phases]; + + updatedPhases + .splice(phaseIndex, 0, newPhase) + + updatedPhases = updatedPhases + .map((phase, i) => ({ ...phase, index: i })); + + onChange(updatedPhases); + setTimelineSelection({ type: 'phase', phaseIndex: phaseIndex }); + } else { + + const position = blockIndex !== undefined ? blockIndex : phases[phaseIndex].blocks.length; + + const phase = phases[phaseIndex]; + if (!phase) return; + if (!experiment) return; + + const experimentSlug = experiment.slug; + const blockSlugArray: [string, number, number] = [experimentSlug, phaseIndex, position]; + let blockSlug = blockSlugArray.join('-'); + + // Check if block slug already exists in the experiment + while (phase.blocks.some(b => b.slug === blockSlug)) { + blockSlugArray[1] += 1; + blockSlug = blockSlugArray.join('-'); + } + + const newBlock = { + index: position, + slug: blockSlug, + rounds: 10, + bonus_points: 0, + rules: '', + translated_contents: [], + playlists: [], + }; + + // use splice to insert new block at position + let newBlocks = [...phase.blocks]; + + newBlocks + .splice(position, 0, newBlock) + + newBlocks = newBlocks + .map((block, i) => ({ ...block, index: i })) + + const updatedPhase = { + ...phase, + blocks: [...newBlocks], + }; + + onChange(phases.map((p, i) => i === phaseIndex ? updatedPhase : p)); + setTimelineSelection({ type: 'block', phaseIndex, blockIndex: position }); + } + }; + + const handleAdd = (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => { + addPhaseOrBlock(type, phaseIndex, blockIndex); + + // then navigate to the new block (if the index(es) are different than the current selection) + if (type === 'phase') { + navigate(`/experiments/${experimentId}/phases/${phaseIndex}`); + } else { + navigate(`/experiments/${experimentId}/phases/${phaseIndex}/blocks/${blockIndex}`); + } + }; + + const deletePhaseOrBlock = (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => { + if (type === 'phase') { + if (!confirm('Are you sure you want to delete this phase?')) return; + + const updatedPhases = phases.filter((_, i) => i !== phaseIndex) + .map((phase, i) => ({ ...phase, index: i })); + + setTimelineSelection(null); + onChange(updatedPhases); + + return updatedPhases; + + } else if (blockIndex !== undefined) { + if (!confirm('Are you sure you want to delete this block?')) return; + + const phase = phases[phaseIndex]; + const updatedBlocks = phase.blocks + .filter((_, i) => i !== blockIndex) + .map((block, i) => ({ ...block, index: i })); + + const updatedPhase = { ...phase, blocks: updatedBlocks }; + const updatedPhases = phases.map((p, i) => i === phaseIndex ? updatedPhase : p); + + setTimelineSelection(null); + onChange(updatedPhases); + + return updatedPhases; + } + }; + + const handleDelete = (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => { + const updatedPhases = deletePhaseOrBlock(type, phaseIndex, blockIndex); + + if (!updatedPhases?.length) { + navigate(`/experiments/${experimentId}/phases`); + return; + } + + // navigate to the most appropriate location after deletion + if (type === 'phase') { + if (phaseIndex === 0) { + navigate(`/experiments/${experimentId}/phases/0`); + } else { + navigate(`/experiments/${experimentId}/phases/${phaseIndex - 1}`); + } + } else { + + if (blockIndex === undefined) { + navigate(`/experiments/${experimentId}/phases/${phaseIndex}`); + return; + } + + if (blockIndex === 0) { + navigate(`/experiments/${experimentId}/phases/${phaseIndex}`); + } else { + navigate(`/experiments/${experimentId}/phases/${phaseIndex}/blocks/${blockIndex - 1}`); + } + } + }; + + // Get selected item details + const getSelected = () => { + + if (!timelineSelection) return null; + + if (timelineSelection.type === 'phase') { + + if (timelineSelection.phaseIndex >= phases.length) { + console.warn('Phase index out of range (type: phase)'); + return null; + } + + const phase = phases[timelineSelection.phaseIndex]; + return { type: 'phase', item: phase }; + } + + if (timelineSelection.type === 'block') { + + if (timelineSelection.phaseIndex >= phases.length) { + console.warn('Phase index out of range (type: block)'); + return null; + } + + const phase = phases[timelineSelection.phaseIndex]; + + // Check if block index is defined as a number and greater than or equal to 0 + if (typeof timelineSelection.blockIndex !== 'number' || timelineSelection.blockIndex < 0) { + console.warn('Block index not found'); + return null; + } + + if (timelineSelection.blockIndex >= phase.blocks.length) { + console.warn('Block index out of range'); + return null; + } + + const block = phase.blocks[timelineSelection.blockIndex]; + + return { type: 'block', item: block, phase }; + } + + return null; + }; + + const selected = getSelected(); + + return ( +
+ + + {/* Add new phase button if there are no phases */} + {!phases.length && ( + + )} + + + +
+ { + const updatedPhase = { + ...selected.phase, + blocks: selected.phase.blocks.map((b, i) => + i === timelineSelection.blockIndex ? updatedBlock : b + ), + }; + onChange(phases.map((p, i) => + i === timelineSelection.phaseIndex ? updatedPhase : p + )); + }} + onDelete={() => handleDelete('block', timelineSelection.phaseIndex, timelineSelection.blockIndex)} + /> +
+
+ ) + } /> + +
+ { + onChange(phases.map((p, i) => + i === timelineSelection.phaseIndex ? updatedPhase : p + )); + }} + onDelete={() => handleDelete('phase', timelineSelection.phaseIndex)} + /> +
+
+ ) + } /> + + + ); +}; 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..d58c95660 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/Tabs.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; + +interface TabAction { + icon: React.ReactNode; + title: string; + onClick: (tabId: string | number) => void; +} + +interface Tab { + id: string | number; + label: string; + icon?: React.ReactNode; // Add icon support +} + +interface TabsProps { + tabs: Tab[]; + activeTab: string | number; + onTabChange: (tabId: string | number) => void; + actions?: TabAction[]; + onReorder?: (startIndex: number, endIndex: number) => void; + draggable?: boolean; + wrap?: boolean; +} + +export const Tabs: React.FC = ({ + tabs, + activeTab, + onTabChange, + actions = [], + onReorder, + draggable = false, + wrap = false, +}) => { + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + const handleDragStart = (e: React.DragEvent, index: number) => { + if (tabs[index].id === 'new') return; + setDraggedIndex(index); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + if (tabs[index].id === 'new') return; + setDragOverIndex(index); + }; + + const handleDragEnd = () => { + if (draggedIndex !== null && dragOverIndex !== null && onReorder) { + onReorder(draggedIndex, dragOverIndex); + } + setDraggedIndex(null); + setDragOverIndex(null); + }; + + return ( +
+ +
+ ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/Timeline.tsx b/backend/experiment/templates/form/experiment-form/src/components/Timeline.tsx new file mode 100644 index 000000000..1655c281e --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/Timeline.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { FiPlus, FiCircle, FiBox } from 'react-icons/fi'; +import { Phase, Selection } from '../types/types'; +import { useNavigate, useParams } from 'react-router-dom'; + +interface TimelineProps { + phases: Array; + selectedItem: Selection | null; + onSelect: (selection: Selection) => void; + onAdd: (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => void; +} + +export const Timeline: React.FC = ({ + phases, + selectedItem, + onSelect, + onAdd, +}) => { + const { id: experimentId } = useParams(); + const navigate = useNavigate(); + + const handleSelect = (selection: Selection) => { + const basePath = `/experiments/${experimentId}/phases`; + if (selection.type === 'block') { + navigate(`${basePath}/${selection.phaseIndex}/blocks/${selection.blockIndex}`); + } else { + navigate(`${basePath}/${selection.phaseIndex}`); + } + onSelect(selection); + }; + + const isSelected = (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => { + return selectedItem?.type === type && + selectedItem.phaseIndex === phaseIndex && + selectedItem.blockIndex === blockIndex; + }; + + return ( +
+
+ {phases.map((phase, phaseIndex) => ( + + {/* Add button before phase */} + {phaseIndex === 0 && ( +
+ onAdd('phase', 0)} + type="phase" + /> +
+ )} + +
+ + {/* Phase node */} + + + {/* Blocks for this phase */} + {phase.blocks.map((_block, blockIndex) => ( + +
+ {/* Add button before block */} + onAdd('block', phaseIndex, blockIndex)} + type="block" + /> +
+ + +
+ ))} + + {/* Add button for new block if no or the last block is selected */} +
+ {/* Add button after block */} + onAdd('block', phaseIndex, phase.blocks.length)} + type="block" + /> +
+ + {/* vertical line between the phases equal to the gap size of gap-2 */} + {phaseIndex < phases.length - 1 && ( +
+ )} + + {/* Add button after phase */} +
+ onAdd('phase', phaseIndex + 1)} + type="phase" + /> +
+
+ + ))} +
+
+ ); +}; + +const AddButton: React.FC<{ + onClick: () => void; + type: 'phase' | 'block'; +}> = ({ onClick, type }) => ( +
+ +
+
+ Add {type} +
+
+
+); diff --git a/backend/experiment/templates/form/experiment-form/src/components/Toast.tsx b/backend/experiment/templates/form/experiment-form/src/components/Toast.tsx new file mode 100644 index 000000000..3435b0f8a --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/Toast.tsx @@ -0,0 +1,20 @@ +import { Toast as ToastType } from '../utils/store'; + +interface ToastProps { + toast: ToastType; +} + +export const Toast: React.FC = ({ toast }) => { + const bgColorClass = { + info: 'bg-gray-800', + success: 'bg-green-600', + warning: 'bg-yellow-600', + error: 'bg-red-600' + }[toast.level]; + + return ( +
+

{toast.message}

+
+ ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/Toasts.tsx b/backend/experiment/templates/form/experiment-form/src/components/Toasts.tsx new file mode 100644 index 000000000..f5899c13a --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/Toasts.tsx @@ -0,0 +1,14 @@ +import { useBoundStore } from '../utils/store'; +import { Toast } from './Toast'; + +export const Toasts: React.FC = () => { + const toasts = useBoundStore((state) => state.toasts); + + return ( +
+ {toasts.map((toast, index) => ( + + ))} +
+ ); +}; 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..4592c6d01 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForm.tsx @@ -0,0 +1,72 @@ +import { ISO_LANGUAGES } from "../constants"; +import { TranslatedContent } from "../types/types"; +import { FormField } from "./form/FormField"; +import { Input } from "./form/Input"; +import { Select } from "./form/Select"; +import { MarkdownEditor } from "./form/MarkdownEditor"; + +interface TranslatedContentProps { + content: TranslatedContent; + onChange: (content: TranslatedContent) => void; +} + +export function TranslatedContentForm({ content, onChange }: TranslatedContentProps) { + + return ( +
+
+ +
+ + + + + + onChange({ ...content, name: e.target.value })} + required + /> + +
+ + + onChange({ ...content, description: value })} + rows={3} + field="Description" + /> + + + + onChange({ ...content, about_content: value })} + rows={8} + field="About Content" + /> + + + + onChange({ ...content, social_media_message: e.target.value })} + /> + +
+
+ ); +} diff --git a/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForms.tsx b/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForms.tsx new file mode 100644 index 000000000..1f344b9e5 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForms.tsx @@ -0,0 +1,127 @@ +import React, { useEffect } from 'react'; +import { Tabs } from './Tabs'; +import { FiPlus, FiTrash } from 'react-icons/fi'; +import { TranslatedContent } from '../types/types'; +import { Flag } from './Flag'; +import { useNavigate, useParams } from 'react-router-dom'; +import { TranslatedContentForm } from './TranslatedContentForm'; + +interface TranslatedContentFormsProps { + 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 TranslatedContentForms: React.FC = ({ contents, onChange }) => { + const navigate = useNavigate(); + const { id: experimentId, language } = useParams(); + + + // Find index of current language in contents + const languageInContents = contents.find(content => content.language === language); + const potentialContentIndex = parseInt(language ?? '0', 10); + const activeTabIndex = languageInContents ? contents.indexOf(languageInContents) : potentialContentIndex; + + useEffect(() => { + // If we have contents but no language in URL, redirect to first language + if (contents.length > 0 && !language) { + const firstContent = contents[0]; + navigate(`/experiments/${experimentId}/translated-content/${firstContent.language || '0'}`); + } + }, [contents, language, experimentId]); + + const handleAdd = () => { + const newContent = { + ...defaultContent, + index: contents.length, + }; + const updatedContents = [...contents, newContent].map((content, index) => ({ ...content, index })); + + onChange(updatedContents); + // Navigate to the new content's tab + navigate(`/experiments/${experimentId}/translated-content/${updatedContents.length - 1}`); + }; + + const handleRemove = (index: number) => { + if (confirm('Are you sure you want to remove this translation?')) { + onChange(contents.filter((_, i) => i !== index)); + // Navigate to the closest tab + const nextIndex = Math.min(index, contents.length - 2); + navigate(`/experiments/${experimentId}/translated-content/${nextIndex}`); + } + }; + + const handleChange = (index: number, newContent: TranslatedContent) => { + const updatedContents = contents.map((content, i) => { + if (i === index) { + return { ...content, ...newContent }; + } + return content; + }); + onChange(updatedContents); + }; + + const getTabLabel = (content: TranslatedContent, index: number) => { + if (content.language) { + return ; + } + return `Translation ${index + 1}`; + }; + + const handleTabChange = (tabIndex: string | number) => { + if (tabIndex === 'new') { + handleAdd(); + } else { + const content = contents[tabIndex as number]; + navigate(`/experiments/${experimentId}/translated-content/${content.language || tabIndex}`); + } + }; + + return ( +
+

+ Translated Content +

+ + ({ + id: index, + label: getTabLabel(content, index), + })), + { + id: 'new', + label: ( +
+ + New Translation +
+ ), + } + ]} + wrap={true} + activeTab={activeTabIndex} + onTabChange={handleTabChange} + actions={[ + { + icon: , + title: 'Remove translation', + onClick: (tabId) => handleRemove(tabId as number), + }, + ]} + /> + + {contents.length > 0 && !!contents[activeTabIndex] && ( + handleChange(activeTabIndex, content)} /> + )} +
+ ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/form/Checkbox.tsx b/backend/experiment/templates/form/experiment-form/src/components/form/Checkbox.tsx new file mode 100644 index 000000000..fa6d789f1 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/form/Checkbox.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +interface CheckboxProps extends Omit, 'type'> { + label?: string; +} + +export const Checkbox: React.FC = ({ className = '', label, ...props }) => { + return ( + + ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/form/FormField.tsx b/backend/experiment/templates/form/experiment-form/src/components/form/FormField.tsx new file mode 100644 index 000000000..a0e8eb0c0 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/form/FormField.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +interface FormFieldProps { + label: string; + error?: string; + className?: string; + children: React.ReactNode; +} + +export const FormField: React.FC = ({ + label, + error, + className = '', + children +}) => { + return ( +
+ + {error &&

{error}

} +
+ ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/form/Input.tsx b/backend/experiment/templates/form/experiment-form/src/components/form/Input.tsx new file mode 100644 index 000000000..f4632ccd6 --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/form/Input.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +interface InputProps extends React.InputHTMLAttributes { + fullWidth?: boolean; +} + +export const Input: React.FC = ({ className = '', fullWidth = true, ...props }) => { + return ( + + ); +}; diff --git a/backend/experiment/templates/form/experiment-form/src/components/form/MarkdownEditor.tsx b/backend/experiment/templates/form/experiment-form/src/components/form/MarkdownEditor.tsx new file mode 100644 index 000000000..c9bf8b6bb --- /dev/null +++ b/backend/experiment/templates/form/experiment-form/src/components/form/MarkdownEditor.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +interface MarkdownEditorProps { + value: string; + onChange: (value: string) => void; + rows?: number; + field?: string; +} + +export function MarkdownEditor({ field, value, onChange, rows = 10 }: MarkdownEditorProps) { + const [isPreview, setIsPreview] = useState(false); + + const placeholder = `# ${field ? field : 'Markdown input'}\n\n**Write your _markdown_ content here...**`; + + return ( +
+
+ + +
+ + {isPreview ? ( +
+ {value} +
+ ) : ( +