From 865dd72c30af97c463a8dd3f97eac6ae30be219b Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Tue, 2 Jul 2024 14:29:25 +0200 Subject: [PATCH] Added: SocialMediaConfig models, admin form, serializer & frontend integration (#1166) * feat: Add SocialMediaConfigForm for managing social media sharing settings * feat: Add serializer for SocialMediaConfig for ExperimentCollection serializer * feat: Add SocialMediaConfig interface to ExperimentCollection * feat: Update ExperimentCollectionDashboard and Header component to include social media sharing settings * refactor: Alter SocialMediaConfig model to include all the social media / share options supported by the frontend * refactor: Convert Social component to TypeScript * chore: Re-create migration after applying the migrations from #1164 * test: Add Social component unit tests * refactor: Use consistent test id naming * test: Add test for experiment collection with social media config, thereby also testing the social media config serializer --- backend/experiment/admin.py | 22 ++++- backend/experiment/forms.py | 17 +++- .../migrations/0041_socialmediaconfig.py | 26 ++++++ backend/experiment/models.py | 59 ++++++++++++ backend/experiment/serializers.py | 18 +++- .../widgets/markdown_preview_text_input.html | 10 +- backend/experiment/tests/test_views.py | 21 ++++- .../ExperimentCollectionDashboard.tsx | 5 +- .../ExperimentCollection/Header/Header.tsx | 25 +++-- .../src/components/Social/Social.test.tsx | 93 +++++++++++++++++++ .../Social/{Social.jsx => Social.tsx} | 40 +++++--- frontend/src/types/ExperimentCollection.ts | 8 ++ 12 files changed, 300 insertions(+), 44 deletions(-) create mode 100644 backend/experiment/migrations/0041_socialmediaconfig.py create mode 100644 frontend/src/components/Social/Social.test.tsx rename frontend/src/components/Social/{Social.jsx => Social.tsx} (77%) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 225143011..51f0e7a53 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -10,7 +10,7 @@ from django.utils import timezone from django.core import serializers from django.shortcuts import render, redirect -from django.forms import CheckboxSelectMultiple, ModelForm, TextInput +from django.forms import CheckboxSelectMultiple from django.http import HttpResponse from inline_actions.admin import InlineActionsModelAdminMixin from django.urls import reverse @@ -20,7 +20,8 @@ ExperimentCollection, Phase, Feedback, - GroupedExperiment + GroupedExperiment, + SocialMediaConfig, ) from question.admin import QuestionSeriesInline from experiment.forms import ( @@ -28,6 +29,7 @@ ExperimentForm, ExportForm, TemplateForm, + SocialMediaConfigForm, EXPORT_TEMPLATES, ) from section.models import Section, Song @@ -201,6 +203,12 @@ class PhaseInline(admin.StackedInline): inlines = [GroupedExperimentInline] +class SocialMediaConfigInline(admin.StackedInline): + form = SocialMediaConfigForm + model = SocialMediaConfig + extra = 0 + + class ExperimentCollectionAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): list_display = ('name', 'slug_link', 'description_excerpt', 'dashboard', 'phases', 'active') @@ -209,7 +217,10 @@ class ExperimentCollectionAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): 'about_content'] inline_actions = ['dashboard'] form = ExperimentCollectionForm - inlines = [PhaseInline] + inlines = [ + PhaseInline, + SocialMediaConfigInline, + ] def slug_link(self, obj): dev_mode = settings.DEBUG is True @@ -245,7 +256,10 @@ def dashboard(self, request, obj, parent_obj=None): 'id': exp.id, 'name': exp.name, 'started': len(all_sessions.filter(experiment=exp)), - 'finished': len(all_sessions.filter(experiment=exp, finished_at__isnull=False)), + 'finished': len(all_sessions.filter( + experiment=exp, + finished_at__isnull=False, + )), 'participant_count': len(exp.current_participants()), 'participants': exp.current_participants() } for exp in all_experiments] diff --git a/backend/experiment/forms.py b/backend/experiment/forms.py index e6511a987..3f522e093 100644 --- a/backend/experiment/forms.py +++ b/backend/experiment/forms.py @@ -1,5 +1,6 @@ from django.forms import CheckboxSelectMultiple, ModelForm, ChoiceField, Form, MultipleChoiceField, ModelMultipleChoiceField, Select, TypedMultipleChoiceField, CheckboxSelectMultiple, TextInput -from experiment.models import ExperimentCollection, Experiment +from django.contrib.postgres.forms import SimpleArrayField +from experiment.models import ExperimentCollection, Experiment, SocialMediaConfig from experiment.rules import EXPERIMENT_RULES @@ -178,7 +179,7 @@ def clean_playlists(self): if not playlists: return self.cleaned_data['playlists'] - + playlist_errors = [] # Validate playlists @@ -237,3 +238,15 @@ class TemplateForm(Form): class QuestionSeriesAdminForm(ModelForm): class Media: js = ["questionseries_admin.js"] + + +class SocialMediaConfigForm(ModelForm): + channels = MultipleChoiceField( + widget=CheckboxSelectMultiple, + choices=SocialMediaConfig.SOCIAL_MEDIA_CHANNELS, + required=False + ) + + class Meta: + model = SocialMediaConfig + fields = '__all__' diff --git a/backend/experiment/migrations/0041_socialmediaconfig.py b/backend/experiment/migrations/0041_socialmediaconfig.py new file mode 100644 index 000000000..f1c85d0e0 --- /dev/null +++ b/backend/experiment/migrations/0041_socialmediaconfig.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2024-07-02 08:46 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiment', '0040_alter_phase_options_remove_phase_order_phase_index_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='SocialMediaConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, default=list, help_text='List of tags for social media sharing', size=None)), + ('url', models.URLField(blank=True, help_text='URL to be shared on social media. If empty, the experiment URL will be used.')), + ('content', models.TextField(blank=True, default='I scored {points} points in {experiment_name}!', help_text='Content for social media sharing. Use {points} and {experiment_name} as placeholders.')), + ('channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('facebook', 'Facebook'), ('whatsapp', 'WhatsApp'), ('twitter', 'Twitter'), ('weibo', 'Weibo'), ('share', 'Share'), ('clipboard', 'Clipboard')], max_length=20), blank=True, default=list, help_text='Selected social media channels for sharing', size=None)), + ('experiment_collection', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='social_media_config', to='experiment.experimentcollection')), + ], + ), + ] diff --git a/backend/experiment/models.py b/backend/experiment/models.py index 7cdaf7427..962efbd68 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -2,12 +2,14 @@ from django.db import models from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from django.contrib.postgres.fields import ArrayField from typing import List, Dict, Tuple, Any from experiment.standards.iso_languages import ISO_LANGUAGES from theme.models import ThemeConfig from image.models import Image from session.models import Session +from typing import Optional from .validators import markdown_html_validator, experiment_slug_validator @@ -41,6 +43,7 @@ class ExperimentCollection(models.Model): dashboard = models.BooleanField(default=False) about_content = models.TextField(blank=True, default='') active = models.BooleanField(default=True) + social_media_config: Optional['SocialMediaConfig'] def __str__(self): return self.name or self.slug @@ -321,3 +324,59 @@ def add_default_question_series(self): class Feedback(models.Model): text = models.TextField() experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE) + + +class SocialMediaConfig(models.Model): + experiment_collection = models.OneToOneField( + ExperimentCollection, + on_delete=models.CASCADE, + related_name='social_media_config' + ) + + tags = ArrayField( + models.CharField(max_length=100), + blank=True, + default=list, + help_text=_("List of tags for social media sharing") + ) + + url = models.URLField( + blank=True, + help_text=_("URL to be shared on social media. If empty, the experiment URL will be used.") + ) + + content = models.TextField( + blank=True, + help_text=_("Content for social media sharing. Use {points} and {experiment_name} as placeholders."), + default="I scored {points} points in {experiment_name}!" + ) + + SOCIAL_MEDIA_CHANNELS = [ + ('facebook', _('Facebook')), + ('whatsapp', _('WhatsApp')), + ('twitter', _('Twitter')), + ('weibo', _('Weibo')), + ('share', _('Share')), + ('clipboard', _('Clipboard')), + ] + channels = ArrayField( + models.CharField(max_length=20, choices=SOCIAL_MEDIA_CHANNELS), + blank=True, + default=list, + help_text=_("Selected social media channels for sharing") + ) + + def get_content(self, score: int = None, experiment_name: str = None): + if self.content: + return self.content + + if not score or not experiment_name: + raise ValueError("score and experiment_name are required") + + return _("I scored {points} points in {experiment_name}").format( + score=self.experiment_collection.points, + experiment_name=self.experiment_collection.name + ) + + def __str__(self): + return f"Social Media for {self.experiment_collection.name}" diff --git a/backend/experiment/serializers.py b/backend/experiment/serializers.py index 15ed6f05e..c81e77b3a 100644 --- a/backend/experiment/serializers.py +++ b/backend/experiment/serializers.py @@ -7,7 +7,7 @@ from participant.models import Participant from session.models import Session from theme.serializers import serialize_theme -from .models import Experiment, ExperimentCollection, Phase, GroupedExperiment +from .models import Experiment, ExperimentCollection, Phase, GroupedExperiment, SocialMediaConfig def serialize_actions(actions): @@ -44,9 +44,25 @@ def serialize_experiment_collection( filter_name='markdown' ) + if experiment_collection.social_media_config: + serialized['socialMedia'] = serialize_social_media_config( + experiment_collection.social_media_config + ) + return serialized +def serialize_social_media_config( + social_media_config: SocialMediaConfig + ) -> dict: + return { + 'tags': social_media_config.tags, + 'url': social_media_config.url, + 'content': social_media_config.content, + 'channels': social_media_config.channels, + } + + def serialize_phase( phase: Phase, participant: Participant diff --git a/backend/experiment/templates/widgets/markdown_preview_text_input.html b/backend/experiment/templates/widgets/markdown_preview_text_input.html index ba51daeb2..8e96f864b 100644 --- a/backend/experiment/templates/widgets/markdown_preview_text_input.html +++ b/backend/experiment/templates/widgets/markdown_preview_text_input.html @@ -8,7 +8,7 @@
- +
@@ -65,7 +65,7 @@ display: block; } - textarea { + .tab-content textarea { width: 100%; min-height: 256px !important; height: 100%; @@ -103,7 +103,7 @@ font-family: inherit; box-sizing: border-box; } - + h1, h2, h3, h4, h5, h6 { display: block; color: var(--text-fg); @@ -286,8 +286,8 @@ renderMarkdown(textarea, markdownPreview); }); - + }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/backend/experiment/tests/test_views.py b/backend/experiment/tests/test_views.py index 1712d304c..ea3db087b 100644 --- a/backend/experiment/tests/test_views.py +++ b/backend/experiment/tests/test_views.py @@ -12,6 +12,7 @@ ExperimentCollection, Phase, GroupedExperiment, + SocialMediaConfig, ) from experiment.rules.hooked import Hooked from participant.models import Participant @@ -28,8 +29,9 @@ def setUpTestData(cls): collection = ExperimentCollection.objects.create( name='Test Series', slug='test_series', - theme_config=theme_config + theme_config=theme_config, ) + collection.social_media_config = create_social_media_config(collection) introductory_phase = Phase.objects.create( name='introduction', series=collection, @@ -108,6 +110,10 @@ def test_get_experiment_collection(self): self.assertEqual(len(response_json['theme']['header']['score']), 3) self.assertEqual(response_json.get('theme').get('footer').get( 'disclaimer'), '

Test Disclaimer

') + self.assertEqual(response_json.get('socialMedia').get('url'), 'https://www.example.com') + self.assertEqual(response_json.get('socialMedia').get('content'), 'Test Content') + self.assertEqual(response_json.get('socialMedia').get('tags'), ['aml', 'toontjehoger']) + self.assertEqual(response_json.get('socialMedia').get('channels'), ['facebook', 'twitter', 'weibo']) def test_get_experiment_collection_not_found(self): # if ExperimentCollection does not exist, return 404 @@ -257,7 +263,7 @@ def test_get_experiment(self): ) -def create_theme_config(): +def create_theme_config() -> ThemeConfig: theme_config = ThemeConfig.objects.create( name='test_theme', description='Test Theme', @@ -278,3 +284,14 @@ def create_theme_config(): footer_config.logos.add(Image.objects.create(file='test-logo.jpg')) return theme_config + + +def create_social_media_config( + collection: ExperimentCollection) -> SocialMediaConfig: + return SocialMediaConfig.objects.create( + experiment_collection=collection, + url='https://www.example.com', + content='Test Content', + channels=['facebook', 'twitter', 'weibo'], + tags=['aml', 'toontjehoger'] + ) diff --git a/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx b/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx index 08b7c7258..8b547803a 100644 --- a/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx +++ b/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx @@ -15,12 +15,13 @@ interface ExperimentCollectionDashboardProps { export const ExperimentCollectionDashboard: React.FC = ({ experimentCollection, participantIdUrl, totalScore }) => { - const { dashboard, description, name } = experimentCollection; + const { dashboard, description } = experimentCollection; const { nextExperimentButtonText, aboutButtonText } = experimentCollection.theme?.header || { nextExperimentButtonText: "", aboutButtonText: "" }; const scoreDisplayConfig = experimentCollection.theme?.header?.score; const nextBlockSlug = experimentCollection.nextExperiment?.slug; const showHeader = experimentCollection.theme?.header; + const socialMediaConfig = experimentCollection.socialMediaConfig; const getExperimentHref = (slug: string) => `/${slug}${participantIdUrl ? `?participant_id=${participantIdUrl}` : ""}`; @@ -32,11 +33,11 @@ export const ExperimentCollectionDashboard: React.FC )} {/* Experiments */} diff --git a/frontend/src/components/ExperimentCollection/Header/Header.tsx b/frontend/src/components/ExperimentCollection/Header/Header.tsx index 67bb14df3..4c6a40297 100644 --- a/frontend/src/components/ExperimentCollection/Header/Header.tsx +++ b/frontend/src/components/ExperimentCollection/Header/Header.tsx @@ -6,9 +6,9 @@ import Social from "../../Social/Social" import HTML from '@/components/HTML/HTML'; import { ScoreDisplayConfig } from "@/types/Theme"; import Rank from "@/components/Rank/Rank"; +import { SocialMediaConfig } from "@/types/ExperimentCollection"; interface HeaderProps { - name: string; description: string; nextBlockSlug: string | undefined; nextBlockButtonText: string; @@ -16,10 +16,10 @@ interface HeaderProps { aboutButtonText: string; totalScore: number; scoreDisplayConfig?: ScoreDisplayConfig; + socialMediaConfig?: SocialMediaConfig; } export const Header: React.FC = ({ - name, description, nextBlockSlug, nextBlockButtonText, @@ -27,20 +27,17 @@ export const Header: React.FC = ({ collectionSlug, totalScore, scoreDisplayConfig, + socialMediaConfig }) => { - // TODO: Fix this permanently and localize in and fetch content from the backend - // See also: https://github.com/Amsterdam-Music-Lab/MUSCLE/issues/1151 // Get current URL minus the query string const currentUrl = window.location.href.split('?')[0]; - const message = totalScore > 0 ? `Ha! Ik ben muzikaler dan ik dacht - heb maar liefst ${totalScore} punten! Speel mee met #ToontjeHoger` : "Ha! Speel mee met #ToontjeHoger en laat je verrassen: je bent muzikaler dat je denkt!"; - const hashtags = [name ? name.replace(/ /g, '') : 'amsterdammusiclab']; const social = { - apps: ['facebook', 'twitter'], - message, - url: currentUrl, - hashtags, + apps: socialMediaConfig?.channels || [], + message: socialMediaConfig?.content || '', + url: socialMediaConfig?.url || currentUrl, + hashtags: socialMediaConfig?.tags || [], } return ( @@ -58,9 +55,11 @@ export const Header: React.FC = ({ cup={{ className: scoreDisplayConfig.scoreClass, text: '' }} score={{ score: totalScore, label: scoreDisplayConfig.scoreLabel }} /> - + {socialMediaConfig?.channels?.length && ( + + )} )} {scoreDisplayConfig && totalScore === 0 && ( diff --git a/frontend/src/components/Social/Social.test.tsx b/frontend/src/components/Social/Social.test.tsx new file mode 100644 index 000000000..be046ff2d --- /dev/null +++ b/frontend/src/components/Social/Social.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Social from './Social'; // Adjust the import path as necessary + +// Mock the next-share components +vi.mock('next-share', () => ({ + FacebookShareButton: ({ children }: React.ComponentProps<'div'>) =>
{children}
, + TwitterShareButton: ({ children }: React.ComponentProps<'div'>) =>
{children}
, + WeiboShareButton: ({ children }: React.ComponentProps<'div'>) =>
{children}
, + WhatsappShareButton: ({ children }: React.ComponentProps<'div'>) =>
{children}
, +})); + +describe('Social Component', () => { + const mockSocial = { + apps: ['facebook', 'whatsapp', 'twitter', 'weibo', 'share', 'clipboard'], + url: 'https://example.com', + message: 'Check this out!', + hashtags: ['test', 'vitest'], + text: 'Share this content' + }; + + beforeEach(() => { + // Reset all mocks before each test + vi.resetAllMocks(); + }); + + it('renders all social media buttons when all apps are included', () => { + render(); + expect(screen.getByTestId('facebook-share')).toBeDefined(); + expect(screen.getByTestId('whatsapp-share')).toBeDefined(); + expect(screen.getByTestId('twitter-share')).toBeDefined(); + expect(screen.getByTestId('weibo-share')).toBeDefined(); + }); + + it('renders only specified social media buttons', () => { + const limitedSocial = { ...mockSocial, apps: ['facebook', 'twitter'] }; + render(); + expect(screen.getByTestId('facebook-share')).toBeDefined(); + expect(screen.getByTestId('twitter-share')).toBeDefined(); + expect(screen.queryByTestId('whatsapp-share')).toBeNull(); + expect(screen.queryByTestId('weibo-share')).toBeNull(); + }); + + it('renders share button when navigator.share is available', () => { + // Mock navigator.share and navigator.canShare + Object.defineProperty(window.navigator, 'share', { + value: vi.fn().mockResolvedValue(undefined), + configurable: true + }); + Object.defineProperty(window.navigator, 'canShare', { + value: vi.fn().mockReturnValue(true), + configurable: true + }); + + render(); + expect(screen.getByTestId('navigator-share')).toBeDefined(); + }); + + it('calls navigator.share when share button is clicked', () => { + const shareMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(window.navigator, 'share', { + value: shareMock, + configurable: true + }); + Object.defineProperty(window.navigator, 'canShare', { + value: vi.fn().mockReturnValue(true), + configurable: true + }); + + render(); + fireEvent.click(screen.getByTestId('navigator-share')); + expect(shareMock).toHaveBeenCalledWith({ + text: mockSocial.text, + url: mockSocial.url + }); + }); + + it('renders clipboard button and calls navigator.clipboard.writeText when clicked', async () => { + const writeTextMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: writeTextMock }, + configurable: true + }); + + render(); + const clipboardButton = screen.getByTestId('clipboard-share'); + expect(clipboardButton).toBeDefined(); + + fireEvent.click(clipboardButton); + expect(writeTextMock).toHaveBeenCalledWith(mockSocial.url); + }); +}); diff --git a/frontend/src/components/Social/Social.jsx b/frontend/src/components/Social/Social.tsx similarity index 77% rename from frontend/src/components/Social/Social.jsx rename to frontend/src/components/Social/Social.tsx index 1fd5dc002..47c05872c 100644 --- a/frontend/src/components/Social/Social.jsx +++ b/frontend/src/components/Social/Social.tsx @@ -1,35 +1,45 @@ -import React, { useRef } from "react"; +import { useRef } from "react"; import { FacebookShareButton, TwitterShareButton, WeiboShareButton, WhatsappShareButton - } from 'next-share' - +} from 'next-share' -const Social = ({ social }) => { - /* Social is a view which returns social media links with icons - if render_social is set to false, returns an empty diff - */ +interface SocialProps { + social: { + apps: 'facebook' | 'whatsapp' | 'twitter' | 'weibo' | 'share' | 'clipboard', + url: string, + message: string, + hashtags: string[], + text: string + } +} + +/** + * Social is a view which returns social media links with icons + * if render_social is set to false, returns an empty diff +*/ +const Social = ({ social }: SocialProps) => { const showShare = useRef( navigator.share !== undefined && navigator.canShare !== undefined ) - - const shareContent = (text, url) => { + + const shareContent = (text: string, url: string) => { const shareData = { text: text, url: url } if (navigator.canShare(shareData)) { navigator.share(shareData).then( - (success) => {}, - (error) => {console.error(error)} + () => void 0, + (error) => { console.error(error) } ); } } - const copyToClipboard = async (url) => { + const copyToClipboard = async (url: string) => { await navigator.clipboard.writeText(url); } - + return (
{social.apps.includes('facebook') && ( @@ -71,12 +81,12 @@ const Social = ({ social }) => { )} {showShare.current && social.apps.includes('share') && ( -
shareContent(social.text, social.url)}> +
shareContent(social.text, social.url)} data-testid="navigator-share">
)} {social.apps.includes('clipboard') && ( -
copyToClipboard(social.url)}> +
copyToClipboard(social.url)} data-testid="clipboard-share">
)} diff --git a/frontend/src/types/ExperimentCollection.ts b/frontend/src/types/ExperimentCollection.ts index dd5f34a27..177320991 100644 --- a/frontend/src/types/ExperimentCollection.ts +++ b/frontend/src/types/ExperimentCollection.ts @@ -9,6 +9,13 @@ export interface Consent { view: 'CONSENT'; } +export interface SocialMediaConfig { + tags: string[]; + url: string; + content: string; + channels: string[]; +} + export default interface ExperimentCollection { slug: string; name: string; @@ -19,4 +26,5 @@ export default interface ExperimentCollection { consent?: Consent; theme?: Theme; totalScore: number; + socialMediaConfig?: SocialMediaConfig; }