diff --git a/backend/core/migrations/0016_migrate_reco_settings.py b/backend/core/migrations/0016_migrate_reco_settings.py new file mode 100644 index 0000000000..843c09d048 --- /dev/null +++ b/backend/core/migrations/0016_migrate_reco_settings.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.14 on 2024-07-17 12:44 + +from django.db import migrations + + +def copy_setting(user, from_key, to_key): + try: + value = user.settings["videos"][from_key] + except KeyError: + return False + + user.settings["videos"][to_key] = value + return True + + +def migrate_reco_settings_forward(apps, schema_editor): + User = apps.get_model("core", "User") + for user in User.objects.iterator(): + if "videos" not in user.settings: + continue + + # We don't migrate the default_unsafe setting, because we think many + # users would appreciate the default configuration of the feed For you. + copied = map( + copy_setting, + [user] * 5, + [ + "recommendations__default_date", + "recommendations__default_languages", + "recommendations__default_exclude_compared_entities", + "recommendations__default_languages", + ], + [ + "feed_foryou__date", + "feed_foryou__languages", + "feed_foryou__exclude_compared_entities", + "feed_topitems__languages", + ], + ) + + if any(list(copied)): + user.save(update_fields=["settings"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0015_use_unlogged_cache_table"), + ] + + operations = [ + migrations.RunPython( + code=migrate_reco_settings_forward, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/backend/core/serializers/user_settings.py b/backend/core/serializers/user_settings.py index 40b8cc72c8..470e171760 100644 --- a/backend/core/serializers/user_settings.py +++ b/backend/core/serializers/user_settings.py @@ -94,6 +94,11 @@ class VideosPollUserSettingsSerializer(GenericPollUserSettingsSerializer): ), ) + # Settings starting with `recommendations__` are deprecated. + # + # They are kept for backward compatibility, mainly for older versions of + # the browser extension. They are replaced by settings dedicated to + # specific recommendation feeds. recommendations__default_date = serializers.ChoiceField( choices=DEFAULT_DATE_CHOICES, allow_blank=True, required=False ) @@ -103,13 +108,35 @@ class VideosPollUserSettingsSerializer(GenericPollUserSettingsSerializer): recommendations__default_unsafe = serializers.BooleanField(required=False) recommendations__default_exclude_compared_entities = serializers.BooleanField(required=False) - def validate_recommendations__default_languages(self, default_languages): + feed_foryou__date = serializers.ChoiceField( + choices=DEFAULT_DATE_CHOICES, allow_blank=True, required=False + ) + feed_foryou__languages = serializers.ListField( + child=serializers.CharField(), allow_empty=True, required=False + ) + feed_foryou__unsafe = serializers.BooleanField(required=False) + feed_foryou__exclude_compared_entities = serializers.BooleanField(required=False) + + feed_topitems__languages = serializers.ListField( + child=serializers.CharField(), allow_empty=True, required=False + ) + + def _validate_languages(self, default_languages): for lang in default_languages: if lang not in ACCEPTED_LANGUAGE_CODES: raise ValidationError(_("Unknown language code: %(lang)s.") % {"lang": lang}) return default_languages + def validate_recommendations__default_languages(self, default_languages): + return self._validate_languages(default_languages) + + def validate_feed_foryou__languages(self, default_languages): + return self._validate_languages(default_languages) + + def validate_feed_topitems__languages(self, default_languages): + return self._validate_languages(default_languages) + class TournesolUserSettingsSerializer(serializers.Serializer): """ diff --git a/backend/core/tests/serializers/test_user_settings.py b/backend/core/tests/serializers/test_user_settings.py index 1551c8be96..b1b5f44d50 100644 --- a/backend/core/tests/serializers/test_user_settings.py +++ b/backend/core/tests/serializers/test_user_settings.py @@ -107,57 +107,69 @@ class VideosPollUserSettingsSerializerTestCase(TestCase): TestCase of the `VideosPollUserSettingsSerializer` serializer. """ - def test_validate_recommendations__default_languages(self): + def test_validate_feed__languages(self): """ - The `validate_recommendations__default_languages` setting must raise + The `validate_feed_foryou__languages` setting and similar must raise an error for unknown languages. """ - serializer = VideosPollUserSettingsSerializer( - data={"recommendations__default_languages": []} - ) - self.assertEqual(serializer.is_valid(), True) + # Also test the deprecated `recommendations__default_languages` + # setting, until its removal. + for setting in [ + "feed_foryou__languages", + "feed_topitems__languages", + "recommendations__default_languages" + ]: - serializer = VideosPollUserSettingsSerializer( - data={"recommendations__default_languages": ["fr"]} - ) - self.assertEqual(serializer.is_valid(), True) + serializer = VideosPollUserSettingsSerializer( + data={setting: []} + ) + self.assertEqual(serializer.is_valid(), True) - serializer = VideosPollUserSettingsSerializer( - data={"recommendations__default_languages": ["fr", "en"]} - ) - self.assertEqual(serializer.is_valid(), True) + serializer = VideosPollUserSettingsSerializer( + data={setting: ["fr"]} + ) + self.assertEqual(serializer.is_valid(), True) - serializer = VideosPollUserSettingsSerializer( - data={"recommendations__default_languages": ["not_a_language"]} - ) - self.assertEqual(serializer.is_valid(), False) - self.assertIn("recommendations__default_languages", serializer.errors) + serializer = VideosPollUserSettingsSerializer( + data={setting: ["fr", "en"]} + ) + self.assertEqual(serializer.is_valid(), True) - serializer = VideosPollUserSettingsSerializer( - data={"recommendations__default_languages": ["en", "not_a_language"]} - ) - self.assertEqual(serializer.is_valid(), False) - self.assertIn("recommendations__default_languages", serializer.errors) + serializer = VideosPollUserSettingsSerializer( + data={setting: ["not_a_language"]} + ) + self.assertEqual(serializer.is_valid(), False) + self.assertIn(setting, serializer.errors) - def test_validate_recommendations__default_date(self): + serializer = VideosPollUserSettingsSerializer( + data={setting: ["en", "not_a_language"]} + ) + self.assertEqual(serializer.is_valid(), False) + self.assertIn(setting, serializer.errors) + + def test_validate_feed_foryou__date(self): """ - The `validate_recommendations__default_date` setting must accept only + The `validate_feed_foryou__date` setting must accept only a specific set of date. """ - for date in ["TODAY", "WEEK", "MONTH", "YEAR", "ALL_TIME"]: - serializer = VideosPollUserSettingsSerializer( - data={"recommendations__default_date": date} - ) - self.assertEqual(serializer.is_valid(), True) + # Also test the deprecated `recommendations__default_date` + # setting, until its removal. + for setting in ["feed_foryou__date", "recommendations__default_date"]: - # A blank value means no default date. - serializer = VideosPollUserSettingsSerializer(data={"recommendations__default_date": ""}) - self.assertEqual(serializer.is_valid(), True) + for date in ["TODAY", "WEEK", "MONTH", "YEAR", "ALL_TIME"]: + serializer = VideosPollUserSettingsSerializer( + data={setting: date} + ) + self.assertEqual(serializer.is_valid(), True) - serializer = VideosPollUserSettingsSerializer( - data={"recommendations__default_date": ["not_a_valid_date"]} - ) - self.assertEqual(serializer.is_valid(), False) - self.assertIn("recommendations__default_date", serializer.errors) + # A blank value means no default date. + serializer = VideosPollUserSettingsSerializer(data={setting: ""}) + self.assertEqual(serializer.is_valid(), True) + + serializer = VideosPollUserSettingsSerializer( + data={setting: ["not_a_valid_date"]} + ) + self.assertEqual(serializer.is_valid(), False) + self.assertIn(setting, serializer.errors) diff --git a/backend/core/tests/test_api_user_settings.py b/backend/core/tests/test_api_user_settings.py index 76e6fdd8de..e01850dc07 100644 --- a/backend/core/tests/test_api_user_settings.py +++ b/backend/core/tests/test_api_user_settings.py @@ -17,9 +17,10 @@ def setUp(self): "videos": { "comparison__criteria_order": ["reliability"], "rate_later__auto_remove": 16, - "recommendation__default_languages": ["en"], - "recommendation__default_date": "Week", - "recommendation__default_unsafe": False, + "feed_foryou__languages": ["en"], + "feed_foryou__date": "Week", + "feed_foryou__unsafe": False, + "feed_topitems__languages": ["en", "fr"], } } @@ -52,10 +53,11 @@ def test_auth_200_get(self): "comparison_ui__weekly_collective_goal_mobile": True, "extension__search_reco": True, "rate_later__auto_remove": 99, - "recommendations__default_languages": ["en"], - "recommendations__default_date": "WEEK", - "recommendations__default_exclude_compared_entities": False, - "recommendations__default_unsafe": False, + "feed_foryou__languages": ["en"], + "feed_foryou__date": "WEEK", + "feed_foryou__exclude_compared_entities": False, + "feed_foryou__unsafe": False, + "feed_topitems__languages": ["en", "fr"], }, } self.user.settings = new_settings diff --git a/backend/tournesol/tests/test_api_preview.py b/backend/tournesol/tests/test_api_preview.py index 8a654a89e0..202c0724f6 100644 --- a/backend/tournesol/tests/test_api_preview.py +++ b/backend/tournesol/tests/test_api_preview.py @@ -454,35 +454,42 @@ def test_anon_200_get_with_yt_connection_error(self): class DynamicRecommendationsPreviewTestCase(TestCase): def setUp(self): self.client = APIClient() - self.preview_url = "/preview/recommendations" self.preview_internal_url = "/preview/_recommendations" + self.preview_urls = [ + "/preview/search", + "/preview/feed/top", + "/preview/recommendations", + ] - def test_recommendations_preview_query_redirection(self): - response = self.client.get(f"{self.preview_url}/?language=fr&date=Month") - self.assertEqual(response.status_code, 302) - self.assertRegex( - response.headers["location"], - rf"{self.preview_internal_url}/\?metadata%5Blanguage%5D=fr&date_gte=.*", - ) - - def test_recommendations_preview_query_redirection_all_languages_filter(self): - response = self.client.get(f"{self.preview_url}/?language=") - self.assertEqual(response.status_code, 302) - # No filter should be present in the redirection - self.assertEqual(response.headers["location"], f"{self.preview_internal_url}/?") - - def test_recommendations_preview_empty_fields(self): - response = self.client.get(f"{self.preview_url}/?duration_lte&duration_gte") - self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers["location"], f"{self.preview_internal_url}/?") - - def test_recommendations_preview_internal_route(self): + def test_preview_internal_route(self): response = self.client.get(f"{self.preview_internal_url}/?metadata[language]=fr") self.assertEqual(response.status_code, 200) self.assertEqual(response.headers["Content-Type"], "image/jpeg") self.assertNotIn("Content-Disposition", response.headers) - def test_recommendations_preview_without_tournesol_score(self): + def test_preview_redirection(self): + for url in self.preview_urls: + response = self.client.get(f"{url}/?language=fr&date=Month") + self.assertEqual(response.status_code, 302) + self.assertRegex( + response.headers["location"], + rf"{self.preview_internal_url}/\?metadata%5Blanguage%5D=fr&date_gte=.*", + ) + + def test_preview_redirection_with_all_languages(self): + for url in self.preview_urls: + response = self.client.get(f"{url}/?language=") + self.assertEqual(response.status_code, 302) + # No filter should be present in the redirection + self.assertEqual(response.headers["location"], f"{self.preview_internal_url}/?") + + def test_preview_redirection_with_empty_parameters(self): + for url in self.preview_urls: + response = self.client.get(f"{url}/?duration_lte&duration_gte") + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["location"], f"{self.preview_internal_url}/?") + + def test_preview_entities_without_tournesol_score(self): """ The API shouldn't fail when displaying entities without computed Tournesol score. diff --git a/backend/tournesol/urls.py b/backend/tournesol/urls.py index 6b9caeb9b2..048f54ce49 100644 --- a/backend/tournesol/urls.py +++ b/backend/tournesol/urls.py @@ -138,7 +138,7 @@ path( "users/me/suggestions//tocompare/", SuggestionsToCompare.as_view(), - name="suggestions_me_to_compare" + name="suggestions_me_to_compare", ), # Sub-samples API path( @@ -228,16 +228,26 @@ DynamicWebsitePreviewFAQ.as_view(), name="website_preview_faq", ), - # This route show the preview for the recommendations page - # after preview/recommendations route rewrite the url paramaters - # to match backend parameters and redirect + # This route creates the preview of an entity list. path( "preview/_recommendations/", DynamicWebsitePreviewRecommendations.as_view(), name="website_preview_recommendations_internal", ), - # This route rewrite the url for the recommendations page preview + # These routes rewrite the URL parameters to match those used by the + # recommendations of the polls API. re_path( + r"^preview/search/?$", + get_preview_recommendations_redirect_params, + name="website_preview_search_redirect", + ), + re_path( + r"^preview/feed/top/?$", + get_preview_recommendations_redirect_params, + name="website_preview_feed_topitems_redirect", + ), + re_path( + # kept for backward compatibility, replaced by preview/search/ r"^preview/recommendations/?$", get_preview_recommendations_redirect_params, name="website_preview_recommendations_redirect", diff --git a/backend/tournesol/views/previews/recommendations.py b/backend/tournesol/views/previews/recommendations.py index d71b8bf6df..891d7c9a79 100644 --- a/backend/tournesol/views/previews/recommendations.py +++ b/backend/tournesol/views/previews/recommendations.py @@ -51,7 +51,9 @@ def get_preview_recommendations_redirect_params(request): """ Preview of a Recommendations page. - Returns HTTP redirection to transform the query parameters into the format used by the backend. + + Returns a HTTP redirection to format the query parameters to match those + used by the polls API. """ # pylint: disable=too-many-branches params = request.GET diff --git a/browser-extension/package.json b/browser-extension/package.json index 19d65a92fe..b3d5d631db 100644 --- a/browser-extension/package.json +++ b/browser-extension/package.json @@ -1,6 +1,6 @@ { "name": "tournesol-extension", - "version": "3.6.1", + "version": "3.7.0", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/browser-extension/prepareExtension.js b/browser-extension/prepareExtension.js index 680d081e38..3e0562b146 100644 --- a/browser-extension/prepareExtension.js +++ b/browser-extension/prepareExtension.js @@ -124,10 +124,7 @@ const manifest = { production: ['https://tournesol.app/*'], 'dev-env': ['http://localhost:3000/*'], }), - js: [ - 'fetchTournesolToken.js', - 'fetchTournesolRecommendationsLanguages.js', - ], + js: ['fetchTournesolToken.js'], run_at: 'document_end', all_frames: true, }, diff --git a/browser-extension/src/fetchTournesolRecommendationsLanguages.js b/browser-extension/src/fetchTournesolRecommendationsLanguages.js deleted file mode 100644 index cb21e7ee0c..0000000000 --- a/browser-extension/src/fetchTournesolRecommendationsLanguages.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Save the recommendations languages from the Tournesol localStorage to the extension - * local storage. - */ - -function saveRecommendationsLanguages(recommendationsLanguages) { - chrome.storage.local.set({ recommendationsLanguages }); -} - -function updateRecommendationsLanguages() { - const recommendationsLanguages = localStorage.getItem( - 'recommendationsLanguages' - ); - saveRecommendationsLanguages(recommendationsLanguages); -} - -function handleRecommendationsLanguagesChange(event) { - const { detail } = event; - const { recommendationsLanguages } = detail; - saveRecommendationsLanguages(recommendationsLanguages); -} - -updateRecommendationsLanguages(); - -document.addEventListener( - 'tournesol:recommendationsLanguagesChange', - handleRecommendationsLanguagesChange -); diff --git a/browser-extension/src/options/options.js b/browser-extension/src/options/options.js index 8b154ff27f..6fb5e337ba 100644 --- a/browser-extension/src/options/options.js +++ b/browser-extension/src/options/options.js @@ -8,8 +8,8 @@ */ import { frontendUrl } from '../config.js'; +import { getRecomendationsFallbackLanguages } from '../utils.js'; -const DEFAULT_RECO_LANG = ['en']; // This delay is designed to be few miliseconds slower than our CSS fadeOut // animation to let the success message disappear before re-enabling the // submit button. Don't make it faster than the fadeOut animation. @@ -47,20 +47,6 @@ const displayFeedback = (type) => { * */ -const loadLegacyRecommendationsLanguages = () => { - return new Promise((resolve) => { - try { - chrome.storage.local.get( - 'recommendationsLanguages', - ({ recommendationsLanguages }) => resolve(recommendationsLanguages) - ); - } catch (reason) { - console.error(reason); - resolve(); - } - }); -}; - const loadLegacySearchState = () => { return new Promise((resolve) => { try { @@ -81,7 +67,7 @@ const loadLegacySearchState = () => { const loadLocalPreferences = async () => { let error = false; - const legacyRecoLangs = await loadLegacyRecommendationsLanguages(); + const fallbackLangs = getRecomendationsFallbackLanguages(); const legacySearchReco = await loadLegacySearchState(); try { @@ -97,9 +83,7 @@ const loadLocalPreferences = async () => { 'recommendations__default_languages', (settings) => { const languages = - settings?.recommendations__default_languages ?? - legacyRecoLangs?.split(',') ?? - DEFAULT_RECO_LANG; + settings?.recommendations__default_languages ?? fallbackLangs; document .querySelectorAll( diff --git a/browser-extension/src/utils.js b/browser-extension/src/utils.js index e56a73d0b1..37bd262bec 100644 --- a/browser-extension/src/utils.js +++ b/browser-extension/src/utils.js @@ -167,13 +167,14 @@ const getObjectFromLocalStorage = async (key, default_ = null) => { * */ +const DEFAULT_RECO_LANG = 'en'; const LEGACY_SETTINGS_MAP = { extension__search_reco: 'searchEnabled', }; -const getRecomendationsFallbackLanguages = () => { +export const getRecomendationsFallbackLanguages = () => { const navLang = navigator.language.split('-')[0].toLowerCase(); - return ['en', 'fr'].includes(navLang) ? [navLang] : ['en']; + return ['en', 'fr'].includes(navLang) ? [navLang] : [DEFAULT_RECO_LANG]; }; const getRecommendationsLanguagesFromStorage = async (default_) => { @@ -183,32 +184,17 @@ const getRecommendationsLanguagesFromStorage = async (default_) => { ); }; -const getRecommendationsLanguagesFromLegacyStorage = async () => { - const langs = await getObjectFromLocalStorage('recommendationsLanguages'); - - if (langs == null) { - return null; - } - - return langs.split(','); -}; - /** * The languages are retrieved following this priority order: * * 1. user's local settings - * 2. else, the lagacy extension storage key - * 3. else, the navigator.language - * 4. else, the English language code is returned + * 2. else, the navigator.language + * 3. else, the English language code is returned */ const getRecommendationsLanguagesAnonymous = async () => { const fallbackLangs = getRecomendationsFallbackLanguages(); - const legacyLangs = await getRecommendationsLanguagesFromLegacyStorage(); - - const languages = await getRecommendationsLanguagesFromStorage( - legacyLangs ?? fallbackLangs - ); + const languages = await getRecommendationsLanguagesFromStorage(fallbackLangs); return languages; }; @@ -232,11 +218,7 @@ export const getRecommendationsLanguagesAuthenticated = async () => { return await getRecommendationsLanguagesAnonymous(); } - languages = settings.body?.videos?.recommendations__default_languages ?? null; - - if (languages == null) { - languages = await getRecommendationsLanguagesFromLegacyStorage(); - } + languages = settings.body?.videos?.feed_foryou__languages ?? null; if (languages == null) { languages = getRecomendationsFallbackLanguages(); diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 2ce17fee6f..756aa67ebf 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -1,4 +1,7 @@ { + "backIconButton": { + "backToThePreviousPage": "Back to the previous page" + }, "copyUriToClipboard": { "copy": "Copy URI", "copied": "Copied!" @@ -6,6 +9,9 @@ "preferencesIconButtonLink": { "linkToThePreferencesPage": "Link to the preferences page" }, + "searchButtonLink": { + "linkToTheSearchPage": "Link to the search page" + }, "components": { "filtersButton": "filters" }, @@ -305,7 +311,9 @@ "menu": { "home": "Home", "homeAriaLabel": "Link to the home page", - "recommendationsAriaLabel": "Link to the recommendations page", + "feedTopItemsAriaLabel": "Link to the collective ranking", + "forYou": "For you", + "recommendationsAriaLabel": "Link to the collective ranking", "compare": "Compare 🌻", "compareAriaLabel": "Link to the comparison page", "myComparisons": "My comparisons", @@ -392,7 +400,7 @@ "filterByVisibility": "Filter by visibility", "includeAllVideos": "Include all videos", "excludeComparedVideos": "Exclude compared videos", - "unsafeTooltip": "Also display videos with a low number of contributors and those with a negative score.", + "unsafeTooltip": "Also display non-recommended videos (few contributions, low score, etc.).", "excludeComparedTooltip": "Remove from the list of recommendations the videos that you have already compared.", "advanced": "Advanced", "ignore": "Ignore", @@ -526,10 +534,31 @@ "displayCollectiveGoalOnMobile": "Display the weekly collective goal on mobile devices.", "letTournesolSuggestElements": "Let Tournesol suggest elements to compare when opening the comparison interface.", "extensionYoutube": "Extension (YouTube)", - "rateLater": "Rate-later list", - "recommendations": "Recommendations (stream)", - "recommendationsPage": "Recommendations page", - "customizeYourDefaultSearchFilter": "Customize <1>the default search filters according to your own preferences. Those filters are applied <4>only when you access the recommendations from the <7>main menu." + "rateLater": "Rate-later list" + }, + "videosUserSettingsForm": { + "feed": { + "generic": { + "uploadDate": "Upload date", + "unsafe": "Display by default the non-recommended items (few contributions, low score, etc.).", + "excludeCompared": "Exclude by default the videos that you have already compared." + }, + "forYou": { + "feedForYou": "Feed - For you", + "customizeItemsAppearingInTheFeedForYou": "Customize items appearing in the feed For you.", + "forYouVideosLanguages": "Languages of the videos For you", + "keepEmptyToSelectAllLang": "Keep empty to select all languages. (Also used by the browser extension.)" + }, + "topItems": { + "feedTopVideos": "Feed - Top videos", + "customizeItemsAppearingInTheFeedTopVideos": "Customize items appearing in the feed Top videos.", + "topVideosLanguages": "Top videos languages", + "keepEmptyToSelectAllLang": "Keep empty to select all languages." + } + }, + "recommendations": { + "defaultUploadDate": "Default value of the uploaded date filter" + } }, "generalUserSettingsForm": { "notificationsEmailNewFeatures": "I want to be informed of new features (~ 1 per month).", @@ -593,15 +622,6 @@ "ur": "Urdu", "vi": "Vietnamese" }, - "videosUserSettingsForm": { - "recommendations": { - "defaultUploadDate": "Default value of the uploaded date filter", - "defaultLanguages": "Videos languages", - "keepEmptyToSelectAllLang": "Keep empty to select all languages. (Also used by the browser extension.)", - "defaultUnsafe": "Display by default the items having a negative score and those with few contributors.", - "defaultExcludeCompared": "Exclude by default the videos that you have already compared." - } - }, "preferences": { "generalPreferences": "General preferences", "preferencesRegarding": "Preferences regarding:", @@ -888,6 +908,27 @@ "faqEntryList": { "questionURLCopied": "Question's URL copied." }, + "feedForYou": { + "forYou": "For you", + "accordingToYourPreferences": "According to <2>your preferences", + "thisPageDisplaysItemsAccordingToYourFilters": "This page displays items according to your personal filters. You can change them by clicking on the gear icon above." + }, + "genericError": { + "errorOnLoadingTryAgainLater": "An error occurred while loading the results. Try again later or contact an administrator if the problem persists." + }, + "entityList": { + "noItems": "There doesn't seem to be anything to display at the moment.", + "noItemMatchesYourFilters": "No item matches your search filters." + }, + "feedTopItems": { + "generic": { + "recommendedByTheCommunity": "Recommended by the community", + "results": "Results" + }, + "videos": { + "title": "Top videos" + } + }, "home": { "exploreTournesolPossibilities": "Explore Tournesol's Possibilities", "tournesolToComparedMultipleTypesOfAlternatives": "Tournesol is used to compare multiple types of alternatives. See the list below and choose the Tournesol that is best for you:", @@ -1044,7 +1085,10 @@ "recommendations": "Recommendations" } }, - "noVideoCorrespondsToSearchCriterias": "No video matches your search criteria.", + "searchPage": { + "search": "Search", + "exploreTheRecommendationsUsingSearchFilters": "Explore the recommendations using search filters" + }, "profile": "Profile", "signup": { "oneLastStep": "One last step", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index 471dcf4bdf..aa886ce991 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -1,4 +1,7 @@ { + "backIconButton": { + "backToThePreviousPage": "Retour à la page précédente" + }, "copyUriToClipboard": { "copy": "Copier URI", "copied": "Copié !" @@ -6,6 +9,9 @@ "preferencesIconButtonLink": { "linkToThePreferencesPage": "Lien vers la page des préférences" }, + "searchButtonLink": { + "linkToTheSearchPage": "Lien vers la page de recherche" + }, "components": { "filtersButton": "filtres" }, @@ -312,7 +318,9 @@ "menu": { "home": "Accueil", "homeAriaLabel": "Lien vers la page d'accueil", - "recommendationsAriaLabel": "Lien vers la page des recommandations", + "feedTopItemsAriaLabel": "Lien vers le classement collectif", + "forYou": "Pour vous", + "recommendationsAriaLabel": "Lien vers le classement collectif", "compare": "Comparer 🌻", "compareAriaLabel": "Lien vers la page comparer", "myComparisons": "Mes comparaisons", @@ -400,7 +408,7 @@ "filterByVisibility": "Filtrer par visibilité", "includeAllVideos": "Inclure toutes les vidéos", "excludeComparedVideos": "Exclure les vidéos comparées", - "unsafeTooltip": "Afficher aussi les vidéos avec peu de contributeurs et les vidéos avec des scores négatifs.", + "unsafeTooltip": "Afficher aussi les vidéos non recommandées (peu de contributions, score faible, etc.).", "excludeComparedTooltip": "Enlever de la liste de recommandations les vidéos que vous avez déjà comparées", "advanced": "Avancés", "ignore": "Ignoré", @@ -535,10 +543,31 @@ "displayCollectiveGoalOnMobile": "Afficher l'objectif collectif hebdomadaire sur les périphériques mobiles.", "letTournesolSuggestElements": "Laisser Tournesol suggérer des éléments à comparer lors de l'ouverture de l'interface de comparaison.", "extensionYoutube": "Extension (YouTube)", - "rateLater": "Liste à comparer plus tard", - "recommendations": "Recommandations (flux)", - "recommendationsPage": "Recommandations (page)", - "customizeYourDefaultSearchFilter": "Personnalisez <1>les filtres de recherche par défaut selon vos préférences. Ces filtres sont appliqués <4>seulement lorsque vous accédez aux recommandations depuis le <7>menu principal." + "rateLater": "Liste à comparer plus tard" + }, + "videosUserSettingsForm": { + "feed": { + "generic": { + "uploadDate": "Vidéos mises à ligne depuis", + "unsafe": "Afficher par défaut les éléments non recommandés (peu de contributions, score faible, etc.).", + "excludeCompared": "Exclure par défault les vidéos que vous avez déjà comparées." + }, + "forYou": { + "feedForYou": "Flux - Pour vous", + "customizeItemsAppearingInTheFeedForYou": "Personnalisez les éléments qui apparaissent dans le flux Pour vous.", + "forYouVideosLanguages": "Langues des vidéos Pour vous", + "keepEmptyToSelectAllLang": "Laisser vide pour tout sélectionner. (S'applique aussi à l'extension de navigateur.)" + }, + "topItems": { + "feedTopVideos": "Flux - Top vidéos", + "customizeItemsAppearingInTheFeedTopVideos": "Personnalisez les éléments qui apparaissent dans le flux Top vidéos.", + "topVideosLanguages": "Langues des Top vidéos", + "keepEmptyToSelectAllLang": "Laisser vide pour tout sélectionner." + } + }, + "recommendations": { + "defaultUploadDate": "Valeur par défaut du filtre date de mise en ligne" + } }, "generalUserSettingsForm": { "notificationsEmailNewFeatures": "Je veux être informé·e des nouvelles fonctionnalités (~ 1 par mois).", @@ -602,15 +631,6 @@ "ur": "Ourdou", "vi": "Vietnamien" }, - "videosUserSettingsForm": { - "recommendations": { - "defaultUploadDate": "Valeur par défaut du filtre date de mise en ligne", - "defaultLanguages": "Langues des vidéos", - "keepEmptyToSelectAllLang": "Laisser vide pour tout sélectionner. (S'applique aussi à l'extension de navigateur.)", - "defaultUnsafe": "Afficher par défaut les éléments avec un score négatif et ceux avec peu de contributeur·rices.", - "defaultExcludeCompared": "Exclure par défault les vidéos que vous avez déjà comparées." - } - }, "preferences": { "generalPreferences": "Préférences générales", "preferencesRegarding": "Préférences concernant :", @@ -897,6 +917,27 @@ "faqEntryList": { "questionURLCopied": "URL de la question copiée." }, + "feedForYou": { + "forYou": "Pour vous", + "accordingToYourPreferences": "Selon <2>vos préférences", + "thisPageDisplaysItemsAccordingToYourFilters": "Cette page affiche des éléments en fonction de vos filtres personnels. Vous pouvez les modifier en cliquant sur l'icône d'engrenage ci-dessus." + }, + "genericError": { + "errorOnLoadingTryAgainLater": "Une erreur est survenue lors du chargement des résultats. Réessayez plus tard ou contactez un administrateur si le problème persiste." + }, + "entityList": { + "noItems": "Il semblerait qu'il n'y ait pas d'élément à afficher pour l'instant.", + "noItemMatchesYourFilters": "Aucun élément ne correspond à vos filtres de recherche." + }, + "feedTopItems": { + "generic": { + "recommendedByTheCommunity": "Recommandées par la communauté", + "results": "Résultats" + }, + "videos": { + "title": "Top vidéos" + } + }, "home": { "exploreTournesolPossibilities": "Découvrez toutes les possibilités de Tournesol", "tournesolToComparedMultipleTypesOfAlternatives": "Tournesol permet de comparer toutes sortes de choses. Avec les boutons ci-dessous vous pouvez basculer d'un univers à un autre:", @@ -1054,7 +1095,10 @@ "recommendations": "Recommandations" } }, - "noVideoCorrespondsToSearchCriterias": "Aucune vidéo ne correspond à vos critères de recherche.", + "searchPage": { + "search": "Recherche", + "exploreTheRecommendationsUsingSearchFilters": "Explorez les recommandations avec les filtres de recherche" + }, "profile": "Profil", "signup": { "oneLastStep": "Une toute dernière étape", diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 9a634a0942..98c1c60191 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -32,7 +32,7 @@ ], "id": "/", "scope": "/", - "start_url": "/feed/recommendations?utm_source=pwa", + "start_url": "/pwa/start?utm_source=pwa", "display": "standalone", "theme_color": "#ffc800", "background_color": "#a09b87", diff --git a/frontend/scripts/openapi.yaml b/frontend/scripts/openapi.yaml index ef2cbf458d..e856097950 100644 --- a/frontend/scripts/openapi.yaml +++ b/frontend/scripts/openapi.yaml @@ -923,12 +923,56 @@ paths: type: string format: binary description: '' + /preview/feed/top/: + get: + operationId: preview_feed_top_retrieve + description: |- + Preview of a Recommendations page. + + Returns a HTTP redirection to format the query parameters to match those + used by the polls API. + tags: + - preview + security: + - oauth2: + - read write groups + responses: + '200': + content: + image/jpeg: + schema: + type: string + format: binary + description: '' /preview/recommendations/: get: operationId: preview_recommendations_retrieve description: |- Preview of a Recommendations page. - Returns HTTP redirection to transform the query parameters into the format used by the backend. + + Returns a HTTP redirection to format the query parameters to match those + used by the polls API. + tags: + - preview + security: + - oauth2: + - read write groups + responses: + '200': + content: + image/jpeg: + schema: + type: string + format: binary + description: '' + /preview/search/: + get: + operationId: preview_search_retrieve + description: |- + Preview of a Recommendations page. + + Returns a HTTP redirection to format the query parameters to match those + used by the polls API. tags: - preview security: @@ -3252,12 +3296,12 @@ components: * `candidate_fr_2022` - Candidate (FR 2022) EventTypeEnum: enum: - - live - talk + - live type: string description: |- - * `live` - Tournesol Live * `talk` - Tournesol Talk + * `live` - Tournesol Live ExtendedCollectiveRating: type: object properties: @@ -3334,6 +3378,20 @@ components: - answer - name - question + FeedForyou_dateEnum: + enum: + - TODAY + - WEEK + - MONTH + - YEAR + - ALL_TIME + type: string + description: |- + * `TODAY` - today + * `WEEK` - week + * `MONTH` - month + * `YEAR` - year + * `ALL_TIME` - all_time GeneralUserSettings: type: object description: The general user settings that are not related to Tournesol polls. @@ -3458,6 +3516,9 @@ components: * `CONTRIBUTORS` - Contributors PaginatedBannerList: type: object + required: + - count + - results properties: count: type: integer @@ -3478,6 +3539,9 @@ components: $ref: '#/components/schemas/Banner' PaginatedComparisonList: type: object + required: + - count + - results properties: count: type: integer @@ -3498,6 +3562,9 @@ components: $ref: '#/components/schemas/Comparison' PaginatedContributorRatingList: type: object + required: + - count + - results properties: count: type: integer @@ -3518,6 +3585,9 @@ components: $ref: '#/components/schemas/ContributorRating' PaginatedContributorRecommendationsList: type: object + required: + - count + - results properties: count: type: integer @@ -3538,6 +3608,9 @@ components: $ref: '#/components/schemas/ContributorRecommendations' PaginatedEmailDomainList: type: object + required: + - count + - results properties: count: type: integer @@ -3558,6 +3631,9 @@ components: $ref: '#/components/schemas/EmailDomain' PaginatedEntityList: type: object + required: + - count + - results properties: count: type: integer @@ -3578,6 +3654,9 @@ components: $ref: '#/components/schemas/Entity' PaginatedEntityNoExtraFieldList: type: object + required: + - count + - results properties: count: type: integer @@ -3598,6 +3677,9 @@ components: $ref: '#/components/schemas/EntityNoExtraField' PaginatedFAQEntryList: type: object + required: + - count + - results properties: count: type: integer @@ -3618,6 +3700,9 @@ components: $ref: '#/components/schemas/FAQEntry' PaginatedRateLaterList: type: object + required: + - count + - results properties: count: type: integer @@ -3638,6 +3723,9 @@ components: $ref: '#/components/schemas/RateLater' PaginatedRecommendationBaseList: type: object + required: + - count + - results properties: count: type: integer @@ -3658,6 +3746,9 @@ components: $ref: '#/components/schemas/RecommendationBase' PaginatedRecommendationList: type: object + required: + - count + - results properties: count: type: integer @@ -3678,6 +3769,9 @@ components: $ref: '#/components/schemas/Recommendation' PaginatedSubSampleList: type: object + required: + - count + - results properties: count: type: integer @@ -3698,6 +3792,9 @@ components: $ref: '#/components/schemas/SubSample' PaginatedTournesolEventList: type: object + required: + - count + - results properties: count: type: integer @@ -3718,6 +3815,9 @@ components: $ref: '#/components/schemas/TournesolEvent' PaginatedUnconnectedEntityList: type: object + required: + - count + - results properties: count: type: integer @@ -4734,6 +4834,22 @@ components: type: boolean recommendations__default_exclude_compared_entities: type: boolean + feed_foryou__date: + oneOf: + - $ref: '#/components/schemas/FeedForyou_dateEnum' + - $ref: '#/components/schemas/BlankEnum' + feed_foryou__languages: + type: array + items: + type: string + feed_foryou__unsafe: + type: boolean + feed_foryou__exclude_compared_entities: + type: boolean + feed_topitems__languages: + type: array + items: + type: string VideosPollUserSettingsRequest: type: object description: |- @@ -4773,6 +4889,24 @@ components: type: boolean recommendations__default_exclude_compared_entities: type: boolean + feed_foryou__date: + oneOf: + - $ref: '#/components/schemas/FeedForyou_dateEnum' + - $ref: '#/components/schemas/BlankEnum' + feed_foryou__languages: + type: array + items: + type: string + minLength: 1 + feed_foryou__unsafe: + type: boolean + feed_foryou__exclude_compared_entities: + type: boolean + feed_topitems__languages: + type: array + items: + type: string + minLength: 1 securitySchemes: cookieAuth: type: apiKey diff --git a/frontend/src/app/PollRoutes.tsx b/frontend/src/app/PollRoutes.tsx index 5543502076..f46a49fa55 100644 --- a/frontend/src/app/PollRoutes.tsx +++ b/frontend/src/app/PollRoutes.tsx @@ -1,8 +1,12 @@ import React, { useEffect } from 'react'; import { Switch, useRouteMatch } from 'react-router-dom'; + import { Box, CircularProgress } from '@mui/material'; + +import { useCurrentPoll } from 'src/hooks/useCurrentPoll'; import PublicRoute from 'src/features/login/PublicRoute'; import PrivateRoute from 'src/features/login/PrivateRoute'; +import PwaEntryPoint from 'src/features/pwa/PwaEntryPoint'; import PageNotFound from 'src/pages/404/PageNotFound'; import ComparisonListPage from 'src/pages/comparisons/ComparisonList'; import CriteriaPage from 'src/pages/criteria/CriteriaPage'; @@ -13,9 +17,10 @@ import ProofByKeywordPage from 'src/pages/me/proof/ProofByKeywordPage'; import RecommendationPage from 'src/pages/recommendations/RecommendationPage'; import VideoRatingsPage from 'src/pages/videos/VideoRatings'; import ComparisonPage from 'src/pages/comparisons/Comparison'; -import FeedCollectiveRecommendations from 'src/pages/feed/FeedCollectiveRecommendations'; +import FeedForYou from 'src/pages/feed/FeedForYou'; +import FeedTopItems from 'src/pages/feed/FeedTopItems'; import RateLaterPage from 'src/pages/rateLater/RateLater'; -import { useCurrentPoll } from 'src/hooks/useCurrentPoll'; +import SearchPage from 'src/pages/search/SearchPage'; import { RouteID } from 'src/utils/types'; interface Props { @@ -57,12 +62,40 @@ const PollRoutes = ({ pollName }: Props) => { page: HomePage, type: PublicRoute, }, + { + id: RouteID.PwaEntryPoint, + url: 'pwa/start', + page: PwaEntryPoint, + type: PublicRoute, + }, + // deprecated, kept for backward compatibility, should be deleted later + // in 2025 { id: RouteID.FeedCollectiveRecommendations, url: 'feed/recommendations', - page: FeedCollectiveRecommendations, + page: PwaEntryPoint, + type: PublicRoute, + }, + { + id: RouteID.FeedTopItems, + url: 'feed/top', + page: FeedTopItems, + type: PublicRoute, + }, + { + id: RouteID.FeedForYou, + url: 'feed/foryou', + page: FeedForYou, + type: PrivateRoute, + }, + { + id: RouteID.Search, + url: 'search', + page: SearchPage, type: PublicRoute, }, + // deprecated, kept for backward compatibility, should be deleted later + // in 2025 { id: RouteID.CollectiveRecommendations, url: 'recommendations', diff --git a/frontend/src/components/ContentHeader.tsx b/frontend/src/components/ContentHeader.tsx index cab5881e64..37e0f5ee0c 100644 --- a/frontend/src/components/ContentHeader.tsx +++ b/frontend/src/components/ContentHeader.tsx @@ -11,10 +11,12 @@ import { Box, Chip, Grid, Typography } from '@mui/material'; */ const ContentHeader = ({ title, + subtitle, chipIcon, chipLabel, }: { title: string; + subtitle?: React.ReactNode; chipIcon?: React.ReactElement; chipLabel?: string; }) => { @@ -51,6 +53,7 @@ const ContentHeader = ({ )} + {subtitle && {subtitle}} ); }; diff --git a/frontend/src/components/buttons/BackIconButton.tsx b/frontend/src/components/buttons/BackIconButton.tsx new file mode 100644 index 0000000000..73d4427996 --- /dev/null +++ b/frontend/src/components/buttons/BackIconButton.tsx @@ -0,0 +1,34 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; + +import { IconButton } from '@mui/material'; +import { Undo } from '@mui/icons-material'; + +import { InternalLink } from 'src/components'; +import { clearBackNavigation } from 'src/features/login/loginSlice'; + +const BackIconButton = ({ path = '' }: { path: string }) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + useEffect(() => { + return () => { + dispatch(clearBackNavigation()); + }; + }, [dispatch]); + + return ( + + + + + + ); +}; + +export default BackIconButton; diff --git a/frontend/src/components/buttons/PreferencesIconButtonLink.tsx b/frontend/src/components/buttons/PreferencesIconButtonLink.tsx index 77fe911779..c565c6334f 100644 --- a/frontend/src/components/buttons/PreferencesIconButtonLink.tsx +++ b/frontend/src/components/buttons/PreferencesIconButtonLink.tsx @@ -13,6 +13,7 @@ const PreferencesIconButtonLink = ({ hash = '' }: { hash?: string }) => { diff --git a/frontend/src/components/buttons/SearchIconButtonLink.tsx b/frontend/src/components/buttons/SearchIconButtonLink.tsx new file mode 100644 index 0000000000..550395ed00 --- /dev/null +++ b/frontend/src/components/buttons/SearchIconButtonLink.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { IconButton } from '@mui/material'; +import { Search } from '@mui/icons-material'; + +import { InternalLink } from 'src/components'; + +const SearchIconButtonLink = ({ params = '' }: { params?: string }) => { + const { t } = useTranslation(); + const searchParams = params ? '?' + params : ''; + + return ( + + + + + + ); +}; + +export default SearchIconButtonLink; diff --git a/frontend/src/components/entity/EntityMetadata.tsx b/frontend/src/components/entity/EntityMetadata.tsx index e31b779a25..6fe65db42f 100644 --- a/frontend/src/components/entity/EntityMetadata.tsx +++ b/frontend/src/components/entity/EntityMetadata.tsx @@ -63,7 +63,7 @@ export const VideoMetadata = ({ placement="bottom" > { const location = useLocation(); const dispatch = useAppDispatch(); - const userSettings = useSelector(selectSettings)?.settings; const { name: pollName, options } = useCurrentPoll(); const path = options && options.path ? options.path : '/'; const disabledItems = options?.disabledRouteIds ?? []; + const langsAutoDiscovery = options?.defaultRecoLanguageDiscovery ?? false; + + const userSettings = useSelector(selectSettings)?.settings; const drawerOpen = useAppSelector(selectFrame); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); @@ -128,19 +132,36 @@ const SideBar = ({ beforeInstallPromptEvent }: Props) => { displayText: t('menu.home'), ariaLabel: t('menu.homeAriaLabel'), }, + { displayText: 'divider_1' }, { - id: RouteID.CollectiveRecommendations, - targetUrl: `${path}recommendations${getDefaultRecommendationsSearchParams( + id: RouteID.FeedTopItems, + targetUrl: `${path}feed/top${getFeedTopItemsDefaultSearchParams( pollName, options, - userSettings + userSettings, + langsAutoDiscovery )}`, + IconComponent: EmojiEventsIcon, + displayText: getFeedTopItemsPageName(t, pollName), + ariaLabel: t('menu.feedTopItemsAriaLabel'), + }, + { + id: RouteID.FeedForYou, + targetUrl: `${path}feed/foryou`, IconComponent: pollName === YOUTUBE_POLL_NAME ? VideoLibrary : TableRowsIcon, + displayText: t('menu.forYou'), + ariaLabel: t('menu.forYou'), + }, + { + id: RouteID.CollectiveRecommendations, + targetUrl: `${path}recommendations`, + IconComponent: TableRowsIcon, displayText: getRecommendationPageName(t, pollName), ariaLabel: t('menu.recommendationsAriaLabel'), + onlyForPolls: [PRESIDENTIELLE_2022_POLL_NAME], }, - { displayText: 'divider_1' }, + { displayText: 'divider_2' }, { id: RouteID.Comparison, targetUrl: `${path}comparison`, @@ -176,7 +197,7 @@ const SideBar = ({ beforeInstallPromptEvent }: Props) => { displayText: t('menu.myResults'), ariaLabel: t('menu.myFeedbackAriaLabel'), }, - { displayText: 'divider_2' }, + { displayText: 'divider_3' }, { id: RouteID.FAQ, targetUrl: '/faq', @@ -232,12 +253,22 @@ const SideBar = ({ beforeInstallPromptEvent }: Props) => { }} > {menuItems.map( - ({ id, targetUrl, IconComponent, displayText, ariaLabel }) => { + ({ + id, + targetUrl, + IconComponent, + displayText, + ariaLabel, + onlyForPolls, + }) => { if (!IconComponent || !targetUrl) return ; if (id && disabledItems.includes(id)) { return; } + if (onlyForPolls != undefined && !onlyForPolls.includes(pollName)) { + return; + } const selected = isItemSelected(targetUrl); return ( diff --git a/frontend/src/features/frame/components/topbar/PersonalMenu.tsx b/frontend/src/features/frame/components/topbar/PersonalMenu.tsx index af7b238042..740ee5903c 100644 --- a/frontend/src/features/frame/components/topbar/PersonalMenu.tsx +++ b/frontend/src/features/frame/components/topbar/PersonalMenu.tsx @@ -82,6 +82,7 @@ const PersonalMenu = ({ to={item.to} onClick={onItemClick} selected={item.to === location.pathname} + data-testid={item.id} > diff --git a/frontend/src/features/frame/components/topbar/Search.tsx b/frontend/src/features/frame/components/topbar/Search.tsx index f6e0bfb75b..56aa55081b 100644 --- a/frontend/src/features/frame/components/topbar/Search.tsx +++ b/frontend/src/features/frame/components/topbar/Search.tsx @@ -72,7 +72,7 @@ const Search = () => { if (videoId) { history.push('/entities/yt:' + videoId.toString()); } else { - history.push('/recommendations?' + searchParams.toString()); + history.push('/search?' + searchParams.toString()); } }; diff --git a/frontend/src/features/login/LoginState.model.ts b/frontend/src/features/login/LoginState.model.ts index e8eff37147..736ef0fc83 100644 --- a/frontend/src/features/login/LoginState.model.ts +++ b/frontend/src/features/login/LoginState.model.ts @@ -4,4 +4,6 @@ export interface LoginState { refresh_token?: string; status: 'idle' | 'loading' | 'failed'; username?: string; + backPath?: string; + backParams?: string; } diff --git a/frontend/src/features/login/loginSlice.ts b/frontend/src/features/login/loginSlice.ts index deb6818aa6..cfd5b53031 100644 --- a/frontend/src/features/login/loginSlice.ts +++ b/frontend/src/features/login/loginSlice.ts @@ -33,6 +33,8 @@ export const loginSlice = createSlice({ state.refresh_token = undefined; state.access_token_expiration_date = undefined; state.username = undefined; + state.backPath = undefined; + state.backParams = undefined; }, updateUsername: ( state: LoginState, @@ -40,6 +42,21 @@ export const loginSlice = createSlice({ ) => { state.username = action.payload.username; }, + /** + * Save the given path and URL parameters to allow the back buttons to + * return to the desired location. + */ + updateBackNagivation: ( + state: LoginState, + action: PayloadAction<{ backPath: string; backParams: string }> + ) => { + state.backPath = action.payload.backPath; + state.backParams = action.payload.backParams; + }, + clearBackNavigation: (state: LoginState) => { + state.backPath = undefined; + state.backParams = undefined; + }, }, extraReducers: (builder) => { builder @@ -82,6 +99,11 @@ export const loginSlice = createSlice({ }); export const selectLogin = (state: RootState) => state.token; -export const { logout, updateUsername } = loginSlice.actions; +export const { + clearBackNavigation, + logout, + updateUsername, + updateBackNagivation, +} = loginSlice.actions; export default loginSlice.reducer; diff --git a/frontend/src/features/pwa/PwaEntryPoint.tsx b/frontend/src/features/pwa/PwaEntryPoint.tsx new file mode 100644 index 0000000000..4f0390d57e --- /dev/null +++ b/frontend/src/features/pwa/PwaEntryPoint.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { Redirect } from 'react-router-dom'; + +import { useCurrentPoll, useLoginState } from 'src/hooks'; +import { selectSettings } from 'src/features/settings/userSettingsSlice'; +import { getFeedTopItemsDefaultSearchParams } from 'src/utils/userSettings'; + +const PwaEntryPoint = () => { + const { name: pollName, options } = useCurrentPoll(); + const langsAutoDiscovery = options?.defaultRecoLanguageDiscovery ?? false; + const userSettings = useSelector(selectSettings)?.settings; + const { isLoggedIn } = useLoginState(); + + if (isLoggedIn) { + return ; + } + + return ( + + ); +}; + +export default PwaEntryPoint; diff --git a/frontend/src/features/recommendation/SearchFilter.spec.tsx b/frontend/src/features/recommendation/SearchFilter.spec.tsx index 7c912567c2..2c20d39ebd 100644 --- a/frontend/src/features/recommendation/SearchFilter.spec.tsx +++ b/frontend/src/features/recommendation/SearchFilter.spec.tsx @@ -19,7 +19,6 @@ import { import userEvent from '@testing-library/user-event'; import { Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; -import { loadRecommendationsLanguages } from 'src/utils/recommendationsLanguages'; import { PollProvider } from 'src/hooks/useCurrentPoll'; import { PollsService } from 'src/services/openapi'; import { combineReducers, createStore } from 'redux'; @@ -75,7 +74,11 @@ describe('Filters feature', () => { - + + localStorage.setItem('languages', langs) + } + /> @@ -180,7 +183,8 @@ describe('Filters feature', () => { expect(pushSpy).toHaveBeenLastCalledWith({ search: 'language=' + encodeURIComponent(expectInUrl), }); - expect(loadRecommendationsLanguages()).toEqual(expectInUrl); + + expect(localStorage.getItem('languages')).toEqual(expectInUrl); } it('Can open and close the filters menu', async () => { diff --git a/frontend/src/features/recommendation/SearchFilter.tsx b/frontend/src/features/recommendation/SearchFilter.tsx index b4834bb457..091a61c641 100644 --- a/frontend/src/features/recommendation/SearchFilter.tsx +++ b/frontend/src/features/recommendation/SearchFilter.tsx @@ -13,29 +13,36 @@ import UploaderFilter from './UploaderFilter'; import AdvancedFilter from './AdvancedFilter'; import ScoreModeFilter from './ScoreModeFilter'; import { - recommendationFilters, - defaultRecommendationFilters, YOUTUBE_POLL_NAME, PRESIDENTIELLE_2022_POLL_NAME, + pollVideosFilters, + pollVideosInitialFilters, } from 'src/utils/constants'; import { ScoreModeEnum } from 'src/utils/api/recommendations'; -import { saveRecommendationsLanguages } from 'src/utils/recommendationsLanguages'; /** * Filter options for Videos recommendations * * The "filters" button has a badge when one of its filter is enabled with a non-default value. - * When adding a new filter, it needs to be defined in constants 'recommendationFilters' - * and 'defaultRecommendationsFilters'. + * When adding a new filter, it needs to be defined in constants 'pollVideosFilters' + * and 'pollVideosInitialFilters'. */ function SearchFilter({ - showAdvancedFilter = true, + appearExpanded = false, extraActions, + disableAdvanced = false, + disableCriteria = false, + disableDuration = false, + onLanguagesChange, }: { - showAdvancedFilter?: boolean; + appearExpanded?: boolean; extraActions?: React.ReactNode; + disableAdvanced?: boolean; + disableCriteria?: boolean; + disableDuration?: boolean; + onLanguagesChange?: (value: string) => void; }) { - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(appearExpanded); const [filterParams, setFilter] = useListFilter({ setEmptyValues: true }); const { name: pollName } = useCurrentPoll(); @@ -45,17 +52,20 @@ function SearchFilter({ }; const isFilterActive = () => - Object.entries(defaultRecommendationFilters).some( + Object.entries(pollVideosInitialFilters).some( ([key, defaultValue]) => ![null, '', defaultValue].includes(filterParams.get(key)) ); const handleLanguageChange = useCallback( (value: string) => { - saveRecommendationsLanguages(value); - setFilter(recommendationFilters.language, value); + if (onLanguagesChange) { + onLanguagesChange(value); + } + + setFilter(pollVideosFilters.language, value); }, - [setFilter] + [setFilter, onLanguagesChange] ); const setFilterCallback = useCallback( @@ -75,11 +85,11 @@ function SearchFilter({ {extraActions} - {filterParams.get(recommendationFilters.uploader) && ( + {filterParams.get(pollVideosFilters.uploader) && ( setFilter(recommendationFilters.uploader, null)} + value={filterParams.get(pollVideosFilters.uploader) ?? ''} + onDelete={() => setFilter(pollVideosFilters.uploader, null)} /> )} @@ -94,12 +104,10 @@ function SearchFilter({ data-testid="search-date-and-advanced-filter" > - setFilter(recommendationFilters.date, value) - } + value={filterParams.get(pollVideosFilters.date) ?? ''} + onChange={(value) => setFilter(pollVideosFilters.date, value)} /> - {showAdvancedFilter && ( + {!disableAdvanced && ( - - - + {!disableDuration && ( + + + + )} )} - - - + {!disableCriteria && ( + + + + )} {pollName == PRESIDENTIELLE_2022_POLL_NAME && ( { const theme = useTheme(); - const { i18n, t } = useTranslation(); + const { t } = useTranslation(); const { criterias, name: pollName } = useCurrentPoll(); const [isLoading, setIsLoading] = useState(true); const [recoDate, setRecoDate] = useState('Month'); const [entities, setEntities] = useState>([]); - const currentLang = i18n.resolvedLanguage || i18n.language; useEffect(() => { const searchParams = new URLSearchParams(); searchParams.append('date', recoDate); - searchParams.append('language', currentLang); + searchParams.append('language', language); const getRecommendationsAsync = async () => { setIsLoading(true); @@ -58,7 +59,7 @@ const RecommendationsSubset = ({ }; getRecommendationsAsync(); - }, [criterias, currentLang, nbr, pollName, recoDate]); + }, [criterias, language, nbr, pollName, recoDate]); const dateControlChangeCallback = (value: string) => { setRecoDate(value); diff --git a/frontend/src/features/settings/preferences/TournesolUserSettingsForm.spec.tsx b/frontend/src/features/settings/preferences/TournesolUserSettingsForm.spec.tsx index e9bbc34d66..2be57ba00c 100644 --- a/frontend/src/features/settings/preferences/TournesolUserSettingsForm.spec.tsx +++ b/frontend/src/features/settings/preferences/TournesolUserSettingsForm.spec.tsx @@ -17,9 +17,9 @@ import { LoginState } from 'src/features/login/LoginState.model'; import { initialState } from 'src/features/login/loginSlice'; import { ComparisonUi_weeklyCollectiveGoalDisplayEnum, + FeedForyou_dateEnum, Notifications_langEnum, OpenAPI, - Recommendations_defaultDateEnum, TournesolUserSettings, } from 'src/services/openapi'; @@ -85,7 +85,7 @@ describe('GenericPollUserSettingsForm', () => { videos: { rate_later__auto_remove: 16, comparison_ui__weekly_collective_goal_display: 'NEVER', - recommendations__default_unsafe: true, + feed_foryou__unsafe: true, }, general: { notifications_email__research: true, @@ -153,12 +153,8 @@ describe('GenericPollUserSettingsForm', () => { const rateLaterAutoRemove = screen.getByTestId( 'videos_rate_later__auto_remove' ); - const recommendationsDefaultDate = screen.getByTestId( - 'videos_recommendations__default_date' - ); - const recommendationsDefaultUnsafe = screen.getByTestId( - 'videos_recommendations__default_unsafe' - ); + const feedForYouDate = screen.getByTestId('videos_feed_foryou__date'); + const feedForYouUnsafe = screen.getByTestId('videos_feed_foryou__unsafe'); const submit = screen.getByRole('button', { name: /update/i }); @@ -168,8 +164,8 @@ describe('GenericPollUserSettingsForm', () => { notificationsEmailNewFeatures, notificationsLang, rateLaterAutoRemove, - recommendationsDefaultDate, - recommendationsDefaultUnsafe, + feedForYouDate, + feedForYouUnsafe, rendered, storeDispatchSpy, submit, @@ -192,8 +188,8 @@ describe('GenericPollUserSettingsForm', () => { notificationsEmailNewFeatures, notificationsLang, rateLaterAutoRemove, - recommendationsDefaultDate, - recommendationsDefaultUnsafe, + feedForYouDate, + feedForYouUnsafe, submit, } = await setup(); @@ -209,10 +205,8 @@ describe('GenericPollUserSettingsForm', () => { expect(compUiWeeklyColGoalDisplay).toHaveValue( ComparisonUi_weeklyCollectiveGoalDisplayEnum.ALWAYS ); - expect(recommendationsDefaultDate).toHaveValue( - Recommendations_defaultDateEnum.MONTH - ); - expect(recommendationsDefaultUnsafe).toHaveProperty('checked', false); + expect(feedForYouDate).toHaveValue(FeedForyou_dateEnum.MONTH); + expect(feedForYouUnsafe).toHaveProperty('checked', false); fireEvent.click(notificationsEmailResearch); fireEvent.click(notificationsEmailNewFeatures); @@ -220,10 +214,10 @@ describe('GenericPollUserSettingsForm', () => { fireEvent.change(compUiWeeklyColGoalDisplay, { target: { value: ComparisonUi_weeklyCollectiveGoalDisplayEnum.NEVER }, }); - fireEvent.change(recommendationsDefaultDate, { - target: { value: Recommendations_defaultDateEnum.ALL_TIME }, + fireEvent.change(feedForYouDate, { + target: { value: FeedForyou_dateEnum.ALL_TIME }, }); - fireEvent.click(recommendationsDefaultUnsafe); + fireEvent.click(feedForYouUnsafe); expect(submit).toBeEnabled(); @@ -237,23 +231,21 @@ describe('GenericPollUserSettingsForm', () => { expect(compUiWeeklyColGoalDisplay).toHaveValue( ComparisonUi_weeklyCollectiveGoalDisplayEnum.NEVER ); - expect(recommendationsDefaultDate).toHaveValue( - Recommendations_defaultDateEnum.ALL_TIME - ); - expect(recommendationsDefaultUnsafe).toHaveProperty('checked', true); + expect(feedForYouDate).toHaveValue(FeedForyou_dateEnum.ALL_TIME); + expect(feedForYouUnsafe).toHaveProperty('checked', true); expect(submit).toBeEnabled(); }); it('retrieves its initial values from the Redux store', async () => { const { rateLaterAutoRemove } = await setup(); - expect(useSelectorSpy).toHaveBeenCalledTimes(1); + expect(useSelectorSpy).toHaveBeenCalled(); expect(rateLaterAutoRemove).toHaveValue(8); }); it("calls the store's dispatch function after a submit", async () => { const { rateLaterAutoRemove, - recommendationsDefaultUnsafe, + feedForYouUnsafe, notificationsEmailResearch, notificationsEmailNewFeatures, storeDispatchSpy, @@ -264,7 +256,7 @@ describe('GenericPollUserSettingsForm', () => { fireEvent.click(notificationsEmailResearch); fireEvent.click(notificationsEmailNewFeatures); fireEvent.change(rateLaterAutoRemove, { target: { value: 16 } }); - fireEvent.click(recommendationsDefaultUnsafe); + fireEvent.click(feedForYouUnsafe); await act(async () => { fireEvent.click(submit); @@ -282,7 +274,7 @@ describe('GenericPollUserSettingsForm', () => { comparison_ui__weekly_collective_goal_display: ComparisonUi_weeklyCollectiveGoalDisplayEnum.NEVER, rate_later__auto_remove: 16, - recommendations__default_unsafe: true, + feed_foryou__unsafe: true, }, }, }); diff --git a/frontend/src/features/settings/preferences/TournesolUserSettingsForm.tsx b/frontend/src/features/settings/preferences/TournesolUserSettingsForm.tsx index 2bebb7f8c4..4141f9435a 100644 --- a/frontend/src/features/settings/preferences/TournesolUserSettingsForm.tsx +++ b/frontend/src/features/settings/preferences/TournesolUserSettingsForm.tsx @@ -12,6 +12,7 @@ import { } from 'src/features/settings/userSettingsSlice'; import { useNotifications, useScrollToLocation } from 'src/hooks'; import { theme } from 'src/theme'; +import { FEED_LANG_KEY as FEED_TOPITEMS_LANG_KEY } from 'src/pages/feed/FeedTopItems'; import { mainSectionGridSpacing, subSectionBreakpoints, @@ -20,8 +21,8 @@ import { ApiError, BlankEnum, ComparisonUi_weeklyCollectiveGoalDisplayEnum, + FeedForyou_dateEnum, Notifications_langEnum, - Recommendations_defaultDateEnum, TournesolUserSettings, UsersService, } from 'src/services/openapi'; @@ -30,20 +31,17 @@ import { YOUTUBE_POLL_NAME, YT_DEFAULT_AUTO_SELECT_ENTITIES, YT_DEFAULT_UI_WEEKLY_COL_GOAL_MOBILE, + polls, } from 'src/utils/constants'; import { - initRecommendationsLanguages, - saveRecommendationsLanguages, + getInitialRecoLanguages, + getInitialRecoLanguagesForFilterableFeed, } from 'src/utils/recommendationsLanguages'; +import { searchFilterToSettingDate } from 'src/utils/userSettings'; import GeneralUserSettingsForm from './GeneralUserSettingsForm'; import VideosPollUserSettingsForm from './VideosPollUserSettingsForm'; -const initialLanguages = () => { - const languages = initRecommendationsLanguages(); - return languages ? languages.split(',') : []; -}; - /** * Display a form allowing the logged users to update all their Tournesol * preferences. @@ -61,6 +59,11 @@ const TournesolUserSettingsForm = () => { const generalSettings = userSettings?.general; const pollSettings = userSettings?.videos; + const youtubePoll = polls.find((p) => p.name === YOUTUBE_POLL_NAME); + const ytDefaultFiltersFeedForYou = new URLSearchParams( + youtubePoll?.defaultFiltersFeedForYou + ); + useScrollToLocation(); /** @@ -117,25 +120,32 @@ const TournesolUserSettingsForm = () => { pollSettings?.rate_later__auto_remove ?? DEFAULT_RATE_LATER_AUTO_REMOVAL ); - // Recommendations (stream) - const [recoDefaultLanguages, setRecoDefaultLanguages] = useState< - Array - >(initialLanguages()); + // Feed: For You + const [forYouLanguages, setForYouLanguages] = useState>([]); - // Recommendations (page) - const [recoDefaultUnsafe, setRecoDefaultUnsafe] = useState( - pollSettings?.recommendations__default_unsafe ?? false + const [forYouUploadDate, setForYouUploadDate] = useState< + FeedForyou_dateEnum | BlankEnum + >( + pollSettings?.feed_foryou__date ?? + searchFilterToSettingDate(ytDefaultFiltersFeedForYou.get('date')) ); - const [recoDefaultExcludeCompared, setRecoDefaultExcludeCompared] = useState( - pollSettings?.recommendations__default_exclude_compared_entities ?? false + + const [forYouUnsafe, setForYouUnsafe] = useState( + pollSettings?.feed_foryou__unsafe ?? false ); - const [recoDefaultUploadDate, setRecoDefaultUploadDate] = useState< - Recommendations_defaultDateEnum | BlankEnum - >( - pollSettings?.recommendations__default_date ?? - Recommendations_defaultDateEnum.MONTH + + const [forYouExcludeCompared, setForYouExcludeCompared] = useState( + pollSettings?.feed_foryou__exclude_compared_entities ?? + ytDefaultFiltersFeedForYou + .get('advanced') + ?.split(',') + .includes('exclude_compared') ?? + true ); + // Feed: Top videos + const [topItemsLanguages, setTopItemsLanguages] = useState>([]); + useEffect(() => { if (!generalSettings && !pollSettings) { return; @@ -189,34 +199,47 @@ const TournesolUserSettingsForm = () => { setRateLaterAutoRemoval(pollSettings.rate_later__auto_remove); } - if (pollSettings?.recommendations__default_languages != undefined) { - setRecoDefaultLanguages(pollSettings.recommendations__default_languages); + if (pollSettings?.feed_foryou__languages != undefined) { + setForYouLanguages(pollSettings?.feed_foryou__languages); + } else if (youtubePoll?.defaultRecoLanguageDiscovery) { + const forYouLangs = getInitialRecoLanguages(); + setForYouLanguages(forYouLangs ? forYouLangs.split(',') : []); } - if (pollSettings?.recommendations__default_unsafe != undefined) { - setRecoDefaultUnsafe(pollSettings.recommendations__default_unsafe); + if (pollSettings?.feed_foryou__unsafe != undefined) { + setForYouUnsafe(pollSettings.feed_foryou__unsafe); } - if ( - pollSettings?.recommendations__default_exclude_compared_entities != - undefined - ) { - setRecoDefaultExcludeCompared( - pollSettings.recommendations__default_exclude_compared_entities + if (pollSettings?.feed_foryou__exclude_compared_entities != undefined) { + setForYouExcludeCompared( + pollSettings.feed_foryou__exclude_compared_entities ); } - if (pollSettings?.recommendations__default_date != undefined) { - setRecoDefaultUploadDate(pollSettings.recommendations__default_date); + if (pollSettings?.feed_foryou__date != undefined) { + setForYouUploadDate(pollSettings.feed_foryou__date); + } + + if (pollSettings?.feed_topitems__languages != undefined) { + setTopItemsLanguages(pollSettings.feed_topitems__languages); + } else if (youtubePoll?.defaultRecoLanguageDiscovery) { + const topItemsLangs = getInitialRecoLanguagesForFilterableFeed( + YOUTUBE_POLL_NAME, + FEED_TOPITEMS_LANG_KEY + ); + + setTopItemsLanguages(topItemsLangs ? topItemsLangs.split(',') : []); } - }, [generalSettings, pollSettings]); + }, [ + generalSettings, + pollSettings, + youtubePoll?.defaultRecoLanguageDiscovery, + ]); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setDisabled(true); - saveRecommendationsLanguages(recoDefaultLanguages.join(',')); - const response: void | TournesolUserSettings = await UsersService.usersMeSettingsPartialUpdate({ requestBody: { @@ -234,11 +257,11 @@ const TournesolUserSettingsForm = () => { compUiWeeklyColGoalMobile, extension__search_reco: extSearchRecommendation, rate_later__auto_remove: rateLaterAutoRemoval, - recommendations__default_languages: recoDefaultLanguages, - recommendations__default_date: recoDefaultUploadDate, - recommendations__default_unsafe: recoDefaultUnsafe, - recommendations__default_exclude_compared_entities: - recoDefaultExcludeCompared, + feed_foryou__languages: forYouLanguages, + feed_foryou__date: forYouUploadDate, + feed_foryou__unsafe: forYouUnsafe, + feed_foryou__exclude_compared_entities: forYouExcludeCompared, + feed_topitems__languages: topItemsLanguages, }, }, }).catch((reason: ApiError) => { @@ -298,14 +321,16 @@ const TournesolUserSettingsForm = () => { setDisplayedCriteria={setDisplayedCriteria} rateLaterAutoRemoval={rateLaterAutoRemoval} setRateLaterAutoRemoval={setRateLaterAutoRemoval} - recoDefaultLanguages={recoDefaultLanguages} - setRecoDefaultLanguages={setRecoDefaultLanguages} - recoDefaultUnsafe={recoDefaultUnsafe} - setRecoDefaultUnsafe={setRecoDefaultUnsafe} - recoDefaultExcludeCompared={recoDefaultExcludeCompared} - setRecoDefaultExcludeCompared={setRecoDefaultExcludeCompared} - recoDefaultUploadDate={recoDefaultUploadDate} - setRecoDefaultUploadDate={setRecoDefaultUploadDate} + forYouLanguages={forYouLanguages} + setForYouLanguages={setForYouLanguages} + forYouUploadDate={forYouUploadDate} + setForYouUploadDate={setForYouUploadDate} + forYouUnsafe={forYouUnsafe} + setForYouUnsafe={setForYouUnsafe} + forYouExcludeCompared={forYouExcludeCompared} + setForYouExcludeCompared={setForYouExcludeCompared} + topVideosLanguages={topItemsLanguages} + setTopVideosLangauges={setTopItemsLanguages} apiErrors={apiErrors} /> diff --git a/frontend/src/features/settings/preferences/VideosPollUserSettingsForm.tsx b/frontend/src/features/settings/preferences/VideosPollUserSettingsForm.tsx index c1ce935532..d286fe7821 100644 --- a/frontend/src/features/settings/preferences/VideosPollUserSettingsForm.tsx +++ b/frontend/src/features/settings/preferences/VideosPollUserSettingsForm.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import { Trans, useTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; -import { Alert, Box, Grid } from '@mui/material'; +import { Box, Grid } from '@mui/material'; import { ApiError, BlankEnum, ComparisonUi_weeklyCollectiveGoalDisplayEnum, - Recommendations_defaultDateEnum, + FeedForyou_dateEnum, } from 'src/services/openapi'; import { YOUTUBE_POLL_NAME } from 'src/utils/constants'; @@ -16,9 +16,9 @@ import ComparisonOptionalCriteriaDisplayed from './fields/ComparisonOptionalCrit import ExtSearchRecommendation from './fields/ExtSearchRecommendation'; import RateLaterAutoRemoveField from './fields/RateLaterAutoRemove'; import WeeklyCollectiveGoalDisplayField from './fields/WeeklyCollectiveGoalDisplay'; -import RecommendationsDefaultLanguage from './fields/RecommendationsDefaultLanguage'; -import RecommendationsDefaultDate from './fields/RecommendationsDefaultDate'; +import FeedForYou from './fieldsets/FeedForYou'; import SettingsHeading from './SettingsHeading'; +import FeedTopItems from './fieldsets/FeedTopItems'; interface VideosPollUserSettingsFormProps { extSearchRecommendation: boolean; @@ -37,16 +37,16 @@ interface VideosPollUserSettingsFormProps { setDisplayedCriteria: (target: string[]) => void; rateLaterAutoRemoval: number; setRateLaterAutoRemoval: (number: number) => void; - recoDefaultLanguages: string[]; - setRecoDefaultLanguages: (target: string[]) => void; - recoDefaultUnsafe: boolean; - setRecoDefaultUnsafe: (target: boolean) => void; - recoDefaultExcludeCompared: boolean; - setRecoDefaultExcludeCompared: (target: boolean) => void; - recoDefaultUploadDate: Recommendations_defaultDateEnum | BlankEnum; - setRecoDefaultUploadDate: ( - target: Recommendations_defaultDateEnum | BlankEnum - ) => void; + forYouLanguages: string[]; + setForYouLanguages: (target: string[]) => void; + forYouUploadDate: FeedForyou_dateEnum | BlankEnum; + setForYouUploadDate: (target: FeedForyou_dateEnum | BlankEnum) => void; + forYouUnsafe: boolean; + setForYouUnsafe: (target: boolean) => void; + forYouExcludeCompared: boolean; + setForYouExcludeCompared: (target: boolean) => void; + topVideosLanguages: string[]; + setTopVideosLangauges: (target: string[]) => void; apiErrors: ApiError | null; } @@ -66,14 +66,16 @@ const VideosPollUserSettingsForm = ({ setDisplayedCriteria, rateLaterAutoRemoval, setRateLaterAutoRemoval, - recoDefaultLanguages, - setRecoDefaultLanguages, - recoDefaultUnsafe, - setRecoDefaultUnsafe, - recoDefaultExcludeCompared, - setRecoDefaultExcludeCompared, - recoDefaultUploadDate, - setRecoDefaultUploadDate, + forYouLanguages, + setForYouLanguages, + forYouUploadDate, + setForYouUploadDate, + forYouUnsafe, + setForYouUnsafe, + forYouExcludeCompared, + setForYouExcludeCompared, + topVideosLanguages, + setTopVideosLangauges, apiErrors, }: VideosPollUserSettingsFormProps) => { const pollName = YOUTUBE_POLL_NAME; @@ -152,6 +154,26 @@ const VideosPollUserSettingsForm = ({ /> + + + + + + - - - - - - - - - - - - - - - - Customize the default search filters according to - your own preferences. Those filters are applied{' '} - only when you access the recommendations from the{' '} - main menu. - - - - - - - - - - - - - - - ); }; diff --git a/frontend/src/features/settings/preferences/fields/FeedForYouDate.tsx b/frontend/src/features/settings/preferences/fields/FeedForYouDate.tsx new file mode 100644 index 0000000000..b69bd61d45 --- /dev/null +++ b/frontend/src/features/settings/preferences/fields/FeedForYouDate.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FormControl, InputLabel, MenuItem, Select } from '@mui/material'; + +import { BlankEnum, FeedForyou_dateEnum } from 'src/services/openapi'; + +interface FeedForYouDateProps { + scope: string; + value: FeedForyou_dateEnum | BlankEnum; + onChange: (target: FeedForyou_dateEnum | BlankEnum) => void; +} + +const FeedForYouDate = ({ scope, value, onChange }: FeedForYouDateProps) => { + const { t } = useTranslation(); + + const settingChoices = [ + { + label: t('filter.today'), + value: FeedForyou_dateEnum.TODAY, + }, + { + label: t('filter.thisWeek'), + value: FeedForyou_dateEnum.WEEK, + }, + { + label: t('filter.thisMonth'), + value: FeedForyou_dateEnum.MONTH, + }, + { + label: t('filter.thisYear'), + value: FeedForyou_dateEnum.YEAR, + }, + { + label: t('filter.allTime'), + value: FeedForyou_dateEnum.ALL_TIME, + }, + ]; + + return ( + + + {t('videosUserSettingsForm.feed.generic.uploadDate')} + + + + ); +}; + +export default FeedForYouDate; diff --git a/frontend/src/features/settings/preferences/fields/ItemsLanguages.tsx b/frontend/src/features/settings/preferences/fields/ItemsLanguages.tsx new file mode 100644 index 0000000000..60d90621fd --- /dev/null +++ b/frontend/src/features/settings/preferences/fields/ItemsLanguages.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { LanguageField } from 'src/features/recommendation/LanguageFilter'; + +interface ItemsLanguagesProps { + label: string; + helpText: string; + value: string[]; + onChange: (target: string[]) => void; +} + +const ItemsLanguages = ({ + label, + helpText, + value, + onChange, +}: ItemsLanguagesProps) => { + return ( + { + if (!values) { + onChange([]); + } else { + onChange(values.split(',')); + } + }} + /> + ); +}; + +export default ItemsLanguages; diff --git a/frontend/src/features/settings/preferences/fields/RecommendationsDefaultDate.tsx b/frontend/src/features/settings/preferences/fields/RecommendationsDefaultDate.tsx deleted file mode 100644 index 83fab2480c..0000000000 --- a/frontend/src/features/settings/preferences/fields/RecommendationsDefaultDate.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { FormControl, InputLabel, MenuItem, Select } from '@mui/material'; - -import { - BlankEnum, - Recommendations_defaultDateEnum, -} from 'src/services/openapi'; - -interface RecommendationsDefaultDateProps { - value: Recommendations_defaultDateEnum | BlankEnum; - onChange: (target: Recommendations_defaultDateEnum | BlankEnum) => void; - pollName: string; -} - -const RecommendationsDefaultDate = ({ - value, - onChange, - pollName, -}: RecommendationsDefaultDateProps) => { - const { t } = useTranslation(); - - const settingChoices = [ - { - label: t('filter.today'), - value: Recommendations_defaultDateEnum.TODAY, - }, - { - label: t('filter.thisWeek'), - value: Recommendations_defaultDateEnum.WEEK, - }, - { - label: t('filter.thisMonth'), - value: Recommendations_defaultDateEnum.MONTH, - }, - { - label: t('filter.thisYear'), - value: Recommendations_defaultDateEnum.YEAR, - }, - { - label: t('filter.allTime'), - value: Recommendations_defaultDateEnum.ALL_TIME, - }, - ]; - - return ( - - - {t('videosUserSettingsForm.recommendations.defaultUploadDate')} - - - - ); -}; - -export default RecommendationsDefaultDate; diff --git a/frontend/src/features/settings/preferences/fields/RecommendationsDefaultLanguage.tsx b/frontend/src/features/settings/preferences/fields/RecommendationsDefaultLanguage.tsx deleted file mode 100644 index 91f5032450..0000000000 --- a/frontend/src/features/settings/preferences/fields/RecommendationsDefaultLanguage.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { LanguageField } from 'src/features/recommendation/LanguageFilter'; - -interface RecommendationsDefaultLanguageProps { - value: string[]; - onChange: (target: string[]) => void; -} - -const RecommendationsDefaultLanguage = ({ - value, - onChange, -}: RecommendationsDefaultLanguageProps) => { - const { t } = useTranslation(); - - return ( - { - if (!values) { - onChange([]); - } else { - onChange(values.split(',')); - } - }} - /> - ); -}; - -export default RecommendationsDefaultLanguage; diff --git a/frontend/src/features/settings/preferences/fieldsets/FeedForYou.tsx b/frontend/src/features/settings/preferences/fieldsets/FeedForYou.tsx new file mode 100644 index 0000000000..aa93136ecd --- /dev/null +++ b/frontend/src/features/settings/preferences/fieldsets/FeedForYou.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Alert, Grid } from '@mui/material'; + +import SettingsHeading from 'src/features/settings/preferences/SettingsHeading'; +import { BlankEnum, FeedForyou_dateEnum } from 'src/services/openapi'; + +import FeedForYouDate from '../fields/FeedForYouDate'; +import ItemsLanguages from '../fields/ItemsLanguages'; +import BooleanField from '../fields/generics/BooleanField'; + +interface FeedForYouProps { + scope: string; + forYouLanguages: string[]; + setForYouLanguages: (target: string[]) => void; + forYouUploadDate: FeedForyou_dateEnum | BlankEnum; + setForYouUploadDate: (target: FeedForyou_dateEnum | BlankEnum) => void; + forYouUnsafe: boolean; + setForYouUnsafe: (target: boolean) => void; + forYouExcludeCompared: boolean; + setForYouExcludeCompared: (target: boolean) => void; +} + +const FeedForYou = ({ + scope, + forYouLanguages, + setForYouLanguages, + forYouUploadDate, + setForYouUploadDate, + forYouUnsafe, + setForYouUnsafe, + forYouExcludeCompared, + setForYouExcludeCompared, +}: FeedForYouProps) => { + const { t } = useTranslation(); + return ( + <> + + + + + + {t( + 'videosUserSettingsForm.feed.forYou.customizeItemsAppearingInTheFeedForYou' + )} + + + + + + + + + + + + + + + + + + ); +}; + +export default FeedForYou; diff --git a/frontend/src/features/settings/preferences/fieldsets/FeedTopItems.tsx b/frontend/src/features/settings/preferences/fieldsets/FeedTopItems.tsx new file mode 100644 index 0000000000..5b1eb0de27 --- /dev/null +++ b/frontend/src/features/settings/preferences/fieldsets/FeedTopItems.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Alert, Grid } from '@mui/material'; + +import SettingsHeading from 'src/features/settings/preferences/SettingsHeading'; + +import ItemsLanguages from '../fields/ItemsLanguages'; + +interface FeedForYouProps { + scope: string; + topItemsLanguages: string[]; + setTopItemsLangauges: (target: string[]) => void; +} + +const FeedTopItems = ({ + scope, + topItemsLanguages, + setTopItemsLangauges, +}: FeedForYouProps) => { + const { t } = useTranslation(); + return ( + <> + + + + + + {t( + 'videosUserSettingsForm.feed.topItems.customizeItemsAppearingInTheFeedTopVideos' + )} + + + + + + + ); +}; + +export default FeedTopItems; diff --git a/frontend/src/hooks/usePreferredLanguages.ts b/frontend/src/hooks/usePreferredLanguages.ts index c7fefb3bc6..5316b2a143 100644 --- a/frontend/src/hooks/usePreferredLanguages.ts +++ b/frontend/src/hooks/usePreferredLanguages.ts @@ -1,15 +1,14 @@ import { useSelector } from 'react-redux'; import { selectSettings } from 'src/features/settings/userSettingsSlice'; +import { getInitialRecoLanguages } from 'src/utils/recommendationsLanguages'; import { PollUserSettingsKeys } from 'src/utils/types'; -import { recommendationsLanguagesFromNavigator } from 'src/utils/recommendationsLanguages'; export const usePreferredLanguages = ({ pollName }: { pollName: string }) => { const userSettings = useSelector(selectSettings).settings; let preferredLanguages = - userSettings?.[pollName as PollUserSettingsKeys] - ?.recommendations__default_languages; + userSettings?.[pollName as PollUserSettingsKeys]?.feed_foryou__languages; - preferredLanguages ??= recommendationsLanguagesFromNavigator().split(','); + preferredLanguages ??= getInitialRecoLanguages().split(','); return preferredLanguages; }; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 9216a5651f..e95aa0999f 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -7,6 +7,14 @@ import packageJson from '../package.json'; export const SUPPORTED_LANGUAGES = packageJson.config.supported_languages; +export const langIsSupported = (langCode: string) => { + return SUPPORTED_LANGUAGES.map((l) => l.code).includes(langCode); +}; + +export const someLangsAreSupported = (langs: string[]) => { + return langs.some(langIsSupported); +}; + i18n // load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales) // learn more: https://github.com/i18next/i18next-http-backend diff --git a/frontend/src/pages/actions/sections/BeEducated.tsx b/frontend/src/pages/actions/sections/BeEducated.tsx index 2f8db257ff..cb7694b11f 100644 --- a/frontend/src/pages/actions/sections/BeEducated.tsx +++ b/frontend/src/pages/actions/sections/BeEducated.tsx @@ -56,22 +56,22 @@ const booksToReadAndOfferFr = [ const videosToWatchAndShareEn = [ { text: 'Science4All (english)', - href: 'https://tournesol.app/recommendations?language=&uploader=Science4All+%28english%29', + href: 'https://tournesol.app/search?language=&uploader=Science4All+%28english%29', }, ]; const videosToWatchAndShareFr = [ { text: 'ApresLaBiere', - href: 'https://tournesol.app/recommendations?language=&uploader=ApresLaBiere', + href: 'https://tournesol.app/search?language=&uploader=ApresLaBiere', }, { text: 'La Fabrique Sociale', - href: 'https://tournesol.app/recommendations?language=&uploader=La%20Fabrique%20Sociale', + href: 'https://tournesol.app/search?language=&uploader=La%20Fabrique%20Sociale', }, { text: 'Science4All', - href: 'https://tournesol.app/recommendations?language=&uploader=Science4All', + href: 'https://tournesol.app/search?language=&uploader=Science4All', }, ]; diff --git a/frontend/src/pages/feed/FeedCollectiveRecommendations.tsx b/frontend/src/pages/feed/FeedCollectiveRecommendations.tsx deleted file mode 100644 index 33acf77f99..0000000000 --- a/frontend/src/pages/feed/FeedCollectiveRecommendations.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; -import { Redirect } from 'react-router-dom'; - -import { selectSettings } from 'src/features/settings/userSettingsSlice'; -import { useCurrentPoll } from 'src/hooks'; -import { getDefaultRecommendationsSearchParams } from 'src/utils/userSettings'; - -const FeedCollectiveRecommendations = () => { - const { name: pollName, options } = useCurrentPoll(); - const userSettings = useSelector(selectSettings).settings; - - const searchParams = getDefaultRecommendationsSearchParams( - pollName, - options, - userSettings - ); - - return ; -}; - -export default FeedCollectiveRecommendations; diff --git a/frontend/src/pages/feed/FeedForYou.tsx b/frontend/src/pages/feed/FeedForYou.tsx new file mode 100644 index 0000000000..9ebdcfea8e --- /dev/null +++ b/frontend/src/pages/feed/FeedForYou.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { Alert, Box } from '@mui/material'; + +import { + ContentBox, + ContentHeader, + InternalLink, + LoaderWrapper, + Pagination, + PreferencesIconButtonLink, + SearchIconButtonLink, +} from 'src/components'; +import EntityList from 'src/features/entities/EntityList'; +import { updateBackNagivation } from 'src/features/login/loginSlice'; +import { selectSettings } from 'src/features/settings/userSettingsSlice'; +import { useCurrentPoll } from 'src/hooks'; +import { PaginatedRecommendationList } from 'src/services/openapi'; +import { getRecommendations } from 'src/utils/api/recommendations'; +import { getFeedForYouDefaultSearchParams } from 'src/utils/userSettings'; + +const ENTITIES_LIMIT = 20; + +const FeedForYou = () => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const history = useHistory(); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const offset = Number(searchParams.get('offset') || 0); + + const { name: pollName, criterias, options } = useCurrentPoll(); + const langsAutoDiscovery = options?.defaultRecoLanguageDiscovery ?? false; + const userSettings = useSelector(selectSettings).settings; + + const userPreferences: URLSearchParams = useMemo(() => { + return getFeedForYouDefaultSearchParams( + pollName, + options, + userSettings, + langsAutoDiscovery + ); + }, [langsAutoDiscovery, options, pollName, userSettings]); + + const [isLoading, setIsLoading] = useState(true); + const [loadingError, setLoadingError] = useState(false); + const [entities, setEntities] = useState({ + count: 0, + results: [], + }); + + const onOffsetChange = (newOffset: number) => { + searchParams.set('offset', newOffset.toString()); + history.push({ search: searchParams.toString() }); + }; + + useEffect(() => { + dispatch( + updateBackNagivation({ + backPath: location.pathname, + backParams: offset > 0 ? `offset=${offset}` : '', + }) + ); + }, [dispatch, location.pathname, offset]); + + useEffect(() => { + const searchString = new URLSearchParams(userPreferences); + searchString.append('offset', offset.toString()); + + const fetchEntities = async () => { + setIsLoading(true); + + try { + const newEntities = await getRecommendations( + pollName, + ENTITIES_LIMIT, + searchString.toString(), + criterias, + options + ); + setEntities(newEntities); + setLoadingError(false); + } catch (error) { + console.error(error); + setLoadingError(true); + } finally { + setIsLoading(false); + } + }; + + fetchEntities(); + }, [criterias, offset, options, pollName, userPreferences]); + + return ( + <> + + According to{' '} + + your preferences + + + } + /> + + + + + + {!isLoading && entities.count === 0 && ( + + {loadingError ? ( + + {t('genericError.errorOnLoadingTryAgainLater')} + + ) : ( + + {t('feedForYou.thisPageDisplaysItemsAccordingToYourFilters')} + + )} + + )} + + + + {!isLoading && (entities.count ?? 0) > 0 && ( + + )} + + + ); +}; + +export default FeedForYou; diff --git a/frontend/src/pages/feed/FeedTopItems.tsx b/frontend/src/pages/feed/FeedTopItems.tsx new file mode 100644 index 0000000000..1da8b94ef8 --- /dev/null +++ b/frontend/src/pages/feed/FeedTopItems.tsx @@ -0,0 +1,216 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { Alert, Box } from '@mui/material'; + +import { + ContentBox, + ContentHeader, + LoaderWrapper, + Pagination, + PreferencesIconButtonLink, + SearchIconButtonLink, +} from 'src/components'; +import { useCurrentPoll } from 'src/hooks'; +import EntityList from 'src/features/entities/EntityList'; +import { updateBackNagivation } from 'src/features/login/loginSlice'; +import ShareMenuButton from 'src/features/menus/ShareMenuButton'; +import SearchFilter from 'src/features/recommendation/SearchFilter'; +import { selectSettings } from 'src/features/settings/userSettingsSlice'; +import { PaginatedRecommendationList } from 'src/services/openapi'; +import { getRecommendations } from 'src/utils/api/recommendations'; +import { + getFeedTopItemsPageName, + pollVideosFilters, +} from 'src/utils/constants'; +import { PollUserSettingsKeys } from 'src/utils/types'; +import { + getInitialRecoLanguagesForFilterableFeed, + saveRecoLanguagesToLocalStorage, +} from 'src/utils/recommendationsLanguages'; + +const ALLOWED_SEARCH_PARAMS = [ + pollVideosFilters.date, + pollVideosFilters.language, + 'offset', +]; +const ENTITIES_LIMIT = 20; +export const FEED_LANG_KEY = 'top-items'; + +const filterAllowedParams = ( + searchParams: URLSearchParams, + allowList: string[] +) => { + for (const key of [...searchParams.keys()]) { + if (!allowList.includes(key)) { + searchParams.delete(key); + } + } + + return searchParams; +}; + +const FeedTopItems = () => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const history = useHistory(); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const offset = Number(searchParams.get('offset') || 0); + + const { name: pollName, criterias, options } = useCurrentPoll(); + const langsAutoDiscovery = options?.defaultRecoLanguageDiscovery ?? false; + + const userSettings = useSelector(selectSettings).settings; + const preferredLanguages = + userSettings?.[pollName as PollUserSettingsKeys]?.feed_topitems__languages; + + const [isLoading, setIsLoading] = useState(true); + const [loadingError, setLoadingError] = useState(false); + const [entities, setEntities] = useState({ + count: 0, + results: [], + }); + + const onOffsetChange = (newOffset: number) => { + searchParams.set('offset', newOffset.toString()); + history.push({ search: searchParams.toString() }); + }; + + useEffect(() => { + const currentParams = filterAllowedParams( + new URLSearchParams(location.search), + ALLOWED_SEARCH_PARAMS + ); + + dispatch( + updateBackNagivation({ + backPath: location.pathname, + backParams: currentParams.toString(), + }) + ); + }, [dispatch, location.pathname, location.search]); + + useEffect(() => { + // `searchParams` is defined as a mutable object outside of this effect. + // It's safer to recreate it here, instead of adding a dependency to the + // current effect. + const searchString = new URLSearchParams(location.search); + searchString.set('offset', offset.toString()); + + if (langsAutoDiscovery && searchString.get('language') === null) { + let loadedLanguages = preferredLanguages?.join(',') ?? null; + + if (loadedLanguages === null) { + loadedLanguages = getInitialRecoLanguagesForFilterableFeed( + pollName, + FEED_LANG_KEY + ); + } + + searchString.set('language', loadedLanguages); + history.replace({ search: searchString.toString() }); + return; + } + + filterAllowedParams(searchString, ALLOWED_SEARCH_PARAMS); + + const fetchEntities = async () => { + setIsLoading(true); + try { + const newEntities = await getRecommendations( + pollName, + ENTITIES_LIMIT, + searchString.toString(), + criterias, + options + ); + setEntities(newEntities); + setLoadingError(false); + } catch (error) { + console.error(error); + setLoadingError(true); + } finally { + setIsLoading(false); + } + }; + + fetchEntities(); + }, [ + langsAutoDiscovery, + criterias, + history, + location.search, + offset, + options, + preferredLanguages, + pollName, + ]); + + const makeSearchPageSearchParams = () => { + const searchPageSearchParams = filterAllowedParams( + new URLSearchParams(location.search), + ALLOWED_SEARCH_PARAMS + ); + searchPageSearchParams.delete('offset'); + return searchPageSearchParams; + }; + + return ( + <> + + + + + + + + + } + onLanguagesChange={(langs) => + saveRecoLanguagesToLocalStorage(pollName, FEED_LANG_KEY, langs) + } + /> + + {loadingError && !isLoading && entities.count === 0 && ( + + + {t('genericError.errorOnLoadingTryAgainLater')} + + + )} + + + + {!isLoading && (entities.count ?? 0) > 0 && ( + + )} + + + ); +}; + +export default FeedTopItems; diff --git a/frontend/src/pages/home/videos/HomeVideos.tsx b/frontend/src/pages/home/videos/HomeVideos.tsx index ed55f50b42..955508c85a 100644 --- a/frontend/src/pages/home/videos/HomeVideos.tsx +++ b/frontend/src/pages/home/videos/HomeVideos.tsx @@ -83,7 +83,7 @@ const HomeVideosPage = () => { color="primary" variant="contained" component={Link} - to={`${baseUrl}/recommendations`} + to={`${baseUrl}/search`} sx={{ px: 4, fontSize: '120%', diff --git a/frontend/src/pages/home/videos/sections/recommendations/RecommendationsSection.tsx b/frontend/src/pages/home/videos/sections/recommendations/RecommendationsSection.tsx index 30c78780dc..1105095d62 100644 --- a/frontend/src/pages/home/videos/sections/recommendations/RecommendationsSection.tsx +++ b/frontend/src/pages/home/videos/sections/recommendations/RecommendationsSection.tsx @@ -15,13 +15,14 @@ import UseOurExtension from './UseOurExtension'; * A home page section that displays a subset of recommended entities. */ const RecommendationsSection = () => { - const { t } = useTranslation(); + const { i18n, t } = useTranslation(); const { name: pollName } = useCurrentPoll(); const stats = useStats({ poll: pollName }); const pollStats = getPollStats(stats, pollName); const titleColor = '#fff'; + const currentLang = i18n.resolvedLanguage || i18n.language; // Determine the date filter applied when the user click on the see more // button. @@ -81,6 +82,7 @@ const RecommendationsSection = () => { gap={2} > @@ -88,7 +90,7 @@ const RecommendationsSection = () => { diff --git a/frontend/src/pages/recommendations/RecommendationPage.spec.tsx b/frontend/src/pages/recommendations/RecommendationPage.spec.tsx deleted file mode 100644 index d8dc16788c..0000000000 --- a/frontend/src/pages/recommendations/RecommendationPage.spec.tsx +++ /dev/null @@ -1,170 +0,0 @@ -/* - Because of a regression in CRA v5, Typescript is wrongly enforced here - See https://github.com/facebook/create-react-app/pull/11875 -*/ -// eslint-disable-next-line -// @ts-nocheck -import React from 'react'; -import * as reactRedux from 'react-redux'; - -import configureStore, { - MockStoreCreator, - MockStoreEnhanced, -} from 'redux-mock-store'; -import { AnyAction } from '@reduxjs/toolkit'; -import thunk, { ThunkDispatch } from 'redux-thunk'; -import { createMemoryHistory } from 'history'; -import { act } from 'react-dom/test-utils'; -import { Router } from 'react-router-dom'; - -import { ThemeProvider } from '@mui/material/styles'; -import { render } from '@testing-library/react'; - -import { initialState } from 'src/features/login/loginSlice'; -import { LoginState } from 'src/features/login/LoginState.model'; -import { PollProvider } from 'src/hooks/useCurrentPoll'; -import RecommendationPage from 'src/pages/recommendations/RecommendationPage'; -import { PollsService, TournesolUserSettings } from 'src/services/openapi'; -import { theme } from 'src/theme'; -import * as RecommendationApi from 'src/utils/api/recommendations'; -import { - saveRecommendationsLanguages, - loadRecommendationsLanguages, -} from 'src/utils/recommendationsLanguages'; - -interface MockState { - token: LoginState; - settings: TournesolUserSettings; -} - -vi.mock('src/features/entities/EntityList', () => { - const EntityList = () => null; - return { - default: EntityList, - }; -}); - -vi.mock('src/features/recommendation/SearchFilter', () => { - const SearchFilter = () => null; - return { - default: SearchFilter, - }; -}); - -describe('RecommendationPage', () => { - let history: ReturnType; - let historySpy: ReturnType; - let navigatorLanguagesGetter: ReturnType; - let getRecommendedVideosSpy: ReturnType; - - const mockStore: MockStoreCreator< - MockState, - ThunkDispatch - > = configureStore([thunk]); - - beforeEach(() => { - history = createMemoryHistory(); - historySpy = vi.spyOn(history, 'replace'); - navigatorLanguagesGetter = vi.spyOn(window.navigator, 'languages', 'get'); - getRecommendedVideosSpy = vi - .spyOn(RecommendationApi, 'getRecommendations') - .mockImplementation(async () => ({ count: 0, results: [] })); - - // prevent the useCurrentPoll hook to make HTTP requests - vi.spyOn(PollsService, 'pollsRetrieve').mockImplementation(async () => ({ - name: 'videos', - criterias: [ - { - name: 'largely_recommended', - label: 'largely recommended', - optional: false, - }, - ], - })); - }); - - const component = async ({ - store, - }: { - store: MockStoreEnhanced; - }) => { - return await act(async () => { - Promise.resolve( - render( - - - - - - - - - - ) - ); - }); - }; - - const setup = async () => { - const state = { token: initialState, settings: {} }; - const store = mockStore(state); - const rendered = await component({ store: store }); - - return { rendered }; - }; - - it('adds the navigator languages to the query string and stores them', async () => { - navigatorLanguagesGetter.mockReturnValue(['fr', 'en-US']); - await setup(); - - expect(historySpy).toHaveBeenLastCalledWith({ - search: 'language=fr%2Cen', - }); - expect(loadRecommendationsLanguages()).toEqual('fr,en'); - expect(getRecommendedVideosSpy).toHaveBeenCalledTimes(1); - expect(getRecommendedVideosSpy).toHaveBeenLastCalledWith( - 'videos', - 20, - '?language=fr%2Cen', - expect.anything(), - expect.anything() - ); - }); - - it('adds the stored languages to the query string', async () => { - navigatorLanguagesGetter.mockReturnValue(['fr', 'en-US']); - saveRecommendationsLanguages('de'); - await setup(); - - expect(historySpy).toHaveBeenLastCalledWith({ - search: 'language=de', - }); - expect(loadRecommendationsLanguages()).toEqual('de'); - expect(getRecommendedVideosSpy).toHaveBeenCalledTimes(1); - expect(getRecommendedVideosSpy).toHaveBeenLastCalledWith( - 'videos', - 20, - '?language=de', - expect.anything(), - expect.anything() - ); - }); - - it("doesn't change the languages already in the query string", async () => { - navigatorLanguagesGetter.mockReturnValue(['fr', 'en-US']); - saveRecommendationsLanguages('de'); - history.push({ search: 'language=fr' }); - await setup(); - - expect(historySpy).not.toHaveBeenCalled(); - expect(loadRecommendationsLanguages()).toEqual('de'); - expect(getRecommendedVideosSpy).toHaveBeenCalledTimes(1); - expect(getRecommendedVideosSpy).toHaveBeenLastCalledWith( - 'videos', - 20, - '?language=fr', - expect.anything(), - expect.anything() - ); - }); -}); diff --git a/frontend/src/pages/recommendations/RecommendationPage.tsx b/frontend/src/pages/recommendations/RecommendationPage.tsx index ae80088523..c0f2570294 100644 --- a/frontend/src/pages/recommendations/RecommendationPage.tsx +++ b/frontend/src/pages/recommendations/RecommendationPage.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect, useRef } from 'react'; -import { useSelector } from 'react-redux'; import { useLocation, useHistory, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -8,12 +7,10 @@ import { Person } from '@mui/icons-material'; import { ContentBox, ContentHeader, LoaderWrapper } from 'src/components'; import Pagination from 'src/components/Pagination'; -import PreferencesIconButtonLink from 'src/components/buttons/PreferencesIconButtonLink'; import { useCurrentPoll } from 'src/hooks/useCurrentPoll'; import EntityList from 'src/features/entities/EntityList'; import SearchFilter from 'src/features/recommendation/SearchFilter'; import ShareMenuButton from 'src/features/menus/ShareMenuButton'; -import { selectSettings } from 'src/features/settings/userSettingsSlice'; import type { PaginatedContributorRecommendationsList, PaginatedRecommendationList, @@ -23,12 +20,6 @@ import { getRecommendations, } from 'src/utils/api/recommendations'; import { getRecommendationPageName } from 'src/utils/constants'; -import { - saveRecommendationsLanguages, - loadRecommendationsLanguages, - recommendationsLanguagesFromNavigator, -} from 'src/utils/recommendationsLanguages'; -import { PollUserSettingsKeys } from 'src/utils/types'; /** * Display a collective or personal recommendations. @@ -41,12 +32,9 @@ function RecommendationsPage() { const history = useHistory(); const location = useLocation(); const { baseUrl, name: pollName, criterias, options } = useCurrentPoll(); - const [isLoading, setIsLoading] = useState(true); + const langsAutoDiscovery = options?.defaultRecoLanguageDiscovery ?? false; - const userSettings = useSelector(selectSettings).settings; - const preferredLanguages = - userSettings?.[pollName as PollUserSettingsKeys] - ?.recommendations__default_languages; + const [isLoading, setIsLoading] = useState(true); const allowPublicPersonalRecommendations = options?.allowPublicPersonalRecommendations ?? false; @@ -74,7 +62,6 @@ function RecommendationsPage() { const limit = 20; const offset = Number(searchParams.get('offset') || 0); - const autoLanguageDiscovery = options?.defaultRecoLanguageDiscovery ?? false; const locationSearchRef = useRef(); @@ -95,19 +82,8 @@ function RecommendationsPage() { // effect. So it's safer to recreate it here, instead of adding // a dependency to the current effect. const searchParams = new URLSearchParams(location.search); - if (autoLanguageDiscovery && searchParams.get('language') === null) { - let loadedLanguages = preferredLanguages?.join(',') ?? null; - - if (loadedLanguages === null) { - loadedLanguages = loadRecommendationsLanguages(); - } - - if (loadedLanguages === null) { - loadedLanguages = recommendationsLanguagesFromNavigator(); - saveRecommendationsLanguages(loadedLanguages); - } - - searchParams.set('language', loadedLanguages); + if (langsAutoDiscovery && searchParams.get('language') === null) { + searchParams.set('language', ''); history.replace({ search: searchParams.toString() }); return; } @@ -143,14 +119,13 @@ function RecommendationsPage() { fetchEntities(); }, [ - autoLanguageDiscovery, criterias, displayPersonalRecommendations, history, + langsAutoDiscovery, location.search, options, pollName, - preferredLanguages, username, ]); @@ -172,12 +147,11 @@ function RecommendationsPage() { {/* Unsafe filter is not available when fetching personal recommendations */} + - - + } /> @@ -187,7 +161,7 @@ function RecommendationsPage() { diff --git a/frontend/src/pages/search/SearchPage.tsx b/frontend/src/pages/search/SearchPage.tsx new file mode 100644 index 0000000000..24d363c6ff --- /dev/null +++ b/frontend/src/pages/search/SearchPage.tsx @@ -0,0 +1,145 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { Alert, Box } from '@mui/material'; + +import { + BackIconButton, + ContentBox, + ContentHeader, + LoaderWrapper, + Pagination, + PreferencesIconButtonLink, +} from 'src/components'; +import { useCurrentPoll } from 'src/hooks'; +import EntityList from 'src/features/entities/EntityList'; +import { selectLogin } from 'src/features/login/loginSlice'; +import ShareMenuButton from 'src/features/menus/ShareMenuButton'; +import SearchFilter from 'src/features/recommendation/SearchFilter'; +import { PaginatedRecommendationList } from 'src/services/openapi'; +import { getRecommendations } from 'src/utils/api/recommendations'; + +const ENTITIES_LIMIT = 20; + +const SearchPage = () => { + const { t } = useTranslation(); + + const history = useHistory(); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const offset = Number(searchParams.get('offset') || 0); + + const { name: pollName, criterias, options } = useCurrentPoll(); + const loginState = useSelector(selectLogin); + + const [isLoading, setIsLoading] = useState(true); + const [loadingError, setLoadingError] = useState(false); + const [entities, setEntities] = useState({ + count: 0, + results: [], + }); + + const onOffsetChange = (newOffset: number) => { + searchParams.set('offset', newOffset.toString()); + history.push({ search: searchParams.toString() }); + }; + + useEffect(() => { + // `searchParams` is defined as a mutable object outside of this effect. + // It's safer to recreate it here, instead of adding a dependency to the + // current effect. + const searchString = new URLSearchParams(location.search); + searchString.set('offset', offset.toString()); + + if (searchString.get('language') === null) { + searchString.set('language', ''); + history.replace({ search: searchString.toString() }); + return; + } + + const fetchEntities = async () => { + setIsLoading(true); + try { + const newEntities = await getRecommendations( + pollName, + ENTITIES_LIMIT, + searchString.toString(), + criterias, + options + ); + setEntities(newEntities); + setLoadingError(false); + } catch (error) { + console.log(error); + setLoadingError(true); + } finally { + setIsLoading(false); + } + }; + + fetchEntities(); + }, [criterias, history, location.search, offset, options, pollName]); + + const createBackButtonPath = () => { + const backPath = loginState.backPath; + const backParams = loginState.backParams; + + if (!backPath) { + return ''; + } + + return backParams ? backPath + '?' + backParams : backPath; + }; + + const backButtonPath = createBackButtonPath(); + + return ( + <> + + + + + + {backButtonPath && } + + + } + /> + + {loadingError && !isLoading && entities.count === 0 && ( + + + {t('genericError.errorOnLoadingTryAgainLater')} + + + )} + + + + {!isLoading && (entities.count ?? 0) > 0 && ( + + )} + + + ); +}; + +export default SearchPage; diff --git a/frontend/src/pages/videos/VideoRatings.tsx b/frontend/src/pages/videos/VideoRatings.tsx index f348a79329..be7ae2d40c 100644 --- a/frontend/src/pages/videos/VideoRatings.tsx +++ b/frontend/src/pages/videos/VideoRatings.tsx @@ -44,7 +44,10 @@ const NoRatingMessage = ({ hasFilter }: { hasFilter: boolean }) => { const VideoRatingsPage = () => { const { name: pollName, options } = useCurrentPoll(); - const [ratings, setRatings] = useState({}); + const [ratings, setRatings] = useState({ + count: 0, + results: [], + }); const [isLoading, setIsLoading] = useState(true); const location = useLocation(); const history = useHistory(); @@ -52,7 +55,6 @@ const VideoRatingsPage = () => { const searchParams = new URLSearchParams(location.search); const limit = 20; const offset = Number(searchParams.get('offset') || 0); - const videoCount = ratings.count || 0; const hasFilter = searchParams.get('isPublic') != null; const handleOffsetChange = (newOffset: number) => { @@ -119,10 +121,10 @@ const VideoRatingsPage = () => { displayContextAlert={true} /> - {!isLoading && videoCount > 0 && videoCount > limit && ( + {!isLoading && ratings.count > 0 && ratings.count > limit && ( diff --git a/frontend/src/utils/api/recommendations.tsx b/frontend/src/utils/api/recommendations.tsx index 5f25a7b8e1..bd7c36b20e 100644 --- a/frontend/src/utils/api/recommendations.tsx +++ b/frontend/src/utils/api/recommendations.tsx @@ -32,6 +32,7 @@ const overwriteDateURLParameter = ( params: URLSearchParams ): void => { if (pollName === YOUTUBE_POLL_NAME) { + const date = params.get('date'); const conversionTime = new Map(); const dayInMillisecs = 1000 * 60 * 60 * 24; @@ -43,13 +44,12 @@ const overwriteDateURLParameter = ( conversionTime.set('Month', dayInMillisecs * 31); conversionTime.set('Year', dayInMillisecs * 365); - if (params.get('date')) { - const date = params.get('date'); + if (conversionTime.has(date)) { params.delete('date'); if (date != 'Any') { const param_date = new Date(Date.now() - conversionTime.get(date)); - // we truncate minutes, seconds and ms from the date in order to benefit + // We truncate minutes, seconds and ms from the date in order to benefit // from caching at the API level. param_date.setMinutes(0, 0, 0); params.append('date_gte', param_date.toISOString()); diff --git a/frontend/src/utils/constants.tsx b/frontend/src/utils/constants.tsx index a32a6b3fe2..78f478fd07 100644 --- a/frontend/src/utils/constants.tsx +++ b/frontend/src/utils/constants.tsx @@ -26,7 +26,7 @@ const PRESIDENTIELLE_2022_ENABLED = const UID_DELIMITER = ':'; export const UID_YT_NAMESPACE = 'yt' + UID_DELIMITER; -export const recommendationFilters = { +export const pollVideosFilters = { date: 'date', language: 'language', uploader: 'uploader', @@ -42,7 +42,7 @@ export const recommendationFilters = { backfire_risk: 'backfire_risk', }; -export const defaultRecommendationFilters = { +export const pollVideosInitialFilters = { date: null, language: null, uploader: null, @@ -168,6 +168,15 @@ export const getRecommendationPageName = ( } }; +export const getFeedTopItemsPageName = (t: TFunction, pollName: string) => { + switch (pollName) { + case YOUTUBE_POLL_NAME: + return t('feedTopItems.videos.title'); + default: + return t('feedTopItems.generic.results'); + } +}; + /** * User settings. * @@ -197,6 +206,9 @@ export const polls: Array = [ mainCriterionName: 'be_president', path: '/presidentielle2022/', disabledRouteIds: [ + RouteID.FeedTopItems, + RouteID.FeedForYou, + RouteID.Search, RouteID.MyRateLaterList, RouteID.MyComparedItems, RouteID.Criteria, @@ -220,6 +232,8 @@ export const polls: Array = [ defaultAnonEntityActions: [], defaultRecoLanguageDiscovery: true, defaultRecoSearchParams: 'date=Month', + defaultFiltersFeedTopItems: 'date=Month', + defaultFiltersFeedForYou: 'date=Month&advanced=exclude_compared', allowPublicPersonalRecommendations: true, mainCriterionName: 'largely_recommended', displayOrder: 10, diff --git a/frontend/src/utils/polls/videos.tsx b/frontend/src/utils/polls/videos.tsx index 44c5aedbb3..4a9847b927 100644 --- a/frontend/src/utils/polls/videos.tsx +++ b/frontend/src/utils/polls/videos.tsx @@ -3,9 +3,8 @@ import { TFunction } from 'react-i18next'; import { Button, Link } from '@mui/material'; -import { SUPPORTED_LANGUAGES } from 'src/i18n'; import { PollsService, Recommendation } from 'src/services/openapi'; -import { recommendationsLanguagesFromNavigator } from 'src/utils/recommendationsLanguages'; +import { getInitialRecoLanguages } from 'src/utils/recommendationsLanguages'; import { OrderedDialogs, OrderedTips } from 'src/utils/types'; import { getWebExtensionUrl } from '../extension'; @@ -35,24 +34,13 @@ export function getTutorialVideos(): Promise { return VIDEOS; } - const supportedLangCodes = SUPPORTED_LANGUAGES.map((l) => l.code); - const langIsSupported = (langCode: string) => - supportedLangCodes.includes(langCode); - const metadata: Record = {}; const minutesMax = 5; const top = 100; metadata['duration:lte:int'] = (60 * minutesMax).toString(); - metadata['language'] = recommendationsLanguagesFromNavigator().split(','); - - // Add "en" to the recommendations request, so that users will always get - // recommendations, even if Tournesol doesn't have any recommendation - // matching their navigator languages. - if (!metadata['language'].some(langIsSupported)) { - metadata['language'].push('en'); - } + metadata['language'] = getInitialRecoLanguages().split(','); VIDEOS = PollsService.pollsRecommendationsList({ name: 'videos', diff --git a/frontend/src/utils/recommendationsLanguages.spec.ts b/frontend/src/utils/recommendationsLanguages.spec.ts index d4b98926d1..5baf803be0 100644 --- a/frontend/src/utils/recommendationsLanguages.spec.ts +++ b/frontend/src/utils/recommendationsLanguages.spec.ts @@ -1,7 +1,4 @@ -import { - recommendationsLanguagesFromNavigator, - saveRecommendationsLanguages, -} from './recommendationsLanguages'; +import { recommendationsLanguagesFromNavigator } from './recommendationsLanguages'; describe('recommendationsLanguagesFromNavigator', () => { const testCases = [ @@ -24,20 +21,3 @@ describe('recommendationsLanguagesFromNavigator', () => { }) ); }); - -describe('saveRecommendationsLanguages', () => { - it('dispatches an event for the extension', () => { - const eventHandler = vi.fn((event) => { - const { detail } = event; - expect(detail).toEqual({ recommendationsLanguages: 'fr,de' }); - }); - document.addEventListener( - 'tournesol:recommendationsLanguagesChange', - eventHandler - ); - - saveRecommendationsLanguages('fr,de'); - - expect(eventHandler).toHaveBeenCalledTimes(1); - }); -}); diff --git a/frontend/src/utils/recommendationsLanguages.ts b/frontend/src/utils/recommendationsLanguages.ts index 4138d92608..37ebe30ce6 100644 --- a/frontend/src/utils/recommendationsLanguages.ts +++ b/frontend/src/utils/recommendationsLanguages.ts @@ -1,7 +1,10 @@ import { TFunction } from 'react-i18next'; import { storage } from 'src/app/localStorage'; +import { someLangsAreSupported } from 'src/i18n'; import { uniq } from 'src/utils/array'; +const FALLBACK_LANG = 'en'; + export const recommendationsLanguages: { [language: string]: (t: TFunction) => string; } = { @@ -79,28 +82,6 @@ export const getLanguageName = (t: TFunction, language: string) => { return labelFunction(t); }; -export const saveRecommendationsLanguages = (value: string) => { - storage?.setItem('recommendationsLanguages', value); - const event = new CustomEvent('tournesol:recommendationsLanguagesChange', { - detail: { recommendationsLanguages: value }, - }); - document.dispatchEvent(event); -}; - -export const initRecommendationsLanguages = (): string => { - const languages = loadRecommendationsLanguages(); - - if (languages === null) { - return recommendationsLanguagesFromNavigator(); - } - - return languages; -}; - -export const loadRecommendationsLanguages = (): string | null => { - return storage?.getItem('recommendationsLanguages') ?? null; -}; - export const recommendationsLanguagesFromNavigator = (): string => // This function also exists in the browser extension so it should be updated there too if it changes here. uniq( @@ -110,3 +91,40 @@ export const recommendationsLanguagesFromNavigator = (): string => availableRecommendationsLanguages.includes(language) ) ).join(','); + +export const loadRecoLanguagesFromLocalStorage = ( + poll: string, + feed: string +): string | null => storage?.getItem(`${poll}:${feed}:languages`) || null; + +export const saveRecoLanguagesToLocalStorage = ( + poll: string, + feed: string, + value: string +) => storage?.setItem(`${poll}:${feed}:languages`, value); + +/** + * Return the recommendations languages that should be used for anonymous and + * authenticated users that have not defined their preferred languages yet. + */ +export const getInitialRecoLanguages = (): string => { + const languages = recommendationsLanguagesFromNavigator(); + + if (!someLangsAreSupported(languages.split(','))) { + return languages + `,${FALLBACK_LANG}`; + } + + return languages; +}; + +/** + * Should be used instead of `getInitialRecoLanguages` to initialize the + * languages of recommendation feeds that display a language filter. + */ +export const getInitialRecoLanguagesForFilterableFeed = ( + poll: string, + feed: string +): string => { + const languages = loadRecoLanguagesFromLocalStorage(poll, feed); + return languages === null ? getInitialRecoLanguages() : languages; +}; diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index 951691742c..6a8d507ab2 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -46,7 +46,14 @@ export type EntityObject = RelatedEntity | EntityNoExtraField; export enum RouteID { // public and collective routes Home = 'home', + PwaEntryPoint = 'pwaEntryPoint', + // new feeds + FeedForYou = 'feedForYou', + FeedTopItems = 'feedTopItems', + Search = 'search', + // deprecated feed, replaced by FeedForYou, should be deleted later in 2025 FeedCollectiveRecommendations = 'feedCollectiveRecommendations', + // depracated feed, replaced by FeedTopItems, should be deleted later in 2025 CollectiveRecommendations = 'collectiveRecommendations', EntityAnalysis = 'entityAnalysis', FAQ = 'FAQ', @@ -94,6 +101,12 @@ export type SelectablePoll = { // the recommendation link. can be date=Month to retrieve the entities // uploaded during the last month for instance defaultRecoSearchParams?: string; + // default filters used by the feed Top Items, formatted as URL search + // parameters + defaultFiltersFeedTopItems?: string; + // default filters used by the feed For You, formatted as URL search + // parameters + defaultFiltersFeedForYou?: string; // enable or disable the public personal recommendations feature. allowPublicPersonalRecommendations?: boolean; displayOrder: number; @@ -103,7 +116,7 @@ export type SelectablePoll = { mainCriterionName: string; // the path used as URL prefix, must include leading and trailing slash path: string; - // a list route id that will be disable in `PollRoutes` and `SideBar` + // a list of route id that will be disabled in `PollRoutes` and `SideBar` disabledRouteIds?: Array; iconComponent: SvgIconComponent; withSearchBar: boolean; diff --git a/frontend/src/utils/userSettings.ts b/frontend/src/utils/userSettings.ts index b67090585c..8bdb9ce9b5 100644 --- a/frontend/src/utils/userSettings.ts +++ b/frontend/src/utils/userSettings.ts @@ -1,22 +1,51 @@ import { BlankEnum, + FeedForyou_dateEnum, Notifications_langEnum, Recommendations_defaultDateEnum, TournesolUserSettings, VideosPollUserSettings, } from 'src/services/openapi'; +import { FEED_LANG_KEY as FEED_TOPITEMS_LANG_KEY } from 'src/pages/feed/FeedTopItems'; + import { YOUTUBE_POLL_NAME } from './constants'; +import { + getInitialRecoLanguages, + getInitialRecoLanguagesForFilterableFeed, +} from './recommendationsLanguages'; import { SelectablePoll, PollUserSettingsKeys } from './types'; /** - * Cast the value of the setting recommendations__default_date to a value - * expected by the recommendations' search filter 'date'. + * Cast `lang` to a value of `Notifications_langEnum` if possible, else + * return Notifications_langEnum.EN. + */ +export const resolvedLangToNotificationsLang = ( + lang: string | undefined +): Notifications_langEnum => { + if (!lang) { + return Notifications_langEnum.EN; + } + + if ( + Object.values(Notifications_langEnum) + .map((x) => String(x)) + .includes(lang) + ) { + return lang as Notifications_langEnum; + } + + return Notifications_langEnum.EN; +}; + +/** + * Cast the value of the setting feed_foryou__date (or similar) to a value + * expected by the search filter 'date'. */ -const recoDefaultDateToSearchFilter = ( - setting: Recommendations_defaultDateEnum | BlankEnum +const settingDateToSearchFilter = ( + setting: FeedForyou_dateEnum | Recommendations_defaultDateEnum | BlankEnum ): string => { - if (setting === Recommendations_defaultDateEnum.ALL_TIME) { + if (setting === FeedForyou_dateEnum.ALL_TIME) { return ''; } @@ -24,82 +53,134 @@ const recoDefaultDateToSearchFilter = ( }; /** - * Cast the value of the setting recommendations__default_languages to a value - * expected by the recommendations' search filter 'language'. + * Cast the value of a date filter to a value expected by the setting + * feed_foryou__date. */ -const recoDefaultLanguagesToSearchFilter = (setting: string[]): string => { +export const searchFilterToSettingDate = ( + filter: string | null +): FeedForyou_dateEnum | BlankEnum => { + if (filter == null) { + return FeedForyou_dateEnum.ALL_TIME; + } + + const setting = + FeedForyou_dateEnum[filter.toUpperCase() as FeedForyou_dateEnum]; + return setting === undefined ? FeedForyou_dateEnum.ALL_TIME : setting; +}; + +/** + * Cast the value of the setting feed_foryou__languages (or similar) to a + * value expected by the search filter 'language'. + */ +const settingLanguagesToSearchFilter = (setting: string[]): string => { return setting.join(','); }; -export const buildVideosDefaultRecoSearchParams = ( +export const buildVideosFeedForYouSearchParams = ( searchParams: URLSearchParams, - userSettings: VideosPollUserSettings | undefined + userSettings: VideosPollUserSettings | undefined, + langsDiscovery = false ) => { - const advancedFilters: string[] = []; - if (userSettings?.recommendations__default_unsafe) { - advancedFilters.push('unsafe'); - } - if (userSettings?.recommendations__default_exclude_compared_entities) { - advancedFilters.push('exclude_compared'); + const advancedFilters = new Set(searchParams.get('advanced')?.split(',')); + + if (userSettings?.feed_foryou__exclude_compared_entities != undefined) { + if (userSettings.feed_foryou__exclude_compared_entities === true) { + advancedFilters.add('exclude_compared'); + } else { + advancedFilters.delete('exclude_compared'); + } } - if (advancedFilters.length > 0) { - searchParams.set('advanced', advancedFilters.join(',')); + + if (userSettings?.feed_foryou__unsafe != undefined) { + if (userSettings.feed_foryou__unsafe === true) { + advancedFilters.add('unsafe'); + } else { + advancedFilters.delete('unsafe'); + } } - if (userSettings?.recommendations__default_date != undefined) { + searchParams.set('advanced', Array.from(advancedFilters).join(',')); + + if (userSettings?.feed_foryou__date != undefined) { searchParams.set( 'date', - recoDefaultDateToSearchFilter(userSettings.recommendations__default_date) + settingDateToSearchFilter(userSettings.feed_foryou__date) ); } - if (userSettings?.recommendations__default_languages != undefined) { + if (userSettings?.feed_foryou__languages != undefined) { searchParams.set( 'language', - recoDefaultLanguagesToSearchFilter( - userSettings.recommendations__default_languages - ) + settingLanguagesToSearchFilter(userSettings.feed_foryou__languages) ); + } else if (langsDiscovery) { + searchParams.set('language', getInitialRecoLanguages()); } }; -export const getDefaultRecommendationsSearchParams = ( +export const getFeedForYouDefaultSearchParams = ( pollName: string, pollOptions: SelectablePoll | undefined, - userSettings: TournesolUserSettings -) => { + userSettings: TournesolUserSettings | undefined, + langsDiscovery = false +): URLSearchParams => { const searchParams = new URLSearchParams( - pollOptions?.defaultRecoSearchParams + pollOptions?.defaultFiltersFeedForYou ); const userPollSettings = userSettings?.[pollName as PollUserSettingsKeys]; if (pollName === YOUTUBE_POLL_NAME) { - buildVideosDefaultRecoSearchParams(searchParams, userPollSettings); + buildVideosFeedForYouSearchParams( + searchParams, + userPollSettings, + langsDiscovery + ); } - const strSearchParams = searchParams.toString(); - return searchParams ? '?' + strSearchParams : ''; + return searchParams; }; -/** - * Cast `lang` to a value of `Notifications_langEnum` if possible, else - * return Notifications_langEnum.EN. - */ -export const resolvedLangToNotificationsLang = ( - lang: string | undefined -): Notifications_langEnum => { - if (!lang) { - return Notifications_langEnum.EN; +export const buildVideosFeedTopItemsSearchParams = ( + pollName: string, + searchParams: URLSearchParams, + userSettings: VideosPollUserSettings | undefined, + langsDiscovery = false +) => { + if (userSettings?.feed_topitems__languages != undefined) { + searchParams.set( + 'language', + settingLanguagesToSearchFilter(userSettings.feed_topitems__languages) + ); + } else if (langsDiscovery) { + searchParams.set( + 'language', + getInitialRecoLanguagesForFilterableFeed(pollName, FEED_TOPITEMS_LANG_KEY) + ); } +}; - if ( - Object.values(Notifications_langEnum) - .map((x) => String(x)) - .includes(lang) - ) { - return lang as Notifications_langEnum; +export const getFeedTopItemsDefaultSearchParams = ( + pollName: string, + pollOptions: SelectablePoll | undefined, + userSettings: TournesolUserSettings | undefined, + langsDiscovery = false +) => { + const searchParams = new URLSearchParams( + pollOptions?.defaultFiltersFeedTopItems + ); + + const userPollSettings = userSettings?.[pollName as PollUserSettingsKeys]; + + if (pollName === YOUTUBE_POLL_NAME) { + buildVideosFeedTopItemsSearchParams( + pollName, + searchParams, + userPollSettings, + langsDiscovery + ); } - return Notifications_langEnum.EN; + const strSearchParams = searchParams.toString(); + return searchParams ? '?' + strSearchParams : ''; }; diff --git a/tests/cypress/e2e/frontend/feedForYouPage.cy.ts b/tests/cypress/e2e/frontend/feedForYouPage.cy.ts new file mode 100644 index 0000000000..3671bf2c66 --- /dev/null +++ b/tests/cypress/e2e/frontend/feedForYouPage.cy.ts @@ -0,0 +1,84 @@ +describe('Page - For you', () => { + + const username = "test-feed-foryou-page"; + + const login = () => { + cy.focused().type(username); + cy.get('input[name="password"]').click().type("tournesol").type('{enter}'); + } + + beforeEach(() => { + cy.recreateUser(username, `${username}@example.org`, "tournesol"); + }); + + after(() => { + cy.deleteUser(username); + }); + + describe('Poll - videos', () => { + + describe('General', () => { + it('requires authentication', () => { + cy.visit('/feed/foryou'); + cy.location('pathname').should('equal', '/login'); + login(); + cy.location('pathname').should('equal', '/feed/foryou'); + }); + + it('is accessible from the side bar', () => { + cy.visit('/login'); + login(); + + cy.contains('For you').click(); + cy.location('pathname').should('equal', '/feed/foryou'); + cy.contains('According to your preferences').should('be.visible'); + + cy.contains('your preferences').click(); + cy.location('pathname').should('equal', '/settings/preferences'); + cy.location('hash').should('equal', '#videos-feed-foryou'); + }); + }); + + describe('Pagination', () => { + it("doesn't display pagination when there is no items", () => { + cy.visit('/feed/foryou'); + login(); + cy.contains('button', '< -10', {matchCase: false}).should('not.exist'); + cy.contains('button', '< -1', {matchCase: false}).should('not.exist'); + cy.contains('button', '+1 >', {matchCase: false}).should('not.exist'); + cy.contains('button', '+10 >', {matchCase: false}).should('not.exist'); + + cy.contains( + "There doesn't seem to be anything to display at the moment.", + {matchCase: false} + ).should('be.visible'); + }); + }); + + describe('Action bar', () => { + it('displays links to Search and Preferences', () => { + cy.visit('/feed/foryou'); + login(); + + cy.get('a[data-testid="icon-link-to-search-page"]').click(); + cy.location('pathname').should('equal', '/search'); + + cy.get('a[data-testid="icon-link-back-to-previous-page"]').click(); + + cy.location('pathname').should('equal', '/feed/foryou'); + cy.get('a[data-testid="icon-link-to-preferences-page"]').click(); + cy.location('pathname').should('equal', '/settings/preferences'); + cy.location('hash').should('equal', '#videos-feed-foryou'); + }); + }); + + describe('Search filters', () => { + it("doesn't display seach filters", () => { + cy.visit('/feed/foryou'); + login(); + cy.contains('Filters', {matchCase: true}).should('not.exist'); + cy.location('search').should('equal', ''); + }); + }); + }); +}); diff --git a/tests/cypress/e2e/frontend/feedRecommendationsPage.cy.ts b/tests/cypress/e2e/frontend/feedRecommendationsPage.cy.ts deleted file mode 100644 index d7e96e9a52..0000000000 --- a/tests/cypress/e2e/frontend/feedRecommendationsPage.cy.ts +++ /dev/null @@ -1,36 +0,0 @@ -describe('Feed - collective recommendations', () => { - const username = "test-feed-reco-page"; - - beforeEach(() => { - cy.recreateUser(username, "test-feed-reco-page@example.com", "tournesol"); - }); - - const login = () => { - cy.focused().type(username); - cy.get('input[name="password"]').click().type("tournesol").type('{enter}'); - } - - describe('redirection', () => { - it('anonymous users are redirected with default filters', () => { - cy.visit('/feed/recommendations'); - cy.location('pathname').should('equal', '/recommendations'); - cy.location('search').should('contain', '?date=Month'); - }); - - it('authenticated users are redirected with their preferences', () => { - cy.visit('/settings/preferences'); - login(); - - cy.get('#videos_recommendations__default_date').click(); - cy.contains('A day ago').click(); - cy.get('[data-testid=videos_recommendations__default_unsafe]').click(); - cy.get('[data-testid=videos_recommendations__default_exclude_compared_entities]').click(); - cy.contains('Update preferences').click(); - - cy.visit('/feed/recommendations'); - cy.location('pathname').should('equal', '/recommendations'); - cy.location('search') - .should('contain', '?date=Today&advanced=unsafe%2Cexclude_compared&language=en'); - }); - }); -}); diff --git a/tests/cypress/e2e/frontend/feedTopItemsPage.cy.ts b/tests/cypress/e2e/frontend/feedTopItemsPage.cy.ts new file mode 100644 index 0000000000..8686d85630 --- /dev/null +++ b/tests/cypress/e2e/frontend/feedTopItemsPage.cy.ts @@ -0,0 +1,139 @@ +describe('Page - Top items', () => { + describe('Poll - videos', () => { + + describe('General', () => { + it('is accessible from the side bar', () => { + cy.visit('/'); + cy.contains('Top videos').click(); + cy.location('pathname').should('equal', '/feed/top'); + cy.contains('Recommended by the community').should('be.visible'); + }); + }); + + describe('Pagination', () => { + it('displays the pagination', () => { + cy.visit('/feed/top'); + cy.contains('button', '< -10', {matchCase: false}).should('exist'); + cy.contains('button', '< -1', {matchCase: false}).should('exist'); + cy.contains('button', '+1 >', {matchCase: false}).should('exist'); + cy.contains('button', '+10 >', {matchCase: false}).should('exist'); + }); + }); + + describe('Action bar', () => { + it('displays links to Search and Preferences', () => { + cy.visit('/feed/top'); + + cy.get('button[aria-label="Share button"]').should('be.visible'); + cy.get('a[data-testid="icon-link-to-search-page"]').click(); + cy.location('pathname').should('equal', '/search'); + + cy.get('a[data-testid="icon-link-back-to-previous-page"]').click(); + + cy.location('pathname').should('equal', '/feed/top'); + cy.get('a[data-testid="icon-link-to-preferences-page"]').click(); + cy.location('pathname').should('equal', '/login'); + }); + }); + + + describe('Search filters', () => { + it('sets default languages properly and backward navigation works', () => { + cy.visit('/'); + cy.location('pathname').should('equal', '/'); + cy.contains('Top videos').click(); + cy.contains('Filters', {matchCase: false}).should('be.visible'); + cy.location('search').should('contain', 'language=en'); + cy.go('back'); + cy.location('pathname').should('equal', '/'); + }); + + it('expand filters and backward navigation works', () => { + cy.visit('/'); + cy.location('pathname').should('equal', '/'); + cy.contains('Top videos').click(); + cy.contains('Filters', {matchCase: false}).click(); + cy.contains('Uploaded', {matchCase: false}).should('be.visible'); + cy.go('back'); + cy.location('pathname').should('equal', '/'); + }); + + describe('Filter - upload date', () => { + it('must propose 5 timedelta', () => { + cy.visit('/feed/top'); + cy.contains('Filters', {matchCase: false}).click(); + cy.contains('Uploaded', {matchCase: false}).should('be.visible'); + cy.contains('A day ago', {matchCase: false}).should('be.visible'); + cy.contains('A week ago', {matchCase: false}).should('be.visible'); + cy.contains('A month ago', {matchCase: false}).should('be.visible'); + cy.contains('A year ago', {matchCase: false}).should('be.visible'); + cy.contains('All time', {matchCase: false}).should('be.visible'); + }); + + it('must filter by month by default ', () => { + cy.visit('/'); + cy.contains('Top videos').click(); + + // The month filter must appear in the URL. + cy.location('search').should('contain', 'date=Month'); + + cy.contains('Filters', {matchCase: false}).click(); + // The month input must be checked. + cy.contains('A month ago', {matchCase: false}).should('be.visible'); + cy.get('input[type=checkbox][name=Month]').should('be.checked'); + }); + + it('allows to filter: a year ago', () => { + cy.visit('/feed/top'); + cy.contains('Filters', {matchCase: false}).click(); + + cy.contains('A year ago', {matchCase: false}).should('be.visible'); + cy.get('input[type=checkbox][name="Year"]').check(); + cy.get('input[type=checkbox][name="Year"]').should('be.checked'); + cy.get('input[type=checkbox][name=Month]').should('not.be.checked'); + cy.location('search').should('contain', 'date=Year'); + cy.contains('No item matches your search filters.', {matchCase: false}).should('be.visible'); + }); + + it('shows no videos for 1 day ago', () => { + cy.visit('/feed/top'); + cy.contains('Filters', {matchCase: false}).click(); + + cy.contains('A day ago', {matchCase: false}).should('be.visible'); + cy.get('input[type=checkbox][name="Today"]').check(); + cy.get('input[type=checkbox][name="Today"]').should('be.checked'); + cy.contains('No item matches your search filters.', {matchCase: false}).should('be.visible'); + }); + + it('allows to filter: all time', () => { + cy.visit('/feed/top?advanced=unsafe'); + cy.contains('Filters', {matchCase: false}).click(); + + cy.contains('A year ago', {matchCase: false}).should('be.visible'); + cy.contains('All time', {matchCase: false}).click(); + cy.get('input[type=checkbox][name=""]').should('be.checked'); + cy.get('input[type=checkbox][name=Month]').should('not.be.checked'); + cy.contains('No item matches your search filters.', {matchCase: false}).should('not.exist'); + }); + }); + }); + + describe('List of recommendations', () => { + it('entities\'s thumbnails are clickable', () => { + cy.visit('/feed/top?date='); + + const thumbnail = cy.get('img.entity-thumbnail').first(); + thumbnail.click(); + cy.location('pathname').should('match', /^\/entities\//); + }); + + it('entities\'s titles are clickable', () => { + cy.visit('/feed/top?date='); + + const videoCard = cy.get('div[data-testid=video-card-info]').first(); + videoCard.find('h5').click(); + cy.location('pathname').should('match', /^\/entities\//); + }); + }); + }); +}); diff --git a/tests/cypress/e2e/frontend/preview.cy.ts b/tests/cypress/e2e/frontend/preview.cy.ts index 47ec4355d0..b8548cccd0 100644 --- a/tests/cypress/e2e/frontend/preview.cy.ts +++ b/tests/cypress/e2e/frontend/preview.cy.ts @@ -1,6 +1,47 @@ -describe('Preview of Recommendations page via API', () => { - it('supports a preview of a recommendations page with filters', () => { - cy.visit('/recommendations'); +describe('Preview of recommenations via API', () => { + + it('page search with filters', () => { + cy.visit('/search'); + cy.contains('Filters', { matchCase: false }).click(); + cy.contains('A week ago', { matchCase: false }).click(); + cy.url().then(url => { + const previewUrl = url.replace("http://localhost:3000/", "http://localhost:8000/preview/"); + cy.request({ + url: previewUrl, + followRedirect: false, + },).then( + response => { + expect(response.status).to.equal(302); + const redirectLocation = response.headers.location; + expect(redirectLocation).to.contain("date_gte=") + expect(redirectLocation).to.not.contain("metadata%5Blanguage%5D=") + } + ); + }); + }); + + it('page feed/top with filters', () => { + cy.visit('/feed/top?language=fr'); + cy.contains('Filters', { matchCase: false }).click(); + cy.url().then(url => { + const previewUrl = url.replace("http://localhost:3000/", "http://localhost:8000/preview/"); + cy.request({ + url: previewUrl, + followRedirect: false, + },).then( + response => { + expect(response.status).to.equal(302); + const redirectLocation = response.headers.location; + expect(redirectLocation).to.not.contain("date_gte=") + expect(redirectLocation).to.contain("metadata%5Blanguage%5D=fr") + } + ); + }); + }); + + + it('(legacy) page recommendations with filters', () => { + cy.visit('/recommendations?language=en'); cy.contains('Filters', { matchCase: false }).click(); cy.contains('A week ago', { matchCase: false }).click(); cy.url().then(url => { @@ -15,7 +56,7 @@ describe('Preview of Recommendations page via API', () => { expect(redirectLocation).to.contain("date_gte=") expect(redirectLocation).to.contain("metadata%5Blanguage%5D=en") } - ) + ); }); - }) + }); }) diff --git a/tests/cypress/e2e/frontend/pwaEntryPoint.cy.ts b/tests/cypress/e2e/frontend/pwaEntryPoint.cy.ts new file mode 100644 index 0000000000..a804a2aed8 --- /dev/null +++ b/tests/cypress/e2e/frontend/pwaEntryPoint.cy.ts @@ -0,0 +1,33 @@ +describe('PWA - entry point', () => { + const username = "test-pwa-entry-point"; + + beforeEach(() => { + cy.recreateUser(username, `${username}@example.com`, "tournesol"); + }); + + after(() => { + cy.deleteUser(username); + }); + + const login = () => { + cy.focused().type(username); + cy.get('input[name="password"]').click().type("tournesol").type('{enter}'); + } + + describe('redirection', () => { + it('anonymous users redirected to Top items', () => { + cy.visit('/pwa/start'); + cy.location('pathname').should('equal', '/feed/top'); + cy.location('search').should('contain', '?date=Month'); + }); + + it('authenticated users redirected to For you', () => { + cy.visit('/settings/preferences'); + login(); + cy.contains('Update preferences').click(); + + cy.visit('/pwa/start'); + cy.location('pathname').should('equal', '/feed/foryou'); + }); + }); +}); diff --git a/tests/cypress/e2e/frontend/recommendationsPage.cy.ts b/tests/cypress/e2e/frontend/recommendationsPage.cy.ts index d43a49e2db..f973ae371b 100644 --- a/tests/cypress/e2e/frontend/recommendationsPage.cy.ts +++ b/tests/cypress/e2e/frontend/recommendationsPage.cy.ts @@ -15,9 +15,9 @@ describe('Recommendations page', () => { it('sets default languages properly and backward navigation works', () => { cy.visit('/'); cy.location('pathname').should('equal', '/'); - cy.contains('Recommendations').click(); + cy.visit('/recommendations'); cy.contains('Filters', {matchCase: false}).should('be.visible'); - cy.location('search').should('contain', 'language=en'); + cy.location('search').should('contain', 'language='); cy.go('back'); cy.location('pathname').should('equal', '/'); }); @@ -25,7 +25,7 @@ describe('Recommendations page', () => { it('expand filters and backward navigation works', () => { cy.visit('/'); cy.location('pathname').should('equal', '/'); - cy.contains('Recommendations').click(); + cy.visit('/recommendations'); cy.contains('Filters', {matchCase: false}).click(); cy.contains('Duration (minutes)', {matchCase: false}).should('be.visible'); cy.go('back'); @@ -34,8 +34,7 @@ describe('Recommendations page', () => { describe('Filter - upload date', () => { it('must propose 5 timedelta', () => { - cy.visit('/'); - cy.contains('Recommendations').click(); + cy.visit('/recommendations'); cy.contains('Filters', {matchCase: false}).click(); cy.contains('Uploaded', {matchCase: false}).should('be.visible'); @@ -46,17 +45,31 @@ describe('Recommendations page', () => { cy.contains('All time', {matchCase: false}).should('be.visible'); }); - it('must filter by month by default ', () => { - cy.visit('/'); - cy.contains('Recommendations').click(); + it('must filter by all time by default ', () => { + cy.visit('/recommendations'); + cy.contains('Filters', {matchCase: false}).click(); + cy.contains('All time', {matchCase: false}).should('be.visible'); + cy.get('input[type=checkbox][name=""]').should('be.checked'); + }); - // The month filter must appear in the URL. - cy.location('search').should('contain', 'date=Month'); + it('shows no videos for 1 day ago', () => { + cy.visit('/recommendations?advanced=unsafe'); + cy.contains('Filters', {matchCase: false}).click(); + cy.contains('A day ago', {matchCase: false}).should('be.visible'); + cy.get('input[type=checkbox][name="Today"]').check(); + cy.get('input[type=checkbox][name="Today"]').should('be.checked'); + cy.contains('No item matches your search filters.', {matchCase: false}).should('be.visible'); + }); + it('allows to filter: a month ago', () => { + cy.visit('/recommendations?advanced=unsafe'); cy.contains('Filters', {matchCase: false}).click(); - // The month input must be checked. + cy.contains('A month ago', {matchCase: false}).should('be.visible'); + cy.contains('A month ago', {matchCase: false}).click(); cy.get('input[type=checkbox][name=Month]').should('be.checked'); + cy.get('input[type=checkbox][name=""]').should('not.be.checked'); + cy.contains('No item matches your search filters.', {matchCase: false}).should('be.visible'); }); it('allows to filter: a year ago', () => { @@ -69,28 +82,8 @@ describe('Recommendations page', () => { cy.get('input[type=checkbox][name=Month]').should('not.be.checked'); cy.location('search').should('contain', 'date=Year'); - cy.contains('No video corresponds to your search criteria.', {matchCase: false}).should('not.exist'); + cy.contains('No item matches your search filters.', {matchCase: false}).should('not.exist'); }); - - it('shows no videos for 1 day ago', () => { - cy.visit('/recommendations?advanced=unsafe'); - cy.contains('Filters', {matchCase: false}).click(); - cy.contains('A day ago', {matchCase: false}).should('be.visible'); - cy.get('input[type=checkbox][name="Today"]').check(); - cy.get('input[type=checkbox][name="Today"]').should('be.checked'); - cy.contains('No video matches your search criteria.', {matchCase: false}).should('be.visible'); - }); - - it('allows to filter: all time', () => { - cy.visit('/recommendations?advanced=unsafe'); - cy.contains('Filters', {matchCase: false}).click(); - - cy.contains('A year ago', {matchCase: false}).should('be.visible'); - cy.contains('All time', {matchCase: false}).click(); - cy.get('input[type=checkbox][name=""]').should('be.checked'); - cy.get('input[type=checkbox][name=Month]').should('not.be.checked'); - cy.contains('No video matches your search criteria.', {matchCase: false}).should('not.exist'); - }) }); }); describe('List of recommendations', () => { diff --git a/tests/cypress/e2e/frontend/settingsPreferencesPage.cy.ts b/tests/cypress/e2e/frontend/settingsPreferencesPage.cy.ts index d001c0ecb0..2c0542bd44 100644 --- a/tests/cypress/e2e/frontend/settingsPreferencesPage.cy.ts +++ b/tests/cypress/e2e/frontend/settingsPreferencesPage.cy.ts @@ -61,10 +61,11 @@ describe('Settings - preferences page', () => { afterEach(() => { deleteComparisons(); - }) + cy.deleteUser(username); + }); - const login = () => { - cy.focused().type('test-preferences-page'); + const login = (user = 'test-preferences-page') => { + cy.focused().type(user); cy.get('input[name="password"]').click().type("tournesol").type('{enter}'); } @@ -80,277 +81,383 @@ describe('Settings - preferences page', () => { }); }); - describe('Setting - display weekly collective goal', () => { - const fieldSelector = '#videos_comparison_ui__weekly_collective_goal_display'; + describe('Poll - videos', () => { + describe('Setting - display weekly collective goal', () => { + const fieldSelector = '#videos_comparison_ui__weekly_collective_goal_display'; - it('handles the value ALWAYS', () => { - cy.visit('/settings/preferences'); - login(); + it('handles the value ALWAYS', () => { + cy.visit('/settings/preferences'); + login(); - // Ensure the default value is ALWAYS - cy.get( - '[data-testid=videos_weekly_collective_goal_display]' - ).should('have.value', 'ALWAYS'); + // Ensure the default value is ALWAYS + cy.get( + '[data-testid=videos_weekly_collective_goal_display]' + ).should('have.value', 'ALWAYS'); - cy.visit('/comparison'); - cy.contains('Weekly collective goal').should('be.visible'); - cy.visit('/comparison?embed=1'); - cy.contains('Weekly collective goal').should('be.visible'); - }); + cy.visit('/comparison'); + cy.contains('Weekly collective goal').should('be.visible'); + cy.visit('/comparison?embed=1'); + cy.contains('Weekly collective goal').should('be.visible'); + }); - it('handles the value WEBSITE_ONLY', () => { - cy.visit('/settings/preferences'); - login(); + it('handles the value WEBSITE_ONLY', () => { + cy.visit('/settings/preferences'); + login(); - cy.get(fieldSelector).click(); - cy.contains('Website only').click(); - cy.contains('Update preferences').click(); + cy.get(fieldSelector).click(); + cy.contains('Website only').click(); + cy.contains('Update preferences').click(); - cy.visit('/comparison'); - cy.contains('Weekly collective goal').should('be.visible'); - cy.visit('/comparison?embed=1'); - cy.contains('Weekly collective goal').should('not.exist'); + cy.visit('/comparison'); + cy.contains('Weekly collective goal').should('be.visible'); + cy.visit('/comparison?embed=1'); + cy.contains('Weekly collective goal').should('not.exist'); - }); + }); - it('handles the value EMBEDDED_ONLY', () => { - cy.visit('/settings/preferences'); - login(); + it('handles the value EMBEDDED_ONLY', () => { + cy.visit('/settings/preferences'); + login(); - cy.get(fieldSelector).click(); - cy.contains('Extension only').click(); - cy.contains('Update preferences').click(); + cy.get(fieldSelector).click(); + cy.contains('Extension only').click(); + cy.contains('Update preferences').click(); - cy.visit('/comparison'); - cy.contains('Weekly collective goal').should('not.exist'); - cy.visit('/comparison?embed=1'); - cy.contains('Weekly collective goal').should('be.visible'); - }); + cy.visit('/comparison'); + cy.contains('Weekly collective goal').should('not.exist'); + cy.visit('/comparison?embed=1'); + cy.contains('Weekly collective goal').should('be.visible'); + }); - it('handles the value NEVER', () => { - cy.visit('/settings/preferences'); - login(); + it('handles the value NEVER', () => { + cy.visit('/settings/preferences'); + login(); - cy.get(fieldSelector).click(); - cy.contains('Never').click(); - cy.contains('Update preferences').click(); + cy.get(fieldSelector).click(); + cy.contains('Never').click(); + cy.contains('Update preferences').click(); - cy.visit('/comparison'); - cy.contains('Weekly collective goal').should('not.exist'); - cy.visit('/comparison?embed=1'); - cy.contains('Weekly collective goal').should('not.exist'); + cy.visit('/comparison'); + cy.contains('Weekly collective goal').should('not.exist'); + cy.visit('/comparison?embed=1'); + cy.contains('Weekly collective goal').should('not.exist'); + }); }); - }); - describe('Setting - automatically select entities', () => { - it("by default, videos are automatically suggested", () => { - cy.visit('/comparison'); - login(); + describe('Setting - automatically select entities', () => { + it("by default, videos are automatically suggested", () => { + cy.visit('/comparison'); + login(); - cy.get('button[data-testid="auto-entity-button-compact"]').should('be.visible'); - cy.get('button[data-testid="entity-select-button-compact"]').should('be.visible'); - cy.get('button[data-testid="auto-entity-button-full"]').should('not.be.visible'); - cy.get('button[data-testid="entity-select-button-full"]').should('not.be.visible'); - }); + cy.get('button[data-testid="auto-entity-button-compact"]').should('be.visible'); + cy.get('button[data-testid="entity-select-button-compact"]').should('be.visible'); + cy.get('button[data-testid="auto-entity-button-full"]').should('not.be.visible'); + cy.get('button[data-testid="entity-select-button-full"]').should('not.be.visible'); + }); - it("when false, videos are not automatically suggested", () => { - cy.visit('/settings/preferences'); - login(); + it("when false, videos are not automatically suggested", () => { + cy.visit('/settings/preferences'); + login(); - cy.get('[data-testid="videos_comparison__auto_select_entities"]').click(); - cy.contains('Update preferences').click(); + cy.get('[data-testid="videos_comparison__auto_select_entities"]').click(); + cy.contains('Update preferences').click(); - cy.visit('/comparison'); + cy.visit('/comparison'); - cy.get('button[data-testid="auto-entity-button-compact"]').should('not.be.visible'); - cy.get('button[data-testid="entity-select-button-compact"]').should('not.be.visible'); - cy.get('button[data-testid="auto-entity-button-full"]').should('be.visible'); - cy.get('button[data-testid="entity-select-button-full"]').should('be.visible'); + cy.get('button[data-testid="auto-entity-button-compact"]').should('not.be.visible'); + cy.get('button[data-testid="entity-select-button-compact"]').should('not.be.visible'); + cy.get('button[data-testid="auto-entity-button-full"]').should('be.visible'); + cy.get('button[data-testid="entity-select-button-full"]').should('be.visible'); - cy.get('button[data-testid="auto-entity-button-full"]').first().click(); - cy.get('button[data-testid="auto-entity-button-full"]').first().should('not.be.visible'); + cy.get('button[data-testid="auto-entity-button-full"]').first().click(); + cy.get('button[data-testid="auto-entity-button-full"]').first().should('not.be.visible'); - cy.get('[class="react-player__preview"]'); + cy.get('[class="react-player__preview"]'); + }); }); - }); - describe('Setting - optional criteria display', () => { - it('handles selecting and ordering criteria', () => { - cy.visit('/comparison'); - login(); + describe('Setting - optional criteria display', () => { + it('handles selecting and ordering criteria', () => { + cy.visit('/comparison'); + login(); - cy.get('div[id="id_container_criteria_backfire_risk"]').should('not.be.visible'); - cy.get('div[id="id_container_criteria_layman_friendly"]').should('not.be.visible'); + cy.get('div[id="id_container_criteria_backfire_risk"]').should('not.be.visible'); + cy.get('div[id="id_container_criteria_layman_friendly"]').should('not.be.visible'); - cy.visit('/settings/preferences'); - cy.contains( - 'No criteria selected. All optional criteria will be hidden by default.' - ).should('be.visible'); + cy.visit('/settings/preferences'); + cy.contains( + 'No criteria selected. All optional criteria will be hidden by default.' + ).should('be.visible'); - cy.get('input[id="id_selected_optional_layman_friendly"]').click(); - cy.get('input[id="id_selected_optional_backfire_risk"]').click(); - cy.get('button[data-testid="videos_move_criterion_up_backfire_risk"]').click(); - cy.contains('Update preferences').click(); + cy.get('input[id="id_selected_optional_layman_friendly"]').click(); + cy.get('input[id="id_selected_optional_backfire_risk"]').click(); + cy.get('button[data-testid="videos_move_criterion_up_backfire_risk"]').click(); + cy.contains('Update preferences').click(); - cy.visit('/comparison'); + cy.visit('/comparison'); - // The selected optional criteria should be visible... - cy.get('div[id="id_container_criteria_backfire_risk"]').should('be.visible'); - cy.get('div[id="id_container_criteria_layman_friendly"]').should('be.visible'); + // The selected optional criteria should be visible... + cy.get('div[id="id_container_criteria_backfire_risk"]').should('be.visible'); + cy.get('div[id="id_container_criteria_layman_friendly"]').should('be.visible'); - // ...and correctly ordered. - cy.get('div[id="id_container_criteria_backfire_risk"]') - .next().should('have.attr', 'id', 'id_container_criteria_layman_friendly'); + // ...and correctly ordered. + cy.get('div[id="id_container_criteria_backfire_risk"]') + .next().should('have.attr', 'id', 'id_container_criteria_layman_friendly'); - cy.get('div[id="id_container_criteria_reliability"]').should('not.be.visible'); + cy.get('div[id="id_container_criteria_reliability"]').should('not.be.visible'); + }); }); - }); - describe('Setting - rate-later auto removal', () => { - it('handles changing the value', () => { - cy.visit('/rate_later'); - login(); + describe('Setting - rate-later auto removal', () => { + it('handles changing the value', () => { + cy.visit('/rate_later'); + login(); - cy.contains('removed after 4 comparison(s)').should('be.visible'); + cy.contains('removed after 4 comparison(s)').should('be.visible'); - cy.get('a[aria-label="Link to the preferences page"]').click(); - cy.contains('Automatic removal').click().type('{selectAll}1'); - cy.contains('Update preferences').click(); + cy.get('a[aria-label="Link to the preferences page"]').click(); + cy.contains('Automatic removal').click().type('{selectAll}1'); + cy.contains('Update preferences').click(); - cy.visit('/rate_later'); - cy.contains('removed after 1 comparison(s)').should('be.visible'); + cy.visit('/rate_later'); + cy.contains('removed after 1 comparison(s)').should('be.visible'); - cy.get('input[placeholder="Video ID or URL"]').type('nffV2ZuEy_M').type('{enter}'); - cy.contains('Your rate-later list contains 1 video').should('be.visible'); + cy.get('input[placeholder="Video ID or URL"]').type('nffV2ZuEy_M').type('{enter}'); + cy.contains('Your rate-later list contains 1 video').should('be.visible'); - cy.visit('/comparison?uidA=yt%3AnffV2ZuEy_M&uidB=yt%3AdQw4w9WgXcQ'); - cy.get('button').contains('submit').click(); - cy.visit('/rate_later'); - cy.contains('Your rate-later list contains 0 videos').should('be.visible'); + cy.visit('/comparison?uidA=yt%3AnffV2ZuEy_M&uidB=yt%3AdQw4w9WgXcQ'); + cy.get('button').contains('submit').click(); + cy.visit('/rate_later'); + cy.contains('Your rate-later list contains 0 videos').should('be.visible'); + }); }); - }); - describe('Setting - upload date', () => { - const fieldSelector = '#videos_recommendations__default_date'; + describe('Feed - For you', () => { + const videosForYouDateSelector = '#videos_feed_foryou__date'; + + describe('Setting - upload date', () => { + it('handles the value A day ago', () => { + cy.visit('/settings/preferences'); + login(); + + cy.get(videosForYouDateSelector).click(); + cy.contains('A day ago').click(); + cy.contains('Update preferences').click(); + + cy.visit('/feed/foryou'); + cy.contains( + "There doesn't seem to be anything to display at the moment.", + {matchCase: false} + ).should('be.visible'); + }); + + it('handles the value A week ago', () => { + cy.visit('/settings/preferences'); + login(); + + cy.get(videosForYouDateSelector).click(); + cy.contains('A week ago').click(); + cy.contains('Update preferences').click(); + + cy.visit('/feed/foryou'); + cy.contains( + "There doesn't seem to be anything to display at the moment.", + {matchCase: false} + ).should('be.visible'); + }); + + it('handles the value A month ago', () => { + cy.visit('/settings/preferences'); + login(); + + // Ensure the default value is A month ago + cy.get( + '[data-testid=videos_feed_foryou__date]' + ).should('have.value', 'MONTH'); + + cy.visit('/feed/foryou'); + cy.contains( + "There doesn't seem to be anything to display at the moment.", + {matchCase: false} + ).should('be.visible'); + }); + + it('handles the value A year ago', () => { + cy.visit('/settings/preferences'); + login(); + + cy.get(videosForYouDateSelector).click(); + cy.contains('A year ago').click(); + cy.contains('Update preferences').click(); + + cy.visit('/feed/foryou'); + cy.contains( + "There doesn't seem to be anything to display at the moment.", + {matchCase: false} + ).should('not.exist'); + }); + + it('handles the value All time', () => { + cy.visit('/settings/preferences'); + login(); + + cy.get(videosForYouDateSelector).click(); + cy.contains('All time').click(); + cy.contains('Update preferences').click(); + + cy.visit('/feed/foryou'); + cy.contains( + "There doesn't seem to be anything to display at the moment.", + {matchCase: false} + ).should('not.exist'); + }); + }); - it('handles the value A month ago', () => { - cy.visit('/settings/preferences'); - login(); + describe('Setting - unsafe', () => { + const videosForYouDateSelector = '#videos_feed_foryou__date'; - // Ensure the default value is A month ago - cy.get( - '[data-testid=videos_recommendations__default_date]' - ).should('have.value', 'MONTH'); + it('handles the value false (hide)', () => { + cy.visit('/settings/preferences'); + login(); - cy.get('a[aria-label="Link to the recommendations page"]').should( - 'have.attr', 'href', '/recommendations?date=Month' - ); - }); + cy.get(videosForYouDateSelector).click(); + cy.contains('All time').click(); - it('handles the value A day ago', () => { - cy.visit('/settings/preferences'); - login(); + cy.get('[data-testid=videos_feed_foryou__unsafe]').should('not.be.checked'); + cy.contains('Update preferences').click(); - cy.get(fieldSelector).click(); - cy.contains('A day ago').click(); - cy.contains('Update preferences').click(); + cy.visit('/feed/foryou'); + cy.get('[aria-label="pagination navigation"] button').last().click(); + cy.get('[data-testid="video-card-overall-score"]').last().trigger("mouseover"); - cy.get('a[aria-label="Link to the recommendations page"]').should( - 'have.attr', 'href', '/recommendations?date=Today&language=en' - ); - }); + cy.contains( + "The score of this video is below the recommendability threshold defined by Tournesol.", + {matchCase: false} + ).should('not.exist'); + }); - it('handles the value A week ago', () => { - cy.visit('/settings/preferences'); - login(); + it('handles the value true (show)', () => { + cy.visit('/settings/preferences'); + login(); - cy.get(fieldSelector).click(); - cy.contains('A week ago').click(); - cy.contains('Update preferences').click(); + cy.get(videosForYouDateSelector).click(); + cy.contains('All time').click(); - cy.get('a[aria-label="Link to the recommendations page"]').should( - 'have.attr', 'href', '/recommendations?date=Week&language=en' - ); - }); + cy.get('[data-testid=videos_feed_foryou__unsafe]').check(); + cy.contains('Update preferences').click(); - it('handles the value A year ago', () => { - cy.visit('/settings/preferences'); - login(); + cy.visit('/feed/foryou'); + cy.get('[aria-label="pagination navigation"] button').last().click(); + cy.get('[data-testid="video-card-overall-score"]').last().trigger("mouseover"); - cy.get(fieldSelector).click(); - cy.contains('A year ago').click(); - cy.contains('Update preferences').click(); + cy.contains( + "The score of this video is below the recommendability threshold defined by Tournesol.", + {matchCase: false} + ).should('be.visible'); + }); + }); - cy.get('a[aria-label="Link to the recommendations page"]').should( - 'have.attr', 'href', '/recommendations?date=Year&language=en' - ); - }); + describe('Setting - exclude compared entities', () => { + it('handles the value false (include)', () => { + cy.recreateUser('test_exclude_false', 'test_exclude_false@example.com', 'tournesol'); - it('handles the value All time', () => { - cy.visit('/settings/preferences'); - login(); + cy.intercept('http://localhost:8000/polls/videos/recommendations/*') + .as('recommendationsRetrievedFromAPI'); - cy.get(fieldSelector).click(); - cy.contains('All time').click(); - cy.contains('Update preferences').click(); + cy.intercept('http://localhost:8000/users/me/settings/') + .as('settingsRetrievedFromAPI'); - cy.get('a[aria-label="Link to the recommendations page"]').should( - 'have.attr', 'href', '/recommendations?date=&language=en' - ); - }); - }); + cy.visit('/login'); + login('test_exclude_false'); + cy.contains('test_exclude_false').click(); + cy.get('[data-testid="settings-preferences"]').click(); - describe('Setting - unsafe', () => { - it('handles the value false (hide)', () => { - cy.visit('/settings/preferences'); - login(); + cy.wait('@settingsRetrievedFromAPI'); + cy.get(videosForYouDateSelector).click(); + cy.contains('All time').click(); + cy.get('[data-testid=videos_feed_foryou__exclude_compared_entities]').uncheck(); + cy.contains('Update preferences').click(); - cy.get('[data-testid=videos_recommendations__default_unsafe]'); - cy.contains('Update preferences').click(); + cy.visit('/feed/foryou'); - cy.get('a[aria-label="Link to the recommendations page"]').should( - 'have.attr', 'href', '/recommendations?date=Month&language=en' - ); - }); + cy.get('[data-testid="video-card-info"] h5') + .first() + .invoke('attr', 'title').then((videoTitle) => { - it('handles the value true (show)', () => { - cy.visit('/settings/preferences'); - login(); + cy.get('[aria-label="Compare now"').first().click(); + cy.get('button#expert_submit_btn').click(); - cy.get('[data-testid=videos_recommendations__default_unsafe]').click(); - cy.contains('Update preferences').click(); + cy.contains('test_exclude_false').click(); + cy.get('[data-testid="settings-preferences"]').click(); + cy.wait('@settingsRetrievedFromAPI'); - cy.get('a[aria-label="Link to the recommendations page"]').should( - 'have.attr', 'href', '/recommendations?date=Month&advanced=unsafe&language=en' - ); - }); - }); + // Change an additional setting to bypass the cache in /feed/foryou. + cy.get('[data-testid="videos_feed_foryou__unsafe"]').check(); + cy.contains('Update preferences').click(); - describe('Setting - exclude compared entities', () => { - it('handles the value false (exclude)', () => { - cy.visit('/settings/preferences'); - login(); + cy.visit('/feed/foryou'); + cy.wait('@recommendationsRetrievedFromAPI'); - cy.get('[data-testid=videos_recommendations__default_exclude_compared_entities]'); - cy.contains('Update preferences').click(); + cy.get('[data-testid="video-card-info"] h5') + .first() + .invoke('attr', 'title') + .should('eq', videoTitle); + }); - cy.get('a[aria-label="Link to the recommendations page"]').should( - 'have.attr', 'href', '/recommendations?date=Month&language=en' - ); - }); + cy.deleteUser('test_exclude_false'); + }); - it('handles the value true (include)', () => { - cy.visit('/settings/preferences'); - login(); + it('handles the value true (exclude)', () => { + cy.recreateUser('test_exclude_true', 'test_exclude_true@example.com', 'tournesol'); - cy.get('[data-testid=videos_recommendations__default_exclude_compared_entities]') - .click(); - cy.contains('Update preferences').click(); + cy.intercept('http://localhost:8000/polls/videos/recommendations/*') + .as('recommendationsRetrievedFromAPI'); - cy.get('a[aria-label="Link to the recommendations page"]').should( - 'have.attr', 'href', '/recommendations?date=Month&advanced=exclude_compared&language=en' - ); + cy.intercept('http://localhost:8000/users/me/settings/') + .as('settingsRetrievedFromAPI'); + + cy.visit('/login'); + login('test_exclude_true'); + cy.contains('test_exclude_true').click(); + cy.get('[data-testid="settings-preferences"]').click(); + + cy.wait('@settingsRetrievedFromAPI'); + cy.get(videosForYouDateSelector).click(); + cy.contains('All time').click(); + cy.get('[data-testid=videos_feed_foryou__exclude_compared_entities]') + .should('be.checked'); + cy.contains('Update preferences').click(); + + cy.visit('/feed/foryou'); + + cy.get('[data-testid="video-card-info"] h5') + .first() + .invoke('attr', 'title').then((videoTitle) => { + + cy.get('[aria-label="Compare now"').first().click(); + cy.get('button#expert_submit_btn').click(); + + cy.contains('test_exclude_true').click(); + cy.get('[data-testid="settings-preferences"]').click(); + cy.wait('@settingsRetrievedFromAPI'); + + // Change an additional setting to bypass the cache in /feed/foryou. + cy.get('[data-testid="videos_feed_foryou__unsafe"]').check(); + cy.contains('Update preferences').click(); + + cy.visit('/feed/foryou'); + cy.wait('@recommendationsRetrievedFromAPI'); + + cy.get('[data-testid="video-card-info"] h5') + .first() + .invoke('attr', 'title') + .should('not.eq', videoTitle); + }); + + cy.deleteUser('test_exclude_true'); + }); + }); }); }); });