From af68613d0e2906c4b911dd783564afe2eb8d279b Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Mon, 27 May 2024 11:03:16 +0200 Subject: [PATCH] Chore: Merge release/2.1.0 into main (#1027) * feat: introduce Header component * add HeaderConfig model in backend * Rename cards for result output * Log boardposition in results * Log response interval in result * fix for test 'updates score after a match' * Fix test for renaming cards in results * feat: add extra view for theme, request and set from ExperimentCollection * frontend changes to display header * Refactor: Componentify participant condition, loader container, and cleanup several imports (#917) * fix: Pass participant id to get experiment collection * fix(lint): Fix formatting in App.jsx * test: Update ExperimentCollectionDashboard.test.tsx with new tests and fix linting issues * refactor: Migrate Zustand store to Typescript and add optional Sentry error capture * type: Add Participant interface * revert: Use existing fetch participant functionality and make sure participant is loaded before fetching the experiment collection * refactor: Convert App.jsx & config.js to .tsx and .ts files * refactor: Update Participant "current" view to include participant_id_url field * refactor: Use participantIdUrl instead of participantId to link / redirect to experiments with pre-existing participant_id(_url) * refactor: Add LoaderContainer and ConditionalRender components * refactor: Update CongoSameDiff to get participant's group variant based on participant's id or random number * fix: Handle missing participant / participant id in experiment collection * test: Test link to experiment with participant id url param * refactor: Remove unused import in ExperimentCollection.tsx * fix: Fix linting warnings * Add comment * set cards initial state to {}, null throws an error during tests * optimize code * Optimize code * Optimize code * feat: working implementation to set Header * add unit test * reformat backend urls * reset response interval * Set first_card seen after posting the result and fix double asignment second_card * fix serializer, adjust variable conversion * stub tests * Chore(deps): Bump ejs from 3.1.9 to 3.1.10 in /frontend (#992) Bumps [ejs](https://github.com/mde/ejs) from 3.1.9 to 3.1.10. - [Release notes](https://github.com/mde/ejs/releases) - [Commits](https://github.com/mde/ejs/compare/v3.1.9...v3.1.10) --- updated-dependencies: - dependency-name: ejs dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update backend/theme/admin.py Co-authored-by: Drikus Roor * Chore(deps): Bump tqdm from 4.65.0 to 4.66.3 in /backend/requirements (#996) Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.65.0 to 4.66.3. - [Release notes](https://github.com/tqdm/tqdm/releases) - [Commits](https://github.com/tqdm/tqdm/compare/v4.65.0...v4.66.3) --- updated-dependencies: - dependency-name: tqdm dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * quick and diry fix: remove header * fix: Fix bug report template (#981) Resolves Fix bug issue template #977 * Fixed: Update final_action_with_optional_button and Final component (#1000) * chore: Update final_action_with_optional_button to handle and add participant_id_url in redirect URL if participant has participant_id_url * refactor: Update Final component to use Link instead of anchor tag for button navigation * fix: Refactor MarkdownPreview widget to be able to use multiple on one page (#991) * fix: do not call onNext() from within map * fix: do not call onNext from preloadResources function * code quality: remove timePassed condition * fix: loop over all sections also for non-buffer loading * feat: Update participant_id assignment in CongoSameDiff class (#1004) The participant_id assignment in the CongoSameDiff class has been updated to use the session's participant ID instead of generating using the participant_id_url property as a base for the pattern index. * feat: move Header to ExperimentCollectionDashboard * camelCase backend output * fix typo * roll back changes to DefaultPage * Revert "stub tests" This reverts commit 76e1863b2b7bac18e7e06265e9d072555ad226ad. * add DefaultPage test * add tests for conditional render of header * fix tests and linting issues * Clear buffers before preloading first section unless previous section was the same * Fix (CI): Fix frontend build in deployment to tst & acc (#1007) * config: Set SENTRY_ENVIRONMENT to "test" and "acceptance" in relevant files so Sentry knows which environment it is running on (#972) * ci: Update podman.yml workflow conditions The `if` conditions in the `podman.yml` workflow file have been updated to include additional checks for the `workflow_dispatch` event. This ensures that the workflow is triggered correctly for the workflow dispatch button in combination with either the `develop` or the `main` branch. * chore: Update podman.yml workflow variables to avoid the vite build failing due to missing environment variables This commit updates the `podman.yml` workflow file to include additional variables related to the frontend HTML. These variables are used for the favicon, logo URL, Open Graph (OG) description, OG image, OG title, OG URL, and body class. The variables are set to empty strings if not provided. This change ensures that the workflow has the necessary variables for the frontend HTML. * ci: Temporarily turn on deploy for acceptance on this branch * chore: Try if manually setting the favicon fixes things * ci: Update podman.yml workflow variables to include frontend HTML environment variables This commit updates the `podman.yml` workflow file to include additional variables related to the frontend HTML. These variables are used for the favicon, logo URL, Open Graph (OG) description, OG image, OG title, OG URL, and body class. The variables are set to empty strings if not provided. This change ensures that the workflow has the necessary variables for the frontend HTML. * ci: Test deploy to test environment * chore: Update podman.yml workflow conditions for develop branch * --- updated-dependencies: - dependency-name: requests dependency-type: direct:production ... Signed-off-by: dependabot[bot] * Update frontend/src/components/Header/Header.tsx Co-authored-by: Drikus Roor * Update frontend/src/components/Header/Header.tsx Co-authored-by: Drikus Roor * fix: adjust translation files * fix: first_round order * fix: `ready_time` be gone * fix: don't await onResult in Trial component * fix: Prefix reversed section url with BASE_URL if present (cherry picked from commit b42f64968acffa9baf54e6c4e4f930bb356cdb28) * chore: Add BASE_URL environment variable to production settings too (cherry picked from commit 0b4f6d8124187c7b89009f09b09d1cb6369d3673) * refactor: Fallback to "http://localhost:8000" even when Docker sets BASE_URL as an empty string (cherry picked from commit 0af40259898855017e3bdf64eaae8f3334fca1cc) * refactor: Strip trailing slash from base url Co-authored-by: Berit (cherry picked from commit 3ef339747d23564cec0827553b9d8451e33c2689) * fix: problem with questionnaires * remove await again * chore: Update package version to 2.1.0 --------- Signed-off-by: dependabot[bot] Co-authored-by: BeritJanssen Co-authored-by: Evert-R Co-authored-by: Evert-R <49793452+Evert-R@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/bug_report.md | 91 --------------- .github/ISSUE_TEMPLATE/bug_report.yml | 89 ++++++++++++++ backend/aml/urls.py | 1 + backend/experiment/actions/playback.py | 3 - backend/experiment/actions/utils.py | 9 +- backend/experiment/actions/wrappers.py | 1 - backend/experiment/rules/congosamediff.py | 7 +- backend/experiment/rules/eurovision_2020.py | 1 - backend/experiment/rules/hooked.py | 3 +- backend/experiment/rules/kuiper_2020.py | 1 - backend/experiment/rules/matching_pairs.py | 4 +- backend/experiment/rules/speech2song.py | 11 +- .../rules/tests/test_matching_pairs.py | 12 +- backend/experiment/serializers.py | 27 +++-- .../widgets/markdown_preview_text_input.html | 51 ++++---- backend/locale/nl/LC_MESSAGES/django.mo | Bin 46484 -> 46625 bytes backend/locale/nl/LC_MESSAGES/django.po | 2 - backend/participant/models.py | 2 +- backend/participant/views.py | 1 + backend/requirements/dev.txt | 4 +- backend/requirements/prod.txt | 4 +- backend/session/views.py | 3 - backend/theme/admin.py | 14 ++- backend/theme/migrations/0006_headerconfig.py | 22 ++++ backend/theme/models.py | 33 +----- backend/theme/serializers.py | 37 ++++++ backend/theme/tests/test_models.py | 60 +--------- backend/theme/tests/test_serializers.py | 109 ++++++++++++++++++ backend/theme/urls.py | 9 ++ backend/theme/views.py | 10 ++ frontend/.pnp.cjs | 8 +- frontend/src/API.js | 15 ++- frontend/src/components/App/App.jsx | 100 ---------------- frontend/src/components/App/App.tsx | 100 ++++++++++++++++ frontend/src/components/AppBar/AppBar.jsx | 6 +- .../ConditionalRender.test.tsx | 37 ++++++ .../ConditionalRender/ConditionalRender.tsx | 17 +++ frontend/src/components/Consent/Consent.jsx | 2 +- .../src/components/Experiment/Experiment.jsx | 28 ++--- .../ExperimentCollection.scss | 17 --- .../ExperimentCollection.tsx | 16 ++- .../ExperimentCollectionDashboard.test.tsx | 88 +++++++++++++- .../ExperimentCollectionDashboard.tsx | 37 +++--- frontend/src/components/Final/Final.jsx | 8 +- frontend/src/components/Header/Header.tsx | 25 ++++ .../LoaderContainer/LoaderContainer.jsx | 10 ++ .../MatchingPairs/MatchingPairs.jsx | 21 ++-- .../MatchingPairs/PlayCard.test.jsx | 1 - .../src/components/Page/DefaultPage.test.jsx | 36 ++++++ .../src/components/PlayButton/PlayButton.jsx | 2 +- frontend/src/components/Playback/Playback.jsx | 1 - .../src/components/Playback/Playback.test.jsx | 9 -- frontend/src/components/Preload/Preload.jsx | 36 +++--- frontend/src/components/Profile/Profile.jsx | 2 +- frontend/src/components/Reload/Reload.jsx | 2 +- .../components/StoreProfile/StoreProfile.jsx | 2 +- .../components/ToontjeHoger/ToontjeHoger.jsx | 2 +- frontend/src/components/Trial/Trial.jsx | 5 +- frontend/src/{config.js => config.ts} | 11 +- frontend/src/hooks/useResultHandler.js | 2 +- frontend/src/hooks/useResultHandler.test.js | 4 +- frontend/src/index.tsx | 2 +- frontend/src/types/ExperimentCollection.ts | 10 +- frontend/src/types/Participant.ts | 7 ++ frontend/src/types/Session.ts | 3 + frontend/src/types/Theme.ts | 16 +++ frontend/src/util/stores.js | 30 ----- frontend/src/util/stores.ts | 64 ++++++++++ frontend/yarn.lock | 6 +- package.json | 2 +- 70 files changed, 888 insertions(+), 523 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 backend/theme/migrations/0006_headerconfig.py create mode 100644 backend/theme/serializers.py create mode 100644 backend/theme/tests/test_serializers.py create mode 100644 backend/theme/urls.py create mode 100644 backend/theme/views.py delete mode 100644 frontend/src/components/App/App.jsx create mode 100644 frontend/src/components/App/App.tsx create mode 100644 frontend/src/components/ConditionalRender/ConditionalRender.test.tsx create mode 100644 frontend/src/components/ConditionalRender/ConditionalRender.tsx create mode 100644 frontend/src/components/Header/Header.tsx create mode 100644 frontend/src/components/LoaderContainer/LoaderContainer.jsx create mode 100644 frontend/src/components/Page/DefaultPage.test.jsx rename frontend/src/{config.js => config.ts} (68%) create mode 100644 frontend/src/types/Participant.ts create mode 100644 frontend/src/types/Session.ts create mode 100644 frontend/src/types/Theme.ts delete mode 100644 frontend/src/util/stores.js create mode 100644 frontend/src/util/stores.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index fe00c980e..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve MUSCLE -title: '' -labels: '' -assignees: '' -labels: [ - "bug" -] -body: - - type: textarea - id: description - attributes: - label: "Description" - description: Please enter an explicit description of your issue - placeholder: Short and explicit description of your incident... - validations: - required: true - - type: input - id: reprod-url - attributes: - label: "Reproduction URL" - description: Please enter your the URL to provide a reproduction of the issue - placeholder: ex. http://acc.amsterdammusiclab.nl/tunetwins - validations: - required: false - - type: textarea - id: reprod - attributes: - label: "Reproduction steps" - description: Please enter an explicit description of your issue - value: | - 1. Go to '...' - 2. Click on '....' - 3. Scroll down to '....' - 4. See error - render: bash - validations: - required: true - - type: textarea - id: screenshot - attributes: - label: "Screenshots" - description: If applicable, add screenshots to help explain your problem. - value: | - ![DESCRIPTION](LINK.png) - render: bash - validations: - required: false - - type: textarea - id: logs - attributes: - label: "Logs" - description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. - render: bash - validations: - required: false - - type: dropdown - id: browsers - attributes: - label: "Browsers" - description: What browsers are you seeing the problem on ? - multiple: true - options: - - Firefox - - Chrome - - Safari - - Microsoft Edge - - Opera - validations: - required: false - - type: dropdown - id: os - attributes: - label: "OS" - description: What is the impacted environment ? - multiple: true - options: - - Windows - - Linux - - Mac - validations: - required: false - type: textarea - id: context - attributes: - label: "Additional context" - description: Add any other context about the problem here. - placeholder: - validations: - required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..8b787700c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,89 @@ +name: "🐛 Bug Report" +title: "🐛 [BUG] - " +description: "Create a report to help us improve MUSCLE" +assignees: [] +labels: [ + "bug" +] +body: +- type: textarea + id: description + attributes: + label: "Description" + description: Please enter an explicit description of your issue + placeholder: | + Short and explicit description of your incident... + + ## Screenshots: + Please also post any screenshots of the bug you encountered + value: | + <!-- Short and explicit description of your incident... --> + + ### Screenshots + <!-- You can upload screenshots of the issue (if any) here... --> + + validations: + required: true +- type: input + id: reprod-url + attributes: + label: "Reproduction URL" + description: Please enter your the URL to provide a reproduction of the issue + placeholder: ex. http://acc.amsterdammusiclab.nl/tunetwins + validations: + required: false +- type: textarea + id: reprod + attributes: + label: "Reproduction steps" + description: Please enter an explicit description of your issue + value: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + render: bash + validations: + required: true +- type: textarea + id: logs + attributes: + label: "Logs" + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: bash + validations: + required: false +- type: dropdown + id: browsers + attributes: + label: "Browsers" + description: What browsers are you seeing the problem on? + multiple: true + options: + - Firefox + - Chrome + - Safari + - Microsoft Edge + - Opera + validations: + required: false +- type: dropdown + id: os + attributes: + label: "OS" + description: What is the impacted environment ? + multiple: true + options: + - Windows + - Linux + - Mac + validations: + required: false +- type: textarea + id: context + attributes: + label: "Additional context" + description: Add any other context about the problem here. + placeholder: + validations: + required: false diff --git a/backend/aml/urls.py b/backend/aml/urls.py index 9444450fc..e053b5883 100644 --- a/backend/aml/urls.py +++ b/backend/aml/urls.py @@ -31,6 +31,7 @@ path('result/', include('result.urls')), path('section/', include('section.urls')), path('session/', include('session.urls')), + path('theme/', include('theme.urls')), path('admin/', admin.site.urls), # Sentry debug (uncomment to test Sentry) diff --git a/backend/experiment/actions/playback.py b/backend/experiment/actions/playback.py index 33eec1f11..73aa6ba32 100644 --- a/backend/experiment/actions/playback.py +++ b/backend/experiment/actions/playback.py @@ -25,7 +25,6 @@ class Playback(BaseAction): - preload_message: text to display during preload - instruction: text to display during presentation of the sound - play_from: where in the audio file to start playing/ - - ready_time: how long to show the "Preload" view (loading spinner) - show_animation: whether to show animations with this player - mute: whether to mute the audio - timeout_after_playback: once playback has finished, add optional timeout (in seconds) before proceeding @@ -38,7 +37,6 @@ def __init__(self, preload_message='', instruction='', play_from=0, - ready_time=0, show_animation=False, mute=False, timeout_after_playback=None, @@ -54,7 +52,6 @@ def __init__(self, self.instruction = instruction self.play_from = play_from self.mute = mute - self.ready_time = ready_time self.timeout_after_playback = timeout_after_playback self.stop_audio_after = stop_audio_after self.resume_play = resume_play diff --git a/backend/experiment/actions/utils.py b/backend/experiment/actions/utils.py index 78672b636..b1994cc97 100644 --- a/backend/experiment/actions/utils.py +++ b/backend/experiment/actions/utils.py @@ -15,6 +15,13 @@ def final_action_with_optional_button(session, final_text='', title=_('End'), bu return a Final.action, which has a button to continue to the next experiment if series is defined """ collection_slug = session.load_json_data().get(COLLECTION_KEY) + + if session.participant.participant_id_url: + participant_id_url = session.participant.participant_id_url + redirect_url = f'/collection/{collection_slug}?participant_id_url={participant_id_url}' + else: + redirect_url = f'/collection/{collection_slug}' + if collection_slug: return Final( title=title, @@ -22,7 +29,7 @@ def final_action_with_optional_button(session, final_text='', title=_('End'), bu final_text=final_text, button={ 'text': button_text, - 'link': f'/collection/{collection_slug}' + 'link': redirect_url } ) else: diff --git a/backend/experiment/actions/wrappers.py b/backend/experiment/actions/wrappers.py index 25b27eb46..ca93d91c9 100644 --- a/backend/experiment/actions/wrappers.py +++ b/backend/experiment/actions/wrappers.py @@ -60,7 +60,6 @@ def song_sync(session: Session, section: Section, title: str, submits=True )]), playback=Autoplay([section], show_animation=True, - ready_time=3, preload_message=_('Get ready!'), instruction=_('Do you recognize the song?'), ), diff --git a/backend/experiment/rules/congosamediff.py b/backend/experiment/rules/congosamediff.py index a922899aa..df83f91ed 100644 --- a/backend/experiment/rules/congosamediff.py +++ b/backend/experiment/rules/congosamediff.py @@ -1,4 +1,5 @@ +import random import re import math import string @@ -112,10 +113,10 @@ def next_round(self, session: Session): groups_amount = session.playlist.section_set.values('group').distinct().count() variants_amount = real_trial_variants.count() - # get the participant's group variant - participant_id = session.participant.participant_id_url + # get the participant's group variant based on the participant's id # else default to random number between 1 and variants_amount + participant_id = session.participant.id participant_group_variant = self.get_participant_group_variant( - int(participant_id), + participant_id, group_number, groups_amount, variants_amount diff --git a/backend/experiment/rules/eurovision_2020.py b/backend/experiment/rules/eurovision_2020.py index d632fa752..ad5f48fb7 100644 --- a/backend/experiment/rules/eurovision_2020.py +++ b/backend/experiment/rules/eurovision_2020.py @@ -142,7 +142,6 @@ def next_heard_before_action(self, session): playback = Autoplay( sections = [section], show_animation=True, - ready_time=3, preload_message=_('Get ready!') ) expected_result=novelty[round_number] diff --git a/backend/experiment/rules/hooked.py b/backend/experiment/rules/hooked.py index f47426ba0..dc97fd4bf 100644 --- a/backend/experiment/rules/hooked.py +++ b/backend/experiment/rules/hooked.py @@ -82,9 +82,9 @@ def first_round(self, experiment): playlist = Playlist(experiment.playlists.all()) return [ - explainer, consent, playlist, + explainer, ] def next_round(self, session): @@ -305,7 +305,6 @@ def next_heard_before_action(self, session): playback = Autoplay( [section], show_animation=True, - ready_time=3, preload_message=_('Get ready!') ) expected_response = this_section_info.get('novelty') diff --git a/backend/experiment/rules/kuiper_2020.py b/backend/experiment/rules/kuiper_2020.py index e661299d7..95731f92c 100644 --- a/backend/experiment/rules/kuiper_2020.py +++ b/backend/experiment/rules/kuiper_2020.py @@ -132,7 +132,6 @@ def next_heard_before_action(self, session): playback = Autoplay( [section], show_animation=True, - ready_time=3, preload_message=_('Get ready!') ) expected_result=novelty[round_number] diff --git a/backend/experiment/rules/matching_pairs.py b/backend/experiment/rules/matching_pairs.py index 8bef62cbd..76b8de1e9 100644 --- a/backend/experiment/rules/matching_pairs.py +++ b/backend/experiment/rules/matching_pairs.py @@ -139,10 +139,10 @@ def calculate_score(self, result, data): def calculate_intermediate_score(self, session, result): ''' will be called every time two cards have been turned ''' result_data = json.loads(result) - first_card = result_data['lastCard'] + first_card = result_data['first_card'] first_section = Section.objects.get(pk=first_card['id']) first_card['filename'] = str(first_section.filename) - second_card = result_data['currentCard'] + second_card = result_data['second_card'] second_section = Section.objects.get(pk=second_card['id']) second_card['filename'] = str(second_section.filename) if first_section.group == second_section.group: diff --git a/backend/experiment/rules/speech2song.py b/backend/experiment/rules/speech2song.py index 05e170315..d90f23c18 100644 --- a/backend/experiment/rules/speech2song.py +++ b/backend/experiment/rules/speech2song.py @@ -182,7 +182,7 @@ def next_repeated_representation(session, is_speech, group_id=-1): section = session.playlist.section_set.get(group=group_id) else: section = session.previous_section() - actions = [sound(section, i) for i in range(1, n_representations+1)] + actions = [sound(section)] * n_representations actions.append(speech_or_sound_question(session, section, is_speech)) return actions @@ -226,15 +226,10 @@ def question_sound(session, section): ) -def sound(section, n_representation=None): - if n_representation and n_representation > 1: - ready_time = 0 - else: - ready_time = 1 +def sound(section): title = _('Listen carefully') playback = Autoplay( - sections = [section], - ready_time = ready_time, + sections=[section], ) view = Trial( playback=playback, diff --git a/backend/experiment/rules/tests/test_matching_pairs.py b/backend/experiment/rules/tests/test_matching_pairs.py index a7fc9abe5..e02602e7f 100644 --- a/backend/experiment/rules/tests/test_matching_pairs.py +++ b/backend/experiment/rules/tests/test_matching_pairs.py @@ -83,21 +83,21 @@ def test_intermediate_score(self): self.session.save() self.session_data = {'session_id': self.session.id} sections = self.playlist.section_set.all() - data = {'lastCard': {'id': sections[0].id}, - 'currentCard': {'id': sections[1].id}} + data = {'first_card': {'id': sections[0].id}, + 'second_card': {'id': sections[1].id}} result = self.intermediate_score_request(data) assert result.score == 10 assert result.given_response == 'lucky match' - data['currentCard'].update({'seen': True}) + data['second_card'].update({'seen': True}) result = self.intermediate_score_request(data) assert result.score == 20 assert result.given_response == 'match' - data['currentCard'] = {'id': sections[3].id, 'seen': True} + data['second_card'] = {'id': sections[3].id, 'seen': True} result = self.intermediate_score_request(data) assert result.score == -10 assert result.given_response == 'misremembered' - data['lastCard'].update({'seen': True}) - data['currentCard'].pop('seen') + data['first_card'].update({'seen': True}) + data['second_card'].pop('seen') result = self.intermediate_score_request(data) assert result.score == 0 assert result.given_response == 'no match' diff --git a/backend/experiment/serializers.py b/backend/experiment/serializers.py index 8d5e5cd70..4bbe2d529 100644 --- a/backend/experiment/serializers.py +++ b/backend/experiment/serializers.py @@ -5,6 +5,7 @@ from experiment.actions.consent import Consent from participant.models import Participant from session.models import Session +from theme.serializers import serialize_theme from .models import Experiment, ExperimentCollection, ExperimentCollectionGroup, GroupedExperiment @@ -18,24 +19,26 @@ def serialize_actions(actions): def serialize_experiment_collection( experiment_collection: ExperimentCollection ) -> dict: - about_content = experiment_collection.about_content - if about_content: - about_content = formatter(about_content, filter_name='markdown') - - if experiment_collection.consent: - consent = Consent(experiment_collection.consent).action() - else: - consent = None - - return { + serialized = { 'slug': experiment_collection.slug, 'name': experiment_collection.name, 'description': experiment_collection.description, - 'consent': consent, - 'about_content': about_content, } + if experiment_collection.consent: + serialized['consent'] = Consent(experiment_collection.consent).action() + + if experiment_collection.theme_config: + serialized['theme'] = serialize_theme( + experiment_collection.theme_config) + + if experiment_collection.about_content: + serialized['aboutContent'] = formatter( + experiment_collection.about_content, filter_name='markdown') + + return serialized + def serialize_experiment_collection_group(group: ExperimentCollectionGroup, participant: Participant) -> dict: grouped_experiments = list(GroupedExperiment.objects.filter( diff --git a/backend/experiment/templates/widgets/markdown_preview_text_input.html b/backend/experiment/templates/widgets/markdown_preview_text_input.html index 458fd042b..fbd3891ae 100644 --- a/backend/experiment/templates/widgets/markdown_preview_text_input.html +++ b/backend/experiment/templates/widgets/markdown_preview_text_input.html @@ -227,7 +227,6 @@ } set markdown(html) { - console.log('knalvis!', html); this.shadowRoot.getElementById('markdownContent').innerHTML = html; } } @@ -236,11 +235,7 @@ const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]').value; - function renderMarkdown() { - // get value - const textarea = document.querySelector('.markdown-preview-text-input textarea'); - const markdownPreview = document.getElementById('markdownPreview'); - + function renderMarkdown(textarea, markdownPreview) { // render markdown through http post request to /experiment/render_markdown return fetch('/experiment/render_markdown/', { method: 'POST', @@ -259,33 +254,39 @@ } document.addEventListener('DOMContentLoaded', function() { - const tabs = document.querySelectorAll('.markdown-preview-text-input .tab'); - const tabContents = document.querySelectorAll('.markdown-preview-text-input .tab-content'); - tabs.forEach(function(tab, index) { - tab.addEventListener('click', function() { - tabs.forEach(function(tab) { - tab.classList.remove('active'); - }); + const markdownWidgets = document.querySelectorAll('.markdown-preview-text-input'); - tab.classList.add('active'); + markdownWidgets.forEach(function(widget) { + const tabs = widget.querySelectorAll('.tab'); + const tabContents = widget.querySelectorAll('.tab-content'); - tabContents.forEach(function(tabContent) { - tabContent.classList.remove('active'); - }); + tabs.forEach(function(tab, index) { + tab.addEventListener('click', function() { + tabs.forEach(function(tab) { + tab.classList.remove('active'); + }); + + tab.classList.add('active'); + + tabContents.forEach(function(tabContent) { + tabContent.classList.remove('active'); + }); - tabContents[index].classList.add('active'); + tabContents[index].classList.add('active'); + }); }); - }); - const textarea = document.querySelector('.markdown-preview-text-input textarea'); - const markdownPreview = document.querySelector('.markdown-preview-text-input .markdown-preview'); + const textarea = widget.querySelector('textarea'); + const markdownPreview = widget.querySelector('#markdownPreview'); - textarea.addEventListener('blur', function() { - renderMarkdown(); - }); + textarea.addEventListener('blur', function() { + renderMarkdown(textarea, markdownPreview); + }); - renderMarkdown(); + renderMarkdown(textarea, markdownPreview); + }); + }); </script> diff --git a/backend/locale/nl/LC_MESSAGES/django.mo b/backend/locale/nl/LC_MESSAGES/django.mo index 303cace092e9fd294a47b5e4326b61529933ca4e..8ce2da1edc69d59e20e7a05216bc9bc3bca17355 100644 GIT binary patch delta 6482 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 S)J!&x&(9pU`}W+j7XJfzT!Qid delta 6347 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 BM8E(5 diff --git a/backend/locale/nl/LC_MESSAGES/django.po b/backend/locale/nl/LC_MESSAGES/django.po index fac9ef19b..1730f5c4a 100644 --- a/backend/locale/nl/LC_MESSAGES/django.po +++ b/backend/locale/nl/LC_MESSAGES/django.po @@ -2327,8 +2327,6 @@ msgstr "" "demografische achtergrond." #: experiment/rules/rhythm_battery_final.py:35 -#, fuzzy -#| msgid "After these questions, you will proceed to the final screen." msgid "After these questions, the experiment will proceed to the final screen." msgstr "Na deze vragen zal u het slotscherm te zien krijgen." diff --git a/backend/participant/models.py b/backend/participant/models.py index f703847e4..3015e92f6 100644 --- a/backend/participant/models.py +++ b/backend/participant/models.py @@ -42,7 +42,7 @@ def export_admin(self): "participant_id_url": self.participant_id_url, "profile": self.profile_object() } - + def export_profiles(self): # export participant profile result objects return self.result_set.all() diff --git a/backend/participant/views.py b/backend/participant/views.py index f40584cde..d71d34783 100644 --- a/backend/participant/views.py +++ b/backend/participant/views.py @@ -24,6 +24,7 @@ def current(request): 'id': participant.id, 'hash': participant.unique_hash, 'csrf_token': get_token(request), + 'participant_id_url': participant.participant_id_url, 'country': participant.country_code }, json_dumps_params={'indent': 4}) return response diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt index a9f360972..40b7bac48 100644 --- a/backend/requirements/dev.txt +++ b/backend/requirements/dev.txt @@ -124,7 +124,7 @@ pytz==2021.3 # pandas regex==2023.12.25 # via textile -requests==2.31.0 +requests==2.32.0 # via # -r requirements.in/dev.txt # genbadge @@ -154,7 +154,7 @@ tomli==2.0.1 # build # pip-tools # pyproject-hooks -tqdm==4.65.0 +tqdm==4.66.3 # via -r requirements.in\base.txt typing-extensions==3.10.0.2 # via diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt index 6fef7612f..433b64a5d 100644 --- a/backend/requirements/prod.txt +++ b/backend/requirements/prod.txt @@ -74,7 +74,7 @@ pytz==2023.3 # pandas regex==2023.12.25 # via textile -requests==2.31.0 +requests==2.32.0 # via genbadge roman==4.1 # via -r requirements.in/base.txt @@ -91,7 +91,7 @@ sqlparse==0.5.0 # via django textile==4.0.2 # via django-markup -tqdm==4.65.0 +tqdm==4.66.3 # via -r requirements.in/base.txt typing-extensions==4.6.3 # via asgiref diff --git a/backend/session/views.py b/backend/session/views.py index d1d5441f3..d0bfed8ac 100644 --- a/backend/session/views.py +++ b/backend/session/views.py @@ -1,6 +1,3 @@ -import json - -from django.conf import settings from django.http import Http404, JsonResponse, HttpResponseBadRequest from django.shortcuts import get_object_or_404, redirect from django.views.decorators.http import require_POST diff --git a/backend/theme/admin.py b/backend/theme/admin.py index 192744d28..4de6ca030 100644 --- a/backend/theme/admin.py +++ b/backend/theme/admin.py @@ -4,7 +4,7 @@ from django.contrib import admin from django.utils.html import format_html from django.utils.safestring import mark_safe -from .models import FooterConfig, ThemeConfig +from .models import FooterConfig, HeaderConfig, ThemeConfig from .forms import ThemeConfigForm, FooterConfigForm @@ -14,13 +14,18 @@ class FooterConfigInline(admin.StackedInline): fields = ['disclaimer', 'logos', 'privacy'] +class HeaderConfigInline(admin.StackedInline): + model = HeaderConfig + fields = ['show_score'] + + @admin.register(ThemeConfig) class ThemeConfigAdmin(admin.ModelAdmin): form = ThemeConfigForm - inlines = [FooterConfigInline] + inlines = [HeaderConfigInline, FooterConfigInline] - list_display = ('name', 'heading_font_preview', + list_display = ('name', 'header_overview', 'heading_font_preview', 'body_font_preview', 'logo_preview', 'background_preview', 'footer_overview') search_fields = ('name', 'description') ordering = ('name',) @@ -44,6 +49,9 @@ class ThemeConfigAdmin(admin.ModelAdmin): }), ) + def header_overview(self, obj): + return 'Header set' if obj.header else '' + def footer_overview(self, obj): return f'Footer with {obj.footer.logos.count()} logos' diff --git a/backend/theme/migrations/0006_headerconfig.py b/backend/theme/migrations/0006_headerconfig.py new file mode 100644 index 000000000..ee25a7bba --- /dev/null +++ b/backend/theme/migrations/0006_headerconfig.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.25 on 2024-04-24 15:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('theme', '0005_footerconfig'), + ] + + operations = [ + migrations.CreateModel( + name='HeaderConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('show_score', models.BooleanField(default=False)), + ('theme', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='header', to='theme.themeconfig')), + ], + ), + ] diff --git a/backend/theme/models.py b/backend/theme/models.py index 34a76cf26..a03d899df 100644 --- a/backend/theme/models.py +++ b/backend/theme/models.py @@ -1,13 +1,4 @@ -from os.path import join - from django.db import models -from django.conf import settings - - -def footer_info_upload_path(instance, filename): - """Generate path to save consent file based on experiment.slug""" - folder_name = instance.slug - return 'consent/{0}/{1}'.format(folder_name, filename) class ThemeConfig(models.Model): @@ -23,17 +14,6 @@ class ThemeConfig(models.Model): def __str__(self): return self.name - def to_json(self): - return { - 'name': self.name, - 'description': self.description, - 'heading_font_url': self.heading_font_url, - 'body_font_url': self.body_font_url, - 'logo_url': join(settings.MEDIA_URL, str(self.logo_image.file)) if self.logo_image else None, - 'background_url': join(settings.MEDIA_URL, str(self.background_image.file)) if self.background_image else None, - 'footer': self.footer.to_json() if hasattr(self, 'footer') else None - } - class FooterConfig(models.Model): theme = models.OneToOneField( @@ -43,11 +23,8 @@ class FooterConfig(models.Model): to='image.Image', blank=True, help_text='Add references to Image objects; make sure these have sufficient contrast with the background (image).') privacy = models.TextField(blank=True, default='') - def to_json(self): - return { - 'disclaimer': self.disclaimer, - 'logos': [ - join(settings.MEDIA_URL, str(logo.file)) for logo in self.logos.all() - ], - 'privacy': self.privacy - } + +class HeaderConfig(models.Model): + theme = models.OneToOneField( + ThemeConfig, on_delete=models.CASCADE, related_name='header') + show_score = models.BooleanField(default=False) diff --git a/backend/theme/serializers.py b/backend/theme/serializers.py new file mode 100644 index 000000000..980b2bda3 --- /dev/null +++ b/backend/theme/serializers.py @@ -0,0 +1,37 @@ +from os.path import join + +from django.conf import settings +from django.utils.translation import activate, gettext_lazy as _ + +from theme.models import FooterConfig, HeaderConfig, ThemeConfig + + +def serialize_footer(footer: FooterConfig) -> dict: + return { + 'disclaimer': footer.disclaimer, + 'logos': [ + join(settings.MEDIA_URL, str(logo.file)) for logo in footer.logos.all() + ], + 'privacy': footer.privacy + } + + +def serialize_header(header: HeaderConfig) -> dict: + return { + 'nextExperimentButtonText': _('Next experiment'), + 'aboutButtonText': _('About us'), + 'showScore': header.show_score + } + + +def serialize_theme(theme: ThemeConfig) -> dict: + return { + 'name': theme.name, + 'description': theme.description, + 'headingFontUrl': theme.heading_font_url, + 'bodyFontUrl': theme.body_font_url, + 'logoUrl': join(settings.MEDIA_URL, str(theme.logo_image.file)) if theme.logo_image else None, + 'backgroundUrl': join(settings.MEDIA_URL, str(theme.background_image.file)) if theme.background_image else None, + 'footer': serialize_footer(theme.footer) if hasattr(theme, 'footer') else None, + 'header': serialize_header(theme.header) if hasattr(theme, 'header') else None + } diff --git a/backend/theme/tests/test_models.py b/backend/theme/tests/test_models.py index b1ed077af..a7e3b062e 100644 --- a/backend/theme/tests/test_models.py +++ b/backend/theme/tests/test_models.py @@ -1,8 +1,7 @@ from django.test import TestCase -from django.conf import settings from image.models import Image -from theme.models import FooterConfig, ThemeConfig +from theme.models import ThemeConfig class ThemeConfigModelTest(TestCase): @@ -22,64 +21,7 @@ def setUpTestData(cls): logo_image=logo_image, background_image=background_image, ) - cls.footer = FooterConfig.objects.create( - theme=theme, - disclaimer='Some [more information][https://example.com/our-team]' - ) - cls.footer.logos.add(logo_image) - cls.footer.logos.add(background_image) def test_theme_config_str(self): theme_config = ThemeConfig.objects.get(name='Default') self.assertEqual(str(theme_config), 'Default') - - def test_footer_to_json(self): - expected_json = { - 'disclaimer': 'Some [more information][https://example.com/our-team]', - 'logos': [f'{settings.MEDIA_URL}someimage.jpg', f'{settings.MEDIA_URL}anotherimage.png'], - 'privacy': '' - } - self.assertEqual(self.footer.to_json(), expected_json) - - def test_theme_config_to_json(self): - theme_config = ThemeConfig.objects.get(name='Default') - expected_json = { - 'name': 'Default', - 'description': 'Default theme configuration', - 'heading_font_url': 'https://example.com/heading_font', - 'body_font_url': 'https://example.com/body_font', - 'logo_url': f'{settings.MEDIA_URL}someimage.jpg', - 'background_url': f'{settings.MEDIA_URL}anotherimage.png', - 'footer': self.footer.to_json(), - } - self.assertEqual(theme_config.to_json(), expected_json) - - def test_theme_serialization_no_image(self): - theme_config = ThemeConfig.objects.get(name='Default') - theme_config.background_image = None - theme_config.logo_image = None - theme_config.save() - expected_json = { - 'name': 'Default', - 'description': 'Default theme configuration', - 'heading_font_url': 'https://example.com/heading_font', - 'body_font_url': 'https://example.com/body_font', - 'logo_url': None, - 'background_url': None, - 'footer': self.footer.to_json() - } - self.assertEqual(theme_config.to_json(), expected_json) - - def test_theme_serialization_no_footer(self): - theme_config = ThemeConfig.objects.get(name='Default') - self.footer.delete() - expected_json = { - 'name': 'Default', - 'description': 'Default theme configuration', - 'heading_font_url': 'https://example.com/heading_font', - 'body_font_url': 'https://example.com/body_font', - 'logo_url': f'{settings.MEDIA_URL}someimage.jpg', - 'background_url': f'{settings.MEDIA_URL}anotherimage.png', - 'footer': None, - } - self.assertEqual(theme_config.to_json(), expected_json) diff --git a/backend/theme/tests/test_serializers.py b/backend/theme/tests/test_serializers.py new file mode 100644 index 000000000..a827fdeac --- /dev/null +++ b/backend/theme/tests/test_serializers.py @@ -0,0 +1,109 @@ +from django.conf import settings +from django.test import TestCase + +from image.models import Image +from theme.models import FooterConfig, HeaderConfig, ThemeConfig +from theme.serializers import serialize_footer, serialize_header, serialize_theme + + +class ThemeConfigSerializerTest(TestCase): + @classmethod + def setUpTestData(cls): + logo_image = Image.objects.create( + file='someimage.jpg' + ) + background_image = Image.objects.create( + file='anotherimage.png' + ) + cls.theme = ThemeConfig.objects.create( + name='Default', + description='Default theme configuration', + heading_font_url='https://example.com/heading_font', + body_font_url='https://example.com/body_font', + logo_image=logo_image, + background_image=background_image, + ) + cls.footer = FooterConfig.objects.create( + theme=cls.theme, + disclaimer='Some [more information][https://example.com/our-team]' + ) + cls.footer.logos.add(logo_image) + cls.footer.logos.add(background_image) + cls.header = HeaderConfig.objects.create( + theme=cls.theme, + show_score=True + ) + + def test_footer_serializer(self): + expected_json = { + 'disclaimer': 'Some [more information][https://example.com/our-team]', + 'logos': [f'{settings.MEDIA_URL}someimage.jpg', f'{settings.MEDIA_URL}anotherimage.png'], + 'privacy': '' + } + self.assertEqual(serialize_footer(self.footer), expected_json) + + def test_header_serializer(self): + expected_json = { + 'showScore': True, + 'nextExperimentButtonText': 'Next experiment', + 'aboutButtonText': 'About us' + } + self.assertEqual(serialize_header(self.header), expected_json) + + def test_theme_config_serializer(self): + expected_json = { + 'name': 'Default', + 'description': 'Default theme configuration', + 'headingFontUrl': 'https://example.com/heading_font', + 'bodyFontUrl': 'https://example.com/body_font', + 'logoUrl': f'{settings.MEDIA_URL}someimage.jpg', + 'backgroundUrl': f'{settings.MEDIA_URL}anotherimage.png', + 'footer': serialize_footer(self.footer), + 'header': serialize_header(self.header), + } + self.assertEqual(serialize_theme(self.theme), expected_json) + + def test_theme_serialization_no_image(self): + theme_config = self.theme + theme_config.background_image = None + theme_config.logo_image = None + theme_config.save() + expected_json = { + 'name': 'Default', + 'description': 'Default theme configuration', + 'headingFontUrl': 'https://example.com/heading_font', + 'bodyFontUrl': 'https://example.com/body_font', + 'logoUrl': None, + 'backgroundUrl': None, + 'footer': serialize_footer(self.footer), + 'header': serialize_header(self.header), + } + self.assertEqual(serialize_theme(theme_config), expected_json) + + def test_theme_serialization_no_footer(self): + self.theme.footer = None + expected_json = { + 'name': 'Default', + 'description': 'Default theme configuration', + 'headingFontUrl': 'https://example.com/heading_font', + 'bodyFontUrl': 'https://example.com/body_font', + 'logoUrl': f'{settings.MEDIA_URL}someimage.jpg', + 'backgroundUrl': f'{settings.MEDIA_URL}anotherimage.png', + 'header': serialize_header(self.header), + 'footer': None, + } + self.assertEqual(serialize_theme(self.theme), expected_json) + + def test_theme_serialization_no_header(self): + self.theme.header = None + expected_json = { + 'name': 'Default', + 'description': 'Default theme configuration', + 'headingFontUrl': 'https://example.com/heading_font', + 'bodyFontUrl': 'https://example.com/body_font', + 'logoUrl': f'{settings.MEDIA_URL}someimage.jpg', + 'backgroundUrl': f'{settings.MEDIA_URL}anotherimage.png', + 'header': None, + 'footer': serialize_footer(self.footer), + } + self.assertEqual(serialize_theme(self.theme), expected_json) diff --git a/backend/theme/urls.py b/backend/theme/urls.py new file mode 100644 index 000000000..ae8ace65d --- /dev/null +++ b/backend/theme/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from .views import get_theme + + +app_name = 'theme' + +urlpatterns = [ + path('<int:theme_id>/', get_theme), +] diff --git a/backend/theme/views.py b/backend/theme/views.py new file mode 100644 index 000000000..fcb531440 --- /dev/null +++ b/backend/theme/views.py @@ -0,0 +1,10 @@ +from django.http import JsonResponse +from django.shortcuts import get_object_or_404 + +from theme.models import ThemeConfig +from theme.serializers import serialize_theme + + +def get_theme(request, theme_id): + theme = get_object_or_404(ThemeConfig, pk=theme_id) + return JsonResponse(serialize_theme(theme)) diff --git a/frontend/.pnp.cjs b/frontend/.pnp.cjs index c74cb3ec2..bd14d39e3 100755 --- a/frontend/.pnp.cjs +++ b/frontend/.pnp.cjs @@ -6984,7 +6984,7 @@ const RAW_RUNTIME_STATE = ["@types/ejs", "npm:3.1.5"],\ ["@yarnpkg/esbuild-plugin-pnp", "virtual:ed7f28c16858fb8f8fe7d7ad57120748a0b415126c036bc8d4f70d1c1979e38146d563b7d278fd516bbefc62e6467d41e0e8ebb3bbd5fbfa66bd7d36551f271c#npm:3.0.0-rc.15"],\ ["browser-assert", "npm:1.2.1"],\ - ["ejs", "npm:3.1.9"],\ + ["ejs", "npm:3.1.10"],\ ["esbuild", "npm:0.20.2"],\ ["esbuild-plugin-alias", "npm:0.2.1"],\ ["express", "npm:4.19.2"],\ @@ -10963,10 +10963,10 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["ejs", [\ - ["npm:3.1.9", {\ - "packageLocation": "../../../.yarn/berry/cache/ejs-npm-3.1.9-e201b2088c-10c0.zip/node_modules/ejs/",\ + ["npm:3.1.10", {\ + "packageLocation": "../../../.yarn/berry/cache/ejs-npm-3.1.10-4e8cf4bdc1-10c0.zip/node_modules/ejs/",\ "packageDependencies": [\ - ["ejs", "npm:3.1.9"],\ + ["ejs", "npm:3.1.10"],\ ["jake", "npm:10.8.7"]\ ],\ "linkType": "HARD"\ diff --git a/frontend/src/API.js b/frontend/src/API.js index c3c9d1bef..c23bf043d 100644 --- a/frontend/src/API.js +++ b/frontend/src/API.js @@ -1,4 +1,4 @@ -import { API_BASE_URL } from "./config"; +import { API_BASE_URL } from "@/config"; import useGet from "./util/useGet"; import axios from "axios"; import qs from "qs"; @@ -15,7 +15,7 @@ export const URLS = { feedback: (slug) => "/experiment/" + slug + "/feedback/", }, experiment_collection: { - get: (slug) => "/experiment/collection/" + slug + "/" + get: (slug) => `/experiment/collection/${slug}/` }, participant: { current: "/participant/", @@ -36,13 +36,18 @@ export const URLS = { next_round: (id) => "/session/" + id + "/next_round/", finalize: (id) => "/session/" + id + "/finalize/" }, + theme: { + get: (id) => `/theme/${id}`, + } }; export const useExperiment = (slug) => useGet(API_BASE_URL + URLS.experiment.get(slug)); -export const useExperimentCollection = (slug) => - useGet(API_BASE_URL + URLS.experiment_collection.get(slug)); +export const useExperimentCollection = (slug) => { + const data = useGet(API_BASE_URL + URLS.experiment_collection.get(slug)); + return data; // snakeToCamel(collection), loading +} export const useParticipantScores = () => useGet(API_BASE_URL + URLS.participant.score); @@ -222,4 +227,4 @@ export const postFeedback = async({ experimentSlug, feedback, participant }) => console.error(err); return null; } -} \ No newline at end of file +} diff --git a/frontend/src/components/App/App.jsx b/frontend/src/components/App/App.jsx deleted file mode 100644 index 716633611..000000000 --- a/frontend/src/components/App/App.jsx +++ /dev/null @@ -1,100 +0,0 @@ -import {useEffect, React} from "react"; -import { - BrowserRouter as Router, - Switch, - Route, - Redirect -} from "react-router-dom"; -import axios from "axios"; - -import { API_BASE_URL, EXPERIMENT_SLUG, URLS } from "../../config.js"; -import { URLS as API_URLS } from "../../API.js"; -import useBoundStore from "../../util/stores.js"; -import Experiment from "../Experiment/Experiment"; -import ExperimentCollection from "../ExperimentCollection/ExperimentCollection"; -import Loading from "../Loading/Loading"; -import Profile from "../Profile/Profile"; -import Reload from "../Reload/Reload"; -import StoreProfile from "../StoreProfile/StoreProfile"; -import useDisableRightClickOnTouchDevices from "../../hooks/useDisableRightClickOnTouchDevices.js"; - - -// App is the root component of our application -const App = () => { - const error = useBoundStore(state => state.error); - const setError = useBoundStore(state => state.setError); - const participant = useBoundStore((state) => state.participant); - const setParticipant = useBoundStore((state) => state.setParticipant); - const queryParams = window.location.search; - - useDisableRightClickOnTouchDevices(); - - useEffect(() => { - const urlParams = new URLSearchParams(queryParams); - const participantId = urlParams.get('participant_id'); - let participantQueryParams = ''; - if (participantId) { - participantQueryParams = `?participant_id=${participantId}`; - } - try { - axios.get(API_BASE_URL + API_URLS.participant.current + participantQueryParams).then(response => { - setParticipant(response.data); - }); - } catch (err) { - console.error(err); - setError('Could not load participant'); - } - }, [setError, queryParams, setParticipant]) - - if (error) { - return <p className="aha__error">Error: {error}</p>; - } - - return ( - <Router className="aha__app"> - { !participant? ( - <div className="loader-container"> - <Loading /> - </div> - ) : ( - <Switch> - {/* Request reload for given participant */} - <Route path={URLS.reloadParticipant}> - <Reload/> - </Route> - - {/* Default experiment */} - <Route path="/" exact> - <Redirect - to={URLS.experiment.replace(":slug", EXPERIMENT_SLUG)} - /> - </Route> - - {/* Profile */} - <Route path={URLS.profile} exact> - <Profile slug={EXPERIMENT_SLUG} /> - </Route> - - {/* Experiment Collection */} - <Route path={URLS.experimentCollection} component={ExperimentCollection} /> - - {/* Experiment */} - <Route path={URLS.experiment} component={Experiment} /> - - <Route path={URLS.session} /> - - {/* Store profile */} - <Route - path={URLS.storeProfile} - exact - component={StoreProfile} - /> - - - </Switch> - )} - </Router> - ); -}; - -export default App; diff --git a/frontend/src/components/App/App.tsx b/frontend/src/components/App/App.tsx new file mode 100644 index 000000000..9563c413e --- /dev/null +++ b/frontend/src/components/App/App.tsx @@ -0,0 +1,100 @@ +import { useEffect } from "react"; +import { + BrowserRouter as Router, + Switch, + Route, + Redirect +} from "react-router-dom"; +import axios from "axios"; + +import { API_BASE_URL, EXPERIMENT_SLUG, URLS } from "@/config"; +import { URLS as API_URLS } from "../../API"; +import useBoundStore from "../../util/stores"; +import Experiment from "../Experiment/Experiment"; +import ExperimentCollection from "../ExperimentCollection/ExperimentCollection"; +import LoaderContainer from "../LoaderContainer/LoaderContainer"; +import ConditionalRender from "../ConditionalRender/ConditionalRender"; +import Profile from "../Profile/Profile"; +import Reload from "../Reload/Reload"; +import StoreProfile from "../StoreProfile/StoreProfile"; +import useDisableRightClickOnTouchDevices from "../../hooks/useDisableRightClickOnTouchDevices"; + + +// App is the root component of our application +const App = () => { + const error = useBoundStore(state => state.error); + const setError = useBoundStore(state => state.setError); + const participant = useBoundStore((state) => state.participant); + const setParticipant = useBoundStore((state) => state.setParticipant); + const setParticipantLoading = useBoundStore((state) => state.setParticipantLoading); + const queryParams = window.location.search; + + useDisableRightClickOnTouchDevices(); + + useEffect(() => { + const urlParams = new URLSearchParams(queryParams); + const participantId = urlParams.get('participant_id'); + let participantQueryParams = ''; + if (participantId) { + participantQueryParams = `?participant_id=${participantId}`; + } + try { + axios.get(API_BASE_URL + API_URLS.participant.current + participantQueryParams).then(response => { + setParticipant(response.data); + }); + } catch (err) { + console.error(err); + setError('Could not load participant', err); + } finally { + setParticipantLoading(false); + } + }, [setError, queryParams, setParticipant]) + + if (error) { + return <p className="aha__error">Error: {error}</p>; + } + + return ( + <Router className="aha__app"> + <ConditionalRender condition={!!participant} fallback={<LoaderContainer />}> + <Switch> + {/* Request reload for given participant */} + <Route path={URLS.reloadParticipant}> + <Reload /> + </Route> + + {/* Default experiment */} + <Route path="/" exact> + <Redirect + to={URLS.experiment.replace(":slug", EXPERIMENT_SLUG)} + /> + </Route> + + {/* Profile */} + <Route path={URLS.profile} exact> + <Profile slug={EXPERIMENT_SLUG} /> + </Route> + + {/* Experiment Collection */} + <Route path={URLS.experimentCollection} component={ExperimentCollection} /> + + {/* Experiment */} + <Route path={URLS.experiment} component={Experiment} /> + + <Route path={URLS.session} /> + + {/* Store profile */} + <Route + path={URLS.storeProfile} + exact + component={StoreProfile} + /> + </Switch> + </ConditionalRender> + + + </Router > + ); +}; + +export default App; diff --git a/frontend/src/components/AppBar/AppBar.jsx b/frontend/src/components/AppBar/AppBar.jsx index 1589181a5..7d13df5c3 100644 --- a/frontend/src/components/AppBar/AppBar.jsx +++ b/frontend/src/components/AppBar/AppBar.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { API_BASE_URL, URLS, LOGO_URL, LOGO_TITLE } from "../../config"; +import { API_BASE_URL, URLS, LOGO_URL, LOGO_TITLE } from "@/config"; import { Link } from "react-router-dom"; import useBoundStore from "@/util/stores"; @@ -39,11 +39,11 @@ const AppBar = ({ title, logoClickConfirm = null }) => { ); return ( - <nav className="aha__app-bar navbar bg-black"> + <div className="aha__app-bar navbar bg-black"> {logo} <h4 className="title text-light">{title}</h4> <span className="action-right"></span> - </nav> + </div> ); }; diff --git a/frontend/src/components/ConditionalRender/ConditionalRender.test.tsx b/frontend/src/components/ConditionalRender/ConditionalRender.test.tsx new file mode 100644 index 000000000..22e85a637 --- /dev/null +++ b/frontend/src/components/ConditionalRender/ConditionalRender.test.tsx @@ -0,0 +1,37 @@ +import ConditionalRender from "./ConditionalRender"; +import { render, screen } from '@testing-library/react'; +import { it, expect, describe } from "vitest"; + +describe("ConditionalRender Component", () => { + it("should render children when condition is true", () => { + render( + <ConditionalRender condition={true} fallback={<div>fallback</div>}> + <div>children</div> + </ConditionalRender> + ); + + expect(document.body.contains(screen.getByText("children"))).toBe(true); + expect(document.body.contains(screen.queryByText("fallback"))).toBe(false); + }); + + it("should render fallback when condition is false", () => { + render( + <ConditionalRender condition={false} fallback={<div>fallback</div>}> + <div>children</div> + </ConditionalRender> + ); + + expect(document.body.contains(screen.getByText("fallback"))).toBe(true); + expect(document.body.contains(screen.queryByText("children"))).toBe(false); + }); + + it("should render nothing when fallback is not provided and condition is false", () => { + const { container } = render( + <ConditionalRender condition={false}> + <div>children</div> + </ConditionalRender> + ); + + expect(container.firstChild).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/ConditionalRender/ConditionalRender.tsx b/frontend/src/components/ConditionalRender/ConditionalRender.tsx new file mode 100644 index 000000000..d7321bce7 --- /dev/null +++ b/frontend/src/components/ConditionalRender/ConditionalRender.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface ConditionalRenderProps { + condition: boolean; + children?: React.ReactNode; + fallback?: React.ReactNode; +} + +const ConditionalRender = ({ condition, children, fallback }: ConditionalRenderProps) => { + if (condition) { + return children; + } + + return fallback || null; +}; + +export default ConditionalRender; diff --git a/frontend/src/components/Consent/Consent.jsx b/frontend/src/components/Consent/Consent.jsx index 255ecfa1c..eb237159b 100644 --- a/frontend/src/components/Consent/Consent.jsx +++ b/frontend/src/components/Consent/Consent.jsx @@ -1,7 +1,7 @@ import React, { useEffect } from "react"; import { saveAs } from 'file-saver'; -import { URLS } from "../../config"; +import { URLS } from "@/config"; import Button from "../Button/Button"; import Loading from "../Loading/Loading"; import { createConsent, useConsent } from "../../API"; diff --git a/frontend/src/components/Experiment/Experiment.jsx b/frontend/src/components/Experiment/Experiment.jsx index 9f65f6ccb..4a3cb5f99 100644 --- a/frontend/src/components/Experiment/Experiment.jsx +++ b/frontend/src/components/Experiment/Experiment.jsx @@ -3,22 +3,22 @@ import { TransitionGroup, CSSTransition } from "react-transition-group"; import { withRouter } from "react-router-dom"; import classNames from "classnames"; -import useBoundStore from "../../util/stores"; -import { createSession, getNextRound, useExperiment } from "../../API"; -import Consent from "../Consent/Consent"; -import DefaultPage from "../Page/DefaultPage"; -import ToontjeHoger from "../ToontjeHoger/ToontjeHoger"; -import Explainer from "../Explainer/Explainer"; -import Final from "../Final/Final"; -import Loading from "../Loading/Loading"; -import Playlist from "../Playlist/Playlist"; -import Score from "../Score/Score"; -import Trial from "../Trial/Trial"; -import useResultHandler from "../../hooks/useResultHandler"; -import Info from "../Info/Info"; +import useBoundStore from "@/util/stores"; +import { createSession, getNextRound, useExperiment } from "@/API"; +import Consent from "@/components/Consent/Consent"; +import DefaultPage from "@/components/Page/DefaultPage"; +import ToontjeHoger from "@/components/ToontjeHoger/ToontjeHoger"; +import Explainer from "@/components/Explainer/Explainer"; +import Final from "@/components/Final/Final"; +import Loading from "@/components/Loading/Loading"; +import Playlist from "@/components/Playlist/Playlist"; +import Score from "@/components/Score/Score"; +import Trial from "@/components/Trial/Trial"; +import Info from "@/components/Info/Info"; import FloatingActionButton from "@/components/FloatingActionButton/FloatingActionButton"; import UserFeedback from "@/components/UserFeedback/UserFeedback"; import FontLoader from "@/components/FontLoader/FontLoader"; +import useResultHandler from "@/hooks/useResultHandler"; // Experiment handles the main experiment flow: // - Loads the experiment and participant @@ -72,7 +72,7 @@ const Experiment = ({ match }) => { return newSession; } catch (err) { - setError(`Could not create a session: ${err}`) + setError(`Could not create a session: ${err}`, err) }; }; diff --git a/frontend/src/components/ExperimentCollection/ExperimentCollection.scss b/frontend/src/components/ExperimentCollection/ExperimentCollection.scss index 60c695eb5..f5688d470 100644 --- a/frontend/src/components/ExperimentCollection/ExperimentCollection.scss +++ b/frontend/src/components/ExperimentCollection/ExperimentCollection.scss @@ -1,21 +1,4 @@ .aha__collection { - // this scss is adopted from Toontjehoger - // logo / hero / score / about will also be adopted in time. - .logo { - height: 90px; - width: 100%; - max-width: 300px; - font-size: 0; - margin: 0 auto 15px; - background-size: contain; - background-position: center center; - background-repeat: no-repeat; - display: block; - - @media (max-width: 720px) { - margin-bottom: 15px; - } - } .hero { display: flex; diff --git a/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx b/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx index 7e7c328d7..32f27472c 100644 --- a/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx +++ b/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx @@ -7,26 +7,30 @@ import { } from "react-router-dom"; import useBoundStore from "../../util/stores"; -import { useExperimentCollection } from "../../API"; +import { useExperimentCollection } from "@/API"; import Consent from "../Consent/Consent"; import DefaultPage from "../Page/DefaultPage"; import Loading from "../Loading/Loading"; import ExperimentCollectionAbout from "./ExperimentCollectionAbout/ExperimentCollectionAbout"; import ExperimentCollectionDashboard from "./ExperimentCollectionDashboard/ExperimentCollectionDashboard"; -import { URLS } from "../../config"; +import { URLS } from "@/config"; import IExperimentCollection from "@/types/ExperimentCollection"; +import IParticipant from "@/types/Participant"; interface RouteParams { slug: string } interface ExperimentCollectionProps extends RouteComponentProps<RouteParams> { + participant: IParticipant } const ExperimentCollection = ({ match }: ExperimentCollectionProps) => { const [experimentCollection, loadingExperimentCollection] = useExperimentCollection(match.params.slug) as [IExperimentCollection, boolean]; const [hasShownConsent, setHasShownConsent] = useState(false); const participant = useBoundStore((state) => state.participant); + const setTheme = useBoundStore((state) => state.setTheme); + const participantIdUrl = participant?.participant_id_url; const nextExperiment = experimentCollection?.next_experiment; const displayDashboard = experimentCollection?.dashboard.length; const showConsent = experimentCollection?.consent; @@ -35,6 +39,8 @@ const ExperimentCollection = ({ match }: ExperimentCollectionProps) => { setHasShownConsent(true); } + const getExperimentHref = (slug: string) => `/${slug}${participantIdUrl ? `?participant_id=${participantIdUrl}` : ""}`; + if (loadingExperimentCollection) { return ( <div className="loader-container"> @@ -59,14 +65,14 @@ const ExperimentCollection = ({ match }: ExperimentCollectionProps) => { } if (!displayDashboard && nextExperiment) { - return <Redirect to={"/" + nextExperiment.slug} />; + return <Redirect to={getExperimentHref(nextExperiment.slug)} /> } return ( <div className="aha__collection"> <Switch> - <Route path={URLS.experimentCollectionAbout} component={() => <ExperimentCollectionAbout content={experimentCollection?.about_content} slug={experimentCollection.slug} />} /> - <Route path={URLS.experimentCollection} exact component={() => <ExperimentCollectionDashboard experimentCollection={experimentCollection} />} /> + <Route path={URLS.experimentCollectionAbout} component={() => <ExperimentCollectionAbout content={experimentCollection?.aboutContent} slug={experimentCollection.slug} />} /> + <Route path={URLS.experimentCollection} exact component={() => <ExperimentCollectionDashboard experimentCollection={experimentCollection} participantIdUrl={participantIdUrl} />} /> </Switch> </div> ) diff --git a/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.test.tsx b/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.test.tsx index 46f33adf6..d2e947002 100644 --- a/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.test.tsx +++ b/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.test.tsx @@ -7,8 +7,11 @@ import Experiment from '@/types/Experiment'; const getExperiment = (overrides = {}) => { return { + id: 1, slug: 'some_slug', name: 'Some Experiment', + description: 'Some description', + image: '', started_session_count: 2, finished_session_count: 1, ...overrides @@ -16,23 +19,45 @@ const getExperiment = (overrides = {}) => { } const experiment1 = getExperiment({ + id: 1, slug: 'some_slug', - name: 'Some Experiment' + name: 'Some Experiment', + description: null, }); const experiment2 = getExperiment({ + id: 2, slug: 'another_slug', name: 'Another Experiment', - finished_session_count: 2 + finished_session_count: 2, + description: 'Some description', }); -const experimentWithAllProps = getExperiment({ image: 'some_image.jpg', description: 'Some description' }); +const collectionWithDashboard = { dashboard: [experiment1, experiment2] } + +const header = { + nextExperimentButtonText: 'Next experiment', + aboutButtonText: 'About us', + showScore: true +} +const collectionWithTheme = { + dashboard: [experiment1, experiment2], + theme: { + backgroundUrl: 'some/url.com', + bodyFontUrl: 'font/url.com', + description: 'description of the theme', + headingFontUrl: 'another/font/url.com', + logoUrl: 'where/is/the/logo.jpg', + name: 'Collection name', + header: header + } +} describe('ExperimentCollectionDashboard', () => { it('shows a dashboard of multiple experiments if it receives an array', async () => { render( <MemoryRouter> - <ExperimentCollectionDashboard experimentCollection={{ dashboard: [experiment1, experiment2] }} /> + <ExperimentCollectionDashboard experimentCollection={collectionWithDashboard} /> </MemoryRouter> ); await waitFor(() => { @@ -43,4 +68,59 @@ describe('ExperimentCollectionDashboard', () => { expect(counters[1].innerHTML).toBe(experiment1.finished_session_count.toString()); }) }); + + it('shows a placeholder if an experiment has no image', async () => { + render( + <MemoryRouter> + <ExperimentCollectionDashboard experimentCollection={collectionWithDashboard} /> + </MemoryRouter> + ); + await waitFor(() => { + expect(screen.getByRole('menu')).toBeTruthy(); + expect(screen.getByRole('menu').querySelector('.placeholder')).toBeTruthy(); + }); + }); + + it('links to the experiment with the correct slug', async () => { + render( + <MemoryRouter> + <ExperimentCollectionDashboard experimentCollection={collectionWithDashboard} /> + </MemoryRouter> + ); + await waitFor(() => { + expect(screen.getByRole('menu')).toBeTruthy(); + expect(screen.getByRole('menu').querySelector('a').getAttribute('href')).toBe('/some_slug'); + }); + }); + + it('links to the experiment with the correct slug and participant id if the participand id url is present', async () => { + render( + <MemoryRouter> + <ExperimentCollectionDashboard experimentCollection={collectionWithDashboard} participantIdUrl="some_id" /> + </MemoryRouter> + ); + await waitFor(() => { + expect(screen.getByRole('menu')).toBeTruthy(); + expect(screen.getByRole('menu').querySelector('a').getAttribute('href')).toBe('/some_slug?participant_id=some_id'); + }); + }); + + it('does not show a header if no theme.header is present', () => { + render( + <MemoryRouter> + <ExperimentCollectionDashboard experimentCollection={collectionWithDashboard} participantIdUrl="some_id" /> + </MemoryRouter> + ); + const aboutButton = screen.queryByText('About us') + expect(aboutButton).toBeFalsy(); + }); + + it('shows a header if a theme.header is present', async () => { + render( + <MemoryRouter> + <ExperimentCollectionDashboard experimentCollection={collectionWithTheme} participantIdUrl="some_id" /> + </MemoryRouter> + ); + await screen.findByText('About us'); + }); }) \ No newline at end of file diff --git a/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx b/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx index c2205e9b2..e9ff1d349 100644 --- a/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx +++ b/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx @@ -1,42 +1,41 @@ import React from "react"; import { Link } from "react-router-dom"; -import { API_ROOT } from "../../../config"; +import { API_ROOT } from "@/config"; import ExperimentCollection from "@/types/ExperimentCollection"; +import AppBar from "@/components/AppBar/AppBar"; +import Header from "@/components/Header/Header"; interface ExperimentCollectionDashboardProps { experimentCollection: ExperimentCollection; + participantIdUrl: string | null; } -export const ExperimentCollectionDashboard: React.FC<ExperimentCollectionDashboardProps> = ({ experimentCollection }) => { +export const ExperimentCollectionDashboard: React.FC<ExperimentCollectionDashboardProps> = ({ experimentCollection, participantIdUrl }) => { - const dashboard = experimentCollection?.dashboard; + const dashboard = experimentCollection.dashboard; + const nextExperimentSlug = experimentCollection.nextExperiment?.slug; + const headerProps = experimentCollection.theme?.header? { + nextExperimentSlug, + collectionSlug: experimentCollection.slug, + ... experimentCollection.theme.header + } : undefined; - // TODO: get next experiment and about link from experimentCollection - const nextExperiment = experimentCollection.next_experiment; // TODO: get next_experiment from experimentCollection - const aboutContent = experimentCollection.about_content; + const getExperimentHref = (slug: string) => `/${slug}${participantIdUrl ? `?participant_id=${participantIdUrl}` : ""}`; return ( <> - <div className="hero"> - <div className="intro"> - <p>{experimentCollection?.description}</p> - <nav className="actions"> - {nextExperiment && <a className="btn btn-lg btn-primary" href={"/" + nextExperiment.slug}>Volgende experiment</a>} - {aboutContent && <Link className="btn btn-lg btn-outline-primary" to={`/collection/${experimentCollection.slug}/about`}>Over ons</Link>} - </nav> - </div> - <div className="results"> - - </div> - </div> + <AppBar title={experimentCollection.name} logoClickConfirm="placeholder text" /> + {headerProps && ( + <Header { ...headerProps }></Header> + )} {/* Experiments */} <div role="menu" className="dashboard"> <ul> {dashboard.map((exp) => ( <li key={exp.slug}> - <Link to={"/" + exp.slug}> + <Link to={getExperimentHref(exp.slug)} role="menuitem"> <ImageOrPlaceholder imagePath={exp.image} alt={exp.description} /> <h3>{exp.name}</h3> <div className="status-bar"> diff --git a/frontend/src/components/Final/Final.jsx b/frontend/src/components/Final/Final.jsx index 9e5c0ec63..f8da6e60f 100644 --- a/frontend/src/components/Final/Final.jsx +++ b/frontend/src/components/Final/Final.jsx @@ -1,10 +1,10 @@ import React, { useState, useEffect, useRef } from "react"; -import { withRouter } from "react-router-dom"; +import { Link, withRouter } from "react-router-dom"; import Rank from "../Rank/Rank"; import Social from "../Social/Social"; -import { URLS } from "../../config"; +import { URLS } from "@/config"; import { finalizeSession } from "../../API"; import useBoundStore from "../../util/stores"; import ParticipantLink from "../ParticipantLink/ParticipantLink"; @@ -65,9 +65,9 @@ const Final = ({ experiment, participant, score, final_text, action_texts, butto </div> {button && ( <div className="text-center pt-4"> - <a className='btn btn-primary btn-lg' href={button.link} onClick={button.link ? undefined : onNext}> + <Link className='btn btn-primary btn-lg' to={button.link} onClick={button.link ? undefined : onNext}> {button.text} - </a> + </Link> </div> )} {logo && ( diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx new file mode 100644 index 000000000..f3018f02f --- /dev/null +++ b/frontend/src/components/Header/Header.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Link } from "react-router-dom"; + +interface HeaderProps { + nextExperimentSlug: string | undefined; + nextExperimentButtonText: string; + collectionSlug: string; + aboutButtonText: string; + showScore: boolean; +} + +export const Header: React.FC<HeaderProps> = ({ nextExperimentSlug, nextExperimentButtonText, collectionSlug, aboutButtonText, showScore }) => { + return ( + <div className="hero aha__header"> + <div className="intro"> + <nav className="actions"> + {nextExperimentSlug && <a className="btn btn-lg btn-primary" href={`/${nextExperimentSlug}`}>{nextExperimentButtonText}</a>} + {aboutButtonText && <Link className="btn btn-lg btn-outline-primary" to={`/collection/${collectionSlug}/about`}>{aboutButtonText}</Link>} + </nav> + </div> + </div> + ); +} + +export default Header; diff --git a/frontend/src/components/LoaderContainer/LoaderContainer.jsx b/frontend/src/components/LoaderContainer/LoaderContainer.jsx new file mode 100644 index 000000000..cfc0c3985 --- /dev/null +++ b/frontend/src/components/LoaderContainer/LoaderContainer.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import Loading from '@/components/Loading/Loading'; + +const LoadingContainer = () => ( + <div className="loader-container"> + <Loading /> + </div> +) + +export default LoadingContainer; diff --git a/frontend/src/components/MatchingPairs/MatchingPairs.jsx b/frontend/src/components/MatchingPairs/MatchingPairs.jsx index cfbdb8657..c3bea678b 100644 --- a/frontend/src/components/MatchingPairs/MatchingPairs.jsx +++ b/frontend/src/components/MatchingPairs/MatchingPairs.jsx @@ -25,8 +25,8 @@ const MatchingPairs = ({ const xPosition = useRef(-1); const yPosition = useRef(-1); - const [firstCard, setFirstCard] = useState(null); - const [secondCard, setSecondCard] = useState(null); + const [firstCard, setFirstCard] = useState({}); + const [secondCard, setSecondCard] = useState({}); const [feedbackText, setFeedbackText] = useState('Pick a card'); const [feedbackClass, setFeedbackClass] = useState(''); const [inBetweenTurns, setInBetweenTurns] = useState(false); @@ -77,7 +77,7 @@ const MatchingPairs = ({ } setFeedbackClass(fbclass); turnedCards[0].matchClass = turnedCards[1].matchClass = fbclass; - turnedCards[1].seen = turnedCards[1].seen = true; + turnedCards[0].seen = turnedCards[1].seen = true; setInBetweenTurns(true); return; } @@ -94,10 +94,14 @@ const MatchingPairs = ({ // set no mouse events for all but current sections.forEach(section => section.noevents = true); currentCard.noevents = true; + currentCard.boardposition = parseInt(index) + 1; + currentCard.timestamp = performance.now(); + currentCard.response_interval_ms = Math.round(currentCard.timestamp - firstCard.timestamp); // check for match - const lastCard = firstCard; + const first_card = firstCard; + const second_card = currentCard; try { - const scoreResponse = await scoreIntermediateResult({ session, participant, result: { currentCard, lastCard } }); + const scoreResponse = await scoreIntermediateResult({ session, participant, result: { first_card, second_card } }); setScore(scoreResponse.score); showFeedback(scoreResponse.score); } catch { @@ -109,8 +113,11 @@ const MatchingPairs = ({ setFirstCard(currentCard); // turn first card, disable events currentCard.turned = true; - currentCard.noevents = true; - currentCard.seen = true; + currentCard.noevents = true; + currentCard.boardposition = parseInt(index) + 1; + currentCard.timestamp = performance.now(); + // reset response interval in case this card has a value from a previous turn + currentCard.response_interval_ms = ''; // clear feedback text setFeedbackText(''); } diff --git a/frontend/src/components/MatchingPairs/PlayCard.test.jsx b/frontend/src/components/MatchingPairs/PlayCard.test.jsx index ea35b12af..46af6b8cc 100644 --- a/frontend/src/components/MatchingPairs/PlayCard.test.jsx +++ b/frontend/src/components/MatchingPairs/PlayCard.test.jsx @@ -62,7 +62,6 @@ describe("PlayCard Component Tests", () => { it("should display a card with fbmemory when memory", () => { render(<PlayCard onClick={mockOnClick} registerUserClicks={mockRegisterUserClicks} showAnimation={true} section={{ ...sectionProps, matchClass: 'fbmemory' }} />); - const check = screen.getByRole("button").classList; expect(screen.getByRole("button").classList.contains("fbmemory")).to.be.true; }); diff --git a/frontend/src/components/Page/DefaultPage.test.jsx b/frontend/src/components/Page/DefaultPage.test.jsx new file mode 100644 index 000000000..2b5c7f88b --- /dev/null +++ b/frontend/src/components/Page/DefaultPage.test.jsx @@ -0,0 +1,36 @@ +import React from "react"; +import { beforeEach, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import DefaultPage from "./DefaultPage"; +import Explainer from "@/components/Explainer/Explainer"; + +vi.mock("../../util/stores"); + +describe("DefaultPage Component Tests", () => { + const explainerProps = { + instruction: 'Some instruction', + button_label: 'Next', + steps: [], + onNext: vi.fn(), + timer: 1 + } + + const defaultProps = { + className:'aha__default', + title: "Default page title", + logoClickConfirm: null, + collectionSlug: 'some_collection', + nextExperimentSlug: 'some_experiment', + } + + it('renders itself with children', async () => { + render( + <DefaultPage { ...defaultProps }> + <Explainer { ...explainerProps } /> + </DefaultPage> + ) + await screen.findByText('Some instruction'); + }) + +}); \ No newline at end of file diff --git a/frontend/src/components/PlayButton/PlayButton.jsx b/frontend/src/components/PlayButton/PlayButton.jsx index 4c046cd18..0bd576152 100644 --- a/frontend/src/components/PlayButton/PlayButton.jsx +++ b/frontend/src/components/PlayButton/PlayButton.jsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React from "react"; import classNames from "classnames"; const PlayButton = ({ playSection, isPlaying, className = "", disabled }) => { diff --git a/frontend/src/components/Playback/Playback.jsx b/frontend/src/components/Playback/Playback.jsx index 7f6e4521e..65c63b4f6 100644 --- a/frontend/src/components/Playback/Playback.jsx +++ b/frontend/src/components/Playback/Playback.jsx @@ -178,7 +178,6 @@ const Playback = ({ return ( <Preload {...attrs} playMethod={playMethod} - duration={playbackArgs.ready_time} preloadMessage={playbackArgs.preload_message} onNext={() => { setView(playbackArgs.view); diff --git a/frontend/src/components/Playback/Playback.test.jsx b/frontend/src/components/Playback/Playback.test.jsx index f3281260e..75eee50d9 100644 --- a/frontend/src/components/Playback/Playback.test.jsx +++ b/frontend/src/components/Playback/Playback.test.jsx @@ -22,7 +22,6 @@ describe('Playback', () => { show_animation: false, instruction: 'Listen, just listen!', play_method: 'HTML', - ready_time: 1, preload_message: 'Get ready', sections: [{id: 13, url: 'some/fancy/tune.mp3'}] }; @@ -35,12 +34,4 @@ describe('Playback', () => { expect(document.body.contains(container.querySelector('.aha__playback'))).to.be.true; }); - it('shows Preload during ready_time', () => { - const { container } = render( - <Playback - {... basicProps} playbackArgs={playbackArgs} - />); - expect(document.body.contains(container.querySelector('.aha__listen'))).to.be.true; - }); - }) \ No newline at end of file diff --git a/frontend/src/components/Preload/Preload.jsx b/frontend/src/components/Preload/Preload.jsx index 96a206ad8..5cb315956 100644 --- a/frontend/src/components/Preload/Preload.jsx +++ b/frontend/src/components/Preload/Preload.jsx @@ -8,13 +8,11 @@ import * as webAudio from "../../util/webAudio"; // Preload is an experiment screen that continues after a given time or after an audio file has been preloaded const Preload = ({ sections, playMethod, duration, preloadMessage, pageTitle, onNext }) => { - const [timePassed, setTimePassed] = useState(false); const [audioAvailable, setAudioAvailable] = useState(false); const [overtime, setOvertime] = useState(false); const [loaderDuration, setLoaderDuration] = useState(duration); const onTimePassed = () => { - setTimePassed(true) setLoaderDuration(0); setOvertime(true); if (audioAvailable) { @@ -33,14 +31,16 @@ const Preload = ({ sections, playMethod, duration, preloadMessage, pageTitle, on } if (playMethod === 'BUFFER') { - // Use Web-audio and preload sections in buffers - sections.map((section, index) => { + sections.forEach((section, index) => { // skip Preload if the section has already been loaded in the previous action if (webAudio.checkSectionLoaded(section)) { - onNext(); - return undefined; + if (index === (sections.length - 1)) { + setAudioAvailable(true); + } + return; } + // Clear buffers if this is the first section if (index === 0) { webAudio.clearBuffers(); @@ -49,10 +49,7 @@ const Preload = ({ sections, playMethod, duration, preloadMessage, pageTitle, on // Load sections in buffer return webAudio.loadBuffer(section.id, section.url, () => { if (index === (sections.length - 1)) { - if (timePassed) { - setAudioAvailable(true); - onNext(); - } + setAudioAvailable(true); } }); }) @@ -61,18 +58,19 @@ const Preload = ({ sections, playMethod, duration, preloadMessage, pageTitle, on webAudio.closeWebAudio(); } // Load audio until available - // Return remove listener - return audio.loadUntilAvailable(sections[0].url, () => { - setAudioAvailable(true); - if (timePassed) { - onNext(); - } - }); + // Return remove listener + sections.forEach((section, index) => { + return audio.loadUntilAvailable(section.url, () => { + if (index === (sections.length - 1)) { + setAudioAvailable(true); + } + }); + }) } } - preloadResources(); - }, [sections, playMethod, onNext, timePassed]); + preloadResources(); + }, [sections, playMethod, onNext]); return ( <ListenFeedback diff --git a/frontend/src/components/Profile/Profile.jsx b/frontend/src/components/Profile/Profile.jsx index 86d4b64a1..8fc492d62 100644 --- a/frontend/src/components/Profile/Profile.jsx +++ b/frontend/src/components/Profile/Profile.jsx @@ -4,7 +4,7 @@ import DefaultPage from "../Page/DefaultPage"; import Loading from "../Loading/Loading"; import Rank from "../Rank/Rank"; import { useParticipantScores } from "../../API"; -import { URLS } from "../../config"; +import { URLS } from "@/config"; import ParticipantLink from "../ParticipantLink/ParticipantLink"; // Profile loads and shows the profile of a participant for a given experiment diff --git a/frontend/src/components/Reload/Reload.jsx b/frontend/src/components/Reload/Reload.jsx index 947d314f1..21792123c 100644 --- a/frontend/src/components/Reload/Reload.jsx +++ b/frontend/src/components/Reload/Reload.jsx @@ -1,7 +1,7 @@ import React, { useEffect } from "react"; import { useLocation } from 'react-router-dom'; -import { API_BASE_URL } from "../../config"; +import { API_BASE_URL } from "@/config"; const Reload = () => { const location = useLocation(); diff --git a/frontend/src/components/StoreProfile/StoreProfile.jsx b/frontend/src/components/StoreProfile/StoreProfile.jsx index 3c0297b76..cd1be0eae 100644 --- a/frontend/src/components/StoreProfile/StoreProfile.jsx +++ b/frontend/src/components/StoreProfile/StoreProfile.jsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import classNames from "classnames"; import { Link, withRouter } from "react-router-dom"; import * as EmailValidator from "email-validator"; -import { URLS } from "../../config"; +import { URLS } from "@/config"; import useBoundStore from "../../util/stores"; import { shareParticipant} from "../../API"; import DefaultPage from "../Page/DefaultPage"; diff --git a/frontend/src/components/ToontjeHoger/ToontjeHoger.jsx b/frontend/src/components/ToontjeHoger/ToontjeHoger.jsx index 93a590990..707b860ec 100644 --- a/frontend/src/components/ToontjeHoger/ToontjeHoger.jsx +++ b/frontend/src/components/ToontjeHoger/ToontjeHoger.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useRef } from "react"; -import { LOGO_TITLE } from "../../config"; +import { LOGO_TITLE } from "@/config"; import { Switch, Route, Link } from "react-router-dom"; import Rank from "../Rank/Rank"; diff --git a/frontend/src/components/Trial/Trial.jsx b/frontend/src/components/Trial/Trial.jsx index 546b3157d..82640ab6d 100644 --- a/frontend/src/components/Trial/Trial.jsx +++ b/frontend/src/components/Trial/Trial.jsx @@ -23,7 +23,8 @@ const Trial = ({ }) => { // Main component state const [formActive, setFormActive] = useState(!config.listen_first); - const [preloadReady, setPreloadReady] = useState(!(playback?.ready_time)); + // Preload is immediately set to ready if we don't have a playback object + const [preloadReady, setPreloadReady] = useState(!playback); const submitted = useRef(false); @@ -54,7 +55,7 @@ const Trial = ({ if (feedback_form.is_skippable) { form.map((formElement => (formElement.value = formElement.value || ''))) } - await onResult({ + onResult({ decision_time: getAndStoreDecisionTime(), form, config diff --git a/frontend/src/config.js b/frontend/src/config.ts similarity index 68% rename from frontend/src/config.js rename to frontend/src/config.ts index 13dc82d78..b3461f5f6 100644 --- a/frontend/src/config.js +++ b/frontend/src/config.ts @@ -1,5 +1,5 @@ // Load experiment slug from hash, or default to env experiment slug -export const EXPERIMENT_SLUG = +export const EXPERIMENT_SLUG: string = document.location.hash.indexOf("slug=") > -1 ? document.location.hash.split("slug=")[1] : import.meta.env.VITE_EXPERIMENT_SLUG; @@ -9,15 +9,15 @@ export const EXPERIMENT_SLUG = // Make sure your app url is set in the CORS_ORIGIN_WHITELIST in // the API's base_settings.py -export const API_ROOT = import.meta.env.VITE_API_ROOT; +export const API_ROOT: string = import.meta.env.VITE_API_ROOT; export const API_BASE_URL = API_ROOT; // Media export const SILENT_MP3 = "/audio/silent.mp3"; // Logo -export const LOGO_URL = import.meta.env.VITE_LOGO_URL || '/images/logo-white.svg'; -export const LOGO_TITLE = import.meta.env.VITE_HTML_PAGE_TITLE || 'Amsterdam Music Lab'; +export const LOGO_URL: string = import.meta.env.VITE_LOGO_URL || '/images/logo-white.svg'; +export const LOGO_TITLE: string = import.meta.env.VITE_HTML_PAGE_TITLE || 'Amsterdam Music Lab'; // Background export const BACKGROUND_URL = import.meta.env.VITE_BACKGROUND_URL || '/images/background.jpg' @@ -32,6 +32,7 @@ export const URLS = { experimentCollectionAbout: "/collection/:slug/about", experimentCollection: "/collection/:slug", reloadParticipant: "/participant/reload/:id/:hash", + theme: "/theme/:id", AMLHome: - import.meta.env.VITE_AML_HOME || "https://www.amsterdammusiclab.nl", + import.meta.env.VITE_AML_HOME as string || "https://www.amsterdammusiclab.nl", }; diff --git a/frontend/src/hooks/useResultHandler.js b/frontend/src/hooks/useResultHandler.js index fa6463fcc..05a855de0 100644 --- a/frontend/src/hooks/useResultHandler.js +++ b/frontend/src/hooks/useResultHandler.js @@ -1,5 +1,5 @@ import { useRef, useCallback } from "react"; -import { scoreResult } from "../API.js"; +import { scoreResult } from "@/API"; // useResult provides a reusable function to handle experiment view data // - collect results in a buffer diff --git a/frontend/src/hooks/useResultHandler.test.js b/frontend/src/hooks/useResultHandler.test.js index 7c3e0667e..c905bb799 100644 --- a/frontend/src/hooks/useResultHandler.test.js +++ b/frontend/src/hooks/useResultHandler.test.js @@ -2,9 +2,9 @@ import { renderHook, act } from "@testing-library/react"; import useResultHandler from "./useResultHandler"; import { vi } from 'vitest'; -import * as API from '../API.js'; +import * as API from '@/API'; -vi.mock('../API.js'); +vi.mock('@/API'); describe('useResultHandler', () => { diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 62f0ad80f..d39a91f9b 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,7 +1,7 @@ import "./index.scss"; import { StrictMode } from "react"; import { createRoot } from 'react-dom/client'; -import App from "./components/App/App"; +import App from "./components/App/App.tsx"; import { initSentry } from "./config/sentry"; import { initAudioListener } from "./util/audio"; import { initWebAudioListener } from "./util/webAudio"; diff --git a/frontend/src/types/ExperimentCollection.ts b/frontend/src/types/ExperimentCollection.ts index b3557af5e..a18fc580f 100644 --- a/frontend/src/types/ExperimentCollection.ts +++ b/frontend/src/types/ExperimentCollection.ts @@ -1,6 +1,7 @@ import Experiment from "./Experiment"; +import Theme from "./Theme"; -export default interface Consent { +export interface Consent { text: string; title: string; confirm: string; @@ -12,8 +13,9 @@ export default interface ExperimentCollection { slug: string; name: string; description: string; - consent: Consent | null; dashboard: Experiment[]; - next_experiment: Experiment | null; - about_content: string; + nextExperiment: Experiment | null; + aboutContent: string; + consent?: Consent; + theme?: Theme; } diff --git a/frontend/src/types/Participant.ts b/frontend/src/types/Participant.ts new file mode 100644 index 000000000..88a241e31 --- /dev/null +++ b/frontend/src/types/Participant.ts @@ -0,0 +1,7 @@ +export default interface Participant { + id: number; + hash: string; + csrf_token: string; + participant_id_url: string; + country: string; +} diff --git a/frontend/src/types/Session.ts b/frontend/src/types/Session.ts new file mode 100644 index 000000000..c3439c21e --- /dev/null +++ b/frontend/src/types/Session.ts @@ -0,0 +1,3 @@ +export default interface Session { + id: Number +} diff --git a/frontend/src/types/Theme.ts b/frontend/src/types/Theme.ts new file mode 100644 index 000000000..b9aeae5df --- /dev/null +++ b/frontend/src/types/Theme.ts @@ -0,0 +1,16 @@ +export interface Header { + nextExperimentButtonText: string; + aboutButtonText: string; + showScore: boolean; +}; + +export default interface Theme { + backgroundUrl: string; + bodyFontUrl: string; + description: string; + headingFontUrl: string; + logoUrl: string; + name: string; + footer: null; + header: Header | null; +} \ No newline at end of file diff --git a/frontend/src/util/stores.js b/frontend/src/util/stores.js deleted file mode 100644 index dbd78552d..000000000 --- a/frontend/src/util/stores.js +++ /dev/null @@ -1,30 +0,0 @@ -import { create } from "zustand"; - -const createErrorSlice = (set) => ({ - error: null, - setError: (error) => set(() => ({ error })) -}); - -const createParticipantSlice = (set) => ({ - participant: null, - setParticipant: (participant) => set(() => ({ participant })) -}); - -const createSessionSlice = (set) => ({ - session: null, - setSession: (session) => set(() => ({ session })) -}); - -const createThemeSlice = (set) => ({ - theme: null, - setTheme: (theme) => set(() => ({ theme })), -}); - -export const useBoundStore = create((...args) => ({ - ...createErrorSlice(...args), - ...createParticipantSlice(...args), - ...createSessionSlice(...args), - ...createThemeSlice(...args), -})); - -export default useBoundStore; \ No newline at end of file diff --git a/frontend/src/util/stores.ts b/frontend/src/util/stores.ts new file mode 100644 index 000000000..ca1625b2b --- /dev/null +++ b/frontend/src/util/stores.ts @@ -0,0 +1,64 @@ +import * as Sentry from '@sentry/react'; +import { StateCreator, create } from "zustand"; + +import IParticipant from "@/types/Participant"; +import ISession from "@/types/Session"; +import ITheme from "@/types/Theme"; + +interface ErrorSlice { + error: string | null; + setError: (message: string, errorToCapture?: Error) => void; +} + +const createErrorSlice: StateCreator<ErrorSlice> = (set) => ({ + error: null, + setError: (message, errorToCapture) => { + set(() => ({ error: message })); + if (errorToCapture) { + Sentry.captureException(errorToCapture); + } + } +}); + +interface ParticipantSlice { + participant: IParticipant | null; + participantLoading: boolean; + setParticipant: (participant: IParticipant) => void; + setParticipantLoading: (participantLoading: boolean) => void; +} + +const createParticipantSlice: StateCreator<ParticipantSlice> = (set) => ({ + participant: null, + participantLoading: true, + setParticipant: (participant: IParticipant) => set(() => ({ participant })), + setParticipantLoading: (participantLoading: boolean) => set(() => ({ participantLoading })) +}); + +interface SessionSlice { + session: ISession | null; + setSession: (session: ISession) => void; +} + +const createSessionSlice: StateCreator<SessionSlice> = (set) => ({ + session: null, + setSession: (session: ISession) => set(() => ({ session })), +}); + +interface ThemeSlice { + theme: ITheme | null; + setTheme: (theme: ITheme) => void; +} + +const createThemeSlice: StateCreator<ThemeSlice> = (set) => ({ + theme: null, + setTheme: (theme: ITheme) => set(() => ({ theme })), +}); + +export const useBoundStore = create<ErrorSlice & ParticipantSlice & SessionSlice & ThemeSlice>((...args) => ({ + ...createErrorSlice(...args), + ...createParticipantSlice(...args), + ...createSessionSlice(...args), + ...createThemeSlice(...args), +})); + +export default useBoundStore; \ No newline at end of file diff --git a/frontend/yarn.lock b/frontend/yarn.lock index b415ef5ad..92cebcb53 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -7240,13 +7240,13 @@ __metadata: linkType: hard "ejs@npm:^3.1.8": - version: 3.1.9 - resolution: "ejs@npm:3.1.9" + version: 3.1.10 + resolution: "ejs@npm:3.1.10" dependencies: jake: "npm:^10.8.5" bin: ejs: bin/cli.js - checksum: 10c0/f0e249c79128810f5f6d5cbf347fc906d86bb9384263db0b2a9004aea649f2bc2d112736de5716c509c80afb4721c47281bd5b57c757d3b63f1bf5ac5f885893 + checksum: 10c0/52eade9e68416ed04f7f92c492183340582a36482836b11eab97b159fcdcfdedc62233a1bf0bf5e5e1851c501f2dca0e2e9afd111db2599e4e7f53ee29429ae1 languageName: node linkType: hard diff --git a/package.json b/package.json index a37256e0e..191a531f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "muscle", - "version": "2.0.0", + "version": "2.1.0", "private": false, "description": "The MUSCLE platform is an application that provides an easy way to implement and run online listening experiments for music research.", "license": "MIT",