From 50be222e48fe9c7ddf7ee4955c8d429f1e5e7f32 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Tue, 12 May 2026 17:12:40 +0500 Subject: [PATCH 01/17] refactor: rename the articles app and update its links all over the places like etl --- main/settings.py | 3 +- main/settings_celery.py | 6 +- main/settings_pluggy.py | 6 +- main/urls.py | 2 +- news_events/etl/articles_news.py | 23 ++- news_events/etl/articles_news_test.py | 2 +- news_events/plugins.py | 38 ++-- news_events/plugins_test.py | 77 ++++---- news_events/tasks.py | 41 ++-- news_events/tasks_test.py | 54 +++--- profiles/serializers.py | 4 +- website_content/__init__.py | 0 website_content/admin.py | 1 + website_content/api.py | 76 ++++++++ website_content/api_test.py | 89 +++++++++ website_content/apps.py | 15 ++ website_content/constants.py | 11 ++ website_content/factories.py | 18 ++ website_content/hooks.py | 33 ++++ website_content/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/sync_website_content_to_news.py | 22 +++ website_content/migrations/0001_initial.py | 96 ++++++++++ .../migrations/0002_migrate_from_articles.py | 127 ++++++++++++ .../migrations/0003_add_editors_group.py | 29 +++ .../0004_alter_websitecontent_created_on.py | 17 ++ website_content/migrations/__init__.py | 0 website_content/models.py | 85 ++++++++ website_content/models_test.py | 116 +++++++++++ website_content/permissions.py | 42 ++++ website_content/serializers.py | 72 +++++++ website_content/serializers_test.py | 69 +++++++ website_content/tasks.py | 52 +++++ website_content/tasks_test.py | 71 +++++++ website_content/urls.py | 42 ++++ website_content/validators.py | 45 +++++ website_content/validators_test.py | 38 ++++ website_content/views.py | 181 ++++++++++++++++++ website_content/views_test.py | 142 ++++++++++++++ 39 files changed, 1622 insertions(+), 123 deletions(-) create mode 100644 website_content/__init__.py create mode 100644 website_content/admin.py create mode 100644 website_content/api.py create mode 100644 website_content/api_test.py create mode 100644 website_content/apps.py create mode 100644 website_content/constants.py create mode 100644 website_content/factories.py create mode 100644 website_content/hooks.py create mode 100644 website_content/management/__init__.py create mode 100644 website_content/management/commands/__init__.py create mode 100644 website_content/management/commands/sync_website_content_to_news.py create mode 100644 website_content/migrations/0001_initial.py create mode 100644 website_content/migrations/0002_migrate_from_articles.py create mode 100644 website_content/migrations/0003_add_editors_group.py create mode 100644 website_content/migrations/0004_alter_websitecontent_created_on.py create mode 100644 website_content/migrations/__init__.py create mode 100644 website_content/models.py create mode 100644 website_content/models_test.py create mode 100644 website_content/permissions.py create mode 100644 website_content/serializers.py create mode 100644 website_content/serializers_test.py create mode 100644 website_content/tasks.py create mode 100644 website_content/tasks_test.py create mode 100644 website_content/urls.py create mode 100644 website_content/validators.py create mode 100644 website_content/validators_test.py create mode 100644 website_content/views.py create mode 100644 website_content/views_test.py diff --git a/main/settings.py b/main/settings.py index b85b79e485..d37c382a5a 100644 --- a/main/settings.py +++ b/main/settings.py @@ -130,6 +130,7 @@ "widgets", "learning_resources", "learning_resources_search", + "website_content.apps.WebsiteContentConfig", "openapi", "articles", "oauth2_provider", @@ -937,4 +938,4 @@ def get_all_config_keys(): # Create all learning material resources for OCW courses # Learning material resources are behind show_ocw_files flag in search -CREATE_OCW_LEARNING_MATERIALS = get_bool("CREATE_OCW_LEARNING_MATERIALS", default=False) +CREATE_OCW_LEARNING_MATERIALS = get_bool("CREATE_OCW_LEARNING_MATERIALS", default=False) \ No newline at end of file diff --git a/main/settings_celery.py b/main/settings_celery.py index 38c8832138..03d7f57dfa 100644 --- a/main/settings_celery.py +++ b/main/settings_celery.py @@ -120,10 +120,10 @@ "NEWS_EVENTS_OL_EVENTS_SCHEDULE_SECONDS", 60 * 60 * 3 ), # default is every 3 hours }, - "update_articles_news": { - "task": "news_events.tasks.get_articles_news", + "update_website_content_news": { + "task": "news_events.tasks.get_website_content_news", "schedule": get_int( - "NEWS_EVENTS_ARTICLES_NEWS_SCHEDULE_SECONDS", 60 * 60 * 1 + "NEWS_EVENTS_WEBSITE_CONTENT_NEWS_SCHEDULE_SECONDS", 60 * 60 * 1 ), # default is every 1 hour }, "sync_canvas_courses-every-1-weeks": { diff --git a/main/settings_pluggy.py b/main/settings_pluggy.py index da6aad907f..06b851c4e7 100644 --- a/main/settings_pluggy.py +++ b/main/settings_pluggy.py @@ -8,7 +8,7 @@ "MITOL_LEARNING_RESOURCES_PLUGINS", "learning_resources_search.plugins.SearchIndexPlugin,channels.plugins.ChannelPlugin", ) -MITOL_ARTICLES_PLUGINS = get_string( - "MITOL_ARTICLES_PLUGINS", - "news_events.plugins.ArticleNewsPlugin", +MITOL_WEBSITE_CONTENT_PLUGINS = get_string( + "MITOL_WEBSITE_CONTENT_PLUGINS", + "news_events.plugins.WebsiteContentNewsPlugin", ) diff --git a/main/urls.py b/main/urls.py index c02736a43b..a6753d36af 100644 --- a/main/urls.py +++ b/main/urls.py @@ -53,7 +53,7 @@ re_path(r"", include("widgets.urls")), re_path(r"", include("openapi.urls")), re_path(r"", include("learning_resources.urls")), - re_path(r"", include("articles.urls")), + re_path(r"", include("website_content.urls")), re_path(r"", include("testimonials.urls")), re_path(r"", include("news_events.urls")), re_path(r"", include("ol_hubspot.urls")), diff --git a/news_events/etl/articles_news.py b/news_events/etl/articles_news.py index e13bda9e7f..620a9570ad 100644 --- a/news_events/etl/articles_news.py +++ b/news_events/etl/articles_news.py @@ -1,17 +1,18 @@ -"""ETL functions for Articles News data.""" +"""ETL functions for website_content news data.""" import logging -from articles.models import Article from news_events.constants import FeedType from news_events.etl import loaders +from website_content.constants import CONTENT_TYPE_NEWS +from website_content.models import WebsiteContent log = logging.getLogger(__name__) -def extract_single_article(article: Article) -> dict: +def extract_single_article(article: WebsiteContent) -> dict: """ - Extract a single published article from the database. + Extract a single published news content item from the database. Returns a dict in the same format as extract(). """ return { @@ -35,13 +36,15 @@ def transform_single_article(article_data: dict) -> dict: return items[0] if items else None -def sync_single_article_to_news(article: Article): +def sync_single_article_to_news(article: WebsiteContent): """ - Sync a single published article to the news feed (create or update FeedItem). + Sync a single published news content item to the news feed. + Only syncs content items with content_type='news'. """ - # Only sync if published if not article.is_published: return + if article.content_type != CONTENT_TYPE_NEWS: + return article_data = extract_single_article(article) item_data = transform_single_article(article_data) if not item_data: @@ -67,8 +70,10 @@ def extract() -> list[dict]: Returns: list[dict]: List of article data dictionaries. """ - # Get only published articles - articles = Article.objects.filter(is_published=True).select_related("user") + # Get only published news-type content items + articles = WebsiteContent.objects.filter( + is_published=True, content_type=CONTENT_TYPE_NEWS + ).select_related("user") return [ { diff --git a/news_events/etl/articles_news_test.py b/news_events/etl/articles_news_test.py index 91fa829cb5..e115e3af56 100644 --- a/news_events/etl/articles_news_test.py +++ b/news_events/etl/articles_news_test.py @@ -35,7 +35,7 @@ def mock_articles(mocker): mock_queryset.select_related.return_value = [mock_article] mocker.patch( - "news_events.etl.articles_news.Article.objects.filter", + "news_events.etl.articles_news.WebsiteContent.objects.filter", return_value=mock_queryset, ) diff --git a/news_events/plugins.py b/news_events/plugins.py index fac6a086f8..937bc04207 100644 --- a/news_events/plugins.py +++ b/news_events/plugins.py @@ -8,34 +8,42 @@ log = logging.getLogger(__name__) -class ArticleNewsPlugin: - """Plugin to sync articles to news feed when published""" +class WebsiteContentNewsPlugin: + """Plugin to sync website_content news items to the news feed when published""" - hookimpl = apps.get_app_config("articles").hookimpl + hookimpl = apps.get_app_config("website_content").hookimpl @hookimpl - def article_published(self, article): + def website_content_published(self, content): """ - Sync a published article to news_events feed + Sync a published news content item to news_events feed. Args: - article (Article): The article that was published or updated + content (WebsiteContent): The content item that was published or updated """ + from website_content.constants import CONTENT_TYPE_NEWS + + if content.content_type != CONTENT_TYPE_NEWS: + log.info( + "WebsiteContentNewsPlugin: Skipping non-news content: id=%s, type=%s", + content.id, + content.content_type, + ) + return + log.info( - "ArticleNewsPlugin: Syncing article to news feed: id=%s, title=%s", - article.id, - article.title, + "WebsiteContentNewsPlugin: Syncing content to news feed: id=%s, title=%s", + content.id, + content.title, ) - # Capture the article ID to use in the on_commit callback - article_id = article.id + content_id = content.id def trigger_async_sync(): """Trigger the async Celery task after the transaction commits""" - from news_events.tasks import sync_article_to_news + from news_events.tasks import sync_website_content_to_news - log.info("Scheduling async sync for article %s to news feed...", article_id) - sync_article_to_news.delay(article_id) + log.info("Scheduling async sync for content %s to news feed...", content_id) + sync_website_content_to_news.delay(content_id) - # Schedule the async task to run after the transaction commits transaction.on_commit(trigger_async_sync) diff --git a/news_events/plugins_test.py b/news_events/plugins_test.py index 46664ac79e..43f3b51f5d 100644 --- a/news_events/plugins_test.py +++ b/news_events/plugins_test.py @@ -4,9 +4,9 @@ import pytest -from articles.models import Article from main.factories import UserFactory -from news_events.plugins import ArticleNewsPlugin +from news_events.plugins import WebsiteContentNewsPlugin +from website_content.models import WebsiteContent pytestmark = [pytest.mark.django_db] @@ -14,102 +14,99 @@ @pytest.fixture(autouse=True) def _mock_cdn_purge(mocker): """Auto-mock CDN purge tasks for all tests in this module""" - mocker.patch("articles.tasks.fastly_purge_relative_url") - mocker.patch("articles.tasks.fastly_purge_relative_url.delay") - mocker.patch("articles.tasks.fastly_purge_articles_list.delay") + mocker.patch("website_content.tasks.fastly_purge_relative_url") + mocker.patch("website_content.tasks.fastly_purge_relative_url.delay") + mocker.patch("website_content.tasks.fastly_purge_website_content_list.delay") -def test_article_published_hook_calls_sync_task(): - """Test that article_published hook schedules the sync task on commit""" +def test_website_content_published_hook_calls_sync_task(): + """Test that website_content_published hook schedules the sync task on commit""" user = UserFactory.create() - article = Article.objects.create( + content = WebsiteContent.objects.create( title="Test Article", content={}, is_published=True, user=user, + content_type="news", ) - plugin = ArticleNewsPlugin() + plugin = WebsiteContentNewsPlugin() with patch("news_events.plugins.transaction.on_commit") as mock_on_commit: - plugin.article_published(article) + plugin.website_content_published(content) - # Verify that on_commit was called assert mock_on_commit.called assert mock_on_commit.call_count == 1 - # Get the callback function that was registered callback = mock_on_commit.call_args[0][0] - # Now patch the Celery task and call the callback - with patch("news_events.tasks.sync_article_to_news.delay") as mock_task: + with patch("news_events.tasks.sync_website_content_to_news.delay") as mock_task: callback() - # Verify the Celery task was called with the article ID - mock_task.assert_called_once_with(article.id) + mock_task.assert_called_once_with(content.id) -def test_article_published_hook_with_unpublished_article(): - """Test that the hook still registers callback even for unpublished articles""" +def test_website_content_published_hook_skips_non_news(): + """Test that the hook skips non-news content types""" user = UserFactory.create() - article = Article.objects.create( + content = WebsiteContent.objects.create( title="Draft Article", content={}, - is_published=False, + is_published=True, user=user, + content_type="article", ) - plugin = ArticleNewsPlugin() + plugin = WebsiteContentNewsPlugin() with patch("news_events.plugins.transaction.on_commit") as mock_on_commit: - plugin.article_published(article) + plugin.website_content_published(content) - # The hook itself doesn't check is_published, that's done in article_published_actions - assert mock_on_commit.called + assert not mock_on_commit.called -def test_article_published_hook_logging(caplog): +def test_website_content_published_hook_logging(caplog): """Test that the hook logs appropriate messages""" user = UserFactory.create() - article = Article.objects.create( + content = WebsiteContent.objects.create( title="Logging Test Article", content={}, is_published=True, user=user, + content_type="news", ) - plugin = ArticleNewsPlugin() + plugin = WebsiteContentNewsPlugin() with patch("news_events.plugins.transaction.on_commit"), caplog.at_level("INFO"): - plugin.article_published(article) + plugin.website_content_published(content) - # Check that the plugin logged the sync message assert any( - "ArticleNewsPlugin: Syncing article to news feed" in record.message + "WebsiteContentNewsPlugin: Syncing content to news feed" in record.message for record in caplog.records ) - assert any(str(article.id) in record.message for record in caplog.records) - assert any(article.title in record.message for record in caplog.records) + assert any(str(content.id) in record.message for record in caplog.records) + assert any(content.title in record.message for record in caplog.records) -def test_article_published_hook_captures_article_id(): - """Test that the callback captures the correct article ID""" +def test_website_content_published_hook_captures_content_id(): + """Test that the callback captures the correct content ID""" user = UserFactory.create() - article = Article.objects.create( + content = WebsiteContent.objects.create( title="ID Capture Test", content={}, is_published=True, user=user, + content_type="news", ) - plugin = ArticleNewsPlugin() + plugin = WebsiteContentNewsPlugin() with patch("news_events.plugins.transaction.on_commit") as mock_on_commit: - plugin.article_published(article) + plugin.website_content_published(content) callback = mock_on_commit.call_args[0][0] - # Verify the callback uses the captured article ID - with patch("news_events.tasks.sync_article_to_news.delay") as mock_task: + with patch("news_events.tasks.sync_website_content_to_news.delay") as mock_task: callback() - mock_task.assert_called_once_with(article.id) + mock_task.assert_called_once_with(content.id) diff --git a/news_events/tasks.py b/news_events/tasks.py index 97693f0193..4fe25cb1a5 100644 --- a/news_events/tasks.py +++ b/news_events/tasks.py @@ -48,23 +48,27 @@ def get_mitpe_events(): @app.task -def get_articles_news(): - """Run the Articles News ETL pipeline""" +def get_website_content_news(): + """Run the website content news ETL pipeline""" pipelines.articles_news_etl() clear_views_cache() +# Backward-compatible alias so any queued Celery tasks using the old name still work. +get_articles_news = get_website_content_news + + @app.task( bind=True, autoretry_for=(Exception,), retry_kwargs={"max_retries": 3, "countdown": 5}, ) -def sync_article_to_news(self, article_id: int): +def sync_website_content_to_news(self, content_id: int): """ - Sync a single article to the news feed. + Sync a single website content news item to the news feed. Args: - article_id (int): The ID of the article to sync + content_id (int): The ID of the WebsiteContent item to sync Retry policy: - Retries up to 3 times on any exception @@ -72,31 +76,34 @@ def sync_article_to_news(self, article_id: int): """ import logging - from articles.models import Article from news_events.etl.articles_news import sync_single_article_to_news + from website_content.models import WebsiteContent logger = logging.getLogger(__name__) try: - article = Article.objects.get(id=article_id, is_published=True) - sync_single_article_to_news(article) + content = WebsiteContent.objects.get(id=content_id, is_published=True) + sync_single_article_to_news(content) clear_views_cache() logger.info( - "Successfully synced article %s to news feed", - article_id, + "Successfully synced content %s to news feed", + content_id, ) - except Article.DoesNotExist: + except WebsiteContent.DoesNotExist: logger.warning( - "Article %s not found or not published, skipping sync", - article_id, + "WebsiteContent %s not found or not published, skipping sync", + content_id, ) - # Don't retry if article doesn't exist return except Exception: logger.exception( - "Failed to sync article %s to news feed (retry %s/%s)", - article_id, + "Failed to sync content %s to news feed (retry %s/%s)", + content_id, self.request.retries, self.max_retries, ) - raise # Re-raise to trigger retry + raise + + +# Backward-compatible alias so any queued Celery tasks using the old name still work. +sync_article_to_news = sync_website_content_to_news diff --git a/news_events/tasks_test.py b/news_events/tasks_test.py index f0b75b123b..c647005f85 100644 --- a/news_events/tasks_test.py +++ b/news_events/tasks_test.py @@ -8,9 +8,9 @@ @pytest.fixture(autouse=True) def _mock_cdn_purge(mocker): """Auto-mock CDN purge tasks for all tests in this module""" - mocker.patch("articles.tasks.fastly_purge_relative_url") - mocker.patch("articles.tasks.fastly_purge_relative_url.delay") - mocker.patch("articles.tasks.fastly_purge_articles_list.delay") + mocker.patch("website_content.tasks.fastly_purge_relative_url") + mocker.patch("website_content.tasks.fastly_purge_relative_url.delay") + mocker.patch("website_content.tasks.fastly_purge_website_content_list.delay") def test_get_medium_mit_news(mocker): @@ -54,15 +54,15 @@ def test_get_mitpe_news(mocker): @pytest.mark.django_db def test_sync_article_to_news_success(mocker, user): - """Task should sync published article to news feed""" - from articles.models import Article + """Task should sync published website content item to news feed""" + from website_content.models import WebsiteContent - # Create a published article - article = Article.objects.create( + content = WebsiteContent.objects.create( title="Test Article", content={"type": "doc", "content": []}, is_published=True, user=user, + content_type="news", ) mock_sync = mocker.patch( @@ -72,16 +72,15 @@ def test_sync_article_to_news_success(mocker, user): "news_events.tasks.clear_views_cache", autospec=True ) - # Execute the task directly (not via .delay()) - tasks.sync_article_to_news(article.id) + tasks.sync_website_content_to_news(content.id) - mock_sync.assert_called_once_with(article) + mock_sync.assert_called_once_with(content) mock_clear_cache.assert_called_once() @pytest.mark.django_db def test_sync_article_to_news_article_not_found(mocker, caplog): - """Task should log warning if article doesn't exist""" + """Task should log warning if content item doesn't exist""" mock_sync = mocker.patch( "news_events.etl.articles_news.sync_single_article_to_news", autospec=True ) @@ -89,28 +88,25 @@ def test_sync_article_to_news_article_not_found(mocker, caplog): "news_events.tasks.clear_views_cache", autospec=True ) - # Execute task with non-existent article ID - tasks.sync_article_to_news(99999) + tasks.sync_website_content_to_news(99999) - # Should not call sync or clear cache mock_sync.assert_not_called() mock_clear_cache.assert_not_called() - # Should log warning - assert "Article 99999 not found or not published" in caplog.text + assert "WebsiteContent 99999 not found or not published" in caplog.text @pytest.mark.django_db def test_sync_article_to_news_unpublished_article(mocker, user, caplog): - """Task should skip unpublished articles""" - from articles.models import Article + """Task should skip unpublished content items""" + from website_content.models import WebsiteContent - # Create an unpublished article - article = Article.objects.create( + content = WebsiteContent.objects.create( title="Draft Article", content={"type": "doc", "content": []}, is_published=False, user=user, + content_type="news", ) mock_sync = mocker.patch( @@ -120,29 +116,27 @@ def test_sync_article_to_news_unpublished_article(mocker, user, caplog): "news_events.tasks.clear_views_cache", autospec=True ) - tasks.sync_article_to_news(article.id) + tasks.sync_website_content_to_news(content.id) - # Should not sync unpublished articles mock_sync.assert_not_called() mock_clear_cache.assert_not_called() - # Should log warning - assert f"Article {article.id} not found or not published" in caplog.text + assert f"WebsiteContent {content.id} not found or not published" in caplog.text @pytest.mark.django_db def test_sync_article_to_news_sync_failure(mocker, user): """Task should retry on sync failure""" - from articles.models import Article + from website_content.models import WebsiteContent - article = Article.objects.create( + content = WebsiteContent.objects.create( title="Test Article", content={"type": "doc", "content": []}, is_published=True, user=user, + content_type="news", ) - # Mock sync to raise an exception mock_sync = mocker.patch( "news_events.etl.articles_news.sync_single_article_to_news", autospec=True, @@ -152,10 +146,8 @@ def test_sync_article_to_news_sync_failure(mocker, user): "news_events.tasks.clear_views_cache", autospec=True ) - # Should raise exception to trigger retry with pytest.raises(Exception, match="Sync failed"): - tasks.sync_article_to_news(article.id) + tasks.sync_website_content_to_news(content.id) - mock_sync.assert_called_once_with(article) - # Cache should not be cleared if sync fails + mock_sync.assert_called_once_with(content) mock_clear_cache.assert_not_called() diff --git a/profiles/serializers.py b/profiles/serializers.py index d3941899d5..5f4cc62a05 100644 --- a/profiles/serializers.py +++ b/profiles/serializers.py @@ -12,7 +12,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from articles.permissions import is_article_group_user +from website_content.permissions import is_website_content_editor from authentication import api as auth_api from learning_resources.models import LearningResourceTopic from learning_resources.permissions import is_admin_user, is_learning_path_editor @@ -320,7 +320,7 @@ def get_is_learning_path_editor(self, instance) -> bool: # noqa: ARG002 def get_is_article_editor(self, instance) -> bool: # noqa: ARG002 request = self.context.get("request") if request: - return is_admin_user(request) or is_article_group_user(request) + return is_admin_user(request) or is_website_content_editor(request) return False def create(self, validated_data): diff --git a/website_content/__init__.py b/website_content/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/website_content/admin.py b/website_content/admin.py new file mode 100644 index 0000000000..846f6b4061 --- /dev/null +++ b/website_content/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/website_content/api.py b/website_content/api.py new file mode 100644 index 0000000000..111f865f78 --- /dev/null +++ b/website_content/api.py @@ -0,0 +1,76 @@ +"""API functions for website_content""" + +import logging + +from website_content.hooks import get_plugin_manager +from website_content.tasks import ( + PURGE_TIMEOUT_SECONDS, + fastly_purge_relative_url, + fastly_purge_website_content_list, +) + +log = logging.getLogger(__name__) + + +def purge_content_on_save(content): + """ + Purge the content item from the CDN cache when it's saved. + + This will trigger a CDN purge for: + - The specific content page (if published and has a slug) - attempted immediately + - The content list page - queued as Celery task + + Args: + content: The WebsiteContent instance being saved + """ + if content.is_published and content.slug: + log.info( + "WebsiteContent %s (%s) saved, purging CDN...", + content.id, + content.slug, + ) + + content_url = content.get_url() + try: + purge_resp = fastly_purge_relative_url( + content_url, timeout=PURGE_TIMEOUT_SECONDS + ) + if purge_resp.get("status") == "ok": + log.info("Content purge request processed OK.") + else: + fastly_purge_relative_url.delay(content_url) + log.error("Content purge request failed, enqueued for retry.") + except Exception: + fastly_purge_relative_url.delay(content_url) + log.exception("Content purge request failed, enqueued for retry.") + + fastly_purge_website_content_list.delay() + else: + log.debug( + "WebsiteContent %s is not published or has no slug, skipping CDN purge.", + content.id, + ) + + +def content_published_actions(*, content): + """ + Trigger plugins when a content item is published or updated. + + Args: + content (WebsiteContent): The content item that was published or updated + """ + if not content.is_published: + log.info( + "WebsiteContent %s is not published, skipping plugin actions", content.id + ) + return + + log.info( + "Triggering website_content_published plugins for content: id=%s, title=%s", + content.id, + content.title, + ) + + pm = get_plugin_manager() + hook = pm.hook + hook.website_content_published(content=content) diff --git a/website_content/api_test.py b/website_content/api_test.py new file mode 100644 index 0000000000..82131f9174 --- /dev/null +++ b/website_content/api_test.py @@ -0,0 +1,89 @@ +"""Tests for website_content API functions""" + +import pytest + + +@pytest.mark.django_db +def test_content_published_actions_triggers_hook(mocker, user): + """Test that content_published_actions triggers the plugin hook for published items""" + from website_content.api import content_published_actions + from website_content.models import WebsiteContent + + mocker.patch("website_content.tasks.fastly_purge_relative_url") + mocker.patch("website_content.tasks.fastly_purge_website_content_list.delay") + + content = WebsiteContent.objects.create( + title="Published Article", + content={"type": "doc", "content": []}, + is_published=True, + user=user, + content_type="news", + ) + + mock_pm = mocker.MagicMock() + mock_hook = mocker.MagicMock() + mock_pm.hook = mock_hook + mocker.patch("website_content.api.get_plugin_manager", return_value=mock_pm) + + content_published_actions(content=content) + + mock_hook.website_content_published.assert_called_once_with(content=content) + + +@pytest.mark.django_db +def test_content_published_actions_skips_unpublished(mocker, user, caplog): + """Test that content_published_actions skips unpublished items""" + from website_content.api import content_published_actions + from website_content.models import WebsiteContent + + content = WebsiteContent.objects.create( + title="Draft Article", + content={"type": "doc", "content": []}, + is_published=False, + user=user, + content_type="news", + ) + + mock_pm = mocker.MagicMock() + mock_hook = mocker.MagicMock() + mock_pm.hook = mock_hook + mocker.patch("website_content.api.get_plugin_manager", return_value=mock_pm) + + content_published_actions(content=content) + + mock_hook.website_content_published.assert_not_called() + + assert ( + f"WebsiteContent {content.id} is not published, skipping plugin actions" + in caplog.text + ) + + +@pytest.mark.django_db +def test_content_published_actions_logs_execution(mocker, user, caplog): + """Test that content_published_actions logs when triggering plugins""" + from website_content.api import content_published_actions + from website_content.models import WebsiteContent + + mocker.patch("website_content.tasks.fastly_purge_relative_url") + mocker.patch("website_content.tasks.fastly_purge_relative_url.delay") + mocker.patch("website_content.tasks.fastly_purge_website_content_list.delay") + + content = WebsiteContent.objects.create( + title="Test Article", + content={"type": "doc", "content": []}, + is_published=True, + user=user, + content_type="news", + ) + + mock_pm = mocker.MagicMock() + mock_hook = mocker.MagicMock() + mock_pm.hook = mock_hook + mocker.patch("website_content.api.get_plugin_manager", return_value=mock_pm) + + content_published_actions(content=content) + + assert "Triggering website_content_published plugins" in caplog.text + assert f"id={content.id}" in caplog.text + assert f"title={content.title}" in caplog.text diff --git a/website_content/apps.py b/website_content/apps.py new file mode 100644 index 0000000000..5264f27ec2 --- /dev/null +++ b/website_content/apps.py @@ -0,0 +1,15 @@ +from django.apps import AppConfig +from pluggy import HookimplMarker, HookspecMarker + + +class WebsiteContentConfig(AppConfig): + """WebsiteContent AppConfig""" + + name = "website_content" + + hookimpl = HookimplMarker(name) + hookspec = HookspecMarker(name) + + def ready(self): + """Import tasks when the app is ready""" + import website_content.tasks # noqa: F401 diff --git a/website_content/constants.py b/website_content/constants.py new file mode 100644 index 0000000000..c84dc0f1b1 --- /dev/null +++ b/website_content/constants.py @@ -0,0 +1,11 @@ +"""Constants for website_content app""" + +GROUP_STAFF_ARTICLE_EDITORS = "article_editors" + +CONTENT_TYPE_NEWS = "news" +CONTENT_TYPE_ARTICLE = "article" + +CONTENT_TYPE_CHOICES = [ + (CONTENT_TYPE_NEWS, "News"), + (CONTENT_TYPE_ARTICLE, "Article"), +] diff --git a/website_content/factories.py b/website_content/factories.py new file mode 100644 index 0000000000..4042bf4b3d --- /dev/null +++ b/website_content/factories.py @@ -0,0 +1,18 @@ +"""Factories for making test data""" + +import factory +from factory.django import DjangoModelFactory + +from website_content import models +from website_content.constants import CONTENT_TYPE_NEWS + + +class WebsiteContentFactory(DjangoModelFactory): + """Factory for WebsiteContent""" + + content = factory.LazyFunction(lambda: {"type": "doc", "content": []}) + title = factory.Faker("sentence", nb_words=4) + content_type = CONTENT_TYPE_NEWS + + class Meta: + model = models.WebsiteContent diff --git a/website_content/hooks.py b/website_content/hooks.py new file mode 100644 index 0000000000..b5047a6e89 --- /dev/null +++ b/website_content/hooks.py @@ -0,0 +1,33 @@ +"""Pluggy hooks for website_content""" + +import logging + +import pluggy +from django.apps import apps +from django.conf import settings +from django.utils.module_loading import import_string + +log = logging.getLogger(__name__) + +app_config = apps.get_app_config("website_content") +hookspec = app_config.hookspec + + +class WebsiteContentHooks: + """Pluggy hook specs for website_content""" + + @hookspec + def website_content_published(self, content): + """Trigger actions after a content item is published or updated""" + + +def get_plugin_manager(): + """Return the plugin manager for website_content hooks""" + pm = pluggy.PluginManager(app_config.name) + pm.add_hookspecs(WebsiteContentHooks) + for module in settings.MITOL_WEBSITE_CONTENT_PLUGINS.split(","): + if module: + plugin_cls = import_string(module) + pm.register(plugin_cls()) + + return pm diff --git a/website_content/management/__init__.py b/website_content/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/website_content/management/commands/__init__.py b/website_content/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/website_content/management/commands/sync_website_content_to_news.py b/website_content/management/commands/sync_website_content_to_news.py new file mode 100644 index 0000000000..feb92da580 --- /dev/null +++ b/website_content/management/commands/sync_website_content_to_news.py @@ -0,0 +1,22 @@ +"""Management command to sync website content news items to the news feed""" + +from django.core.management.base import BaseCommand + +from news_events.etl import pipelines + + +class Command(BaseCommand): + help = "Sync published news-type website content to the news feed" + + def handle(self, *args, **options): # noqa: ARG002 + self.stdout.write("Syncing website content to news feed...") + + try: + pipelines.articles_news_etl() + + self.stdout.write( + self.style.SUCCESS("Successfully synced website content to news feed!") + ) + except Exception as e: + self.stdout.write(self.style.ERROR(f"Error syncing website content: {e!s}")) + raise diff --git a/website_content/migrations/0001_initial.py b/website_content/migrations/0001_initial.py new file mode 100644 index 0000000000..25afcd78ee --- /dev/null +++ b/website_content/migrations/0001_initial.py @@ -0,0 +1,96 @@ +# Generated migration for website_content app + +import django.db.models.deletion +import django.utils.timezone +import profiles.utils +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="WebsiteContent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("content", models.JSONField(default=dict)), + ("title", models.CharField(max_length=255)), + ("author_name", models.TextField(blank=True, default="")), + ( + "slug", + models.SlugField( + blank=True, max_length=255, null=True, unique=True + ), + ), + ("is_published", models.BooleanField(default=False)), + ("publish_date", models.DateTimeField(blank=True, null=True)), + ( + "content_type", + models.CharField( + choices=[("news", "News"), ("article", "Article")], + default="news", + max_length=50, + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="WebsiteContentImageUpload", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "image_file", + models.ImageField( + editable=False, + max_length=2083, + null=True, + upload_to=profiles.utils.article_image_upload_uri, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/website_content/migrations/0002_migrate_from_articles.py b/website_content/migrations/0002_migrate_from_articles.py new file mode 100644 index 0000000000..a1502b1385 --- /dev/null +++ b/website_content/migrations/0002_migrate_from_articles.py @@ -0,0 +1,127 @@ +""" +Data migration: copy existing records from the old articles_* tables into the +new website_content_* tables. + +Uses raw SQL and Python so this migration can run without the articles app +being present in INSTALLED_APPS at migration time. + +All migrated WebsiteContent rows receive content_type='news' because all +existing Article records are news posts. + +Handles fresh databases (no articles_* tables) gracefully. +""" + +import logging + +from django.db import migrations + +log = logging.getLogger(__name__) + + +def migrate_articles_to_website_content(apps, schema_editor): + conn = schema_editor.connection + + with conn.cursor() as cursor: + # Skip if the old articles table doesn't exist (fresh DB / CI) + cursor.execute( + "SELECT EXISTS (" + " SELECT 1 FROM information_schema.tables" + " WHERE table_name = 'articles_article'" + ")" + ) + if not cursor.fetchone()[0]: + log.info("articles_article not found; skipping data migration.") + return + + cursor.execute( + "INSERT INTO website_content_websitecontent" + " (id, created_on, updated_on, user_id, content, title," + " author_name, slug, is_published, publish_date, content_type)" + " SELECT id, created_on, updated_on, user_id, content, title," + " author_name, slug, is_published, publish_date, 'news'" + " FROM articles_article" + " ON CONFLICT (id) DO NOTHING" + ) + log.info("Copied %s rows from articles_article.", cursor.rowcount) + + # Advance sequence so future inserts don't collide with copied IDs + cursor.execute( + "SELECT setval(" + " pg_get_serial_sequence(" + " 'website_content_websitecontent', 'id'" + " )," + " COALESCE(" + " (SELECT MAX(id) FROM website_content_websitecontent), 1" + " )" + ")" + ) + + cursor.execute( + "SELECT EXISTS (" + " SELECT 1 FROM information_schema.tables" + " WHERE table_name = 'articles_articleimageupload'" + ")" + ) + if cursor.fetchone()[0]: + cursor.execute( + "INSERT INTO website_content_websitecontentimageupload" + " (id, user_id, image_file, created_at)" + " SELECT id, user_id, image_file, created_at" + " FROM articles_articleimageupload" + " ON CONFLICT (id) DO NOTHING" + ) + log.info( + "Copied %s rows from articles_articleimageupload.", + cursor.rowcount, + ) + cursor.execute( + "SELECT setval(" + " pg_get_serial_sequence(" + " 'website_content_websitecontentimageupload', 'id'" + " )," + " COALESCE(" + " (SELECT MAX(id) FROM" + " website_content_websitecontentimageupload), 1" + " )" + ")" + ) + + +def reverse_migration(apps, schema_editor): + conn = schema_editor.connection + with conn.cursor() as cursor: + cursor.execute( + "SELECT EXISTS (" + " SELECT 1 FROM information_schema.tables" + " WHERE table_name = 'articles_article'" + ")" + ) + if cursor.fetchone()[0]: + cursor.execute( + "DELETE FROM website_content_websitecontent" + " WHERE id IN (SELECT id FROM articles_article)" + ) + cursor.execute( + "SELECT EXISTS (" + " SELECT 1 FROM information_schema.tables" + " WHERE table_name = 'articles_articleimageupload'" + ")" + ) + if cursor.fetchone()[0]: + cursor.execute( + "DELETE FROM website_content_websitecontentimageupload" + " WHERE id IN (SELECT id FROM articles_articleimageupload)" + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("website_content", "0001_initial"), + ] + + operations = [ + migrations.RunPython( + migrate_articles_to_website_content, + reverse_migration, + ), + ] diff --git a/website_content/migrations/0003_add_editors_group.py b/website_content/migrations/0003_add_editors_group.py new file mode 100644 index 0000000000..9caf9dda48 --- /dev/null +++ b/website_content/migrations/0003_add_editors_group.py @@ -0,0 +1,29 @@ +""" +Ensure the article_editors Group exists in the database. + +Reuses the same group name as the old articles app so that existing users +already in the group retain their permissions without any manual intervention. +""" + +from django.contrib.auth.models import Group +from django.db import migrations + +from website_content.constants import GROUP_STAFF_ARTICLE_EDITORS + + +def add_editors_group(apps, schema_editor): # noqa: ARG001 + Group.objects.get_or_create(name=GROUP_STAFF_ARTICLE_EDITORS) + + +def remove_editors_group(apps, schema_editor): # noqa: ARG001 + Group.objects.filter(name=GROUP_STAFF_ARTICLE_EDITORS).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("website_content", "0002_migrate_from_articles"), + ] + + operations = [ + migrations.RunPython(add_editors_group, remove_editors_group), + ] diff --git a/website_content/migrations/0004_alter_websitecontent_created_on.py b/website_content/migrations/0004_alter_websitecontent_created_on.py new file mode 100644 index 0000000000..725572ded1 --- /dev/null +++ b/website_content/migrations/0004_alter_websitecontent_created_on.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.25 on 2026-05-12 10:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("website_content", "0003_add_editors_group"), + ] + + operations = [ + migrations.AlterField( + model_name="websitecontent", + name="created_on", + field=models.DateTimeField(auto_now_add=True, db_index=True), + ), + ] diff --git a/website_content/migrations/__init__.py b/website_content/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/website_content/models.py b/website_content/models.py new file mode 100644 index 0000000000..05fc29c234 --- /dev/null +++ b/website_content/models.py @@ -0,0 +1,85 @@ +"""website_content models""" + +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.text import slugify + +from main.models import TimestampedModel +from profiles.utils import article_image_upload_uri +from website_content.constants import ( + CONTENT_TYPE_CHOICES, + CONTENT_TYPE_NEWS, +) + + +class WebsiteContent(TimestampedModel): + """ + Stores rich-text content created by staff members. + + The `content_type` field distinguishes between different kinds of authored + content (e.g. "news" posts vs standalone "article" pages). + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=True, + blank=True, # optional for admin forms + ) + content = models.JSONField(default=dict) + title = models.CharField(max_length=255) + author_name = models.TextField(blank=True, default="") + slug = models.SlugField(max_length=255, unique=True, blank=True, null=True) + is_published = models.BooleanField(default=False) + publish_date = models.DateTimeField(null=True, blank=True) + content_type = models.CharField( + max_length=50, + choices=CONTENT_TYPE_CHOICES, + default=CONTENT_TYPE_NEWS, + ) + + def save(self, *args, **kwargs): + previous = WebsiteContent.objects.get(pk=self.pk) if self.pk else None + was_published = getattr(previous, "is_published", None) + + slug = self.slug or None + + if not was_published and self.is_published: + if not self.publish_date: + self.publish_date = timezone.now() + + max_length = self._meta.get_field("slug").max_length + + base_slug = slugify(self.title)[:max_length] + slug = base_slug + counter = 1 + + while WebsiteContent.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + suffix = f"-{counter}" + slug = f"{base_slug[: max_length - len(suffix)]}{suffix}" + counter += 1 + + self.slug = slug + super().save(*args, **kwargs) + + def get_url(self): + """ + Return the relative URL for this content item. + """ + if not self.slug: + return None + if self.content_type == CONTENT_TYPE_NEWS: + return f"/news/{self.slug}" + return f"/articles/{self.slug}" + + +class WebsiteContentImageUpload(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + image_file = models.ImageField( + null=True, upload_to=article_image_upload_uri, max_length=2083, editable=False + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"WebsiteContentImageUpload({self.user_id})" diff --git a/website_content/models_test.py b/website_content/models_test.py new file mode 100644 index 0000000000..8bde5ff67d --- /dev/null +++ b/website_content/models_test.py @@ -0,0 +1,116 @@ +"""Tests for website_content models""" + +from unittest.mock import patch + +import pytest +from django.contrib.auth import get_user_model + +from website_content.constants import CONTENT_TYPE_ARTICLE, CONTENT_TYPE_NEWS +from website_content.models import WebsiteContent + +User = get_user_model() + + +@pytest.mark.django_db +@patch("website_content.tasks.fastly_purge_website_content_list.delay") +@patch("website_content.tasks.fastly_purge_relative_url") +@patch("website_content.tasks.fastly_purge_relative_url.delay") +class TestWebsiteContentModel: + """Tests for WebsiteContent model""" + + def test_get_url_news_with_slug( + self, + _mock_queue_purge_delay, # noqa: PT019 + _mock_purge_url, # noqa: PT019 + _mock_queue_list, # noqa: PT019 + ): + """Test that get_url returns /news/slug for news content""" + user = User.objects.create_user(username="testuser", email="test@example.com") + content = WebsiteContent.objects.create( + title="Test News", + content={"type": "doc", "content": []}, + is_published=True, + user=user, + content_type=CONTENT_TYPE_NEWS, + ) + + assert content.get_url() == f"/news/{content.slug}" + + def test_get_url_article_with_slug( + self, + _mock_queue_purge_delay, # noqa: PT019 + _mock_purge_url, # noqa: PT019 + _mock_queue_list, # noqa: PT019 + ): + """Test that get_url returns /articles/slug for article content""" + user = User.objects.create_user( + username="testuser_art", email="art@example.com" + ) + content = WebsiteContent.objects.create( + title="Test Article Page", + content={"type": "doc", "content": []}, + is_published=True, + user=user, + content_type=CONTENT_TYPE_ARTICLE, + ) + + assert content.get_url() == f"/articles/{content.slug}" + + def test_get_url_without_slug( + self, + _mock_queue_purge_delay, # noqa: PT019 + _mock_purge_url, # noqa: PT019 + _mock_queue_list, # noqa: PT019 + ): + """Test that get_url returns None for unpublished content without a slug""" + user = User.objects.create_user(username="testuser2", email="test2@example.com") + content = WebsiteContent.objects.create( + title="Draft Content", + content={"type": "doc", "content": []}, + is_published=False, + user=user, + content_type=CONTENT_TYPE_NEWS, + ) + + assert content.get_url() is None + + def test_slug_generation_on_publish( + self, + _mock_queue_purge_delay, # noqa: PT019 + _mock_purge_url, # noqa: PT019 + _mock_queue_list, # noqa: PT019 + ): + """Test that slug is generated when content is published""" + user = User.objects.create_user(username="testuser4", email="test4@example.com") + content = WebsiteContent.objects.create( + title="Test Content Title", + content={"type": "doc", "content": []}, + is_published=False, + user=user, + content_type=CONTENT_TYPE_NEWS, + ) + + assert content.slug is None + + content.is_published = True + content.save() + + assert content.slug is not None + assert content.slug == "test-content-title" + assert content.get_url() == "/news/test-content-title" + + def test_content_type_defaults_to_news( + self, + _mock_queue_purge_delay, # noqa: PT019 + _mock_purge_url, # noqa: PT019 + _mock_queue_list, # noqa: PT019 + ): + """Test that content_type defaults to 'news'""" + user = User.objects.create_user(username="testuser5", email="test5@example.com") + content = WebsiteContent.objects.create( + title="News Piece", + content={}, + user=user, + ) + + assert content.content_type == CONTENT_TYPE_NEWS diff --git a/website_content/permissions.py b/website_content/permissions.py new file mode 100644 index 0000000000..9f7979344f --- /dev/null +++ b/website_content/permissions.py @@ -0,0 +1,42 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + +from learning_resources.permissions import is_admin_user +from website_content.constants import GROUP_STAFF_ARTICLE_EDITORS + + +def is_website_content_editor(request): + """ + Return True if the request user belongs to the article_editors group or + is an admin. + + Args: + request (HTTPRequest): django request object + + Returns: + bool: True if user is a content editor or admin + """ + return ( + request.user is not None + and request.user.groups.filter(name=GROUP_STAFF_ARTICLE_EDITORS).first() + is not None + ) + + +class CanViewWebsiteContent(BasePermission): + """ + Allow viewing a content item if: + - user is admin/content editor, OR + - the item is published + """ + + def has_object_permission(self, request, _, obj): + if is_admin_user(request) or is_website_content_editor(request): + return True + return obj.is_published + + +class CanEditWebsiteContent(BasePermission): + def has_permission(self, request, _view): + if request.method not in SAFE_METHODS: + return is_admin_user(request) or is_website_content_editor(request) + return True diff --git a/website_content/serializers.py b/website_content/serializers.py new file mode 100644 index 0000000000..4bf151c12c --- /dev/null +++ b/website_content/serializers.py @@ -0,0 +1,72 @@ +from django.contrib.auth import get_user_model +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from website_content import models +from website_content.constants import CONTENT_TYPE_NEWS +from website_content.validators import clean_html + +User = get_user_model() + + +@extend_schema_field(str) +class SanitizedHtmlField(serializers.Field): + @staticmethod + def to_representation(value): + return value + + def to_internal_value(self, data): + return clean_html(data) + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["first_name", "last_name"] + + +class WebsiteContentSerializer(serializers.ModelSerializer): + """ + Serializer for WebsiteContent model. + """ + + created_on = serializers.DateTimeField(read_only=True, required=False) + updated_on = serializers.DateTimeField(read_only=True, required=False) + publish_date = serializers.DateTimeField(read_only=True, required=False) + content = serializers.JSONField(default={}) + slug = serializers.SlugField(max_length=60, required=False, allow_blank=True) + title = serializers.CharField(max_length=255) + author_name = serializers.CharField(required=False, allow_blank=True, default="") + user = UserSerializer(read_only=True) + content_type = serializers.ChoiceField( + choices=["news", "article"], + default=CONTENT_TYPE_NEWS, + required=False, + ) + + class Meta: + model = models.WebsiteContent + fields = [ + "id", + "title", + "author_name", + "content", + "content_type", + "user", + "created_on", + "updated_on", + "publish_date", + "is_published", + "slug", + ] + + +class WebsiteContentImageUploadSerializer(serializers.Serializer): + image_file = serializers.ImageField(required=True) + + def create(self, validated_data): + user = self.context.get("request").user + return models.WebsiteContentImageUpload.objects.create( + user=user, + image_file=validated_data["image_file"], + ) diff --git a/website_content/serializers_test.py b/website_content/serializers_test.py new file mode 100644 index 0000000000..5b94922de1 --- /dev/null +++ b/website_content/serializers_test.py @@ -0,0 +1,69 @@ +from io import BytesIO + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from PIL import Image +from rest_framework import serializers + +from website_content.models import WebsiteContentImageUpload +from website_content.serializers import ( + SanitizedHtmlField, + WebsiteContentImageUploadSerializer, +) + + +class HTMLSanitizingSerializer(serializers.Serializer): + html = SanitizedHtmlField() + + +def test_html_sanitization(): + serializer = HTMLSanitizingSerializer( + data={"html": "

"} + ) + serializer.is_valid() + + assert serializer.data["html"] == "

" + + +def generate_test_image(): + """Create a valid in-memory JPEG image.""" + file = BytesIO() + image = Image.new("RGB", (100, 100), color="red") + image.save(file, "JPEG") + file.seek(0) + return SimpleUploadedFile( + "test.jpg", + file.read(), + content_type="image/jpeg", + ) + + +@pytest.mark.django_db +def test_website_content_image_upload_serializer(django_user_model): + image_file = generate_test_image() + + user = django_user_model.objects.create_user( + username="testuser", + email="user@example.com", + password="password123", # noqa: S106 + ) + + class FakeRequest: + pass + + request = FakeRequest() + request.user = user + + serializer = WebsiteContentImageUploadSerializer( + data={"image_file": image_file}, + context={"request": request}, + ) + + assert serializer.is_valid(), serializer.errors + + instance = serializer.save() + + assert isinstance(instance, WebsiteContentImageUpload) + assert instance.user == user + assert instance.image_file + assert instance.image_file.name.endswith(".jpg") diff --git a/website_content/tasks.py b/website_content/tasks.py new file mode 100644 index 0000000000..0a310864be --- /dev/null +++ b/website_content/tasks.py @@ -0,0 +1,52 @@ +"""Tasks for website_content CDN purge""" + +import logging + +from mitol.common.decorators import single_task + +from main.celery import app +from main.utils import call_fastly_purge_api + +log = logging.getLogger(__name__) + +PURGE_TIMEOUT_SECONDS = 5 # 5 seconds + + +@app.task() +def fastly_purge_relative_url(relative_url, timeout=30): + """ + Purge the given relative URL from the Fastly cache. + + Can be called directly (runs immediately) or via .delay() (enqueued for Celery). + + Args: + relative_url: The relative URL path to purge (e.g., "/news/article-slug/") + timeout: Timeout in seconds for the API request (default: 30) + + Returns: + dict: Response from Fastly API with status + """ + return call_fastly_purge_api(relative_url, timeout=timeout) + + +@app.task() +def fastly_full_purge(): + """ + Purges everything from the Fastly cache. + + Passing * to the purge API instructs Fastly to purge everything. + """ + log.info("Purging all pages from the Fastly cache...") + return call_fastly_purge_api("*") + + +@app.task() +@single_task(10) +def fastly_purge_website_content_list(): + """ + Purges the news listing page from the Fastly cache. + + Can be called directly (runs immediately) or via .delay() (enqueued for Celery). + """ + log.info("Purging website content list pages from the Fastly cache...") + return call_fastly_purge_api("/news") diff --git a/website_content/tasks_test.py b/website_content/tasks_test.py new file mode 100644 index 0000000000..5913fe6158 --- /dev/null +++ b/website_content/tasks_test.py @@ -0,0 +1,71 @@ +"""Tests for website_content CDN purge tasks""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests +from requests import Response + +from main.utils import call_fastly_purge_api +from website_content.factories import WebsiteContentFactory +from website_content.tasks import ( + fastly_full_purge, + fastly_purge_relative_url, + fastly_purge_website_content_list, +) + + +@pytest.fixture +def mock_fastly_response(): + """Create a mock successful Fastly response""" + response = MagicMock(spec=Response) + response.status_code = 200 + response.json.return_value = {"status": "ok", "id": "123-456"} + response.text = '{"status": "ok", "id": "123-456"}' + return response + + +@pytest.fixture +def mock_fastly_error_response(): + """Create a mock error Fastly response""" + response = MagicMock(spec=Response) + response.status_code = 403 + response.reason = "Forbidden" + response.text = '{"status": "error", "msg": "Invalid API key"}' + response.raise_for_status.side_effect = requests.HTTPError("403 Forbidden") + return response + + +@pytest.mark.django_db +class TestCallFastlyPurgeApi: + """Tests for call_fastly_purge_api function""" + + @patch("main.utils.requests.request") + @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") + @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") + @patch("django.conf.settings.FASTLY_API_KEY", "test-token") + def test_call_fastly_purge_api_success(self, mock_request, mock_fastly_response): + """Test successful Fastly API call""" + mock_request.return_value = mock_fastly_response + + result = call_fastly_purge_api("/api/v1/website_content/test-article/") + + assert result == {"status": "ok", "id": "123-456"} + mock_request.assert_called_once() + + call_kwargs = mock_request.call_args.kwargs + assert call_kwargs["headers"]["fastly-key"] == "test-token" + assert call_kwargs["timeout"] == 30 + + @patch("main.utils.requests.request") + @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") + @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") + @patch("django.conf.settings.FASTLY_API_KEY", "") + def test_call_fastly_purge_api_no_token(self, mock_request, mock_fastly_response): + """Test API call without auth token - skips in dev""" + mock_request.return_value = mock_fastly_response + + result = call_fastly_purge_api("/api/v1/news/test/") + + assert result == {"status": "ok", "skipped": True} + mock_request.assert_not_called() diff --git a/website_content/urls.py b/website_content/urls.py new file mode 100644 index 0000000000..9d9499bbcd --- /dev/null +++ b/website_content/urls.py @@ -0,0 +1,42 @@ +from django.urls import include, path, re_path +from rest_framework.routers import SimpleRouter + +from website_content import views +from website_content.views import MediaUploadView + +v1_router = SimpleRouter() +v1_router.register( + r"website_content", + views.WebsiteContentViewSet, + basename="website_content", +) + +# Backward-compatible alias: /api/v1/articles/ → same viewset +v1_articles_router = SimpleRouter() +v1_articles_router.register( + r"articles", + views.WebsiteContentViewSet, + basename="articles", +) + +app_name = "website_content" + +urlpatterns = [ + re_path( + r"^api/v1/", + include( + ( + [ + *v1_router.urls, + *v1_articles_router.urls, + path( + "upload-media/", + MediaUploadView.as_view(), + name="api-media-upload", + ), + ], + "v1", + ) + ), + ), +] diff --git a/website_content/validators.py b/website_content/validators.py new file mode 100644 index 0000000000..4fb8549d36 --- /dev/null +++ b/website_content/validators.py @@ -0,0 +1,45 @@ +import nh3 + +website_content_html_config = { + "tags": { + # Headings + "h2", + "h3", + "h4", + # Basic typographic styles + "p", + "strong", + "i", + "em", + # Lists + "ol", + "ul", + "li", + # Links + "a", + # Images + "figure", + "img", + "figcaption", + # Blockquotes + "blockquote", + # media embed + # This is a custom tag that won't be rendered directly by browsers + "oembed", + }, + # See ammonia defaults: + # - On specific tags: https://docs.rs/ammonia/latest/ammonia/struct.Builder.html#method.tag_attributes + # - On all tags: https://docs.rs/ammonia/latest/ammonia/struct.Builder.html#method.generic_attributes + "attributes": { + "a": {"href", "hreflang"}, + "img": {"alt", "height", "src", "width", "srcset", "sizes", "style"}, + "figure": {"class", "style"}, + "oembed": {"url"}, + }, + # Allow data: URLs for src attributes + "url_schemes": {"data"}, +} + + +def clean_html(html: str) -> str: + return nh3.clean(html, **website_content_html_config) diff --git a/website_content/validators_test.py b/website_content/validators_test.py new file mode 100644 index 0000000000..319412d7a3 --- /dev/null +++ b/website_content/validators_test.py @@ -0,0 +1,38 @@ +import pytest + +from website_content.validators import clean_html + + +@pytest.mark.parametrize( + ("html_in", "expected_out"), + [ + ( + 'ammonia', + 'ammonia', + ), + ( + ( + 'alt' + ), + ( + 'alt' + ), + ), + ( + '
', + '
', + ), + ( + '', + "", + ), + ( + "

1111

2222

3333

4444

", + "1111

2222

3333

4444

", + ), + ], +) +def test_clean_html(html_in, expected_out): + assert clean_html(html_in) == expected_out diff --git a/website_content/views.py b/website_content/views.py new file mode 100644 index 0000000000..17b86e8009 --- /dev/null +++ b/website_content/views.py @@ -0,0 +1,181 @@ +from django.conf import settings +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, +) +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from learning_resources.permissions import is_admin_user +from main.constants import VALID_HTTP_METHODS +from main.utils import cache_page_per_user, clear_views_cache +from website_content.api import content_published_actions, purge_content_on_save +from website_content.models import WebsiteContent +from website_content.permissions import ( + CanEditWebsiteContent, + CanViewWebsiteContent, + is_website_content_editor, +) +from website_content.serializers import ( + WebsiteContentImageUploadSerializer, + WebsiteContentSerializer, +) + + +@extend_schema_view( + list=extend_schema( + summary="List", + description="Get a paginated list of website content items", + parameters=[ + OpenApiParameter( + name="draft", + type=bool, + location=OpenApiParameter.QUERY, + description=( + "Filter to show only draft items. Only available for " + "admins and content editors. If true, returns unpublished " + "items. If not specified, returns all items." + ), + required=False, + ), + OpenApiParameter( + name="content_type", + type=str, + location=OpenApiParameter.QUERY, + description="Filter by content type (e.g. 'news' or 'article').", + required=False, + ), + ], + ), + retrieve=extend_schema( + summary="Retrieve", description="Retrieve a single content item" + ), + create=extend_schema(summary="Create", description="Create a new content item"), + destroy=extend_schema(summary="Destroy", description="Delete a content item"), + partial_update=extend_schema(summary="Update", description="Update a content item"), +) +class WebsiteContentViewSet(viewsets.ModelViewSet): + """ + Viewset for WebsiteContent viewing and editing. + + Registered under both `api/v1/website_content/` (primary) and + `api/v1/articles/` (backward-compatible alias). + """ + + serializer_class = WebsiteContentSerializer + queryset = WebsiteContent.objects.all() + permission_classes = [CanViewWebsiteContent, CanEditWebsiteContent] + http_method_names = VALID_HTTP_METHODS + + def get_queryset(self): + qs = WebsiteContent.objects.all() + + if is_admin_user(self.request) or is_website_content_editor(self.request): + draft_param = self.request.query_params.get("draft") + if draft_param and draft_param.lower() in ("true", "1"): + qs = qs.filter(is_published=False) + else: + qs = qs.filter(is_published=True) + + content_type_param = self.request.query_params.get("content_type") + if content_type_param: + qs = qs.filter(content_type=content_type_param) + + return qs + + @method_decorator( + cache_page_per_user( + settings.REDIS_VIEW_CACHE_DURATION, + cache="redis", + key_prefix="website_content", + ) + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + def perform_create(self, serializer): + clear_views_cache() + content = serializer.save(user=self.request.user) + purge_content_on_save(content) + content_published_actions(content=content) + + def perform_update(self, serializer): + clear_views_cache() + content = serializer.save() + purge_content_on_save(content) + content_published_actions(content=content) + + def destroy(self, request, *args, **kwargs): + clear_views_cache() + return super().destroy(request, *args, **kwargs) + + @extend_schema( + summary="Retrieve by ID or slug", + description="Retrieve a content item by numeric ID or slug", + parameters=[ + OpenApiParameter( + name="identifier", + type=str, + location=OpenApiParameter.PATH, + description="Numeric ID or slug of the content item", + ) + ], + ) + @action( + detail=False, + methods=["get"], + url_path="detail/(?P[^/.]+)", + url_name="detail-by-id-or-slug", + ) + def detail_by_id_or_slug(self, _request, identifier): + qs = self.get_queryset() + + if identifier.isdigit(): + content = get_object_or_404(qs, id=int(identifier)) + else: + content = get_object_or_404(qs, slug=identifier) + + serializer = self.get_serializer(content) + return Response(serializer.data, status=status.HTTP_200_OK) + + +@extend_schema_view( + post=extend_schema( + request={ + "multipart/form-data": WebsiteContentImageUploadSerializer, + }, + responses={ + 201: OpenApiResponse( + description="Successful Upload", + response=( + {"type": "object", "properties": {"url": {"type": "string"}}} + ), + ), + 400: OpenApiResponse(description="Bad request"), + 401: OpenApiResponse(description="Authentication required"), + }, + description="Upload an image (multipart/form-data) and return the storage URL.", + operation_id="media_upload", + tags=["media"], + ) +) +class MediaUploadView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = WebsiteContentImageUploadSerializer( + data=request.data, context={"request": request} + ) + if serializer.is_valid(): + instance = serializer.save() + return Response( + {"url": instance.image_file.url}, status=status.HTTP_201_CREATED + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/website_content/views_test.py b/website_content/views_test.py new file mode 100644 index 0000000000..036706cfdd --- /dev/null +++ b/website_content/views_test.py @@ -0,0 +1,142 @@ +"""Test for website_content views""" + +import pytest +from rest_framework.reverse import reverse + +from main.factories import UserFactory +from website_content.models import WebsiteContent + +pytestmark = [pytest.mark.django_db] + + +@pytest.fixture(autouse=True) +def _mock_cdn_purge(mocker): + """Auto-mock CDN purge tasks for all tests in this module""" + mocker.patch("website_content.tasks.fastly_purge_relative_url") + mocker.patch("website_content.tasks.fastly_purge_relative_url.delay") + mocker.patch("website_content.tasks.fastly_purge_website_content_list.delay") + + +def test_website_content_creation(staff_client, user): + """Test website content creation.""" + url = reverse("website_content:v1:website_content-list") + data = { + "content": {}, + "title": "Some title", + "content_type": "news", + } + resp = staff_client.post(url, data) + json = resp.json() + assert json["content"] == {} + assert json["title"] == "Some title" + assert json["content_type"] == "news" + + +def test_website_content_creation_via_articles_alias(staff_client, user): + """Test that the backward-compatible /api/v1/articles/ endpoint still works.""" + url = reverse("website_content:v1:articles-list") + data = { + "content": {}, + "title": "Articles alias title", + "content_type": "news", + } + resp = staff_client.post(url, data) + json = resp.json() + assert resp.status_code == 201 + assert json["title"] == "Articles alias title" + + +def test_retrieve_content_by_id(client, user): + """Should retrieve published content by numeric ID""" + content = WebsiteContent.objects.create( + title="Test Article", + content={}, + is_published=True, + user=user, + content_type="news", + ) + + url = reverse( + "website_content:v1:website_content-detail-by-id-or-slug", + kwargs={"identifier": str(content.id)}, + ) + + resp = client.get(url) + data = resp.json() + + assert resp.status_code == 200 + assert data["id"] == content.id + assert data["title"] == "Test Article" + + +def test_retrieve_content_by_slug(client, user): + """Should retrieve published content by slug""" + content = WebsiteContent.objects.create( + title="Slug Article", + content={}, + is_published=True, + user=user, + content_type="news", + ) + + url = reverse( + "website_content:v1:website_content-detail-by-id-or-slug", + kwargs={"identifier": content.slug}, + ) + + resp = client.get(url) + data = resp.json() + + assert resp.status_code == 200 + assert data["slug"] == content.slug + assert data["title"] == "Slug Article" + + +def test_staff_can_access_unpublished_content(client): + """Staff should be able to see unpublished content items""" + staff_user = UserFactory.create(is_staff=True) + client.force_login(staff_user) + + content = WebsiteContent.objects.create( + title="Draft Article", + content={}, + is_published=False, + user=staff_user, + content_type="news", + ) + + url = reverse( + "website_content:v1:website_content-detail-by-id-or-slug", + kwargs={"identifier": str(content.id)}, + ) + + resp = client.get(url) + data = resp.json() + + assert resp.status_code == 200 + assert data["id"] == content.id + + +def test_content_type_filter(client, user): + """content_type query param should filter results""" + WebsiteContent.objects.create( + title="News piece", + content={}, + is_published=True, + user=user, + content_type="news", + ) + WebsiteContent.objects.create( + title="Article piece", + content={}, + is_published=True, + user=user, + content_type="article", + ) + + url = reverse("website_content:v1:website_content-list") + "?content_type=news" + resp = client.get(url) + results = resp.json()["results"] + + assert all(r["content_type"] == "news" for r in results) + assert len(results) == 1 From 84ea5016b4fe2a923c0c0c55e7d5b7cfff1224b5 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Wed, 13 May 2026 11:56:14 +0500 Subject: [PATCH 02/17] refactor: change references in openapi for articles --- frontends/api/src/clients.ts | 7 + frontends/api/src/generated/v1/api.ts | 1518 +++++++++++++---- frontends/api/src/hooks/articles/index.ts | 18 +- frontends/api/src/hooks/articles/queries.ts | 15 +- .../api/src/test-utils/factories/articles.ts | 4 +- .../Articles/ArticleDraftListingPage.tsx | 4 +- .../main/src/app/news/[slugOrId]/page.tsx | 6 +- .../TiptapEditor/ArticleContext.tsx | 4 +- .../TiptapEditor/ArticleEditor.tsx | 6 +- openapi/specs/v1.yaml | 475 ++++-- 10 files changed, 1585 insertions(+), 472 deletions(-) diff --git a/frontends/api/src/clients.ts b/frontends/api/src/clients.ts index 48d9286a3b..f8dd905213 100644 --- a/frontends/api/src/clients.ts +++ b/frontends/api/src/clients.ts @@ -5,6 +5,7 @@ import { OfferorsApi, TopicsApi, ArticlesApi, + WebsiteContentApi, ProgramLettersApi, LearningResourcesSearchApi, PlatformsApi, @@ -72,6 +73,11 @@ const platformsApi = new PlatformsApi(undefined, BASE_PATH, axiosInstance) const topicsApi = new TopicsApi(undefined, BASE_PATH, axiosInstance) const articlesApi = new ArticlesApi(undefined, BASE_PATH, axiosInstance) +const websiteContentApi = new WebsiteContentApi( + undefined, + BASE_PATH, + axiosInstance, +) const mediaApi = new MediaApi(undefined, BASE_PATH, axiosInstance) const hubspotApi = new HubspotApi(undefined, BASE_PATH, axiosInstance) @@ -119,6 +125,7 @@ export { userListsApi, topicsApi, articlesApi, + websiteContentApi, mediaApi, hubspotApi, offerorsApi, diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index 4b885d1546..fbb8bb5d40 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -338,10 +338,10 @@ export interface ContentFile { content_feature_type: Array /** * - * @type {ContentTypeEnum} + * @type {ContentFileContentTypeEnum} * @memberof ContentFile */ - content_type?: ContentTypeEnum + content_type?: ContentFileContentTypeEnum /** * * @type {string} @@ -458,6 +458,41 @@ export interface ContentFile { youtube_id?: string | null } +/** + * * `page` - page * `file` - file * `video` - video * `pdf` - pdf + * @export + * @enum {string} + */ + +export const ContentFileContentTypeEnumDescriptions = { + page: "page", + file: "file", + video: "video", + pdf: "pdf", +} as const + +export const ContentFileContentTypeEnum = { + /** + * page + */ + Page: "page", + /** + * file + */ + File: "file", + /** + * video + */ + Video: "video", + /** + * pdf + */ + Pdf: "pdf", +} as const + +export type ContentFileContentTypeEnum = + (typeof ContentFileContentTypeEnum)[keyof typeof ContentFileContentTypeEnum] + /** * SearchResponseSerializer with OpenAPI annotations for Content Files search * @export @@ -601,41 +636,6 @@ export interface ContentFileWebHookRequestRequest { course_readable_id?: string } -/** - * * `page` - page * `file` - file * `video` - video * `pdf` - pdf - * @export - * @enum {string} - */ - -export const ContentTypeEnumDescriptions = { - page: "page", - file: "file", - video: "video", - pdf: "pdf", -} as const - -export const ContentTypeEnum = { - /** - * page - */ - Page: "page", - /** - * file - */ - File: "file", - /** - * video - */ - Video: "video", - /** - * pdf - */ - Pdf: "pdf", -} as const - -export type ContentTypeEnum = - (typeof ContentTypeEnum)[keyof typeof ContentTypeEnum] - /** * Serializer for the Course model * @export @@ -5764,37 +5764,6 @@ export interface PaginatedProgramResourceList { */ results: Array } -/** - * - * @export - * @interface PaginatedRichTextArticleList - */ -export interface PaginatedRichTextArticleList { - /** - * - * @type {number} - * @memberof PaginatedRichTextArticleList - */ - count: number - /** - * - * @type {string} - * @memberof PaginatedRichTextArticleList - */ - next?: string | null - /** - * - * @type {string} - * @memberof PaginatedRichTextArticleList - */ - previous?: string | null - /** - * - * @type {Array} - * @memberof PaginatedRichTextArticleList - */ - results: Array -} /** * * @export @@ -5919,6 +5888,37 @@ export interface PaginatedVideoResourceList { */ results: Array } +/** + * + * @export + * @interface PaginatedWebsiteContentList + */ +export interface PaginatedWebsiteContentList { + /** + * + * @type {number} + * @memberof PaginatedWebsiteContentList + */ + count: number + /** + * + * @type {string} + * @memberof PaginatedWebsiteContentList + */ + next?: string | null + /** + * + * @type {string} + * @memberof PaginatedWebsiteContentList + */ + previous?: string | null + /** + * + * @type {Array} + * @memberof PaginatedWebsiteContentList + */ + results: Array +} /** * Create serializer for nested learning path items. The parent is derived from the nested route and must not be client-supplied. * @export @@ -6110,43 +6110,6 @@ export interface PatchedLearningResourceRelationshipRequest { child?: number } -/** - * Serializer for LearningResourceInstructor model - * @export - * @interface PatchedRichTextArticleRequest - */ -export interface PatchedRichTextArticleRequest { - /** - * - * @type {string} - * @memberof PatchedRichTextArticleRequest - */ - title?: string - /** - * - * @type {string} - * @memberof PatchedRichTextArticleRequest - */ - author_name?: string - /** - * - * @type {any} - * @memberof PatchedRichTextArticleRequest - */ - content?: any - /** - * - * @type {boolean} - * @memberof PatchedRichTextArticleRequest - */ - is_published?: boolean - /** - * - * @type {string} - * @memberof PatchedRichTextArticleRequest - */ - slug?: string -} /** * Serializer for UserListRelationship model * @export @@ -6204,6 +6167,50 @@ export interface PatchedUserListRequest { privacy_level?: PrivacyLevelEnum } +/** + * Serializer for WebsiteContent model. + * @export + * @interface PatchedWebsiteContentRequest + */ +export interface PatchedWebsiteContentRequest { + /** + * + * @type {string} + * @memberof PatchedWebsiteContentRequest + */ + title?: string + /** + * + * @type {string} + * @memberof PatchedWebsiteContentRequest + */ + author_name?: string + /** + * + * @type {any} + * @memberof PatchedWebsiteContentRequest + */ + content?: any + /** + * + * @type {WebsiteContentContentTypeEnum} + * @memberof PatchedWebsiteContentRequest + */ + content_type?: WebsiteContentContentTypeEnum + /** + * + * @type {boolean} + * @memberof PatchedWebsiteContentRequest + */ + is_published?: boolean + /** + * + * @type {string} + * @memberof PatchedWebsiteContentRequest + */ + slug?: string +} + /** * Serializer for PercolateQuery objects * @export @@ -8571,110 +8578,6 @@ export const ResourceTypeGroupEnum = { export type ResourceTypeGroupEnum = (typeof ResourceTypeGroupEnum)[keyof typeof ResourceTypeGroupEnum] -/** - * Serializer for LearningResourceInstructor model - * @export - * @interface RichTextArticle - */ -export interface RichTextArticle { - /** - * - * @type {number} - * @memberof RichTextArticle - */ - id: number - /** - * - * @type {string} - * @memberof RichTextArticle - */ - title: string - /** - * - * @type {string} - * @memberof RichTextArticle - */ - author_name?: string - /** - * - * @type {any} - * @memberof RichTextArticle - */ - content?: any - /** - * - * @type {User} - * @memberof RichTextArticle - */ - user: User - /** - * - * @type {string} - * @memberof RichTextArticle - */ - created_on: string - /** - * - * @type {string} - * @memberof RichTextArticle - */ - updated_on: string - /** - * - * @type {string} - * @memberof RichTextArticle - */ - publish_date: string - /** - * - * @type {boolean} - * @memberof RichTextArticle - */ - is_published?: boolean - /** - * - * @type {string} - * @memberof RichTextArticle - */ - slug?: string -} -/** - * Serializer for LearningResourceInstructor model - * @export - * @interface RichTextArticleRequest - */ -export interface RichTextArticleRequest { - /** - * - * @type {string} - * @memberof RichTextArticleRequest - */ - title: string - /** - * - * @type {string} - * @memberof RichTextArticleRequest - */ - author_name?: string - /** - * - * @type {any} - * @memberof RichTextArticleRequest - */ - content?: any - /** - * - * @type {boolean} - * @memberof RichTextArticleRequest - */ - is_published?: boolean - /** - * - * @type {string} - * @memberof RichTextArticleRequest - */ - slug?: string -} /** * * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * @export @@ -10179,6 +10082,148 @@ export interface WebhookResponse { */ error?: string } +/** + * Serializer for WebsiteContent model. + * @export + * @interface WebsiteContent + */ +export interface WebsiteContent { + /** + * + * @type {number} + * @memberof WebsiteContent + */ + id: number + /** + * + * @type {string} + * @memberof WebsiteContent + */ + title: string + /** + * + * @type {string} + * @memberof WebsiteContent + */ + author_name?: string + /** + * + * @type {any} + * @memberof WebsiteContent + */ + content?: any + /** + * + * @type {WebsiteContentContentTypeEnum} + * @memberof WebsiteContent + */ + content_type?: WebsiteContentContentTypeEnum + /** + * + * @type {User} + * @memberof WebsiteContent + */ + user: User + /** + * + * @type {string} + * @memberof WebsiteContent + */ + created_on: string + /** + * + * @type {string} + * @memberof WebsiteContent + */ + updated_on: string + /** + * + * @type {string} + * @memberof WebsiteContent + */ + publish_date: string + /** + * + * @type {boolean} + * @memberof WebsiteContent + */ + is_published?: boolean + /** + * + * @type {string} + * @memberof WebsiteContent + */ + slug?: string +} + +/** + * * `news` - news * `article` - article + * @export + * @enum {string} + */ + +export const WebsiteContentContentTypeEnumDescriptions = { + news: "news", + article: "article", +} as const + +export const WebsiteContentContentTypeEnum = { + /** + * news + */ + News: "news", + /** + * article + */ + Article: "article", +} as const + +export type WebsiteContentContentTypeEnum = + (typeof WebsiteContentContentTypeEnum)[keyof typeof WebsiteContentContentTypeEnum] + +/** + * Serializer for WebsiteContent model. + * @export + * @interface WebsiteContentRequest + */ +export interface WebsiteContentRequest { + /** + * + * @type {string} + * @memberof WebsiteContentRequest + */ + title: string + /** + * + * @type {string} + * @memberof WebsiteContentRequest + */ + author_name?: string + /** + * + * @type {any} + * @memberof WebsiteContentRequest + */ + content?: any + /** + * + * @type {WebsiteContentContentTypeEnum} + * @memberof WebsiteContentRequest + */ + content_type?: WebsiteContentContentTypeEnum + /** + * + * @type {boolean} + * @memberof WebsiteContentRequest + */ + is_published?: boolean + /** + * + * @type {string} + * @memberof WebsiteContentRequest + */ + slug?: string +} /** * ArticlesApi - axios parameter creator @@ -10189,21 +10234,21 @@ export const ArticlesApiAxiosParamCreator = function ( ) { return { /** - * Create a new article + * Create a new content item * @summary Create - * @param {RichTextArticleRequest} RichTextArticleRequest + * @param {WebsiteContentRequest} WebsiteContentRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ articlesCreate: async ( - RichTextArticleRequest: RichTextArticleRequest, + WebsiteContentRequest: WebsiteContentRequest, options: RawAxiosRequestConfig = {}, ): Promise => { - // verify required parameter 'RichTextArticleRequest' is not null or undefined + // verify required parameter 'WebsiteContentRequest' is not null or undefined assertParamExists( "articlesCreate", - "RichTextArticleRequest", - RichTextArticleRequest, + "WebsiteContentRequest", + WebsiteContentRequest, ) const localVarPath = `/api/v1/articles/` // use dummy base URL string because the URL constructor only accepts absolute URLs. @@ -10232,7 +10277,7 @@ export const ArticlesApiAxiosParamCreator = function ( ...options.headers, } localVarRequestOptions.data = serializeDataIfNeeded( - RichTextArticleRequest, + WebsiteContentRequest, localVarRequestOptions, configuration, ) @@ -10243,9 +10288,9 @@ export const ArticlesApiAxiosParamCreator = function ( } }, /** - * Delete an article + * Delete a content item * @summary Destroy - * @param {number} id A unique integer value identifying this article. + * @param {number} id A unique integer value identifying this website content. * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -10289,9 +10334,9 @@ export const ArticlesApiAxiosParamCreator = function ( } }, /** - * If the path parameter is numeric → ID, else → slug. - * @summary Retrieve article by ID or slug - * @param {string} identifier Article ID (number) or slug (string) + * Retrieve a content item by numeric ID or slug + * @summary Retrieve by ID or slug + * @param {string} identifier Numeric ID or slug of the content item * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -10335,15 +10380,17 @@ export const ArticlesApiAxiosParamCreator = function ( } }, /** - * Get a paginated list of articles + * Get a paginated list of website content items * @summary List - * @param {boolean} [draft] Filter to show only draft articles. Only available for admins and article editors. If true, returns unpublished articles. If not specified, returns all articles. + * @param {string} [content_type] Filter by content type (e.g. \'news\' or \'article\'). + * @param {boolean} [draft] Filter to show only draft items. Only available for admins and content editors. If true, returns unpublished items. If not specified, returns all items. * @param {number} [limit] Number of results to return per page. * @param {number} [offset] The initial index from which to return the results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ articlesList: async ( + content_type?: string, draft?: boolean, limit?: number, offset?: number, @@ -10365,6 +10412,10 @@ export const ArticlesApiAxiosParamCreator = function ( const localVarHeaderParameter = {} as any const localVarQueryParameter = {} as any + if (content_type !== undefined) { + localVarQueryParameter["content_type"] = content_type + } + if (draft !== undefined) { localVarQueryParameter["draft"] = draft } @@ -10392,16 +10443,16 @@ export const ArticlesApiAxiosParamCreator = function ( } }, /** - * Update an article + * Update a content item * @summary Update - * @param {number} id A unique integer value identifying this article. - * @param {PatchedRichTextArticleRequest} [PatchedRichTextArticleRequest] + * @param {number} id A unique integer value identifying this website content. + * @param {PatchedWebsiteContentRequest} [PatchedWebsiteContentRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ articlesPartialUpdate: async ( id: number, - PatchedRichTextArticleRequest?: PatchedRichTextArticleRequest, + PatchedWebsiteContentRequest?: PatchedWebsiteContentRequest, options: RawAxiosRequestConfig = {}, ): Promise => { // verify required parameter 'id' is not null or undefined @@ -10436,7 +10487,7 @@ export const ArticlesApiAxiosParamCreator = function ( ...options.headers, } localVarRequestOptions.data = serializeDataIfNeeded( - PatchedRichTextArticleRequest, + PatchedWebsiteContentRequest, localVarRequestOptions, configuration, ) @@ -10447,9 +10498,9 @@ export const ArticlesApiAxiosParamCreator = function ( } }, /** - * Retrieve a single article + * Retrieve a single content item * @summary Retrieve - * @param {number} id A unique integer value identifying this article. + * @param {number} id A unique integer value identifying this website content. * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -10503,23 +10554,20 @@ export const ArticlesApiFp = function (configuration?: Configuration) { const localVarAxiosParamCreator = ArticlesApiAxiosParamCreator(configuration) return { /** - * Create a new article + * Create a new content item * @summary Create - * @param {RichTextArticleRequest} RichTextArticleRequest + * @param {WebsiteContentRequest} WebsiteContentRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ async articlesCreate( - RichTextArticleRequest: RichTextArticleRequest, + WebsiteContentRequest: WebsiteContentRequest, options?: RawAxiosRequestConfig, ): Promise< - ( - axios?: AxiosInstance, - basePath?: string, - ) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.articlesCreate( - RichTextArticleRequest, + WebsiteContentRequest, options, ) const index = configuration?.serverIndex ?? 0 @@ -10534,9 +10582,9 @@ export const ArticlesApiFp = function (configuration?: Configuration) { )(axios, operationBasePath || basePath) }, /** - * Delete an article + * Delete a content item * @summary Destroy - * @param {number} id A unique integer value identifying this article. + * @param {number} id A unique integer value identifying this website content. * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -10562,9 +10610,9 @@ export const ArticlesApiFp = function (configuration?: Configuration) { )(axios, operationBasePath || basePath) }, /** - * If the path parameter is numeric → ID, else → slug. - * @summary Retrieve article by ID or slug - * @param {string} identifier Article ID (number) or slug (string) + * Retrieve a content item by numeric ID or slug + * @summary Retrieve by ID or slug + * @param {string} identifier Numeric ID or slug of the content item * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -10572,10 +10620,7 @@ export const ArticlesApiFp = function (configuration?: Configuration) { identifier: string, options?: RawAxiosRequestConfig, ): Promise< - ( - axios?: AxiosInstance, - basePath?: string, - ) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.articlesDetailRetrieve( @@ -10594,15 +10639,17 @@ export const ArticlesApiFp = function (configuration?: Configuration) { )(axios, operationBasePath || basePath) }, /** - * Get a paginated list of articles + * Get a paginated list of website content items * @summary List - * @param {boolean} [draft] Filter to show only draft articles. Only available for admins and article editors. If true, returns unpublished articles. If not specified, returns all articles. + * @param {string} [content_type] Filter by content type (e.g. \'news\' or \'article\'). + * @param {boolean} [draft] Filter to show only draft items. Only available for admins and content editors. If true, returns unpublished items. If not specified, returns all items. * @param {number} [limit] Number of results to return per page. * @param {number} [offset] The initial index from which to return the results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ async articlesList( + content_type?: string, draft?: boolean, limit?: number, offset?: number, @@ -10611,9 +10658,10 @@ export const ArticlesApiFp = function (configuration?: Configuration) { ( axios?: AxiosInstance, basePath?: string, - ) => AxiosPromise + ) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.articlesList( + content_type, draft, limit, offset, @@ -10631,27 +10679,24 @@ export const ArticlesApiFp = function (configuration?: Configuration) { )(axios, operationBasePath || basePath) }, /** - * Update an article + * Update a content item * @summary Update - * @param {number} id A unique integer value identifying this article. - * @param {PatchedRichTextArticleRequest} [PatchedRichTextArticleRequest] + * @param {number} id A unique integer value identifying this website content. + * @param {PatchedWebsiteContentRequest} [PatchedWebsiteContentRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ async articlesPartialUpdate( id: number, - PatchedRichTextArticleRequest?: PatchedRichTextArticleRequest, + PatchedWebsiteContentRequest?: PatchedWebsiteContentRequest, options?: RawAxiosRequestConfig, ): Promise< - ( - axios?: AxiosInstance, - basePath?: string, - ) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.articlesPartialUpdate( id, - PatchedRichTextArticleRequest, + PatchedWebsiteContentRequest, options, ) const index = configuration?.serverIndex ?? 0 @@ -10666,9 +10711,9 @@ export const ArticlesApiFp = function (configuration?: Configuration) { )(axios, operationBasePath || basePath) }, /** - * Retrieve a single article + * Retrieve a single content item * @summary Retrieve - * @param {number} id A unique integer value identifying this article. + * @param {number} id A unique integer value identifying this website content. * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -10676,10 +10721,7 @@ export const ArticlesApiFp = function (configuration?: Configuration) { id: number, options?: RawAxiosRequestConfig, ): Promise< - ( - axios?: AxiosInstance, - basePath?: string, - ) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.articlesRetrieve(id, options) @@ -10709,7 +10751,7 @@ export const ArticlesApiFactory = function ( const localVarFp = ArticlesApiFp(configuration) return { /** - * Create a new article + * Create a new content item * @summary Create * @param {ArticlesApiArticlesCreateRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -10718,13 +10760,13 @@ export const ArticlesApiFactory = function ( articlesCreate( requestParameters: ArticlesApiArticlesCreateRequest, options?: RawAxiosRequestConfig, - ): AxiosPromise { + ): AxiosPromise { return localVarFp - .articlesCreate(requestParameters.RichTextArticleRequest, options) + .articlesCreate(requestParameters.WebsiteContentRequest, options) .then((request) => request(axios, basePath)) }, /** - * Delete an article + * Delete a content item * @summary Destroy * @param {ArticlesApiArticlesDestroyRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -10739,8 +10781,8 @@ export const ArticlesApiFactory = function ( .then((request) => request(axios, basePath)) }, /** - * If the path parameter is numeric → ID, else → slug. - * @summary Retrieve article by ID or slug + * Retrieve a content item by numeric ID or slug + * @summary Retrieve by ID or slug * @param {ArticlesApiArticlesDetailRetrieveRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -10748,13 +10790,13 @@ export const ArticlesApiFactory = function ( articlesDetailRetrieve( requestParameters: ArticlesApiArticlesDetailRetrieveRequest, options?: RawAxiosRequestConfig, - ): AxiosPromise { + ): AxiosPromise { return localVarFp .articlesDetailRetrieve(requestParameters.identifier, options) .then((request) => request(axios, basePath)) }, /** - * Get a paginated list of articles + * Get a paginated list of website content items * @summary List * @param {ArticlesApiArticlesListRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -10763,9 +10805,10 @@ export const ArticlesApiFactory = function ( articlesList( requestParameters: ArticlesApiArticlesListRequest = {}, options?: RawAxiosRequestConfig, - ): AxiosPromise { + ): AxiosPromise { return localVarFp .articlesList( + requestParameters.content_type, requestParameters.draft, requestParameters.limit, requestParameters.offset, @@ -10774,7 +10817,7 @@ export const ArticlesApiFactory = function ( .then((request) => request(axios, basePath)) }, /** - * Update an article + * Update a content item * @summary Update * @param {ArticlesApiArticlesPartialUpdateRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -10783,17 +10826,17 @@ export const ArticlesApiFactory = function ( articlesPartialUpdate( requestParameters: ArticlesApiArticlesPartialUpdateRequest, options?: RawAxiosRequestConfig, - ): AxiosPromise { + ): AxiosPromise { return localVarFp .articlesPartialUpdate( requestParameters.id, - requestParameters.PatchedRichTextArticleRequest, + requestParameters.PatchedWebsiteContentRequest, options, ) .then((request) => request(axios, basePath)) }, /** - * Retrieve a single article + * Retrieve a single content item * @summary Retrieve * @param {ArticlesApiArticlesRetrieveRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -10802,7 +10845,7 @@ export const ArticlesApiFactory = function ( articlesRetrieve( requestParameters: ArticlesApiArticlesRetrieveRequest, options?: RawAxiosRequestConfig, - ): AxiosPromise { + ): AxiosPromise { return localVarFp .articlesRetrieve(requestParameters.id, options) .then((request) => request(axios, basePath)) @@ -10818,10 +10861,10 @@ export const ArticlesApiFactory = function ( export interface ArticlesApiArticlesCreateRequest { /** * - * @type {RichTextArticleRequest} + * @type {WebsiteContentRequest} * @memberof ArticlesApiArticlesCreate */ - readonly RichTextArticleRequest: RichTextArticleRequest + readonly WebsiteContentRequest: WebsiteContentRequest } /** @@ -10831,7 +10874,7 @@ export interface ArticlesApiArticlesCreateRequest { */ export interface ArticlesApiArticlesDestroyRequest { /** - * A unique integer value identifying this article. + * A unique integer value identifying this website content. * @type {number} * @memberof ArticlesApiArticlesDestroy */ @@ -10845,7 +10888,7 @@ export interface ArticlesApiArticlesDestroyRequest { */ export interface ArticlesApiArticlesDetailRetrieveRequest { /** - * Article ID (number) or slug (string) + * Numeric ID or slug of the content item * @type {string} * @memberof ArticlesApiArticlesDetailRetrieve */ @@ -10859,7 +10902,14 @@ export interface ArticlesApiArticlesDetailRetrieveRequest { */ export interface ArticlesApiArticlesListRequest { /** - * Filter to show only draft articles. Only available for admins and article editors. If true, returns unpublished articles. If not specified, returns all articles. + * Filter by content type (e.g. \'news\' or \'article\'). + * @type {string} + * @memberof ArticlesApiArticlesList + */ + readonly content_type?: string + + /** + * Filter to show only draft items. Only available for admins and content editors. If true, returns unpublished items. If not specified, returns all items. * @type {boolean} * @memberof ArticlesApiArticlesList */ @@ -10887,7 +10937,7 @@ export interface ArticlesApiArticlesListRequest { */ export interface ArticlesApiArticlesPartialUpdateRequest { /** - * A unique integer value identifying this article. + * A unique integer value identifying this website content. * @type {number} * @memberof ArticlesApiArticlesPartialUpdate */ @@ -10895,10 +10945,10 @@ export interface ArticlesApiArticlesPartialUpdateRequest { /** * - * @type {PatchedRichTextArticleRequest} + * @type {PatchedWebsiteContentRequest} * @memberof ArticlesApiArticlesPartialUpdate */ - readonly PatchedRichTextArticleRequest?: PatchedRichTextArticleRequest + readonly PatchedWebsiteContentRequest?: PatchedWebsiteContentRequest } /** @@ -10908,7 +10958,7 @@ export interface ArticlesApiArticlesPartialUpdateRequest { */ export interface ArticlesApiArticlesRetrieveRequest { /** - * A unique integer value identifying this article. + * A unique integer value identifying this website content. * @type {number} * @memberof ArticlesApiArticlesRetrieve */ @@ -10923,7 +10973,7 @@ export interface ArticlesApiArticlesRetrieveRequest { */ export class ArticlesApi extends BaseAPI { /** - * Create a new article + * Create a new content item * @summary Create * @param {ArticlesApiArticlesCreateRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -10935,12 +10985,12 @@ export class ArticlesApi extends BaseAPI { options?: RawAxiosRequestConfig, ) { return ArticlesApiFp(this.configuration) - .articlesCreate(requestParameters.RichTextArticleRequest, options) + .articlesCreate(requestParameters.WebsiteContentRequest, options) .then((request) => request(this.axios, this.basePath)) } /** - * Delete an article + * Delete a content item * @summary Destroy * @param {ArticlesApiArticlesDestroyRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -10957,8 +11007,8 @@ export class ArticlesApi extends BaseAPI { } /** - * If the path parameter is numeric → ID, else → slug. - * @summary Retrieve article by ID or slug + * Retrieve a content item by numeric ID or slug + * @summary Retrieve by ID or slug * @param {ArticlesApiArticlesDetailRetrieveRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -10974,7 +11024,7 @@ export class ArticlesApi extends BaseAPI { } /** - * Get a paginated list of articles + * Get a paginated list of website content items * @summary List * @param {ArticlesApiArticlesListRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -10987,6 +11037,7 @@ export class ArticlesApi extends BaseAPI { ) { return ArticlesApiFp(this.configuration) .articlesList( + requestParameters.content_type, requestParameters.draft, requestParameters.limit, requestParameters.offset, @@ -10996,7 +11047,7 @@ export class ArticlesApi extends BaseAPI { } /** - * Update an article + * Update a content item * @summary Update * @param {ArticlesApiArticlesPartialUpdateRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -11010,14 +11061,14 @@ export class ArticlesApi extends BaseAPI { return ArticlesApiFp(this.configuration) .articlesPartialUpdate( requestParameters.id, - requestParameters.PatchedRichTextArticleRequest, + requestParameters.PatchedWebsiteContentRequest, options, ) .then((request) => request(this.axios, this.basePath)) } /** - * Retrieve a single article + * Retrieve a single content item * @summary Retrieve * @param {ArticlesApiArticlesRetrieveRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. @@ -33795,3 +33846,876 @@ export const WebhooksContentFilesCreateSourceEnum = { } as const export type WebhooksContentFilesCreateSourceEnum = (typeof WebhooksContentFilesCreateSourceEnum)[keyof typeof WebhooksContentFilesCreateSourceEnum] + +/** + * WebsiteContentApi - axios parameter creator + * @export + */ +export const WebsiteContentApiAxiosParamCreator = function ( + configuration?: Configuration, +) { + return { + /** + * Create a new content item + * @summary Create + * @param {WebsiteContentRequest} WebsiteContentRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentCreate: async ( + WebsiteContentRequest: WebsiteContentRequest, + options: RawAxiosRequestConfig = {}, + ): Promise => { + // verify required parameter 'WebsiteContentRequest' is not null or undefined + assertParamExists( + "websiteContentCreate", + "WebsiteContentRequest", + WebsiteContentRequest, + ) + const localVarPath = `/api/v1/website_content/` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "POST", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + localVarHeaderParameter["Content-Type"] = "application/json" + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + localVarRequestOptions.data = serializeDataIfNeeded( + WebsiteContentRequest, + localVarRequestOptions, + configuration, + ) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * Delete a content item + * @summary Destroy + * @param {number} id A unique integer value identifying this website content. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentDestroy: async ( + id: number, + options: RawAxiosRequestConfig = {}, + ): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists("websiteContentDestroy", "id", id) + const localVarPath = `/api/v1/website_content/{id}/`.replace( + `{${"id"}}`, + encodeURIComponent(String(id)), + ) + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "DELETE", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * Retrieve a content item by numeric ID or slug + * @summary Retrieve by ID or slug + * @param {string} identifier Numeric ID or slug of the content item + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentDetailRetrieve: async ( + identifier: string, + options: RawAxiosRequestConfig = {}, + ): Promise => { + // verify required parameter 'identifier' is not null or undefined + assertParamExists( + "websiteContentDetailRetrieve", + "identifier", + identifier, + ) + const localVarPath = + `/api/v1/website_content/detail/{identifier}/`.replace( + `{${"identifier"}}`, + encodeURIComponent(String(identifier)), + ) + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * Get a paginated list of website content items + * @summary List + * @param {string} [content_type] Filter by content type (e.g. \'news\' or \'article\'). + * @param {boolean} [draft] Filter to show only draft items. Only available for admins and content editors. If true, returns unpublished items. If not specified, returns all items. + * @param {number} [limit] Number of results to return per page. + * @param {number} [offset] The initial index from which to return the results. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentList: async ( + content_type?: string, + draft?: boolean, + limit?: number, + offset?: number, + options: RawAxiosRequestConfig = {}, + ): Promise => { + const localVarPath = `/api/v1/website_content/` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + if (content_type !== undefined) { + localVarQueryParameter["content_type"] = content_type + } + + if (draft !== undefined) { + localVarQueryParameter["draft"] = draft + } + + if (limit !== undefined) { + localVarQueryParameter["limit"] = limit + } + + if (offset !== undefined) { + localVarQueryParameter["offset"] = offset + } + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * Update a content item + * @summary Update + * @param {number} id A unique integer value identifying this website content. + * @param {PatchedWebsiteContentRequest} [PatchedWebsiteContentRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentPartialUpdate: async ( + id: number, + PatchedWebsiteContentRequest?: PatchedWebsiteContentRequest, + options: RawAxiosRequestConfig = {}, + ): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists("websiteContentPartialUpdate", "id", id) + const localVarPath = `/api/v1/website_content/{id}/`.replace( + `{${"id"}}`, + encodeURIComponent(String(id)), + ) + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "PATCH", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + localVarHeaderParameter["Content-Type"] = "application/json" + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + localVarRequestOptions.data = serializeDataIfNeeded( + PatchedWebsiteContentRequest, + localVarRequestOptions, + configuration, + ) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * Retrieve a single content item + * @summary Retrieve + * @param {number} id A unique integer value identifying this website content. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentRetrieve: async ( + id: number, + options: RawAxiosRequestConfig = {}, + ): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists("websiteContentRetrieve", "id", id) + const localVarPath = `/api/v1/website_content/{id}/`.replace( + `{${"id"}}`, + encodeURIComponent(String(id)), + ) + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + } +} + +/** + * WebsiteContentApi - functional programming interface + * @export + */ +export const WebsiteContentApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = + WebsiteContentApiAxiosParamCreator(configuration) + return { + /** + * Create a new content item + * @summary Create + * @param {WebsiteContentRequest} WebsiteContentRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async websiteContentCreate( + WebsiteContentRequest: WebsiteContentRequest, + options?: RawAxiosRequestConfig, + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.websiteContentCreate( + WebsiteContentRequest, + options, + ) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["WebsiteContentApi.websiteContentCreate"]?.[index] + ?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, + /** + * Delete a content item + * @summary Destroy + * @param {number} id A unique integer value identifying this website content. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async websiteContentDestroy( + id: number, + options?: RawAxiosRequestConfig, + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.websiteContentDestroy(id, options) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["WebsiteContentApi.websiteContentDestroy"]?.[index] + ?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, + /** + * Retrieve a content item by numeric ID or slug + * @summary Retrieve by ID or slug + * @param {string} identifier Numeric ID or slug of the content item + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async websiteContentDetailRetrieve( + identifier: string, + options?: RawAxiosRequestConfig, + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.websiteContentDetailRetrieve( + identifier, + options, + ) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["WebsiteContentApi.websiteContentDetailRetrieve"]?.[ + index + ]?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, + /** + * Get a paginated list of website content items + * @summary List + * @param {string} [content_type] Filter by content type (e.g. \'news\' or \'article\'). + * @param {boolean} [draft] Filter to show only draft items. Only available for admins and content editors. If true, returns unpublished items. If not specified, returns all items. + * @param {number} [limit] Number of results to return per page. + * @param {number} [offset] The initial index from which to return the results. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async websiteContentList( + content_type?: string, + draft?: boolean, + limit?: number, + offset?: number, + options?: RawAxiosRequestConfig, + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string, + ) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.websiteContentList( + content_type, + draft, + limit, + offset, + options, + ) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["WebsiteContentApi.websiteContentList"]?.[index]?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, + /** + * Update a content item + * @summary Update + * @param {number} id A unique integer value identifying this website content. + * @param {PatchedWebsiteContentRequest} [PatchedWebsiteContentRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async websiteContentPartialUpdate( + id: number, + PatchedWebsiteContentRequest?: PatchedWebsiteContentRequest, + options?: RawAxiosRequestConfig, + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.websiteContentPartialUpdate( + id, + PatchedWebsiteContentRequest, + options, + ) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["WebsiteContentApi.websiteContentPartialUpdate"]?.[ + index + ]?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, + /** + * Retrieve a single content item + * @summary Retrieve + * @param {number} id A unique integer value identifying this website content. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async websiteContentRetrieve( + id: number, + options?: RawAxiosRequestConfig, + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.websiteContentRetrieve(id, options) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["WebsiteContentApi.websiteContentRetrieve"]?.[index] + ?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, + } +} + +/** + * WebsiteContentApi - factory interface + * @export + */ +export const WebsiteContentApiFactory = function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance, +) { + const localVarFp = WebsiteContentApiFp(configuration) + return { + /** + * Create a new content item + * @summary Create + * @param {WebsiteContentApiWebsiteContentCreateRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentCreate( + requestParameters: WebsiteContentApiWebsiteContentCreateRequest, + options?: RawAxiosRequestConfig, + ): AxiosPromise { + return localVarFp + .websiteContentCreate(requestParameters.WebsiteContentRequest, options) + .then((request) => request(axios, basePath)) + }, + /** + * Delete a content item + * @summary Destroy + * @param {WebsiteContentApiWebsiteContentDestroyRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentDestroy( + requestParameters: WebsiteContentApiWebsiteContentDestroyRequest, + options?: RawAxiosRequestConfig, + ): AxiosPromise { + return localVarFp + .websiteContentDestroy(requestParameters.id, options) + .then((request) => request(axios, basePath)) + }, + /** + * Retrieve a content item by numeric ID or slug + * @summary Retrieve by ID or slug + * @param {WebsiteContentApiWebsiteContentDetailRetrieveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentDetailRetrieve( + requestParameters: WebsiteContentApiWebsiteContentDetailRetrieveRequest, + options?: RawAxiosRequestConfig, + ): AxiosPromise { + return localVarFp + .websiteContentDetailRetrieve(requestParameters.identifier, options) + .then((request) => request(axios, basePath)) + }, + /** + * Get a paginated list of website content items + * @summary List + * @param {WebsiteContentApiWebsiteContentListRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentList( + requestParameters: WebsiteContentApiWebsiteContentListRequest = {}, + options?: RawAxiosRequestConfig, + ): AxiosPromise { + return localVarFp + .websiteContentList( + requestParameters.content_type, + requestParameters.draft, + requestParameters.limit, + requestParameters.offset, + options, + ) + .then((request) => request(axios, basePath)) + }, + /** + * Update a content item + * @summary Update + * @param {WebsiteContentApiWebsiteContentPartialUpdateRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentPartialUpdate( + requestParameters: WebsiteContentApiWebsiteContentPartialUpdateRequest, + options?: RawAxiosRequestConfig, + ): AxiosPromise { + return localVarFp + .websiteContentPartialUpdate( + requestParameters.id, + requestParameters.PatchedWebsiteContentRequest, + options, + ) + .then((request) => request(axios, basePath)) + }, + /** + * Retrieve a single content item + * @summary Retrieve + * @param {WebsiteContentApiWebsiteContentRetrieveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + websiteContentRetrieve( + requestParameters: WebsiteContentApiWebsiteContentRetrieveRequest, + options?: RawAxiosRequestConfig, + ): AxiosPromise { + return localVarFp + .websiteContentRetrieve(requestParameters.id, options) + .then((request) => request(axios, basePath)) + }, + } +} + +/** + * Request parameters for websiteContentCreate operation in WebsiteContentApi. + * @export + * @interface WebsiteContentApiWebsiteContentCreateRequest + */ +export interface WebsiteContentApiWebsiteContentCreateRequest { + /** + * + * @type {WebsiteContentRequest} + * @memberof WebsiteContentApiWebsiteContentCreate + */ + readonly WebsiteContentRequest: WebsiteContentRequest +} + +/** + * Request parameters for websiteContentDestroy operation in WebsiteContentApi. + * @export + * @interface WebsiteContentApiWebsiteContentDestroyRequest + */ +export interface WebsiteContentApiWebsiteContentDestroyRequest { + /** + * A unique integer value identifying this website content. + * @type {number} + * @memberof WebsiteContentApiWebsiteContentDestroy + */ + readonly id: number +} + +/** + * Request parameters for websiteContentDetailRetrieve operation in WebsiteContentApi. + * @export + * @interface WebsiteContentApiWebsiteContentDetailRetrieveRequest + */ +export interface WebsiteContentApiWebsiteContentDetailRetrieveRequest { + /** + * Numeric ID or slug of the content item + * @type {string} + * @memberof WebsiteContentApiWebsiteContentDetailRetrieve + */ + readonly identifier: string +} + +/** + * Request parameters for websiteContentList operation in WebsiteContentApi. + * @export + * @interface WebsiteContentApiWebsiteContentListRequest + */ +export interface WebsiteContentApiWebsiteContentListRequest { + /** + * Filter by content type (e.g. \'news\' or \'article\'). + * @type {string} + * @memberof WebsiteContentApiWebsiteContentList + */ + readonly content_type?: string + + /** + * Filter to show only draft items. Only available for admins and content editors. If true, returns unpublished items. If not specified, returns all items. + * @type {boolean} + * @memberof WebsiteContentApiWebsiteContentList + */ + readonly draft?: boolean + + /** + * Number of results to return per page. + * @type {number} + * @memberof WebsiteContentApiWebsiteContentList + */ + readonly limit?: number + + /** + * The initial index from which to return the results. + * @type {number} + * @memberof WebsiteContentApiWebsiteContentList + */ + readonly offset?: number +} + +/** + * Request parameters for websiteContentPartialUpdate operation in WebsiteContentApi. + * @export + * @interface WebsiteContentApiWebsiteContentPartialUpdateRequest + */ +export interface WebsiteContentApiWebsiteContentPartialUpdateRequest { + /** + * A unique integer value identifying this website content. + * @type {number} + * @memberof WebsiteContentApiWebsiteContentPartialUpdate + */ + readonly id: number + + /** + * + * @type {PatchedWebsiteContentRequest} + * @memberof WebsiteContentApiWebsiteContentPartialUpdate + */ + readonly PatchedWebsiteContentRequest?: PatchedWebsiteContentRequest +} + +/** + * Request parameters for websiteContentRetrieve operation in WebsiteContentApi. + * @export + * @interface WebsiteContentApiWebsiteContentRetrieveRequest + */ +export interface WebsiteContentApiWebsiteContentRetrieveRequest { + /** + * A unique integer value identifying this website content. + * @type {number} + * @memberof WebsiteContentApiWebsiteContentRetrieve + */ + readonly id: number +} + +/** + * WebsiteContentApi - object-oriented interface + * @export + * @class WebsiteContentApi + * @extends {BaseAPI} + */ +export class WebsiteContentApi extends BaseAPI { + /** + * Create a new content item + * @summary Create + * @param {WebsiteContentApiWebsiteContentCreateRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof WebsiteContentApi + */ + public websiteContentCreate( + requestParameters: WebsiteContentApiWebsiteContentCreateRequest, + options?: RawAxiosRequestConfig, + ) { + return WebsiteContentApiFp(this.configuration) + .websiteContentCreate(requestParameters.WebsiteContentRequest, options) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * Delete a content item + * @summary Destroy + * @param {WebsiteContentApiWebsiteContentDestroyRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof WebsiteContentApi + */ + public websiteContentDestroy( + requestParameters: WebsiteContentApiWebsiteContentDestroyRequest, + options?: RawAxiosRequestConfig, + ) { + return WebsiteContentApiFp(this.configuration) + .websiteContentDestroy(requestParameters.id, options) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * Retrieve a content item by numeric ID or slug + * @summary Retrieve by ID or slug + * @param {WebsiteContentApiWebsiteContentDetailRetrieveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof WebsiteContentApi + */ + public websiteContentDetailRetrieve( + requestParameters: WebsiteContentApiWebsiteContentDetailRetrieveRequest, + options?: RawAxiosRequestConfig, + ) { + return WebsiteContentApiFp(this.configuration) + .websiteContentDetailRetrieve(requestParameters.identifier, options) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * Get a paginated list of website content items + * @summary List + * @param {WebsiteContentApiWebsiteContentListRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof WebsiteContentApi + */ + public websiteContentList( + requestParameters: WebsiteContentApiWebsiteContentListRequest = {}, + options?: RawAxiosRequestConfig, + ) { + return WebsiteContentApiFp(this.configuration) + .websiteContentList( + requestParameters.content_type, + requestParameters.draft, + requestParameters.limit, + requestParameters.offset, + options, + ) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * Update a content item + * @summary Update + * @param {WebsiteContentApiWebsiteContentPartialUpdateRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof WebsiteContentApi + */ + public websiteContentPartialUpdate( + requestParameters: WebsiteContentApiWebsiteContentPartialUpdateRequest, + options?: RawAxiosRequestConfig, + ) { + return WebsiteContentApiFp(this.configuration) + .websiteContentPartialUpdate( + requestParameters.id, + requestParameters.PatchedWebsiteContentRequest, + options, + ) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * Retrieve a single content item + * @summary Retrieve + * @param {WebsiteContentApiWebsiteContentRetrieveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof WebsiteContentApi + */ + public websiteContentRetrieve( + requestParameters: WebsiteContentApiWebsiteContentRetrieveRequest, + options?: RawAxiosRequestConfig, + ) { + return WebsiteContentApiFp(this.configuration) + .websiteContentRetrieve(requestParameters.id, options) + .then((request) => request(this.axios, this.basePath)) + } +} diff --git a/frontends/api/src/hooks/articles/index.ts b/frontends/api/src/hooks/articles/index.ts index 73b45c037d..65b92ea258 100644 --- a/frontends/api/src/hooks/articles/index.ts +++ b/frontends/api/src/hooks/articles/index.ts @@ -2,10 +2,10 @@ import { useRef } from "react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import type { AxiosProgressEvent } from "axios" -import { articlesApi, mediaApi } from "../../clients" +import { websiteContentApi, mediaApi } from "../../clients" import type { - ArticlesApiArticlesListRequest as ArticleListRequest, - RichTextArticle as Article, + WebsiteContentApiWebsiteContentListRequest as ArticleListRequest, + WebsiteContent as Article, } from "../../generated/v1" import { articleQueries, articleKeys } from "./queries" @@ -45,8 +45,8 @@ const useArticleCreate = () => { "id" | "user" | "created_on" | "updated_on" | "publish_date" >, ) => - articlesApi - .articlesCreate({ RichTextArticleRequest: data }) + websiteContentApi + .websiteContentCreate({ WebsiteContentRequest: data }) .then((response) => response.data), onSuccess: () => { client.invalidateQueries({ queryKey: articleKeys.listRoot() }) @@ -100,7 +100,7 @@ export const useMediaUpload = () => { const useArticleDestroy = () => { const client = useQueryClient() return useMutation({ - mutationFn: (id: number) => articlesApi.articlesDestroy({ id }), + mutationFn: (id: number) => websiteContentApi.websiteContentDestroy({ id }), onSuccess: () => { client.invalidateQueries({ queryKey: articleKeys.listRoot() }) }, @@ -110,10 +110,10 @@ const useArticlePartialUpdate = () => { const client = useQueryClient() return useMutation({ mutationFn: ({ id, ...data }: Partial
& Pick) => - articlesApi - .articlesPartialUpdate({ + websiteContentApi + .websiteContentPartialUpdate({ id, - PatchedRichTextArticleRequest: data, + PatchedWebsiteContentRequest: data, }) .then((response) => response.data), onSuccess: (article: Article) => { diff --git a/frontends/api/src/hooks/articles/queries.ts b/frontends/api/src/hooks/articles/queries.ts index 4261642631..38d7193479 100644 --- a/frontends/api/src/hooks/articles/queries.ts +++ b/frontends/api/src/hooks/articles/queries.ts @@ -1,6 +1,6 @@ import { queryOptions } from "@tanstack/react-query" -import { articlesApi } from "../../clients" -import type { ArticlesApiArticlesListRequest as ArticleListRequest } from "../../generated/v1" +import { websiteContentApi } from "../../clients" +import type { WebsiteContentApiWebsiteContentListRequest as ArticleListRequest } from "../../generated/v1" const articleKeys = { root: ["articles"], @@ -18,20 +18,23 @@ const articleQueries = { list: (params: ArticleListRequest) => queryOptions({ queryKey: articleKeys.list(params), - queryFn: () => articlesApi.articlesList(params).then((res) => res.data), + queryFn: () => + websiteContentApi.websiteContentList(params).then((res) => res.data), }), detail: (id: number) => queryOptions({ queryKey: articleKeys.detail(id), queryFn: () => - articlesApi.articlesRetrieve({ id }).then((res) => res.data), + websiteContentApi + .websiteContentRetrieve({ id }) + .then((res) => res.data), }), articlesDetailRetrieve: (identifier: string) => queryOptions({ queryKey: articleKeys.articlesDetailRetrieve(identifier), queryFn: () => - articlesApi - .articlesDetailRetrieve({ identifier }) + websiteContentApi + .websiteContentDetailRetrieve({ identifier }) .then((res) => res.data), }), } diff --git a/frontends/api/src/test-utils/factories/articles.ts b/frontends/api/src/test-utils/factories/articles.ts index 9c762f3252..2583690c91 100644 --- a/frontends/api/src/test-utils/factories/articles.ts +++ b/frontends/api/src/test-utils/factories/articles.ts @@ -1,9 +1,9 @@ import { faker } from "@faker-js/faker/locale/en" import { makePaginatedFactory } from "ol-test-utilities" import type { Factory } from "ol-test-utilities" -import type { RichTextArticle } from "../../generated/v1" +import type { WebsiteContent } from "../../generated/v1" -const article: Factory = (overrides = {}) => ({ +const article: Factory = (overrides = {}) => ({ id: faker.number.int(), title: faker.lorem.sentence(), content: { diff --git a/frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx b/frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx index 5411b021ea..82ed4141f3 100644 --- a/frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx @@ -14,7 +14,7 @@ import { } from "ol-components" import { Permission } from "api/hooks/user" import { useArticleList } from "api/hooks/articles" -import type { RichTextArticle } from "api/v1" +import type { WebsiteContent } from "api/v1" import { LocalDate } from "ol-utilities" import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" import { ArticleBanner, DEFAULT_BACKGROUND_IMAGE_URL } from "./ArticleBanner" @@ -66,7 +66,7 @@ const DraftBadge = styled.span` font-weight: ${theme.typography.fontWeightMedium}; ` -export const DraftArticle: React.FC<{ article: RichTextArticle }> = ({ +export const DraftArticle: React.FC<{ article: WebsiteContent }> = ({ article, }) => { const articleUrl = article.is_published diff --git a/frontends/main/src/app/news/[slugOrId]/page.tsx b/frontends/main/src/app/news/[slugOrId]/page.tsx index f3cac4795b..460f3ea80f 100644 --- a/frontends/main/src/app/news/[slugOrId]/page.tsx +++ b/frontends/main/src/app/news/[slugOrId]/page.tsx @@ -6,12 +6,12 @@ import { getQueryClient } from "@/app/getQueryClient" import { learningResourceQueries } from "api/hooks/learningResources" import { extractLearningResourceIds } from "@/page-components/TiptapEditor/extensions/utils" import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata" -import type { RichTextArticle } from "api/v1" +import type { WebsiteContent } from "api/v1" import type { JSONContent } from "@tiptap/react" // Extracts the banner subheading paragraph at known location const extractArticleDescription = ( - article: RichTextArticle, + article: WebsiteContent, ): string | undefined => { const banner = article.content?.content?.[0] const subheading = banner?.content?.[1] @@ -20,7 +20,7 @@ const extractArticleDescription = ( } const extractImageMetadata = ( - article: RichTextArticle, + article: WebsiteContent, ): { src: string; alt: string } | null => { const imageWithCaption = article.content?.content?.find( (node: JSONContent) => node.type === "imageWithCaption", diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx index 343233fb08..2dc29b1b72 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx +++ b/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx @@ -1,8 +1,8 @@ import { createContext, useContext } from "react" -import type { RichTextArticle } from "api/v1" +import type { WebsiteContent } from "api/v1" interface ArticleContextValue { - article?: RichTextArticle + article?: WebsiteContent } const ArticleContext = createContext({}) diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx index 6c59c6bfb5..ec580b7aa5 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx @@ -3,7 +3,7 @@ import React, { ChangeEventHandler, useState, useEffect } from "react" import styled from "@emotion/styled" import { EditorContext, JSONContent, useEditor } from "@tiptap/react" -import type { RichTextArticle } from "api/v1" +import type { WebsiteContent } from "api/v1" import { LoadingSpinner, Typography, @@ -77,11 +77,11 @@ const StyledAlert = styled(Alert)({ interface ArticleEditorProps { value?: object - onSave?: (article: RichTextArticle) => void + onSave?: (article: WebsiteContent) => void readOnly?: boolean title?: string setTitle?: ChangeEventHandler - article?: RichTextArticle + article?: WebsiteContent } const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { const [title, setTitle] = React.useState(article?.title) diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index b9f6be1a18..c14cdbe1ff 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -7,16 +7,21 @@ paths: /api/v1/articles/: get: operationId: articles_list - description: Get a paginated list of articles + description: Get a paginated list of website content items summary: List parameters: + - in: query + name: content_type + schema: + type: string + description: Filter by content type (e.g. 'news' or 'article'). - in: query name: draft schema: type: boolean - description: Filter to show only draft articles. Only available for admins - and article editors. If true, returns unpublished articles. If not specified, - returns all articles. + description: Filter to show only draft items. Only available for admins and + content editors. If true, returns unpublished items. If not specified, returns + all items. - name: limit required: false in: query @@ -36,11 +41,11 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PaginatedRichTextArticleList' + $ref: '#/components/schemas/PaginatedWebsiteContentList' description: '' post: operationId: articles_create - description: Create a new article + description: Create a new content item summary: Create tags: - articles @@ -48,32 +53,32 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/RichTextArticleRequest' + $ref: '#/components/schemas/WebsiteContentRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/RichTextArticleRequest' + $ref: '#/components/schemas/WebsiteContentRequest' multipart/form-data: schema: - $ref: '#/components/schemas/RichTextArticleRequest' + $ref: '#/components/schemas/WebsiteContentRequest' required: true responses: '201': content: application/json: schema: - $ref: '#/components/schemas/RichTextArticle' + $ref: '#/components/schemas/WebsiteContent' description: '' /api/v1/articles/{id}/: get: operationId: articles_retrieve - description: Retrieve a single article + description: Retrieve a single content item summary: Retrieve parameters: - in: path name: id schema: type: integer - description: A unique integer value identifying this article. + description: A unique integer value identifying this website content. required: true tags: - articles @@ -82,18 +87,18 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/RichTextArticle' + $ref: '#/components/schemas/WebsiteContent' description: '' patch: operationId: articles_partial_update - description: Update an article + description: Update a content item summary: Update parameters: - in: path name: id schema: type: integer - description: A unique integer value identifying this article. + description: A unique integer value identifying this website content. required: true tags: - articles @@ -101,30 +106,30 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PatchedRichTextArticleRequest' + $ref: '#/components/schemas/PatchedWebsiteContentRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/PatchedRichTextArticleRequest' + $ref: '#/components/schemas/PatchedWebsiteContentRequest' multipart/form-data: schema: - $ref: '#/components/schemas/PatchedRichTextArticleRequest' + $ref: '#/components/schemas/PatchedWebsiteContentRequest' responses: '200': content: application/json: schema: - $ref: '#/components/schemas/RichTextArticle' + $ref: '#/components/schemas/WebsiteContent' description: '' delete: operationId: articles_destroy - description: Delete an article + description: Delete a content item summary: Destroy parameters: - in: path name: id schema: type: integer - description: A unique integer value identifying this article. + description: A unique integer value identifying this website content. required: true tags: - articles @@ -134,14 +139,14 @@ paths: /api/v1/articles/detail/{identifier}/: get: operationId: articles_detail_retrieve - description: If the path parameter is numeric → ID, else → slug. - summary: Retrieve article by ID or slug + description: Retrieve a content item by numeric ID or slug + summary: Retrieve by ID or slug parameters: - in: path name: identifier schema: type: string - description: Article ID (number) or slug (string) + description: Numeric ID or slug of the content item required: true tags: - articles @@ -150,10 +155,8 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/RichTextArticle' + $ref: '#/components/schemas/WebsiteContent' description: '' - '404': - description: No response body /api/v1/content_file_search/: get: operationId: content_file_search_retrieve @@ -8590,7 +8593,7 @@ paths: content: multipart/form-data: schema: - $ref: '#/components/schemas/ArticleImageUploadRequest' + $ref: '#/components/schemas/WebsiteContentImageUploadRequest' required: true responses: '201': @@ -9967,6 +9970,159 @@ paths: schema: $ref: '#/components/schemas/WebhookResponse' description: '' + /api/v1/website_content/: + get: + operationId: website_content_list + description: Get a paginated list of website content items + summary: List + parameters: + - in: query + name: content_type + schema: + type: string + description: Filter by content type (e.g. 'news' or 'article'). + - in: query + name: draft + schema: + type: boolean + description: Filter to show only draft items. Only available for admins and + content editors. If true, returns unpublished items. If not specified, returns + all items. + - name: limit + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: offset + required: false + in: query + description: The initial index from which to return the results. + schema: + type: integer + tags: + - website_content + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedWebsiteContentList' + description: '' + post: + operationId: website_content_create + description: Create a new content item + summary: Create + tags: + - website_content + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/WebsiteContentRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/WebsiteContentRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/WebsiteContentRequest' + required: true + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/WebsiteContent' + description: '' + /api/v1/website_content/{id}/: + get: + operationId: website_content_retrieve + description: Retrieve a single content item + summary: Retrieve + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this website content. + required: true + tags: + - website_content + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/WebsiteContent' + description: '' + patch: + operationId: website_content_partial_update + description: Update a content item + summary: Update + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this website content. + required: true + tags: + - website_content + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedWebsiteContentRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedWebsiteContentRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedWebsiteContentRequest' + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/WebsiteContent' + description: '' + delete: + operationId: website_content_destroy + description: Delete a content item + summary: Destroy + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this website content. + required: true + tags: + - website_content + responses: + '204': + description: No response body + /api/v1/website_content/detail/{identifier}/: + get: + operationId: website_content_detail_retrieve + description: Retrieve a content item by numeric ID or slug + summary: Retrieve by ID or slug + parameters: + - in: path + name: identifier + schema: + type: string + description: Numeric ID or slug of the content item + required: true + tags: + - website_content + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/WebsiteContent' + description: '' components: schemas: AggregationsEnum: @@ -10016,14 +10172,6 @@ components: - delivery - resource_type_group - resource_category - ArticleImageUploadRequest: - type: object - properties: - image_file: - type: string - format: binary - required: - - image_file AvailabilityEnum: enum: - dated @@ -10141,7 +10289,7 @@ components: items: type: string content_type: - $ref: '#/components/schemas/ContentTypeEnum' + $ref: '#/components/schemas/ContentFileContentTypeEnum' content: type: string nullable: true @@ -10218,6 +10366,23 @@ components: - resource_id - resource_readable_id - topics + ContentFileContentTypeEnum: + enum: + - page + - file + - video + - pdf + type: string + description: |- + * `page` - page + * `file` - file + * `video` - video + * `pdf` - pdf + x-enum-descriptions: + - page + - file + - video + - pdf ContentFileSearchResponse: type: object description: SearchResponseSerializer with OpenAPI annotations for Content Files @@ -10298,23 +10463,6 @@ components: type: string required: - source - ContentTypeEnum: - enum: - - page - - file - - video - - pdf - type: string - description: |- - * `page` - page - * `file` - file - * `video` - video - * `pdf` - pdf - x-enum-descriptions: - - page - - file - - video - - pdf Course: type: object description: Serializer for the Course model @@ -13939,7 +14087,7 @@ components: type: array items: $ref: '#/components/schemas/ProgramResource' - PaginatedRichTextArticleList: + PaginatedUserListList: type: object required: - count @@ -13961,8 +14109,8 @@ components: results: type: array items: - $ref: '#/components/schemas/RichTextArticle' - PaginatedUserListList: + $ref: '#/components/schemas/UserList' + PaginatedUserListRelationshipList: type: object required: - count @@ -13984,8 +14132,8 @@ components: results: type: array items: - $ref: '#/components/schemas/UserList' - PaginatedUserListRelationshipList: + $ref: '#/components/schemas/UserListRelationship' + PaginatedVideoPlaylistResourceList: type: object required: - count @@ -14007,8 +14155,8 @@ components: results: type: array items: - $ref: '#/components/schemas/UserListRelationship' - PaginatedVideoPlaylistResourceList: + $ref: '#/components/schemas/VideoPlaylistResource' + PaginatedVideoResourceList: type: object required: - count @@ -14030,8 +14178,8 @@ components: results: type: array items: - $ref: '#/components/schemas/VideoPlaylistResource' - PaginatedVideoResourceList: + $ref: '#/components/schemas/VideoResource' + PaginatedWebsiteContentList: type: object required: - count @@ -14053,7 +14201,7 @@ components: results: type: array items: - $ref: '#/components/schemas/VideoResource' + $ref: '#/components/schemas/WebsiteContent' PatchedLearningPathRelationshipCreateRequest: type: object description: |- @@ -14174,25 +14322,6 @@ components: type: integer child: type: integer - PatchedRichTextArticleRequest: - type: object - description: Serializer for LearningResourceInstructor model - properties: - title: - type: string - minLength: 1 - maxLength: 255 - author_name: - type: string - default: '' - content: - default: {} - is_published: - type: boolean - slug: - type: string - maxLength: 60 - pattern: ^[-a-zA-Z0-9_]+$ PatchedUserListRelationshipRequest: type: object description: Serializer for UserListRelationship model @@ -14221,6 +14350,29 @@ components: type: string privacy_level: $ref: '#/components/schemas/PrivacyLevelEnum' + PatchedWebsiteContentRequest: + type: object + description: Serializer for WebsiteContent model. + properties: + title: + type: string + minLength: 1 + maxLength: 255 + author_name: + type: string + default: '' + content: + default: {} + content_type: + allOf: + - $ref: '#/components/schemas/WebsiteContentContentTypeEnum' + default: news + is_published: + type: boolean + slug: + type: string + maxLength: 60 + pattern: ^[-a-zA-Z0-9_]+$ PercolateQuery: type: object description: Serializer for PercolateQuery objects @@ -16118,71 +16270,6 @@ components: - Course - Program - Learning Material - RichTextArticle: - type: object - description: Serializer for LearningResourceInstructor model - properties: - id: - type: integer - readOnly: true - title: - type: string - maxLength: 255 - author_name: - type: string - default: '' - content: - default: {} - user: - allOf: - - $ref: '#/components/schemas/User' - readOnly: true - created_on: - type: string - format: date-time - readOnly: true - updated_on: - type: string - format: date-time - readOnly: true - publish_date: - type: string - format: date-time - readOnly: true - is_published: - type: boolean - slug: - type: string - maxLength: 60 - pattern: ^[-a-zA-Z0-9_]+$ - required: - - created_on - - id - - publish_date - - title - - updated_on - - user - RichTextArticleRequest: - type: object - description: Serializer for LearningResourceInstructor model - properties: - title: - type: string - minLength: 1 - maxLength: 255 - author_name: - type: string - default: '' - content: - default: {} - is_published: - type: boolean - slug: - type: string - maxLength: 60 - pattern: ^[-a-zA-Z0-9_]+$ - required: - - title SearchModeEnum: enum: - phrase @@ -17337,3 +17424,95 @@ components: type: string required: - status + WebsiteContent: + type: object + description: Serializer for WebsiteContent model. + properties: + id: + type: integer + readOnly: true + title: + type: string + maxLength: 255 + author_name: + type: string + default: '' + content: + default: {} + content_type: + allOf: + - $ref: '#/components/schemas/WebsiteContentContentTypeEnum' + default: news + user: + allOf: + - $ref: '#/components/schemas/User' + readOnly: true + created_on: + type: string + format: date-time + readOnly: true + updated_on: + type: string + format: date-time + readOnly: true + publish_date: + type: string + format: date-time + readOnly: true + is_published: + type: boolean + slug: + type: string + maxLength: 60 + pattern: ^[-a-zA-Z0-9_]+$ + required: + - created_on + - id + - publish_date + - title + - updated_on + - user + WebsiteContentContentTypeEnum: + enum: + - news + - article + type: string + description: |- + * `news` - news + * `article` - article + x-enum-descriptions: + - news + - article + WebsiteContentImageUploadRequest: + type: object + properties: + image_file: + type: string + format: binary + required: + - image_file + WebsiteContentRequest: + type: object + description: Serializer for WebsiteContent model. + properties: + title: + type: string + minLength: 1 + maxLength: 255 + author_name: + type: string + default: '' + content: + default: {} + content_type: + allOf: + - $ref: '#/components/schemas/WebsiteContentContentTypeEnum' + default: news + is_published: + type: boolean + slug: + type: string + maxLength: 60 + pattern: ^[-a-zA-Z0-9_]+$ + required: + - title From 6e84bb2192d14bfb20a2fe3732f5d6b44f6dd29f Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Wed, 13 May 2026 12:19:56 +0500 Subject: [PATCH 03/17] fix python test --- articles/views_test.py | 99 ------------------- frontends/api/src/clients.ts | 3 - .../api/src/hooks/articles/index.test.ts | 10 +- .../test-utils/factories/learningResources.ts | 6 +- frontends/api/src/test-utils/urls.ts | 10 ++ .../ArticleEditor.happydom.test.tsx | 31 +++--- main/settings.py | 1 - main/settings_celery.py | 6 +- news_events/etl/articles_news_test.py | 1 + website_content/permissions.py | 5 +- website_content/tasks_test.py | 34 ++++++- 11 files changed, 76 insertions(+), 130 deletions(-) delete mode 100644 articles/views_test.py diff --git a/articles/views_test.py b/articles/views_test.py deleted file mode 100644 index 0ee3fb16ba..0000000000 --- a/articles/views_test.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Test for articles views""" - -import pytest -from rest_framework.reverse import reverse - -from articles.models import Article -from main.factories import UserFactory - -pytestmark = [pytest.mark.django_db] - - -@pytest.fixture(autouse=True) -def _mock_cdn_purge(mocker): - """Auto-mock CDN purge tasks for all tests in this module""" - mocker.patch("articles.tasks.fastly_purge_relative_url") - mocker.patch("articles.tasks.fastly_purge_relative_url.delay") - mocker.patch("articles.tasks.fastly_purge_articles_list.delay") - - -def test_article_creation(staff_client, user): - """Test article creation HTML sanitization.""" - - url = reverse("articles:v1:articles-list") - data = { - "content": {}, - "title": "Some title", - } - resp = staff_client.post(url, data) - json = resp.json() - assert json["content"] == {} - assert json["title"] == "Some title" - - -def test_retrieve_article_by_id(client, user): - """Should retrieve published article by numeric ID""" - article = Article.objects.create( - title="Test Article", - content={}, - is_published=True, - user=user, - ) - - url = reverse( - "articles:v1:articles-detail-by-id-or-slug", - kwargs={"identifier": str(article.id)}, - ) - - resp = client.get(url) - data = resp.json() - - assert resp.status_code == 200 - assert data["id"] == article.id - assert data["title"] == "Test Article" - - -def test_retrieve_article_by_slug(client, user): - """Should retrieve published article by slug""" - article = Article.objects.create( - title="Slug Article", - content={}, - is_published=True, - user=user, - ) - - url = reverse( - "articles:v1:articles-detail-by-id-or-slug", - kwargs={"identifier": article.slug}, - ) - - resp = client.get(url) - data = resp.json() - - assert resp.status_code == 200 - assert data["slug"] == article.slug - assert data["title"] == "Slug Article" - - -def test_staff_can_access_unpublished_article(client): - """Staff should be able to see unpublished articles""" - staff_user = UserFactory.create(is_staff=True) - client.force_login(staff_user) - - article = Article.objects.create( - title="Draft Article", - content={}, - is_published=False, - user=staff_user, - ) - - url = reverse( - "articles:v1:articles-detail-by-id-or-slug", - kwargs={"identifier": str(article.id)}, - ) - - resp = client.get(url) - data = resp.json() - - assert resp.status_code == 200 - assert data["id"] == article.id diff --git a/frontends/api/src/clients.ts b/frontends/api/src/clients.ts index f8dd905213..1f35ddd7c0 100644 --- a/frontends/api/src/clients.ts +++ b/frontends/api/src/clients.ts @@ -4,7 +4,6 @@ import { UserlistsApi, OfferorsApi, TopicsApi, - ArticlesApi, WebsiteContentApi, ProgramLettersApi, LearningResourcesSearchApi, @@ -72,7 +71,6 @@ const platformsApi = new PlatformsApi(undefined, BASE_PATH, axiosInstance) const topicsApi = new TopicsApi(undefined, BASE_PATH, axiosInstance) -const articlesApi = new ArticlesApi(undefined, BASE_PATH, axiosInstance) const websiteContentApi = new WebsiteContentApi( undefined, BASE_PATH, @@ -124,7 +122,6 @@ export { learningPathsApi, userListsApi, topicsApi, - articlesApi, websiteContentApi, mediaApi, hubspotApi, diff --git a/frontends/api/src/hooks/articles/index.test.ts b/frontends/api/src/hooks/articles/index.test.ts index 3f8a65d9d0..43f8c6f037 100644 --- a/frontends/api/src/hooks/articles/index.test.ts +++ b/frontends/api/src/hooks/articles/index.test.ts @@ -33,7 +33,7 @@ describe("useArticleList", () => { "Calls the correct API", async (params) => { const data = factory.articles({ count: 3 }) - const url = urls.articles.list(params) + const url = urls.websiteContent.list(params) const { wrapper } = setupReactQueryTest() setMockResponse.get(url, data) const useTestHook = () => useArticleList(params) @@ -46,7 +46,7 @@ describe("useArticleList", () => { describe("useArticleDetail", () => { it("Calls the correct API", async () => { const data = factory.article() - const url = urls.articles.details(data.id) + const url = urls.websiteContent.details(data.id) const { wrapper } = setupReactQueryTest() setMockResponse.get(url, data) @@ -59,7 +59,7 @@ describe("useArticleDetail", () => { describe("Article CRUD", () => { test("useArticleCreate calls correct API", async () => { - const url = urls.articles.list() + const url = urls.websiteContent.list() const data = factory.article() const { id, ...requestData } = factory.article() setMockResponse.post(url, data) @@ -78,7 +78,7 @@ describe("Article CRUD", () => { test("useArticlePartialUpdate calls correct API", async () => { const article = factory.article() - const url = urls.articles.details(article.id) + const url = urls.websiteContent.details(article.id) setMockResponse.patch(url, article) const { wrapper, queryClient } = setupReactQueryTest() @@ -96,7 +96,7 @@ describe("Article CRUD", () => { test("useArticleDestroy calls correct API", async () => { const { id } = factory.article() - const url = urls.articles.details(id) + const url = urls.websiteContent.details(id) setMockResponse.delete(url, null) const { wrapper, queryClient } = setupReactQueryTest() diff --git a/frontends/api/src/test-utils/factories/learningResources.ts b/frontends/api/src/test-utils/factories/learningResources.ts index 39c942a8c8..136ea5d30a 100644 --- a/frontends/api/src/test-utils/factories/learningResources.ts +++ b/frontends/api/src/test-utils/factories/learningResources.ts @@ -41,7 +41,7 @@ import { LearningResourceRunLevelInnerCodeEnum, PlatformEnum, CourseResourceCertificationTypeCodeEnum, - ContentTypeEnum, + ContentFileContentTypeEnum, ResourceTypeGroupEnum, } from "api" @@ -253,7 +253,9 @@ const contentFile: Factory = (overrides = {}) => { key: faker.string.uuid(), uid: faker.string.uuid(), url: faker.internet.url(), - content_type: faker.helpers.arrayElement(Object.values(ContentTypeEnum)), + content_type: faker.helpers.arrayElement( + Object.values(ContentFileContentTypeEnum), + ), content: faker.lorem.paragraph(), ...overrides, } diff --git a/frontends/api/src/test-utils/urls.ts b/frontends/api/src/test-utils/urls.ts index 0e1a781452..e9cde2d204 100644 --- a/frontends/api/src/test-utils/urls.ts +++ b/frontends/api/src/test-utils/urls.ts @@ -18,6 +18,7 @@ import type { LearningpathsApi, ArticlesApi, HubspotApi, + WebsiteContentApi, UserlistsApi, OfferorsApi, PlatformsApi, @@ -159,6 +160,14 @@ const articles = { `${API_BASE_URL}/api/v1/articles/detail/${identifier}/`, } +const websiteContent = { + list: (params?: Params) => + `${API_BASE_URL}/api/v1/website_content/${query(params)}`, + details: (id: number) => `${API_BASE_URL}/api/v1/website_content/${id}/`, + detailRetrieve: (identifier: string) => + `${API_BASE_URL}/api/v1/website_content/detail/${identifier}/`, +} + const hubspot = { list: (params?: Params) => `${API_BASE_URL}/api/v1/hubspot/forms/${query(params)}`, @@ -240,6 +249,7 @@ export { topics, learningPaths, articles, + websiteContent, hubspot, search, userLists, diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx index f9d42ea1df..95f0ef7026 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx @@ -42,7 +42,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { content, is_published: false, }) - setMockResponse.get(urls.articles.details(articleId), article) + setMockResponse.get(urls.websiteContent.details(articleId), article) renderWithProviders( , @@ -119,7 +119,10 @@ describe("ArticleEditor - Content Editing and Saving", () => { ], }, } - setMockResponse.patch(urls.articles.details(article.id), updatedArticle) + setMockResponse.patch( + urls.websiteContent.details(article.id), + updatedArticle, + ) const heading = screen.getByRole("heading", { level: 1 }) await userEvent.click(heading) @@ -137,7 +140,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { expect(makeRequest).toHaveBeenCalledWith( "patch", - urls.articles.details(article.id), + urls.websiteContent.details(article.id), expect.objectContaining({ content: updatedArticle.content, is_published: true, @@ -237,13 +240,16 @@ describe("ArticleEditor - Content Editing and Saving", () => { ], }, } - setMockResponse.patch(urls.articles.details(article.id), updatedArticle) + setMockResponse.patch( + urls.websiteContent.details(article.id), + updatedArticle, + ) await userEvent.click(updateButton) expect(makeRequest).toHaveBeenCalledWith( "patch", - urls.articles.details(article.id), + urls.websiteContent.details(article.id), expect.objectContaining({ content: updatedArticle.content, is_published: true, @@ -413,7 +419,10 @@ describe("ArticleEditor - Content Editing and Saving", () => { }), is_published: false, } - setMockResponse.patch(urls.articles.details(article.id), updatedArticle) + setMockResponse.patch( + urls.websiteContent.details(article.id), + updatedArticle, + ) const saveDraftButton = await screen.findByRole("button", { name: "Save As Draft", @@ -425,7 +434,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { expect(makeRequest).toHaveBeenCalledWith( "patch", - urls.articles.details(article.id), + urls.websiteContent.details(article.id), expect.objectContaining({ is_published: false, author_name: "", @@ -466,7 +475,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { const article = await setupEditor(initialContent, 209, "Title") setMockResponse.patch( - urls.articles.details(article.id), + urls.websiteContent.details(article.id), { detail: "Server error" }, { code: 500 }, ) @@ -510,7 +519,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { title: "My Article", is_published: true, }) - setMockResponse.post(urls.articles.list(), createdArticle) + setMockResponse.post(urls.websiteContent.list(), createdArticle) renderWithProviders(, { user }) @@ -543,7 +552,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { () => { expect(makeRequest).toHaveBeenCalledWith( "post", - urls.articles.list(), + urls.websiteContent.list(), expect.objectContaining({ title: "My Article", author_name: "", @@ -635,7 +644,7 @@ describe("ArticleEditor - Document Rendering", () => { title: "Test Article", content, }) - setMockResponse.get(urls.articles.details(articleId), article) + setMockResponse.get(urls.websiteContent.details(articleId), article) renderWithProviders( , diff --git a/main/settings.py b/main/settings.py index d37c382a5a..36c9766382 100644 --- a/main/settings.py +++ b/main/settings.py @@ -132,7 +132,6 @@ "learning_resources_search", "website_content.apps.WebsiteContentConfig", "openapi", - "articles", "oauth2_provider", "news_events", "testimonials", diff --git a/main/settings_celery.py b/main/settings_celery.py index 03d7f57dfa..292f4a177d 100644 --- a/main/settings_celery.py +++ b/main/settings_celery.py @@ -123,7 +123,11 @@ "update_website_content_news": { "task": "news_events.tasks.get_website_content_news", "schedule": get_int( - "NEWS_EVENTS_WEBSITE_CONTENT_NEWS_SCHEDULE_SECONDS", 60 * 60 * 1 + # NEWS_EVENTS_WEBSITE_CONTENT_NEWS_SCHEDULE_SECONDS is the new name; + # fall back to the legacy NEWS_EVENTS_ARTICLES_NEWS_SCHEDULE_SECONDS + # so existing deployments keep their configured schedule during cutover. + "NEWS_EVENTS_WEBSITE_CONTENT_NEWS_SCHEDULE_SECONDS", + get_int("NEWS_EVENTS_ARTICLES_NEWS_SCHEDULE_SECONDS", 60 * 60 * 1), ), # default is every 1 hour }, "sync_canvas_courses-every-1-weeks": { diff --git a/news_events/etl/articles_news_test.py b/news_events/etl/articles_news_test.py index e115e3af56..b9303e14d8 100644 --- a/news_events/etl/articles_news_test.py +++ b/news_events/etl/articles_news_test.py @@ -633,6 +633,7 @@ class MockArticle: ], } is_published = True + content_type = "news" created_on = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) updated_on = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) publish_date = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) diff --git a/website_content/permissions.py b/website_content/permissions.py index 9f7979344f..622c59e7c2 100644 --- a/website_content/permissions.py +++ b/website_content/permissions.py @@ -6,14 +6,13 @@ def is_website_content_editor(request): """ - Return True if the request user belongs to the article_editors group or - is an admin. + Return True if the request user belongs to the article_editors group. Args: request (HTTPRequest): django request object Returns: - bool: True if user is a content editor or admin + bool: True if user is a content editor """ return ( request.user is not None diff --git a/website_content/tasks_test.py b/website_content/tasks_test.py index 5913fe6158..a0fabc524f 100644 --- a/website_content/tasks_test.py +++ b/website_content/tasks_test.py @@ -8,11 +8,6 @@ from main.utils import call_fastly_purge_api from website_content.factories import WebsiteContentFactory -from website_content.tasks import ( - fastly_full_purge, - fastly_purge_relative_url, - fastly_purge_website_content_list, -) @pytest.fixture @@ -69,3 +64,32 @@ def test_call_fastly_purge_api_no_token(self, mock_request, mock_fastly_response assert result == {"status": "ok", "skipped": True} mock_request.assert_not_called() + + +@pytest.mark.django_db +class TestFastlyPurgeWebsiteContentList: + """Tests for fastly_purge_website_content_list task""" + + @patch("main.utils.requests.request") + @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") + @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") + @patch("django.conf.settings.FASTLY_API_KEY", "test-token") + def test_purge_website_content_list_purges_news_path( + self, mock_request, mock_fastly_response + ): + """ + Ensure the /news listing path is purged from Fastly regardless of + what content exists in the DB. WebsiteContentFactory creates a + realistic content row to verify no DB errors occur during the task. + """ + mock_request.return_value = mock_fastly_response + WebsiteContentFactory.create(is_published=True, slug="some-article") + + # Call the underlying purge helper directly; single_task uses a Redis + # lock (get_redis_connection) that is unavailable in the test runner. + result = call_fastly_purge_api("/news") + + assert result == {"status": "ok", "id": "123-456"} + mock_request.assert_called_once() + call_args = mock_request.call_args + assert "/news" in call_args.args[1] From ad5e34cccd1b7b66eb29ae8d2c7bd0a730b0f1f3 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Wed, 13 May 2026 14:34:36 +0500 Subject: [PATCH 04/17] removal of articles app --- articles/__init__.py | 0 articles/admin.py | 1 - articles/api.py | 79 ------- articles/api_test.py | 187 --------------- articles/apps.py | 15 -- articles/constants.py | 1 - articles/factories.py | 16 -- articles/hooks.py | 33 --- articles/management/__init__.py | 0 articles/management/commands/__init__.py | 0 .../commands/sync_articles_to_news.py | 23 -- articles/migrations/0001_initial.py | 33 --- .../0002_alter_article_created_on.py | 17 -- ...003_remove_article_html_article_content.py | 21 -- articles/migrations/0004_article_user.py | 25 -- .../migrations/0004_articleimageupload.py | 48 ---- ...04_article_user_0004_articleimageupload.py | 12 - .../migrations/0006_article_is_published.py | 17 -- articles/migrations/0007_add_editors_group.py | 29 --- articles/migrations/0008_article_slug.py | 17 -- .../migrations/0009_article_publish_date.py | 17 -- .../migrations/0010_fix_article_default.py | 17 -- .../migrations/0011_article_author_name.py | 17 -- articles/migrations/__init__.py | 0 articles/models.py | 74 ------ articles/models_test.py | 104 --------- articles/permissions.py | 42 ---- articles/serializers.py | 65 ------ articles/serializers_test.py | 67 ------ articles/tasks.py | 58 ----- articles/tasks_test.py | 216 ------------------ articles/urls.py | 36 --- articles/validators.py | 45 ---- articles/validators_test.py | 44 ---- articles/views.py | 180 --------------- 35 files changed, 1556 deletions(-) delete mode 100644 articles/__init__.py delete mode 100644 articles/admin.py delete mode 100644 articles/api.py delete mode 100644 articles/api_test.py delete mode 100644 articles/apps.py delete mode 100644 articles/constants.py delete mode 100644 articles/factories.py delete mode 100644 articles/hooks.py delete mode 100644 articles/management/__init__.py delete mode 100644 articles/management/commands/__init__.py delete mode 100644 articles/management/commands/sync_articles_to_news.py delete mode 100644 articles/migrations/0001_initial.py delete mode 100644 articles/migrations/0002_alter_article_created_on.py delete mode 100644 articles/migrations/0003_remove_article_html_article_content.py delete mode 100644 articles/migrations/0004_article_user.py delete mode 100644 articles/migrations/0004_articleimageupload.py delete mode 100644 articles/migrations/0005_merge_0004_article_user_0004_articleimageupload.py delete mode 100644 articles/migrations/0006_article_is_published.py delete mode 100644 articles/migrations/0007_add_editors_group.py delete mode 100644 articles/migrations/0008_article_slug.py delete mode 100644 articles/migrations/0009_article_publish_date.py delete mode 100644 articles/migrations/0010_fix_article_default.py delete mode 100644 articles/migrations/0011_article_author_name.py delete mode 100644 articles/migrations/__init__.py delete mode 100644 articles/models.py delete mode 100644 articles/models_test.py delete mode 100644 articles/permissions.py delete mode 100644 articles/serializers.py delete mode 100644 articles/serializers_test.py delete mode 100644 articles/tasks.py delete mode 100644 articles/tasks_test.py delete mode 100644 articles/urls.py delete mode 100644 articles/validators.py delete mode 100644 articles/validators_test.py delete mode 100644 articles/views.py diff --git a/articles/__init__.py b/articles/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/articles/admin.py b/articles/admin.py deleted file mode 100644 index 846f6b4061..0000000000 --- a/articles/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/articles/api.py b/articles/api.py deleted file mode 100644 index 71341c2dca..0000000000 --- a/articles/api.py +++ /dev/null @@ -1,79 +0,0 @@ -"""API functions for articles""" - -import logging - -from articles.hooks import get_plugin_manager -from articles.tasks import ( - PURGE_TIMEOUT_SECONDS, - fastly_purge_articles_list, - fastly_purge_relative_url, -) - -log = logging.getLogger(__name__) - - -def purge_article_on_save(article): - """ - Purge the article from the CDN cache when it's saved. - - This will trigger a CDN purge for: - - The specific article page (if published and has a slug) - attempted immediately - - The articles list page - queued as Celery task - - Args: - article: The article instance being saved - """ - # Only purge if the article is published - if article.is_published and article.slug: - log.info( - "Article %s (%s) saved, purging CDN...", - article.id, - article.slug, - ) - - # Try to purge the article immediately with a short timeout - article_url = article.get_url() - try: - article_purge_resp = fastly_purge_relative_url( - article_url, timeout=PURGE_TIMEOUT_SECONDS - ) - if article_purge_resp.get("status") == "ok": - log.info("Article purge request processed OK.") - else: - # If immediate purge fails, queue it for Celery - fastly_purge_relative_url.delay(article_url) - log.error("Article purge request failed, enqueued for retry.") - except Exception: - # On any exception (timeout, network error, etc.), queue for Celery - fastly_purge_relative_url.delay(article_url) - log.exception("Article purge request failed, enqueued for retry.") - - # Also purge the articles list since it may now include this article - fastly_purge_articles_list.delay() - else: - log.debug( - "Article %s is not published or has no slug, skipping CDN purge.", - article.id, - ) - - -def article_published_actions(*, article): - """ - Trigger plugins when an article is published or updated - - Args: - article (Article): The article that was published or updated - """ - if not article.is_published: - log.info("Article %s is not published, skipping plugin actions", article.id) - return - - log.info( - "Triggering article_published plugins for article: id=%s, title=%s", - article.id, - article.title, - ) - - pm = get_plugin_manager() - hook = pm.hook - hook.article_published(article=article) diff --git a/articles/api_test.py b/articles/api_test.py deleted file mode 100644 index a8496433b9..0000000000 --- a/articles/api_test.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Tests for articles API functions""" - -import pytest - - -@pytest.mark.django_db -def test_article_published_actions_triggers_hook(mocker, user): - """Test that article_published_actions triggers the plugin hook for published articles""" - from articles.api import article_published_actions - from articles.models import Article - - # Mock CDN purge tasks - mocker.patch("articles.tasks.fastly_purge_relative_url") - mocker.patch("articles.tasks.fastly_purge_articles_list.delay") - - # Create a published article - article = Article.objects.create( - title="Published Article", - content={"type": "doc", "content": []}, - is_published=True, - user=user, - ) - - # Mock the plugin manager and hook - mock_pm = mocker.MagicMock() - mock_hook = mocker.MagicMock() - mock_pm.hook = mock_hook - mocker.patch("articles.api.get_plugin_manager", return_value=mock_pm) - - # Call the function - article_published_actions(article=article) - - # Verify hook was called with the article - mock_hook.article_published.assert_called_once_with(article=article) - - -@pytest.mark.django_db -def test_article_published_actions_skips_unpublished(mocker, user, caplog): - """Test that article_published_actions skips unpublished articles""" - from articles.api import article_published_actions - from articles.models import Article - - # Create an unpublished article - article = Article.objects.create( - title="Draft Article", - content={"type": "doc", "content": []}, - is_published=False, - user=user, - ) - - # Mock the plugin manager - mock_pm = mocker.MagicMock() - mock_hook = mocker.MagicMock() - mock_pm.hook = mock_hook - mocker.patch("articles.api.get_plugin_manager", return_value=mock_pm) - - # Call the function - article_published_actions(article=article) - - # Verify hook was NOT called - mock_hook.article_published.assert_not_called() - - # Verify log message - assert ( - f"Article {article.id} is not published, skipping plugin actions" in caplog.text - ) - - -@pytest.mark.django_db -def test_article_published_actions_logs_execution(mocker, user, caplog): - """Test that article_published_actions logs when triggering plugins""" - from articles.api import article_published_actions - from articles.models import Article - - # Mock CDN purge tasks - mocker.patch("articles.tasks.fastly_purge_relative_url") - mocker.patch("articles.tasks.fastly_purge_relative_url.delay") - mocker.patch("articles.tasks.fastly_purge_articles_list.delay") - - # Create a published article - article = Article.objects.create( - title="Test Article", - content={"type": "doc", "content": []}, - is_published=True, - user=user, - ) - - # Mock the plugin manager - mock_pm = mocker.MagicMock() - mock_hook = mocker.MagicMock() - mock_pm.hook = mock_hook - mocker.patch("articles.api.get_plugin_manager", return_value=mock_pm) - - # Call the function - article_published_actions(article=article) - - # Verify logging - assert "Triggering article_published plugins for article" in caplog.text - assert f"id={article.id}" in caplog.text - assert f"title={article.title}" in caplog.text - - -@pytest.mark.django_db -class TestPurgeArticleOnSave: - """Tests for purge_article_on_save function""" - - def test_purge_on_published_article(self, mocker): - """Test that CDN purge is triggered for a published article""" - from articles.api import purge_article_on_save - from articles.factories import ArticleFactory - - # Mock call_fastly_purge_api to fail so it falls back to Celery task - mocker.patch( - "articles.tasks.call_fastly_purge_api", - side_effect=Exception("Network error"), - ) - - mock_purge_url = mocker.patch("articles.tasks.fastly_purge_relative_url.delay") - mock_purge_list = mocker.patch( - "articles.tasks.fastly_purge_articles_list.delay" - ) - - article = ArticleFactory(is_published=True) - - purge_article_on_save(article) - - # Should enqueue purge due to exception - mock_purge_url.assert_called_once_with(article.get_url()) - mock_purge_list.assert_called_once() - - def test_no_purge_on_unpublished_article(self, mocker): - """Test that CDN purge is NOT triggered for unpublished articles""" - from articles.api import purge_article_on_save - from articles.factories import ArticleFactory - - mock_purge_url = mocker.patch("articles.tasks.fastly_purge_relative_url") - mock_purge_list = mocker.patch( - "articles.tasks.fastly_purge_articles_list.delay" - ) - - article = ArticleFactory(is_published=False) - - purge_article_on_save(article) - - mock_purge_url.assert_not_called() - mock_purge_list.assert_not_called() - - def test_no_purge_on_article_without_slug(self, mocker): - """Test that CDN purge is NOT triggered for articles without slug""" - from articles.api import purge_article_on_save - from articles.factories import ArticleFactory - - mock_purge_url = mocker.patch("articles.tasks.fastly_purge_relative_url") - mock_purge_list = mocker.patch( - "articles.tasks.fastly_purge_articles_list.delay" - ) - - article = ArticleFactory(is_published=True) - article.slug = None - - purge_article_on_save(article) - - mock_purge_url.assert_not_called() - mock_purge_list.assert_not_called() - - def test_purge_on_article_with_slug(self, mocker): - """Test that CDN purge is triggered when article has slug and is published""" - from articles.api import purge_article_on_save - from articles.factories import ArticleFactory - - # Mock call_fastly_purge_api to fail so it falls back to Celery task - mocker.patch( - "articles.tasks.call_fastly_purge_api", - side_effect=Exception("Network error"), - ) - - mock_purge_url = mocker.patch("articles.tasks.fastly_purge_relative_url.delay") - mock_purge_list = mocker.patch( - "articles.tasks.fastly_purge_articles_list.delay" - ) - - article = ArticleFactory(is_published=True, slug="test-article") - - purge_article_on_save(article) - - mock_purge_url.assert_called_once_with(article.get_url()) - mock_purge_list.assert_called_once() diff --git a/articles/apps.py b/articles/apps.py deleted file mode 100644 index 3f59b0c928..0000000000 --- a/articles/apps.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.apps import AppConfig -from pluggy import HookimplMarker, HookspecMarker - - -class ArticlesConfig(AppConfig): - """Articles AppConfig""" - - name = "articles" - - hookimpl = HookimplMarker(name) - hookspec = HookspecMarker(name) - - def ready(self): - """Import tasks when the app is ready""" - import articles.tasks # noqa: F401 diff --git a/articles/constants.py b/articles/constants.py deleted file mode 100644 index c4b784d800..0000000000 --- a/articles/constants.py +++ /dev/null @@ -1 +0,0 @@ -GROUP_STAFF_ARTICLE_EDITORS = "article_editors" diff --git a/articles/factories.py b/articles/factories.py deleted file mode 100644 index f4c2ed700e..0000000000 --- a/articles/factories.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Factories for making test data""" - -import factory -from factory.django import DjangoModelFactory - -from articles import models - - -class ArticleFactory(DjangoModelFactory): - """Factory for Articles""" - - content = factory.LazyFunction(lambda: {"type": "doc", "content": []}) - title = factory.Faker("sentence", nb_words=4) - - class Meta: - model = models.Article diff --git a/articles/hooks.py b/articles/hooks.py deleted file mode 100644 index 638af328e2..0000000000 --- a/articles/hooks.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Pluggy hooks for articles""" - -import logging - -import pluggy -from django.apps import apps -from django.conf import settings -from django.utils.module_loading import import_string - -log = logging.getLogger(__name__) - -app_config = apps.get_app_config("articles") -hookspec = app_config.hookspec - - -class ArticlesHooks: - """Pluggy hook specs for articles""" - - @hookspec - def article_published(self, article): - """Trigger actions after an article is published or updated""" - - -def get_plugin_manager(): - """Return the plugin manager for articles hooks""" - pm = pluggy.PluginManager(app_config.name) - pm.add_hookspecs(ArticlesHooks) - for module in settings.MITOL_ARTICLES_PLUGINS.split(","): - if module: - plugin_cls = import_string(module) - pm.register(plugin_cls()) - - return pm diff --git a/articles/management/__init__.py b/articles/management/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/articles/management/commands/__init__.py b/articles/management/commands/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/articles/management/commands/sync_articles_to_news.py b/articles/management/commands/sync_articles_to_news.py deleted file mode 100644 index e78ece1b47..0000000000 --- a/articles/management/commands/sync_articles_to_news.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Management command to sync articles to news feed""" - -from django.core.management.base import BaseCommand - -from news_events.etl import pipelines - - -class Command(BaseCommand): - help = "Sync published articles to the news feed" - - def handle(self, *args, **options): # noqa: ARG002 - self.stdout.write("Syncing articles to news feed...") - - try: - # Run the ETL pipeline - pipelines.articles_news_etl() - - self.stdout.write( - self.style.SUCCESS("Successfully synced articles to news feed!") - ) - except Exception as e: - self.stdout.write(self.style.ERROR(f"Error syncing articles: {e!s}")) - raise diff --git a/articles/migrations/0001_initial.py b/articles/migrations/0001_initial.py deleted file mode 100644 index f894d3b89b..0000000000 --- a/articles/migrations/0001_initial.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 4.1.10 on 2023-10-04 17:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="Article", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created_on", models.DateTimeField(auto_now_add=True)), - ("updated_on", models.DateTimeField(auto_now=True)), - ("html", models.TextField()), - ("title", models.CharField(max_length=255)), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/articles/migrations/0002_alter_article_created_on.py b/articles/migrations/0002_alter_article_created_on.py deleted file mode 100644 index 2c1c1104f9..0000000000 --- a/articles/migrations/0002_alter_article_created_on.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.9 on 2024-01-23 15:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("articles", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="article", - name="created_on", - field=models.DateTimeField(auto_now_add=True, db_index=True), - ), - ] diff --git a/articles/migrations/0003_remove_article_html_article_content.py b/articles/migrations/0003_remove_article_html_article_content.py deleted file mode 100644 index 54860c8f1e..0000000000 --- a/articles/migrations/0003_remove_article_html_article_content.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.25 on 2025-11-07 11:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("articles", "0002_alter_article_created_on"), - ] - - operations = [ - migrations.RemoveField( - model_name="article", - name="html", - ), - migrations.AddField( - model_name="article", - name="content", - field=models.JSONField(default={}), - ), - ] diff --git a/articles/migrations/0004_article_user.py b/articles/migrations/0004_article_user.py deleted file mode 100644 index f97128e77f..0000000000 --- a/articles/migrations/0004_article_user.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 4.2.25 on 2025-11-28 09:31 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("articles", "0003_remove_article_html_article_content"), - ] - - operations = [ - migrations.AddField( - model_name="article", - name="user", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/articles/migrations/0004_articleimageupload.py b/articles/migrations/0004_articleimageupload.py deleted file mode 100644 index 80f8fb35fb..0000000000 --- a/articles/migrations/0004_articleimageupload.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 4.2.25 on 2025-11-25 09:44 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - -import profiles.utils - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("articles", "0003_remove_article_html_article_content"), - ] - - operations = [ - migrations.CreateModel( - name="ArticleImageUpload", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "image_file", - models.ImageField( - editable=False, - max_length=2083, - null=True, - upload_to=profiles.utils.article_image_upload_uri, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - ] diff --git a/articles/migrations/0005_merge_0004_article_user_0004_articleimageupload.py b/articles/migrations/0005_merge_0004_article_user_0004_articleimageupload.py deleted file mode 100644 index 1dcd0edb47..0000000000 --- a/articles/migrations/0005_merge_0004_article_user_0004_articleimageupload.py +++ /dev/null @@ -1,12 +0,0 @@ -# Generated by Django 4.2.25 on 2025-12-02 11:36 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("articles", "0004_article_user"), - ("articles", "0004_articleimageupload"), - ] - - operations = [] diff --git a/articles/migrations/0006_article_is_published.py b/articles/migrations/0006_article_is_published.py deleted file mode 100644 index 2a1c4defa5..0000000000 --- a/articles/migrations/0006_article_is_published.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.25 on 2025-12-03 09:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("articles", "0005_merge_0004_article_user_0004_articleimageupload"), - ] - - operations = [ - migrations.AddField( - model_name="article", - name="is_published", - field=models.BooleanField(default=False), - ), - ] diff --git a/articles/migrations/0007_add_editors_group.py b/articles/migrations/0007_add_editors_group.py deleted file mode 100644 index 084798a7cb..0000000000 --- a/articles/migrations/0007_add_editors_group.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 3.1 on 2021-01-28 16:27 -from django.contrib.auth.models import Group -from django.db import migrations - -from articles import constants - - -def add_staff_list_group(apps, schema_editor): - """ - Create the staff list editors group - """ - Group.objects.get_or_create(name=constants.GROUP_STAFF_ARTICLE_EDITORS) - - -def remove_staff_list_group(apps, schema_editor): - """ - Delete the staff list editors group - """ - Group.objects.filter(name=constants.GROUP_STAFF_ARTICLE_EDITORS).delete() - - -class Migration(migrations.Migration): - dependencies = [ - ("articles", "0006_article_is_published"), - ] - - operations = [ - migrations.RunPython(add_staff_list_group, remove_staff_list_group), - ] diff --git a/articles/migrations/0008_article_slug.py b/articles/migrations/0008_article_slug.py deleted file mode 100644 index e37d232cc7..0000000000 --- a/articles/migrations/0008_article_slug.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.25 on 2025-12-08 09:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("articles", "0007_add_editors_group"), - ] - - operations = [ - migrations.AddField( - model_name="article", - name="slug", - field=models.SlugField(blank=True, max_length=255, null=True, unique=True), - ), - ] diff --git a/articles/migrations/0009_article_publish_date.py b/articles/migrations/0009_article_publish_date.py deleted file mode 100644 index 60fefc35d5..0000000000 --- a/articles/migrations/0009_article_publish_date.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.25 on 2026-01-08 15:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("articles", "0008_article_slug"), - ] - - operations = [ - migrations.AddField( - model_name="article", - name="publish_date", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/articles/migrations/0010_fix_article_default.py b/articles/migrations/0010_fix_article_default.py deleted file mode 100644 index fed4dea5cc..0000000000 --- a/articles/migrations/0010_fix_article_default.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.26 on 2026-01-22 16:48 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("articles", "0009_article_publish_date"), - ] - - operations = [ - migrations.AlterField( - model_name="article", - name="content", - field=models.JSONField(default=dict), - ), - ] diff --git a/articles/migrations/0011_article_author_name.py b/articles/migrations/0011_article_author_name.py deleted file mode 100644 index f005cde6f6..0000000000 --- a/articles/migrations/0011_article_author_name.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.25 on 2026-02-09 08:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("articles", "0010_fix_article_default"), - ] - - operations = [ - migrations.AddField( - model_name="article", - name="author_name", - field=models.TextField(blank=True, default=""), - ), - ] diff --git a/articles/migrations/__init__.py b/articles/migrations/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/articles/models.py b/articles/models.py deleted file mode 100644 index a5dcd28332..0000000000 --- a/articles/models.py +++ /dev/null @@ -1,74 +0,0 @@ -"""ckeditor models""" - -from django.conf import settings -from django.db import models -from django.utils import timezone -from django.utils.text import slugify - -from main.models import TimestampedModel -from profiles.utils import article_image_upload_uri - - -class Article(TimestampedModel): - """ - Stores rich-text content created by staff members. - """ - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - null=True, - blank=True, # optional for admin forms - ) - content = models.JSONField(default=dict) - title = models.CharField(max_length=255) - author_name = models.TextField(blank=True, default="") - slug = models.SlugField(max_length=255, unique=True, blank=True, null=True) - is_published = models.BooleanField(default=False) - publish_date = models.DateTimeField(null=True, blank=True) - - def save(self, *args, **kwargs): - previous = Article.objects.get(pk=self.pk) if self.pk else None - was_published = getattr(previous, "is_published", None) - - # Always initialize slug - slug = self.slug or None - - if not was_published and self.is_published: - # Set publish_date only on first publish - if not self.publish_date: - self.publish_date = timezone.now() - - max_length = self._meta.get_field("slug").max_length - - base_slug = slugify(self.title)[:max_length] - slug = base_slug - counter = 1 - - # Prevent collisions - while Article.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - suffix = f"-{counter}" - slug = f"{base_slug[: max_length - len(suffix)]}{suffix}" - counter += 1 - - self.slug = slug - super().save(*args, **kwargs) - - def get_url(self): - """ - Return the relative URL for this article. - """ - if self.slug: - return f"/news/{self.slug}" - return None - - -class ArticleImageUpload(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - image_file = models.ImageField( - null=True, upload_to=article_image_upload_uri, max_length=2083, editable=False - ) - created_at = models.DateTimeField(auto_now_add=True) - - def __str__(self): - return f"ArticleImageUpload({self.user_id})" diff --git a/articles/models_test.py b/articles/models_test.py deleted file mode 100644 index ea3a08980e..0000000000 --- a/articles/models_test.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Tests for articles models""" - -from unittest.mock import patch - -import pytest -from django.contrib.auth import get_user_model - -from articles.models import Article - -User = get_user_model() - - -@pytest.mark.django_db -@patch("articles.tasks.fastly_purge_articles_list.delay") -@patch("articles.tasks.fastly_purge_relative_url") -@patch("articles.tasks.fastly_purge_relative_url.delay") -class TestArticleModel: - """Tests for Article model""" - - def test_get_url_with_slug( - self, - _mock_queue_purge_delay, # noqa: PT019 - _mock_purge_url, # noqa: PT019 - _mock_queue_list, # noqa: PT019 - ): - """Test that get_url returns the correct URL for an article with a slug""" - user = User.objects.create_user(username="testuser", email="test@example.com") - article = Article.objects.create( - title="Test Article", - content={"type": "doc", "content": []}, - is_published=True, - user=user, - ) - - assert article.get_url() == f"/news/{article.slug}" - - def test_get_url_without_slug( - self, - _mock_queue_purge_delay, # noqa: PT019 - _mock_purge_url, # noqa: PT019 - _mock_queue_list, # noqa: PT019 - ): - """Test that get_url returns None for an article without a slug""" - user = User.objects.create_user(username="testuser2", email="test2@example.com") - article = Article.objects.create( - title="Draft Article", - content={"type": "doc", "content": []}, - is_published=False, - user=user, - ) - - assert article.get_url() is None - - def test_get_url_with_different_slugs( - self, - _mock_queue_purge_delay, # noqa: PT019 - _mock_purge_url, # noqa: PT019 - _mock_queue_list, # noqa: PT019 - ): - """Test that get_url returns different URLs for different slugs""" - user = User.objects.create_user(username="testuser3", email="test3@example.com") - article1 = Article.objects.create( - title="First Article", - content={"type": "doc", "content": []}, - is_published=True, - user=user, - ) - article2 = Article.objects.create( - title="Second Article", - content={"type": "doc", "content": []}, - is_published=True, - user=user, - ) - - assert article1.get_url() == f"/news/{article1.slug}" - assert article2.get_url() == f"/news/{article2.slug}" - assert article1.get_url() != article2.get_url() - - def test_slug_generation_on_publish( - self, - _mock_queue_purge_delay, # noqa: PT019 - _mock_purge_url, # noqa: PT019 - _mock_queue_list, # noqa: PT019 - ): - """Test that slug is generated when article is published""" - user = User.objects.create_user(username="testuser4", email="test4@example.com") - article = Article.objects.create( - title="Test Article Title", - content={"type": "doc", "content": []}, - is_published=False, - user=user, - ) - - # Initially no slug since not published - assert article.slug is None - - # Publish the article - article.is_published = True - article.save() - - # Now should have a slug - assert article.slug is not None - assert article.slug == "test-article-title" - assert article.get_url() == "/news/test-article-title" diff --git a/articles/permissions.py b/articles/permissions.py deleted file mode 100644 index 096dbc5a9e..0000000000 --- a/articles/permissions.py +++ /dev/null @@ -1,42 +0,0 @@ -from rest_framework.permissions import SAFE_METHODS, BasePermission - -from articles.constants import GROUP_STAFF_ARTICLE_EDITORS -from learning_resources.permissions import is_admin_user - - -def is_article_group_user(request): - """ - Args: - request (HTTPRequest): django request object - - Returns: - bool: True if user is staff/admin - """ - return ( - request.user is not None - and request.user.groups.filter(name=GROUP_STAFF_ARTICLE_EDITORS).first() - is not None - ) - - -class CanViewArticle(BasePermission): - """ - Allow viewing an article if: - - user is admin (article editor), OR - - article is published - """ - - def has_object_permission(self, request, _, obj): - # Editors (admins) may view any article - if is_admin_user(request) or is_article_group_user(request): - return True - - # Normal users may view ONLY published articles - return obj.is_published - - -class CanEditArticle(BasePermission): - def has_permission(self, request, _view): - if request.method not in SAFE_METHODS: - return is_admin_user(request) or is_article_group_user(request) - return True diff --git a/articles/serializers.py b/articles/serializers.py deleted file mode 100644 index 959fa8d1c1..0000000000 --- a/articles/serializers.py +++ /dev/null @@ -1,65 +0,0 @@ -from django.contrib.auth import get_user_model -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers - -from articles import models -from articles.validators import clean_html - -User = get_user_model() - - -@extend_schema_field(str) -class SanitizedHtmlField(serializers.Field): - @staticmethod - def to_representation(value): - return value - - def to_internal_value(self, data): - return clean_html(data) - - -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ["first_name", "last_name"] - - -class RichTextArticleSerializer(serializers.ModelSerializer): - """ - Serializer for LearningResourceInstructor model - """ - - created_on = serializers.DateTimeField(read_only=True, required=False) - updated_on = serializers.DateTimeField(read_only=True, required=False) - publish_date = serializers.DateTimeField(read_only=True, required=False) - content = serializers.JSONField(default={}) - slug = serializers.SlugField(max_length=60, required=False, allow_blank=True) - title = serializers.CharField(max_length=255) - author_name = serializers.CharField(required=False, allow_blank=True, default="") - user = UserSerializer(read_only=True) - - class Meta: - model = models.Article - fields = [ - "id", - "title", - "author_name", - "content", - "user", - "created_on", - "updated_on", - "publish_date", - "is_published", - "slug", - ] - - -class ArticleImageUploadSerializer(serializers.Serializer): - image_file = serializers.ImageField(required=True) - - def create(self, validated_data): - user = self.context.get("request").user - return models.ArticleImageUpload.objects.create( - user=user, - image_file=validated_data["image_file"], - ) diff --git a/articles/serializers_test.py b/articles/serializers_test.py deleted file mode 100644 index da07328f94..0000000000 --- a/articles/serializers_test.py +++ /dev/null @@ -1,67 +0,0 @@ -from io import BytesIO - -import pytest -from django.core.files.uploadedfile import SimpleUploadedFile -from PIL import Image -from rest_framework import serializers - -from articles.models import ArticleImageUpload -from articles.serializers import ArticleImageUploadSerializer, SanitizedHtmlField - - -class HTMLSantizingSerializer(serializers.Serializer): - html = SanitizedHtmlField() - - -def test_html_sanitization(): - serializer = HTMLSantizingSerializer( - data={"html": "

"} - ) - serializer.is_valid() - - assert serializer.data["html"] == "

" - - -def generate_test_image(): - """Create a valid in-memory JPEG image.""" - file = BytesIO() - image = Image.new("RGB", (100, 100), color="red") - image.save(file, "JPEG") - file.seek(0) - return SimpleUploadedFile( - "test.jpg", - file.read(), - content_type="image/jpeg", - ) - - -@pytest.mark.django_db -def test_article_image_upload_serializer(django_user_model): - image_file = generate_test_image() - - user = django_user_model.objects.create_user( - username="testuser", - email="user@example.com", - password="password123", # noqa: S106 - ) - - class FakeRequest: - pass - - request = FakeRequest() - request.user = user - - serializer = ArticleImageUploadSerializer( - data={"image_file": image_file}, - context={"request": request}, - ) - - assert serializer.is_valid(), serializer.errors - - instance = serializer.save() - - assert isinstance(instance, ArticleImageUpload) - assert instance.user == user - # ✅ Check for valid saved file - assert instance.image_file - assert instance.image_file.name.endswith(".jpg") diff --git a/articles/tasks.py b/articles/tasks.py deleted file mode 100644 index a665361124..0000000000 --- a/articles/tasks.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tasks for articles CDN purge""" - -import logging - -from mitol.common.decorators import single_task - -from main.celery import app -from main.utils import call_fastly_purge_api - -log = logging.getLogger(__name__) - -PURGE_TIMEOUT_SECONDS = 5 # 5 seconds - - -@app.task() -def fastly_purge_relative_url(relative_url, timeout=30): - """ - Purge the given relative URL from the Fastly cache. - - Can be called directly (runs immediately) or via .delay() (enqueued for Celery). - - Args: - relative_url: The relative URL path to purge (e.g., "/news/article-slug/") - timeout: Timeout in seconds for the API request (default: 30) - - Returns: - dict: Response from Fastly API with status - """ - return call_fastly_purge_api(relative_url, timeout=timeout) - - -@app.task() -def fastly_full_purge(): - """ - Purges everything from the Fastly cache. - - Passing * to the purge API instructs Fastly to purge everything. - """ - log.info("Purging all pages from the Fastly cache...") - return call_fastly_purge_api("*") - - -@app.task() -@single_task(10) -def fastly_purge_articles_list(): - """ - Purges the articles list page from the Fastly cache. - - Can be called directly (runs immediately) or via .delay() (enqueued for Celery). - """ - log.info("Purging articles list page from the Fastly cache...") - articles_url = "/news" - return call_fastly_purge_api(articles_url) - - -# Backwards compatibility aliases -queue_fastly_purge_articles_list = fastly_purge_articles_list -queue_fastly_full_purge = fastly_full_purge diff --git a/articles/tasks_test.py b/articles/tasks_test.py deleted file mode 100644 index 0c8c2776cb..0000000000 --- a/articles/tasks_test.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Tests for articles CDN purge tasks""" - -from unittest.mock import MagicMock, patch - -import pytest -import requests -from requests import Response - -from articles.factories import ArticleFactory -from articles.tasks import ( - fastly_full_purge, - fastly_purge_articles_list, - fastly_purge_relative_url, -) -from main.utils import call_fastly_purge_api - - -@pytest.fixture -def mock_fastly_response(): - """Create a mock successful Fastly response""" - response = MagicMock(spec=Response) - response.status_code = 200 - response.json.return_value = {"status": "ok", "id": "123-456"} - response.text = '{"status": "ok", "id": "123-456"}' - return response - - -@pytest.fixture -def mock_fastly_error_response(): - """Create a mock error Fastly response""" - response = MagicMock(spec=Response) - response.status_code = 403 - response.reason = "Forbidden" - response.text = '{"status": "error", "msg": "Invalid API key"}' - response.raise_for_status.side_effect = requests.HTTPError("403 Forbidden") - return response - - -@pytest.mark.django_db -class TestCallFastlyPurgeApi: - """Tests for call_fastly_purge_api function""" - - @patch("main.utils.requests.request") - @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") - @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") - @patch("django.conf.settings.FASTLY_API_KEY", "test-token") - def test_call_fastly_purge_api_success(self, mock_request, mock_fastly_response): - """Test successful Fastly API call""" - mock_request.return_value = mock_fastly_response - - result = call_fastly_purge_api("/api/v1/articles/test-article/") - - assert result == {"status": "ok", "id": "123-456"} - mock_request.assert_called_once() - - # Verify headers were set correctly - call_kwargs = mock_request.call_args.kwargs - assert call_kwargs["headers"]["fastly-key"] == "test-token" - assert call_kwargs["timeout"] == 30 - - @patch("main.utils.requests.request") - @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") - @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") - @patch("django.conf.settings.FASTLY_API_KEY", "test-token") - def test_call_fastly_purge_api_full_purge(self, mock_request, mock_fastly_response): - """Test full cache purge (wildcard)""" - mock_request.return_value = mock_fastly_response - - result = call_fastly_purge_api("*") - - assert result == {"status": "ok", "id": "123-456"} - - # Verify soft-purge is NOT set for wildcard - call_kwargs = mock_request.call_args.kwargs - assert "fastly-soft-purge" not in call_kwargs["headers"] - - @patch("main.utils.requests.request") - @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") - @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") - @patch("django.conf.settings.FASTLY_API_KEY", "") - def test_call_fastly_purge_api_no_token(self, mock_request, mock_fastly_response): - """Test API call without auth token - skips in dev""" - mock_request.return_value = mock_fastly_response - - result = call_fastly_purge_api("/api/v1/news/test/") - - # Should skip purge when API key is empty (dev environment) - assert result == {"status": "ok", "skipped": True} - - # Verify API was not called - mock_request.assert_not_called() - - @patch("main.utils.requests.request") - @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") - @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") - @patch("django.conf.settings.FASTLY_API_KEY", "test-token") - def test_call_fastly_purge_api_error( - self, mock_request, mock_fastly_error_response - ): - """Test Fastly API error response""" - mock_request.return_value = mock_fastly_error_response - - with pytest.raises(requests.HTTPError): - call_fastly_purge_api("/api/v1/news/test/") - - -@pytest.mark.django_db -class TestFastlyPurgeRelativeUrl: - """Tests for fastly_purge_relative_url task""" - - @patch("django.conf.settings.FASTLY_API_KEY", "test-token") - @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") - @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") - @patch("main.utils.requests.request") - def test_purge_url_success(self, mock_request, mock_fastly_response): - """Test purging a URL successfully""" - mock_request.return_value = mock_fastly_response - - article = ArticleFactory(is_published=True, slug="test-article") - article_url = article.get_url() - - result = fastly_purge_relative_url(article_url) - - assert result["status"] == "ok" - mock_request.assert_called_once() - - @patch("django.conf.settings.FASTLY_API_KEY", "test-token") - @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") - @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") - @patch("main.utils.requests.request") - def test_purge_url_api_failure(self, mock_request, mock_fastly_error_response): - """Test handling API failure""" - mock_request.return_value = mock_fastly_error_response - - with pytest.raises(requests.HTTPError): - fastly_purge_relative_url("/news/test-article/") - - @patch("django.conf.settings.FASTLY_API_KEY", "test-token") - @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") - @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") - @patch("main.utils.requests.request") - def test_purge_url_with_timeout(self, mock_request, mock_fastly_response): - """Test purging with custom timeout""" - mock_request.return_value = mock_fastly_response - - result = fastly_purge_relative_url("/news/test/", timeout=5) - - assert result["status"] == "ok" - call_kwargs = mock_request.call_args.kwargs - assert call_kwargs["timeout"] == 5 - - -@pytest.mark.django_db -class TestFastlyFullPurge: - """Tests for fastly_full_purge task""" - - @patch("django.conf.settings.FASTLY_API_KEY", "test-token") - @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") - @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") - @patch("main.utils.requests.request") - def test_full_purge_success(self, mock_request, mock_fastly_response): - """Test successful full cache purge""" - mock_request.return_value = mock_fastly_response - - result = fastly_full_purge() - - assert result["status"] == "ok" - mock_request.assert_called_once() - - @patch("django.conf.settings.FASTLY_API_KEY", "test-token") - @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") - @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") - @patch("main.utils.requests.request") - def test_full_purge_failure(self, mock_request, mock_fastly_error_response): - """Test failed full cache purge""" - mock_request.return_value = mock_fastly_error_response - - with pytest.raises(requests.HTTPError): - fastly_full_purge() - - -@pytest.mark.django_db -class TestFastlyPurgeArticlesList: - """Tests for fastly_purge_articles_list task""" - - @patch("django.conf.settings.FASTLY_API_KEY", "test-token") - @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") - @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") - @patch("main.utils.requests.request") - @patch("mitol.common.decorators.get_redis_connection") - def test_purge_articles_list_success( - self, mock_redis, mock_request, mock_fastly_response - ): - """Test successful articles list purge""" - mock_request.return_value = mock_fastly_response - mock_redis.return_value.get.return_value = None - - result = fastly_purge_articles_list() - - assert result["status"] == "ok" - mock_request.assert_called_once() - - @patch("django.conf.settings.FASTLY_API_KEY", "test-token") - @patch("django.conf.settings.APP_BASE_URL", "https://learn.mit.edu") - @patch("django.conf.settings.FASTLY_URL", "https://api.fastly.com") - @patch("main.utils.requests.request") - @patch("mitol.common.decorators.get_redis_connection") - def test_purge_articles_list_failure( - self, mock_redis, mock_request, mock_fastly_error_response - ): - """Test failed articles list purge""" - mock_request.return_value = mock_fastly_error_response - mock_redis.return_value.get.return_value = None - - with pytest.raises(requests.HTTPError): - fastly_purge_articles_list() diff --git a/articles/urls.py b/articles/urls.py deleted file mode 100644 index d5f0da8ada..0000000000 --- a/articles/urls.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.urls import include, path, re_path -from rest_framework.routers import SimpleRouter - -from articles import views - -from .views import MediaUploadView - -v1_router = SimpleRouter() -v1_router.register( - r"articles", - views.ArticleViewSet, - basename="articles", -) - -app_name = "articles" - -urlpatterns = [ - re_path( - r"^api/v1/", - include( - ( - [ - # All ViewSet routes - *v1_router.urls, - # Media upload endpoint - path( - "upload-media/", - MediaUploadView.as_view(), - name="api-media-upload", - ), - ], - "v1", - ) - ), - ), -] diff --git a/articles/validators.py b/articles/validators.py deleted file mode 100644 index aa7997f340..0000000000 --- a/articles/validators.py +++ /dev/null @@ -1,45 +0,0 @@ -import nh3 - -article_html_config = { - "tags": { - # Headings - "h2", - "h3", - "h4", - # Basic typographic styles - "p", - "strong", - "i", - "em", - # Lists - "ol", - "ul", - "li", - # Links - "a", - # Images - "figure", - "img", - "figcaption", - # Blockquotes - "blockquote", - # media embed - # This is a custom tag that won't be rendered directly by browsers - "oembed", - }, - # See ammonia defaults: - # - On specific tags: https://docs.rs/ammonia/latest/ammonia/struct.Builder.html#method.tag_attributes - # - On all tags: https://docs.rs/ammonia/latest/ammonia/struct.Builder.html#method.generic_attributes - "attributes": { - "a": {"href", "hreflang"}, - "img": {"alt", "height", "src", "width", "srcset", "sizes", "style"}, - "figure": {"class", "style"}, - "oembed": {"url"}, - }, - # 👇 Allow data: URLs for src attributes - "url_schemes": {"data"}, -} - - -def clean_html(html: str) -> str: - return nh3.clean(html, **article_html_config) diff --git a/articles/validators_test.py b/articles/validators_test.py deleted file mode 100644 index 9d55ff1811..0000000000 --- a/articles/validators_test.py +++ /dev/null @@ -1,44 +0,0 @@ -import pytest - -from articles.validators import clean_html - -# # - On all tags: https://docs.rs/ammonia/latest/ammonia/struct.Builder.html#method.generic_attributes -# }, - - -@pytest.mark.parametrize( - ("html_in", "expected_out"), - [ - ( - 'ammonia', - 'ammonia', - ), - ( - ( - 'alt' - ), - ( - 'alt' - ), - ), - ( - # class alllowed on figures - '
', - '
', - ), - ( - # scripts not allowed - '', - "", - ), - ( - # h1 not allowed - "

1111

2222

3333

4444

", - "1111

2222

3333

4444

", - ), - ], -) -def test_clean_html(html_in, expected_out): - assert clean_html(html_in) == expected_out diff --git a/articles/views.py b/articles/views.py deleted file mode 100644 index 85c36badab..0000000000 --- a/articles/views.py +++ /dev/null @@ -1,180 +0,0 @@ -from django.conf import settings -from django.shortcuts import get_object_or_404 -from django.utils.decorators import method_decorator -from drf_spectacular.utils import ( - OpenApiParameter, - OpenApiResponse, - extend_schema, - extend_schema_view, -) -from rest_framework import status, viewsets -from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from articles.api import article_published_actions, purge_article_on_save -from articles.models import Article -from articles.serializers import RichTextArticleSerializer -from learning_resources.permissions import is_admin_user -from main.constants import VALID_HTTP_METHODS -from main.utils import cache_page_per_user, clear_views_cache - -from .permissions import CanEditArticle, CanViewArticle, is_article_group_user -from .serializers import ArticleImageUploadSerializer - - -@extend_schema_view( - list=extend_schema( - summary="List", - description="Get a paginated list of articles", - parameters=[ - OpenApiParameter( - name="draft", - type=bool, - location=OpenApiParameter.QUERY, - description=( - "Filter to show only draft articles. Only available for " - "admins and article editors. If true, returns unpublished " - "articles. If not specified, returns all articles." - ), - required=False, - ) - ], - ), - retrieve=extend_schema(summary="Retrieve", description="Retrieve a single article"), - create=extend_schema(summary="Create", description="Create a new article"), - destroy=extend_schema(summary="Destroy", description="Delete an article"), - partial_update=extend_schema(summary="Update", description="Update an article"), -) -class ArticleViewSet(viewsets.ModelViewSet): - """ - Viewset for Article viewing and editing. - """ - - serializer_class = RichTextArticleSerializer - queryset = Article.objects.all() - permission_classes = [CanViewArticle, CanEditArticle] - http_method_names = VALID_HTTP_METHODS - - def get_queryset(self): - qs = Article.objects.all() - - # Admins/staff/learning_path_article_editors group see everything - if is_admin_user(self.request) or is_article_group_user(self.request): - # Apply optional draft filter for admins/editors - draft_param = self.request.query_params.get("draft") - if draft_param and draft_param.lower() in ("true", "1"): - # If draft=true, return only unpublished articles - qs = qs.filter(is_published=False) - # Otherwise, return all articles (both published and unpublished) - return qs - - # Normal users only see published articles - return qs.filter(is_published=True) - - @method_decorator( - cache_page_per_user( # Need user-specific caching here (see filtering above) - settings.REDIS_VIEW_CACHE_DURATION, cache="redis", key_prefix="articles" - ) - ) - def list(self, request, *args, **kwargs): - return super().list(request, *args, **kwargs) - - def perform_create(self, serializer): - clear_views_cache() - article = serializer.save(user=self.request.user) - purge_article_on_save(article) - article_published_actions(article=article) - - def perform_update(self, serializer): - clear_views_cache() - article = serializer.save() - purge_article_on_save(article) - article_published_actions(article=article) - - def destroy(self, request, *args, **kwargs): - clear_views_cache() - return super().destroy(request, *args, **kwargs) - - @extend_schema( - summary="Retrieve article by ID or slug", - description="If the path parameter is numeric → ID, else → slug.", - parameters=[ - OpenApiParameter( - name="identifier", - type=str, - location=OpenApiParameter.PATH, - description="Article ID (number) or slug (string)", - required=True, - ) - ], - responses={200: RichTextArticleSerializer, 404: OpenApiResponse()}, - ) - @action( - detail=False, - methods=["get"], - url_path="detail/(?P[^/.]+)", - url_name="detail-by-id-or-slug", - ) - def detail_by_id_or_slug(self, _request, identifier): - qs = self.get_queryset() - - if identifier.isdigit(): - article = get_object_or_404(qs, id=int(identifier)) - else: - article = get_object_or_404(qs, slug=identifier) - - serializer = self.get_serializer(article) - return Response(serializer.data, status=status.HTTP_200_OK) - - -@extend_schema_view( - post=extend_schema( - # request: multipart/form-data with a binary file field - request={ - "multipart/form-data": ArticleImageUploadSerializer, - }, - # response: 201 with JSON containing the URL - responses={ - 201: OpenApiResponse( - description="Successful Upload", - response=( - {"type": "object", "properties": {"url": {"type": "string"}}} - ), - ), - 400: OpenApiResponse(description="Bad request"), - 401: OpenApiResponse(description="Authentication required"), - }, - description="Upload an image (multipart/form-data) and return the storage URL.", - operation_id="media_upload", - tags=["media"], - ) -) -class MediaUploadView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request): - serializer = ArticleImageUploadSerializer( - data=request.data, context={"request": request} - ) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - obj = serializer.save() - - file_url = None - if obj.image_file: - try: - file_url = obj.image_file.url - except (AttributeError, ValueError, OSError): - file_url = None - - if not file_url: - # Defensive: if save didn't attach image_file for any reason - return Response( - {"error": "Upload failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - if settings.DEBUG: - file_url = request.build_absolute_uri(file_url) - return Response({"url": file_url}, status=status.HTTP_201_CREATED) From 3f1359b5844ce7f8b705d14f6ed0f681457b879d Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Wed, 13 May 2026 16:14:09 +0500 Subject: [PATCH 05/17] address the feedback --- website_content/views.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/website_content/views.py b/website_content/views.py index 17b86e8009..4967c57d8c 100644 --- a/website_content/views.py +++ b/website_content/views.py @@ -173,9 +173,25 @@ def post(self, request): serializer = WebsiteContentImageUploadSerializer( data=request.data, context={"request": request} ) - if serializer.is_valid(): - instance = serializer.save() + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + instance = serializer.save() + + file_url = None + if instance.image_file: + try: + file_url = instance.image_file.url + except (AttributeError, ValueError, OSError): + file_url = None + + if not file_url: return Response( - {"url": instance.image_file.url}, status=status.HTTP_201_CREATED + {"error": "Upload failed"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + if settings.DEBUG: + file_url = request.build_absolute_uri(file_url) + + return Response({"url": file_url}, status=status.HTTP_201_CREATED) From 366c5cbff6460b205298699b8fa0940698807c0b Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Thu, 14 May 2026 15:47:59 +0500 Subject: [PATCH 06/17] refactor: make internal editor code more generic (Second Part) --- .../UserArticles/UserArticleDetailPage.tsx | 52 ++ .../UserArticleDraftListingPage.tsx | 182 +++++++ .../UserArticles/UserArticleEditPage.tsx | 62 +++ .../UserArticles/UserArticleListingPage.tsx | 177 +++++++ .../UserArticles/UserArticleNewPage.tsx | 39 ++ .../app/articles/[slugOrId]/draft/page.tsx | 25 + .../src/app/articles/[slugOrId]/edit/page.tsx | 12 + .../main/src/app/articles/[slugOrId]/page.tsx | 95 ++++ .../main/src/app/articles/draft/page.tsx | 15 + frontends/main/src/app/articles/new/page.tsx | 15 + frontends/main/src/app/articles/page.tsx | 15 + frontends/main/src/common/urls.ts | 13 + .../TiptapEditor/ArticleEditor.tsx | 403 +--------------- .../contentTypes/article/ArticleEditor.tsx | 83 ++++ .../contentTypes/article/articleExtensions.ts | 11 + .../contentTypes/news/NewsEditor.tsx | 74 +++ .../contentTypes/news/newsExtensions.ts | 153 ++++++ .../TiptapEditor/core/GenericEditor.tsx | 449 ++++++++++++++++++ .../src/page-components/TiptapEditor/index.ts | 7 + .../TiptapEditor/useArticleSchema.ts | 149 +----- 20 files changed, 1494 insertions(+), 537 deletions(-) create mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleDetailPage.tsx create mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx create mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx create mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx create mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx create mode 100644 frontends/main/src/app/articles/[slugOrId]/draft/page.tsx create mode 100644 frontends/main/src/app/articles/[slugOrId]/edit/page.tsx create mode 100644 frontends/main/src/app/articles/[slugOrId]/page.tsx create mode 100644 frontends/main/src/app/articles/draft/page.tsx create mode 100644 frontends/main/src/app/articles/new/page.tsx create mode 100644 frontends/main/src/app/articles/page.tsx create mode 100644 frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx create mode 100644 frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts create mode 100644 frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx create mode 100644 frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts create mode 100644 frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleDetailPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleDetailPage.tsx new file mode 100644 index 0000000000..592546c9dd --- /dev/null +++ b/frontends/main/src/app-pages/UserArticles/UserArticleDetailPage.tsx @@ -0,0 +1,52 @@ +"use client" + +import React from "react" +import { useArticleDetailRetrieve } from "api/hooks/articles" +import { LoadingSpinner, styled } from "ol-components" +import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" +import { LearningResourceProvider } from "@/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceDataProvider" +import { notFound } from "next/navigation" + +const PageContainer = styled.div({ + display: "flex", + height: "100%", +}) + +const Spinner = styled(LoadingSpinner)({ + margin: "auto", + position: "absolute", + top: "40%", + left: "50%", + transform: "translate(-50%, -50%)", +}) + +const UserArticleDetailPage = ({ + articleId, + learningResourceIds = [], +}: { + articleId: string + learningResourceIds?: number[] +}) => { + const { data: article, isLoading } = useArticleDetailRetrieve(articleId) + + if (isLoading) { + return ( + + + + ) + } + if (!article) { + return notFound() + } + + return ( + + + + + + ) +} + +export { UserArticleDetailPage } diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx new file mode 100644 index 0000000000..bc8b162509 --- /dev/null +++ b/frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx @@ -0,0 +1,182 @@ +"use client" + +import React, { useState, useRef, useEffect } from "react" +import { + Container, + styled, + theme, + Grid2, + Card, + Pagination, + PaginationItem, + LoadingSpinner, + Typography, +} from "ol-components" +import { Permission } from "api/hooks/user" +import { useArticleList } from "api/hooks/articles" +import type { WebsiteContent } from "api/v1" +import { LocalDate } from "ol-utilities" +import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" +import { extractFirstImageFromArticle } from "@/common/articleUtils" +import { userArticlesDraftView, userArticlesView } from "@/common/urls" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" + +const PAGE_SIZE = 20 + +export const DEFAULT_BACKGROUND_IMAGE_URL = + "/images/backgrounds/banner_background.webp" + +const PageWrapper = styled.div` + background: ${theme.custom.colors.white}; + min-height: calc(100vh - 200px); + padding: 80px 0; + ${theme.breakpoints.down("md")} { + padding: 40px 0; + } +` + +const DraftArticleCard = styled(Card)` + display: flex; + flex-direction: column; + height: 100%; +` + +const PaginationContainer = styled.div` + display: flex; + justify-content: center; + margin-top: 40px; +` + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; +` + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + gap: 16px; +` + +const DraftBadge = styled.span` + color: ${theme.custom.colors.silverGrayDark}; + font-weight: ${theme.typography.fontWeightMedium}; +` + +const DraftUserArticle: React.FC<{ article: WebsiteContent }> = ({ + article, +}) => { + const articleUrl = article.is_published + ? userArticlesView(article.slug || String(article.id)) + : userArticlesDraftView(String(article.id)) + + const imageUrl = extractFirstImageFromArticle(article.content) + + return ( + + { + + } + + {article.title} + + + + {!article.is_published && ( + <> + {" • "} + Draft + + )} + + + ) +} + +const UserArticleDraftPage: React.FC = () => { + const [page, setPage] = useState(1) + const scrollRef = useRef(null) + + const { data: articles, isLoading: isLoadingArticles } = useArticleList({ + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + draft: true, + }) + + useEffect(() => { + if (page > 1 && scrollRef.current) { + scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" }) + } + }, [page]) + + const draftArticles = articles?.results + const totalPages = articles?.count ? Math.ceil(articles.count / PAGE_SIZE) : 0 + + if (isLoadingArticles) { + return + } + + return ( + + + + {isLoadingArticles ? ( + + + + ) : draftArticles && draftArticles.length > 0 ? ( + <> + + {draftArticles.map((article) => ( + + + + ))} + + + {totalPages > 1 && ( + + setPage(newPage)} + renderItem={(item) => ( + + )} + /> + + )} + + ) : ( + + No Draft Articles + + You don't have any draft articles yet. Create a new article + to get started. + + + )} + + + + ) +} + +export { UserArticleDraftPage } diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx new file mode 100644 index 0000000000..95f3ab0902 --- /dev/null +++ b/frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx @@ -0,0 +1,62 @@ +"use client" + +import React from "react" +import { useRouter } from "next-nprogress-bar" +import { notFound } from "next/navigation" +import { Permission } from "api/hooks/user" +import { useArticleDetailRetrieve } from "api/hooks/articles" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { styled, LoadingSpinner } from "ol-components" +import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" +import { userArticlesDraftView, userArticlesView } from "@/common/urls" +import invariant from "tiny-invariant" + +const PageContainer = styled.div(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "flex", + height: "100%", +})) + +const Spinner = styled(LoadingSpinner)({ + margin: "auto", + position: "absolute", + top: "40%", + left: "50%", + transform: "translate(-50%, -50%)", +}) + +const UserArticleEditPage = ({ articleId }: { articleId: string }) => { + const { + data: article, + isLoading, + isFetching, + } = useArticleDetailRetrieve(articleId) + const router = useRouter() + + if (isLoading || isFetching) { + return + } + if (!article) { + return notFound() + } + + return ( + + + { + if (saved.is_published) { + invariant(saved.slug, "Published article must have a slug") + return router.push(userArticlesView(saved.slug)) + } else { + router.push(userArticlesDraftView(String(saved.id))) + } + }} + /> + + + ) +} + +export { UserArticleEditPage } diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx new file mode 100644 index 0000000000..f23e45117b --- /dev/null +++ b/frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx @@ -0,0 +1,177 @@ +"use client" + +import React, { useState, useRef, useEffect } from "react" +import { + Container, + styled, + theme, + Grid2, + Card, + Pagination, + PaginationItem, + LoadingSpinner, + Typography, +} from "ol-components" +import { ButtonLink } from "@mitodl/smoot-design" +import { useArticleList } from "api/hooks/articles" +import type { WebsiteContent } from "api/v1" +import { LocalDate } from "ol-utilities" +import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" +import { extractFirstImageFromArticle } from "@/common/articleUtils" +import { + userArticlesView, + USER_ARTICLES_CREATE, + USER_ARTICLES_LISTING, +} from "@/common/urls" + +const PAGE_SIZE = 20 +const MAX_PAGE = 50 + +export const DEFAULT_BACKGROUND_IMAGE_URL = + "/images/backgrounds/banner_background.webp" + +const getLastPage = (count: number): number => { + const pages = Math.ceil(count / PAGE_SIZE) + return pages > MAX_PAGE ? MAX_PAGE : pages +} + +const PageHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; +` + +const Section = styled.section` + background: ${theme.custom.colors.white}; + padding: 80px 0; + ${theme.breakpoints.down("sm")} { + padding: 40px 0; + } +` + +const ArticleCardWrapper = styled(Card)` + display: flex; + flex-direction: column; + height: 100%; +` + +const PaginationContainer = styled.div` + display: flex; + justify-content: center; + margin-top: 40px; +` + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + gap: 16px; +` + +const UserArticleCard: React.FC<{ article: WebsiteContent }> = ({ + article, +}) => { + const articleUrl = article.is_published + ? userArticlesView(article.slug || String(article.id)) + : `${USER_ARTICLES_LISTING}${article.id}/draft` + + const imageUrl = extractFirstImageFromArticle(article.content) + + return ( + + + + {article.title} + + + + + + ) +} + +const UserArticleListingPage: React.FC = () => { + const [page, setPage] = useState(1) + const scrollRef = useRef(null) + + const { data: articles, isLoading } = useArticleList({ + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }) + + useEffect(() => { + if (page > 1 && scrollRef.current) { + scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" }) + } + }, [page]) + + const results = articles?.results + const totalPages = articles?.count ? getLastPage(articles.count) : 0 + + return ( +
+ + + Articles + + New Article + + + + {isLoading ? ( + + ) : results && results.length > 0 ? ( + <> + + {results.map((article) => ( + + + + ))} + + + {totalPages > 1 && ( + + setPage(newPage)} + renderItem={(item) => ( + + )} + /> + + )} + + ) : ( + + No Articles Yet + + Get started by creating your first article. + + + New Article + + + )} + +
+ ) +} + +export { UserArticleListingPage } diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx new file mode 100644 index 0000000000..bb03defc55 --- /dev/null +++ b/frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx @@ -0,0 +1,39 @@ +"use client" + +import React from "react" +import { useRouter } from "next-nprogress-bar" +import { Permission } from "api/hooks/user" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { styled } from "ol-components" +import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" +import { userArticlesDraftView, userArticlesView } from "@/common/urls" +import invariant from "tiny-invariant" + +const PageContainer = styled.div(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "flex", + height: "100%", +})) + +const UserArticleNewPage: React.FC = () => { + const router = useRouter() + + return ( + + + { + if (article.is_published) { + invariant(article.slug, "Published article must have a slug") + return router.push(userArticlesView(article.slug)) + } else { + router.push(userArticlesDraftView(String(article.id))) + } + }} + /> + + + ) +} + +export { UserArticleNewPage } diff --git a/frontends/main/src/app/articles/[slugOrId]/draft/page.tsx b/frontends/main/src/app/articles/[slugOrId]/draft/page.tsx new file mode 100644 index 0000000000..a375abaa8f --- /dev/null +++ b/frontends/main/src/app/articles/[slugOrId]/draft/page.tsx @@ -0,0 +1,25 @@ +import React from "react" +import { standardizeMetadata } from "@/common/metadata" +import { UserArticleDetailPage } from "@/app-pages/UserArticles/UserArticleDetailPage" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { Permission } from "api/hooks/user" + +export const generateMetadata = async () => { + return standardizeMetadata({ + title: "Draft Article", + }) +} + +const Page: React.FC> = async ( + props, +) => { + const { slugOrId } = await props.params + + return ( + + + + ) +} + +export default Page diff --git a/frontends/main/src/app/articles/[slugOrId]/edit/page.tsx b/frontends/main/src/app/articles/[slugOrId]/edit/page.tsx new file mode 100644 index 0000000000..50010e8367 --- /dev/null +++ b/frontends/main/src/app/articles/[slugOrId]/edit/page.tsx @@ -0,0 +1,12 @@ +import React from "react" +import { UserArticleEditPage } from "@/app-pages/UserArticles/UserArticleEditPage" + +const Page: React.FC> = async ( + props, +) => { + const { slugOrId } = await props.params + + return +} + +export default Page diff --git a/frontends/main/src/app/articles/[slugOrId]/page.tsx b/frontends/main/src/app/articles/[slugOrId]/page.tsx new file mode 100644 index 0000000000..635bbc6e1d --- /dev/null +++ b/frontends/main/src/app/articles/[slugOrId]/page.tsx @@ -0,0 +1,95 @@ +import React from "react" +import { HydrationBoundary, dehydrate } from "@tanstack/react-query" +import { articleQueries } from "api/hooks/articles/queries" +import { UserArticleDetailPage } from "@/app-pages/UserArticles/UserArticleDetailPage" +import { getQueryClient } from "@/app/getQueryClient" +import { learningResourceQueries } from "api/hooks/learningResources" +import { extractLearningResourceIds } from "@/page-components/TiptapEditor/extensions/utils" +import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata" +import type { WebsiteContent } from "api/v1" +import type { JSONContent } from "@tiptap/react" + +// Extracts the banner subheading paragraph at known location +const extractArticleDescription = ( + article: WebsiteContent, +): string | undefined => { + const banner = article.content?.content?.[0] + const subheading = banner?.content?.[1] + const textNode = subheading?.content?.[0] + return textNode?.text +} + +const extractImageMetadata = ( + article: WebsiteContent, +): { src: string; alt: string } | null => { + const imageWithCaption = article.content?.content?.find( + (node: JSONContent) => node.type === "imageWithCaption", + ) + if (!imageWithCaption) { + return null + } + return { + src: imageWithCaption.attrs.src, + alt: imageWithCaption.attrs.caption || imageWithCaption.attrs.alt, + } +} + +export const generateMetadata = async ( + props: PageProps<"/articles/[slugOrId]">, +) => { + const params = await props.params + const { slugOrId } = params + + const queryClient = getQueryClient() + + return safeGenerateMetadata(async () => { + const article = await queryClient.fetchQuery( + articleQueries.articlesDetailRetrieve(slugOrId), + ) + + const description = extractArticleDescription(article) + const leadImage = extractImageMetadata(article) + + return standardizeMetadata({ + title: article.title, + description, + image: leadImage?.src, + imageAlt: leadImage?.alt, + }) + }) +} + +const Page: React.FC> = async (props) => { + const { slugOrId } = await props.params + + const queryClient = getQueryClient() + + await queryClient.fetchQueryOr404( + articleQueries.articlesDetailRetrieve(slugOrId), + ) + + const queryKey = articleQueries.articlesDetailRetrieve(slugOrId).queryKey + const cacheData = queryClient.getQueryData(queryKey) + + const learningResourceIds = cacheData?.content + ? extractLearningResourceIds(cacheData.content) + : [] + + if (learningResourceIds.length > 0) { + const bulkQuery = learningResourceQueries.list({ + resource_id: learningResourceIds, + }) + await queryClient.prefetchQuery(bulkQuery) + } + + return ( + + + + ) +} + +export default Page diff --git a/frontends/main/src/app/articles/draft/page.tsx b/frontends/main/src/app/articles/draft/page.tsx new file mode 100644 index 0000000000..a16b38a550 --- /dev/null +++ b/frontends/main/src/app/articles/draft/page.tsx @@ -0,0 +1,15 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { UserArticleDraftPage } from "@/app-pages/UserArticles/UserArticleDraftListingPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "Article Drafts", + robots: "noindex, nofollow", +}) + +const Page: React.FC> = () => { + return +} + +export default Page diff --git a/frontends/main/src/app/articles/new/page.tsx b/frontends/main/src/app/articles/new/page.tsx new file mode 100644 index 0000000000..8bcd4029c2 --- /dev/null +++ b/frontends/main/src/app/articles/new/page.tsx @@ -0,0 +1,15 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { UserArticleNewPage } from "@/app-pages/UserArticles/UserArticleNewPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "MIT Learn | New Article", + robots: "noindex, nofollow", +}) + +const Page: React.FC> = () => { + return +} + +export default Page diff --git a/frontends/main/src/app/articles/page.tsx b/frontends/main/src/app/articles/page.tsx new file mode 100644 index 0000000000..1a2061323b --- /dev/null +++ b/frontends/main/src/app/articles/page.tsx @@ -0,0 +1,15 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { UserArticleListingPage } from "@/app-pages/UserArticles/UserArticleListingPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "MIT Learn | Articles", + robots: "noindex, nofollow", +}) + +const Page: React.FC> = () => { + return +} + +export default Page diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index d2a334a86f..2c47c3c2b2 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -48,6 +48,19 @@ export const articlesDraftView = (id: string) => export const articlesEditView = (id: number) => generatePath(ARTICLES_EDIT, { id: String(id) }) +// User-created articles (served under /articles) +export const USER_ARTICLES_LISTING = "/articles/" +export const USER_ARTICLES_VIEW = "/articles/[id]" +export const USER_ARTICLES_DRAFT_VIEW = "/articles/[id]/draft" +export const USER_ARTICLES_EDIT = "/articles/[id]/edit" +export const USER_ARTICLES_CREATE = "/articles/new" +export const userArticlesView = (id: string) => + generatePath(USER_ARTICLES_VIEW, { id: String(id) }) +export const userArticlesDraftView = (id: string) => + generatePath(USER_ARTICLES_DRAFT_VIEW, { id: String(id) }) +export const userArticlesEditView = (id: number) => + generatePath(USER_ARTICLES_EDIT, { id: String(id) }) + export const DEPARTMENTS = "/departments/" export const TOPICS = "/topics/" diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx index ec580b7aa5..f5c01fe11e 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx @@ -1,395 +1,8 @@ -"use client" - -import React, { ChangeEventHandler, useState, useEffect } from "react" -import styled from "@emotion/styled" -import { EditorContext, JSONContent, useEditor } from "@tiptap/react" -import type { WebsiteContent } from "api/v1" -import { - LoadingSpinner, - Typography, - HEADER_HEIGHT, - HEADER_HEIGHT_MD, -} from "ol-components" - -import { Toolbar } from "./vendor/components/tiptap-ui-primitive/toolbar" -import { Spacer } from "./vendor/components/tiptap-ui-primitive/spacer" - -import { TiptapEditor, MainToolbarContent, TipTapViewer } from "./TiptapEditor" -import { ArticleProvider } from "./ArticleContext" - -import { handleImageUpload } from "./vendor/lib/tiptap-utils" -import { useArticleSchema, newArticleDocument } from "./useArticleSchema" - -import "./vendor/styles/_keyframe-animations.scss" -import "./vendor/styles/_variables.scss" -import "./vendor/components/tiptap-templates/simple/simple-editor.scss" - -import { - useArticleCreate, - useArticlePartialUpdate, - useMediaUpload, -} from "api/hooks/articles" -import { Alert, Button, ButtonLink } from "@mitodl/smoot-design" -import { useUserHasPermission, Permission } from "api/hooks/user" -import dynamic from "next/dynamic" -import { extractLearningResourceIds, contentsMatch } from "./extensions/utils" -import { LearningResourceProvider } from "./extensions/node/LearningResource/LearningResourceDataProvider" - -const LearningResourceDrawer = dynamic( - () => - import("@/page-components/LearningResourceDrawer/LearningResourceDrawer"), - { ssr: false }, -) - -const TOOLBAR_HEIGHT = 43 - -const ViewContainer = styled.div<{ toolbarVisible: boolean }>( - ({ toolbarVisible, theme }) => ({ - width: "100vw", - marginTop: toolbarVisible ? TOOLBAR_HEIGHT : 0, - backgroundColor: theme.custom.colors.white, - }), -) - -const StyledToolbar = styled(Toolbar)(({ theme }) => ({ - "&&": { - position: "fixed", - top: HEADER_HEIGHT, - [theme.breakpoints.down("md")]: { - top: HEADER_HEIGHT_MD, - }, - }, -})) - -const StyledAlert = styled(Alert)({ - margin: "20px auto", - maxWidth: "1000px", - position: "fixed", - top: "108px", - left: "50%", - width: "690px", - transform: "translateX(-50%)", - zIndex: 1, - "p:not(:first-child)": { - margin: "10px 0", - }, -}) - -interface ArticleEditorProps { - value?: object - onSave?: (article: WebsiteContent) => void - readOnly?: boolean - title?: string - setTitle?: ChangeEventHandler - article?: WebsiteContent -} -const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { - const [title, setTitle] = React.useState(article?.title) - const [isPublishing, setIsPublishing] = useState(false) - const [uploadError, setUploadError] = useState(null) - const [resetAttempted, setResetAttempted] = useState(false) - - const { - mutate: createArticle, - isPending: isCreating, - error: createError, - } = useArticleCreate() - const { - mutate: updateArticle, - isPending: isUpdating, - error: updateError, - } = useArticlePartialUpdate() - - const uploadImage = useMediaUpload() - - const isArticleEditor = useUserHasPermission(Permission.ArticleEditor) - - const [content, setContent] = useState( - article?.content || newArticleDocument, - ) - const [touched, setTouched] = useState(false) - - // Extract author_name from the byline node - const extractAuthorName = (content: JSONContent): string | "" => { - const bylineNode = content.content?.find((node) => node.type === "byline") - return bylineNode?.attrs?.authorName || "" - } - - const handleSave = (publish: boolean) => { - if (!title) return - const authorName = extractAuthorName(content) - if (article) { - updateArticle( - { - id: article.id, - title: title.trim(), - content, - is_published: publish, - author_name: authorName, - }, - { - onSuccess: onSave, - }, - ) - } else { - createArticle( - { - title: title.trim(), - content, - is_published: publish, - author_name: authorName, - }, - { - onSuccess: onSave, - }, - ) - } - } - - const uploadHandler = async ( - file: File, - onProgress?: (e: { progress: number }) => void, - abortSignal?: AbortSignal, - ) => { - setUploadError(null) - return handleImageUpload( - file, - async (file: File, progressCb?: (percent: number) => void) => { - try { - uploadImage.setNextProgressCallback(progressCb) - - const response = await uploadImage.mutateAsync({ file }) - - if (!response?.url) throw new Error("Upload failed") - return response.url - } catch (error) { - if (error instanceof Error) { - setUploadError(error.message) - } else { - setUploadError(String(error) || "Upload failed") - } - - throw error - } - }, - onProgress, - abortSignal, - ) - } - - const { extensions, schemaError } = useArticleSchema({ - uploadHandler, - setUploadError, - enabled: isArticleEditor, - content, - }) - - const editor = useEditor({ - immediatelyRender: false, - shouldRerenderOnTransaction: false, - content, - editable: !readOnly, - - onUpdate: ({ editor }) => { - const json = editor.getJSON() - setContent(json) - setTouched(true) - }, - - onCreate: ({ editor }) => { - setTimeout(() => { - editor.commands.setTextSelection(1) - editor.commands.focus() - }, 0) - - editor.commands.updateAttributes("mediaEmbed", { editable: !readOnly }) - editor.commands.updateAttributes("byline", { editable: readOnly }) - }, - - editorProps: { - attributes: { - autocomplete: "off", - autocorrect: "off", - autocapitalize: "off", - "aria-label": "Main content area, start typing to enter text.", - class: "simple-editor", - }, - }, - extensions, - }) - - useEffect(() => { - if (!article || !editor) return - - if (article.content) { - const currentContent = editor.getJSON() - if (!contentsMatch(article.content, currentContent)) { - setContent(article.content) - setTouched(true) - editor.commands.setContent(article.content) - } - } - - if (article.title !== undefined) { - setTitle(article.title) - } - }, [article, editor]) - - useEffect(() => { - if (!editor) return - const title = editor.$node("heading", { level: 1 })?.textContent || "" - setTitle(title) - }, [editor, content]) - - useEffect(() => { - if (!editor) return - editor - .chain() - .command(({ tr, state }) => { - state.doc.descendants((node, pos) => { - if ( - node.type.name === "mediaEmbed" || - node.type.name === "imageWithCaption" || - node.type.name === "byline" || - node.type.name === "learningResource" - ) { - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - editable: !readOnly, - }) - } - }) - return true - }) - .run() - }, [editor, readOnly]) - - if (!editor) return null - - const isPending = isCreating || isUpdating - const error = createError || updateError || uploadError || schemaError - - const resourceIds = extractLearningResourceIds(content) - - return ( - - - - - {isArticleEditor ? ( - readOnly ? ( - - - - Drafts - - - Edit - - - ) : ( - - - {!article?.is_published ? ( - - ) : null} - - - - ) - ) : null} - {error ? ( - - - {error instanceof Error ? error.message : error} - - {schemaError && !readOnly ? ( - <> - - Reset to attempt to align the article to the content - template. - - {resetAttempted ? ( - - Reset attempt failed. - - ) : null} - - - ) : null} - - ) : null} - {readOnly ? ( - <> - - - - ) : ( - - )} - - - - - ) -} - -export { ArticleEditor } +/** + * Backward-compatible re-export. + * The news editor logic now lives in contentTypes/news/NewsEditor.tsx. + * All /news pages continue to import from this path without changes. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export { NewsEditor as ArticleEditor } from "./contentTypes/news/NewsEditor" +export type { NewsEditorProps as ArticleEditorProps } from "./contentTypes/news/NewsEditor" diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx new file mode 100644 index 0000000000..1450e5ec5a --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx @@ -0,0 +1,83 @@ +"use client" + +import React from "react" +import type { ChangeEventHandler } from "react" +import type { WebsiteContent } from "api/v1" +import { ButtonLink } from "@mitodl/smoot-design" +import { useArticleCreate, useArticlePartialUpdate } from "api/hooks/articles" +import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer" +import { GenericEditor } from "../../core/GenericEditor" +import { + createArticleExtensions, + newArticleDocument, +} from "./articleExtensions" + +// Article-specific: extract author name from the byline node +const extractArticleExtraFields = (content: { + content?: Array<{ type?: string; attrs?: Record }> +}): Record => { + const bylineNode = content.content?.find((node) => node.type === "byline") + return { author_name: bylineNode?.attrs?.authorName || "" } +} + +interface ArticleEditorProps { + /** @deprecated unused, kept for API compatibility */ + value?: object + onSave?: (article: WebsiteContent) => void + readOnly?: boolean + /** @deprecated unused, kept for API compatibility */ + title?: string + /** @deprecated unused, kept for API compatibility */ + setTitle?: ChangeEventHandler + article?: WebsiteContent +} + +/** + * Editor shell configured for the article content type (served under /articles). + * Owns its own save mutations so GenericEditor stays API-agnostic. + * + * Currently uses the same websiteContent API as the news editor. When /articles + * gets its own Django model and viewset, swap in the new hooks here: + * + * const createMutation = useUserArticleCreate() // future hook + * const updateMutation = useUserArticlePartialUpdate() + * + * GenericEditor does not need to change at all. + */ +const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { + // Swap these two lines when a dedicated UserArticle API exists. + const createMutation = useArticleCreate() + const updateMutation = useArticlePartialUpdate() + + const editUrl = article + ? `/articles/${article.is_published ? article.slug : article.id}/edit` + : "/articles/new" + + const toolbarSlot = readOnly ? ( + <> + + + Drafts + + + Edit + + + ) : null + + return ( + + ) +} + +export { ArticleEditor } +export type { ArticleEditorProps } diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts new file mode 100644 index 0000000000..3c5ba195fb --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts @@ -0,0 +1,11 @@ +/** + * Article content type extensions. + * + * Currently mirrors the news content type. Extensions and document structure + * will diverge as the /articles feature evolves. + */ +export { + createNewsExtensions as createArticleExtensions, + newNewsDocument as newArticleDocument, + NewsDocument as ArticleDocument, +} from "../news/newsExtensions" diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx new file mode 100644 index 0000000000..ab380a4928 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx @@ -0,0 +1,74 @@ +"use client" + +import React from "react" +import type { ChangeEventHandler } from "react" +import type { WebsiteContent } from "api/v1" +import { ButtonLink } from "@mitodl/smoot-design" +import { useArticleCreate, useArticlePartialUpdate } from "api/hooks/articles" +import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer" +import { GenericEditor } from "../../core/GenericEditor" +import { createNewsExtensions, newNewsDocument } from "./newsExtensions" + +// News-specific: extract the author name from the byline node in the document +const extractNewsExtraFields = (content: { + content?: Array<{ type?: string; attrs?: Record }> +}): Record => { + const bylineNode = content.content?.find((node) => node.type === "byline") + return { author_name: bylineNode?.attrs?.authorName || "" } +} + +interface NewsEditorProps { + /** @deprecated unused, kept for API compatibility */ + value?: object + onSave?: (article: WebsiteContent) => void + readOnly?: boolean + /** @deprecated unused, kept for API compatibility */ + title?: string + /** @deprecated unused, kept for API compatibility */ + setTitle?: ChangeEventHandler + article?: WebsiteContent +} + +/** + * Editor shell configured for the news content type (served under /news). + * Owns its own save mutations (websiteContent API) and passes them to + * GenericEditor — keeping the generic shell decoupled from any specific API. + */ +const NewsEditor = ({ onSave, readOnly, article }: NewsEditorProps) => { + // News content type uses the websiteContent (articles) API. + // A different content type would call different hooks here. + const createMutation = useArticleCreate() + const updateMutation = useArticlePartialUpdate() + + const editUrl = article + ? `/news/${article.is_published ? article.slug : article.id}/edit` + : "/news/new" + + const toolbarSlot = readOnly ? ( + <> + + + Drafts + + + Edit + + + ) : null + + return ( + + ) +} + +export { NewsEditor } +export type { NewsEditorProps } diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts new file mode 100644 index 0000000000..edd34eab87 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts @@ -0,0 +1,153 @@ +import type { Extension, Node, Mark } from "@tiptap/core" +import Document from "@tiptap/extension-document" +import { Placeholder, Selection } from "@tiptap/extensions" +import { StarterKit } from "@tiptap/starter-kit" +import { TaskItem, TaskList } from "@tiptap/extension-list" +import { Heading } from "@tiptap/extension-heading" +import { Image } from "@tiptap/extension-image" +import { TextAlign } from "@tiptap/extension-text-align" +import { Typography as TiptapTypography } from "@tiptap/extension-typography" +import { Subscript } from "@tiptap/extension-subscript" +import { Superscript } from "@tiptap/extension-superscript" +import type { Node as ProseMirrorNode } from "@tiptap/pm/model" +import { HorizontalRule } from "../../vendor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension" +import { ImageNode } from "../../extensions/node/Image/ImageNode" +import { ImageWithCaptionNode } from "../../extensions/node/Image/ImageWithCaptionNode" +import { DividerNode } from "../../extensions/node/Divider/DividerNode" +import { ArticleByLineInfoBarNode } from "../../extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode" +import { LearningResourceNode } from "../../extensions/node/LearningResource/LearningResourceNode" +import { LearningResourceInputNode } from "../../extensions/node/LearningResource/LearningResourceInputNode" +import { LearningResourceURLHandler } from "../../extensions/node/LearningResource/LearningResourcePaste" +import { MediaEmbedURLHandler } from "../../extensions/node/MediaEmbed/MediaEmbedURLHandler" +import { MediaEmbedNode } from "../../extensions/node/MediaEmbed/MediaEmbedNode" +import { MediaEmbedInputNode } from "../../extensions/node/MediaEmbed/MediaEmbedInputNode" +import { BannerNode } from "../../extensions/node/Banner/BannerNode" +import type { ExtendedNodeConfig } from "../../extensions/node/types" +import { MAX_FILE_SIZE } from "../../vendor/lib/tiptap-utils" +import type { CreateExtensionsFn } from "../../core/GenericEditor" + +export const NewsDocument = Document.extend({ + content: "banner byline block+", +}) + +export const newNewsDocument = { + type: "doc", + content: [ + { + type: "banner", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [], + }, + { + type: "paragraph", + content: [], + }, + ], + }, + { + type: "byline", + attrs: { authorName: null }, + }, + { type: "paragraph", content: [] }, + ], +} + +/** + * Factory function that builds the full extensions list for the news content type. + * Pass to GenericEditor as `createExtensions`. + */ +export const createNewsExtensions: CreateExtensionsFn = ( + uploadHandler, + setUploadError, +): (Extension | Node | Mark)[] => [ + NewsDocument, + StarterKit.configure({ + document: false, + horizontalRule: false, + heading: false, + link: { + openOnClick: false, + enableClickSelection: true, + }, + trailingNode: { + node: "paragraph", + }, + }), + Heading.configure({ + levels: [1, 2, 3, 4, 5, 6], + }), + Placeholder.configure({ + showOnlyCurrent: false, + includeChildren: true, + placeholder: ({ node, editor }): string => { + let parentNode: typeof node | null = null + + editor.state.doc.descendants((n: ProseMirrorNode) => { + n.forEach((childNode: ProseMirrorNode) => { + if (childNode === node) { + parentNode = n + } + }) + if (parentNode) { + return false + } + return undefined + }) + + if (parentNode) { + const parentExtension = editor.extensionManager.extensions.find( + (ext) => ext.name === parentNode!.type.name, + ) + + if ( + parentExtension && + "config" in parentExtension && + parentExtension.config && + typeof (parentExtension.config as ExtendedNodeConfig) + .getPlaceholders === "function" + ) { + const placeholder = ( + parentExtension.config as ExtendedNodeConfig + ).getPlaceholders(node) + if (placeholder) { + return placeholder + } + } + } + + if (node.type.name === "heading") { + return "Add a heading" + } + return "Add some text" + }, + }), + HorizontalRule, + LearningResourceURLHandler, + LearningResourceNode, + LearningResourceInputNode, + TextAlign.configure({ types: ["heading", "paragraph"] }), + TaskList, + TaskItem.configure({ nested: true }), + TiptapTypography, + Superscript, + Subscript, + Selection, + Image, + MediaEmbedNode, + MediaEmbedInputNode, + DividerNode, + ArticleByLineInfoBarNode, + ImageWithCaptionNode, + MediaEmbedURLHandler, + ImageNode.configure({ + accept: "image/*", + maxSize: MAX_FILE_SIZE, + limit: 3, + upload: uploadHandler, + onError: (error) => setUploadError(error.message), + }), + BannerNode, +] diff --git a/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx new file mode 100644 index 0000000000..27a7be5bd3 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx @@ -0,0 +1,449 @@ +"use client" + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import styled from "@emotion/styled" +import { EditorContext, JSONContent, useEditor } from "@tiptap/react" +import type { Extension, Node, Mark } from "@tiptap/core" +import { getSchema } from "@tiptap/core" +import type { WebsiteContent } from "api/v1" +import { + LoadingSpinner, + Typography, + HEADER_HEIGHT, + HEADER_HEIGHT_MD, +} from "ol-components" +import { Alert, Button } from "@mitodl/smoot-design" +import { useUserHasPermission, Permission } from "api/hooks/user" +import { useMediaUpload } from "api/hooks/articles" +import dynamic from "next/dynamic" + +import { Toolbar } from "../vendor/components/tiptap-ui-primitive/toolbar" +import { TiptapEditor, MainToolbarContent, TipTapViewer } from "../TiptapEditor" +import { handleImageUpload } from "../vendor/lib/tiptap-utils" +import { useSchema } from "../useSchema" +import { ArticleProvider } from "../ArticleContext" +import { extractLearningResourceIds, contentsMatch } from "../extensions/utils" +import { LearningResourceProvider } from "../extensions/node/LearningResource/LearningResourceDataProvider" + +const LearningResourceDrawer = dynamic( + () => + import("@/page-components/LearningResourceDrawer/LearningResourceDrawer"), + { ssr: false }, +) + +const TOOLBAR_HEIGHT = 43 + +const ViewContainer = styled.div<{ toolbarVisible: boolean }>( + ({ toolbarVisible, theme }) => ({ + width: "100vw", + marginTop: toolbarVisible ? TOOLBAR_HEIGHT : 0, + backgroundColor: theme.custom.colors.white, + }), +) + +const StyledToolbar = styled(Toolbar)(({ theme }) => ({ + "&&": { + position: "fixed", + top: HEADER_HEIGHT, + [theme.breakpoints.down("md")]: { + top: HEADER_HEIGHT_MD, + }, + }, +})) + +const StyledAlert = styled(Alert)({ + margin: "20px auto", + maxWidth: "1000px", + position: "fixed", + top: "108px", + left: "50%", + width: "690px", + transform: "translateX(-50%)", + zIndex: 1, + "p:not(:first-child)": { + margin: "10px 0", + }, +}) + +export type UploadHandler = ( + file: File, + onProgress?: (e: { progress: number }) => void, + abortSignal?: AbortSignal, +) => Promise + +/** + * The data shape sent to the create/update API. + * `[key: string]: unknown` allows per-type extra fields (e.g. author_name). + */ +export interface SavePayload { + title: string + content: JSONContent + is_published: boolean + [key: string]: unknown +} + +/** + * Per-type save mutations. Each content type owns its own API hooks and passes + * the resulting mutation objects here, so GenericEditor never imports a + * specific API hook directly. + * + * Example — news type uses websiteContent API: + * const create = useArticleCreate() + * const update = useArticlePartialUpdate() + * + * + * A future user-article type could use a completely different API hook: + * const create = useUserArticleCreate() + * const update = useUserArticlePartialUpdate() + * + */ +export interface SaveMutations { + create: { + mutate: ( + data: SavePayload, + options: { onSuccess?: (result: WebsiteContent) => void }, + ) => void + isPending: boolean + error: Error | null | unknown + } + update: { + mutate: ( + data: SavePayload & { id: number }, + options: { onSuccess?: (result: WebsiteContent) => void }, + ) => void + isPending: boolean + error: Error | null | unknown + } +} + +/** + * A factory function that builds the Tiptap extensions for a given content type. + * Receives upload utilities so extensions that handle image upload can be configured. + */ +export type CreateExtensionsFn = ( + uploadHandler: UploadHandler, + setUploadError: (error: string | null) => void, +) => (Extension | Node | Mark)[] + +export interface GenericEditorProps { + /** + * Factory that builds the full extensions list for this content type. + * Must be a stable reference (module-level function or useCallback). + */ + createExtensions: CreateExtensionsFn + /** Initial document structure when no article is provided. */ + initialDoc: JSONContent + /** + * Content-type-specific toolbar content. + * - In read-only mode this slot provides all toolbar items (e.g. Drafts + Edit links). + * - In edit mode this slot is appended after the Publish button. + */ + toolbarSlot?: React.ReactNode + /** Optional CSS class forwarded to the editor container for per-type theming. */ + className?: string + /** + * Extract additional fields to include in the save payload. + * E.g., for news: `(content) => ({ author_name: extractAuthorName(content) })` + */ + extractExtraFields?: (content: JSONContent) => Record + /** + * Mutations for create and update. Provided by the content-type wrapper so + * GenericEditor stays decoupled from any specific API endpoint. + */ + saveMutations: SaveMutations + onSave?: (article: WebsiteContent) => void + readOnly?: boolean + article?: WebsiteContent +} + +const GenericEditor = ({ + createExtensions, + initialDoc, + toolbarSlot, + className, + extractExtraFields, + saveMutations, + onSave, + readOnly, + article, +}: GenericEditorProps) => { + const [isPublishing, setIsPublishing] = useState(false) + const [uploadError, setUploadError] = useState(null) + const [resetAttempted, setResetAttempted] = useState(false) + const [content, setContent] = useState( + article?.content || initialDoc, + ) + const [title, setTitle] = useState(article?.title) + const [touched, setTouched] = useState(false) + + const { create: createMutation, update: updateMutation } = saveMutations + const isPending = createMutation.isPending || updateMutation.isPending + const saveError = createMutation.error || updateMutation.error + + const uploadImage = useMediaUpload() + // Keep a ref so the stable uploadHandler callback always calls the latest mutation. + const uploadImageRef = useRef(uploadImage) + uploadImageRef.current = uploadImage + + const isArticleEditor = useUserHasPermission(Permission.ArticleEditor) + + const uploadHandler = useCallback( + async (file, onProgress, abortSignal) => { + setUploadError(null) + return handleImageUpload( + file, + async (f, progressCb) => { + try { + uploadImageRef.current.setNextProgressCallback(progressCb) + const response = await uploadImageRef.current.mutateAsync({ + file: f, + }) + if (!response?.url) throw new Error("Upload failed") + return response.url + } catch (error) { + const msg = + error instanceof Error + ? error.message + : String(error) || "Upload failed" + setUploadError(msg) + throw error + } + }, + onProgress, + abortSignal, + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) + + const extensions = useMemo( + () => createExtensions(uploadHandler, setUploadError), + [createExtensions, uploadHandler], + ) + + const schema = useMemo(() => getSchema(extensions), [extensions]) + + const schemaError = useSchema({ + schema, + content, + enabled: isArticleEditor, + }) + + const handleSave = (publish: boolean) => { + if (!title) return + const extraFields = extractExtraFields?.(content) ?? {} + if (article) { + updateMutation.mutate( + { + id: article.id, + title: title.trim(), + content, + is_published: publish, + ...extraFields, + }, + { onSuccess: onSave }, + ) + } else { + createMutation.mutate( + { + title: title.trim(), + content, + is_published: publish, + ...extraFields, + }, + { onSuccess: onSave }, + ) + } + } + + const editor = useEditor({ + immediatelyRender: false, + shouldRerenderOnTransaction: false, + content, + editable: !readOnly, + + onUpdate: ({ editor }) => { + const json = editor.getJSON() + setContent(json) + setTouched(true) + }, + + onCreate: ({ editor }) => { + setTimeout(() => { + editor.commands.setTextSelection(1) + editor.commands.focus() + }, 0) + + editor.commands.updateAttributes("mediaEmbed", { editable: !readOnly }) + editor.commands.updateAttributes("byline", { editable: readOnly }) + }, + + editorProps: { + attributes: { + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + "aria-label": "Main content area, start typing to enter text.", + class: "simple-editor", + }, + }, + extensions, + }) + + // Sync incoming article changes (e.g., after a refetch) + useEffect(() => { + if (!article || !editor) return + + if (article.content) { + const currentContent = editor.getJSON() + if (!contentsMatch(article.content, currentContent)) { + setContent(article.content) + setTouched(true) + editor.commands.setContent(article.content) + } + } + + if (article.title !== undefined) { + setTitle(article.title) + } + }, [article, editor]) + + // Keep title in sync with the h1 heading inside the editor + useEffect(() => { + if (!editor) return + const headingTitle = + editor.$node("heading", { level: 1 })?.textContent || "" + setTitle(headingTitle) + }, [editor, content]) + + // Propagate readOnly changes to interactive node attrs + useEffect(() => { + if (!editor) return + editor + .chain() + .command(({ tr, state }) => { + state.doc.descendants((node, pos) => { + if ( + node.type.name === "mediaEmbed" || + node.type.name === "imageWithCaption" || + node.type.name === "byline" || + node.type.name === "learningResource" + ) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + editable: !readOnly, + }) + } + }) + return true + }) + .run() + }, [editor, readOnly]) + + if (!editor) return null + + const error = saveError || uploadError || schemaError + const errorMessage = + error instanceof Error ? error.message : (error as string | null) + const resourceIds = extractLearningResourceIds(content) + + return ( + + + + + {isArticleEditor ? ( + readOnly ? ( + {toolbarSlot} + ) : ( + + + {!article?.is_published ? ( + + ) : null} + + {toolbarSlot} + + ) + ) : null} + + {error ? ( + + + {errorMessage} + + {schemaError && !readOnly ? ( + <> + + Reset to attempt to align the article to the content + template. + + {resetAttempted ? ( + + Reset attempt failed. + + ) : null} + + + ) : null} + + ) : null} + + {readOnly ? ( + <> + + + + ) : ( + + )} + + + + + ) +} + +export { GenericEditor } diff --git a/frontends/main/src/page-components/TiptapEditor/index.ts b/frontends/main/src/page-components/TiptapEditor/index.ts index 60304b2ea2..ce26ad9bdb 100644 --- a/frontends/main/src/page-components/TiptapEditor/index.ts +++ b/frontends/main/src/page-components/TiptapEditor/index.ts @@ -1 +1,8 @@ export { ArticleEditor } from "./ArticleEditor" +export { NewsEditor } from "./contentTypes/news/NewsEditor" +export { ArticleEditor as UserArticleEditor } from "./contentTypes/article/ArticleEditor" +export { GenericEditor } from "./core/GenericEditor" +export type { + GenericEditorProps, + CreateExtensionsFn, +} from "./core/GenericEditor" diff --git a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts index 5e192b7265..ddbe4c3f17 100644 --- a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts +++ b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts @@ -1,38 +1,13 @@ "use client" import { useMemo } from "react" -import type { Node as ProseMirrorNode } from "@tiptap/pm/model" import { getSchema } from "@tiptap/core" import { useSchema } from "./useSchema" -import Document from "@tiptap/extension-document" -import { Placeholder, Selection } from "@tiptap/extensions" -import { StarterKit } from "@tiptap/starter-kit" -import { TaskItem, TaskList } from "@tiptap/extension-list" -import { Heading } from "@tiptap/extension-heading" -import { Image } from "@tiptap/extension-image" -import { TextAlign } from "@tiptap/extension-text-align" -import { Typography as TiptapTypography } from "@tiptap/extension-typography" -import { Subscript } from "@tiptap/extension-subscript" -import { Superscript } from "@tiptap/extension-superscript" import type { JSONContent } from "@tiptap/react" -import { HorizontalRule } from "./vendor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension" -import { ImageNode } from "./extensions/node/Image/ImageNode" -import { ImageWithCaptionNode } from "./extensions/node/Image/ImageWithCaptionNode" -import { DividerNode } from "./extensions/node/Divider/DividerNode" -import { ArticleByLineInfoBarNode } from "./extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode" -import { LearningResourceNode } from "./extensions/node/LearningResource/LearningResourceNode" -import { LearningResourceInputNode } from "./extensions/node/LearningResource/LearningResourceInputNode" -import { LearningResourceURLHandler } from "./extensions/node/LearningResource/LearningResourcePaste" -import { MediaEmbedURLHandler } from "./extensions/node/MediaEmbed/MediaEmbedURLHandler" -import { MediaEmbedNode } from "./extensions/node/MediaEmbed/MediaEmbedNode" -import { MediaEmbedInputNode } from "./extensions/node/MediaEmbed/MediaEmbedInputNode" -import { BannerNode } from "./extensions/node/Banner/BannerNode" -import type { ExtendedNodeConfig } from "./extensions/node/types" -import { MAX_FILE_SIZE } from "./vendor/lib/tiptap-utils" - -const ArticleDocument = Document.extend({ - content: "banner byline block+", -}) +import { + createNewsExtensions, + newNewsDocument, +} from "./contentTypes/news/newsExtensions" interface UseArticleSchemaOptions { uploadHandler: ( @@ -45,30 +20,8 @@ interface UseArticleSchemaOptions { content: JSONContent } -export const newArticleDocument = { - type: "doc", - content: [ - { - type: "banner", - content: [ - { - type: "heading", - attrs: { level: 1 }, - content: [], - }, - { - type: "paragraph", - content: [], - }, - ], - }, - { - type: "byline", - attrs: { authorName: null }, - }, - { type: "paragraph", content: [] }, - ], -} +/** @deprecated Use newNewsDocument from contentTypes/news/newsExtensions */ +export const newArticleDocument = newNewsDocument export const useArticleSchema = ({ uploadHandler, @@ -77,95 +30,7 @@ export const useArticleSchema = ({ content, }: UseArticleSchemaOptions) => { const extensions = useMemo( - () => [ - ArticleDocument, - StarterKit.configure({ - document: false, // Disable default document to use our ArticleDocument - horizontalRule: false, - heading: false, - link: { - openOnClick: false, - enableClickSelection: true, - }, - trailingNode: { - node: "paragraph", - }, - }), - Heading.configure({ - levels: [1, 2, 3, 4, 5, 6], - }), - Placeholder.configure({ - showOnlyCurrent: false, - includeChildren: true, - placeholder: ({ node, editor }): string => { - let parentNode: typeof node | null = null - - editor.state.doc.descendants((n: ProseMirrorNode) => { - n.forEach((childNode: ProseMirrorNode) => { - if (childNode === node) { - parentNode = n - } - }) - if (parentNode) { - return false - } - return undefined - }) - - if (parentNode) { - const parentExtension = editor.extensionManager.extensions.find( - (ext) => ext.name === parentNode!.type.name, - ) - - if ( - parentExtension && - "config" in parentExtension && - parentExtension.config && - typeof (parentExtension.config as ExtendedNodeConfig) - .getPlaceholders === "function" - ) { - const placeholder = ( - parentExtension.config as ExtendedNodeConfig - ).getPlaceholders(node) - if (placeholder) { - return placeholder - } - } - } - - if (node.type.name === "heading") { - return "Add a heading" - } - return "Add some text" - }, - }), - HorizontalRule, - LearningResourceURLHandler, - LearningResourceNode, - LearningResourceInputNode, - TextAlign.configure({ types: ["heading", "paragraph"] }), - TaskList, - TaskItem.configure({ nested: true }), - TiptapTypography, - Superscript, - Subscript, - Selection, - Image, - MediaEmbedNode, - MediaEmbedInputNode, - DividerNode, - ArticleByLineInfoBarNode, - ImageWithCaptionNode, - MediaEmbedURLHandler, - ImageNode.configure({ - accept: "image/*", - maxSize: MAX_FILE_SIZE, - limit: 3, - upload: uploadHandler, - onError: (error) => setUploadError(error.message), - }), - BannerNode, - ], + () => createNewsExtensions(uploadHandler, setUploadError), [uploadHandler, setUploadError], ) From 6087067d9ff08ec8353718697fb42b792086139c Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Tue, 19 May 2026 15:30:16 +0500 Subject: [PATCH 07/17] add changes related to editor refactoring --- .../WebsiteContentDraftListingPage.tsx | 222 ++++++++++++++++++ .../WebsiteContent/WebsiteContentEditPage.tsx | 96 ++++++++ .../WebsiteContent/WebsiteContentNewPage.tsx | 71 ++++++ .../src/app/articles/[slugOrId]/edit/page.tsx | 10 +- .../main/src/app/articles/draft/page.tsx | 14 +- frontends/main/src/app/articles/new/page.tsx | 14 +- .../src/app/news/[slugOrId]/edit/page.tsx | 9 +- frontends/main/src/app/news/draft/page.tsx | 14 +- frontends/main/src/app/news/new/page.tsx | 14 +- .../[type]/[idOrSlug]/edit/page.tsx | 13 + .../app/website_content/[type]/new/page.tsx | 16 ++ .../src/app/website_content/drafts/page.tsx | 20 ++ frontends/main/src/common/urls.ts | 15 ++ .../TiptapEditor/ArticleContext.tsx | 16 +- .../TiptapEditor/TiptapEditor.tsx | 15 +- .../contentTypes/article/ArticleEditor.tsx | 20 +- .../contentTypes/article/articleExtensions.ts | 56 ++++- .../contentTypes/news/NewsEditor.tsx | 14 +- .../contentTypes/news/newsExtensions.ts | 114 +-------- .../TiptapEditor/core/GenericEditor.tsx | 59 +++-- .../TiptapEditor/extensions/baseExtensions.ts | 121 ++++++++++ .../ArticleByLineInfoBar.tsx | 17 +- .../ArticleByLineInfoBarInBanner.tsx | 60 +++++ .../ArticleByLineInfoBarNode.ts | 5 + .../ArticleByLineInfoBarViewer.tsx | 34 ++- .../node/Banner/ArticleBannerNode.tsx | 215 +++++++++++++++++ .../src/page-components/TiptapEditor/index.ts | 6 +- 27 files changed, 1057 insertions(+), 223 deletions(-) create mode 100644 frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx create mode 100644 frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx create mode 100644 frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx create mode 100644 frontends/main/src/app/website_content/[type]/[idOrSlug]/edit/page.tsx create mode 100644 frontends/main/src/app/website_content/[type]/new/page.tsx create mode 100644 frontends/main/src/app/website_content/drafts/page.tsx create mode 100644 frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts create mode 100644 frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarInBanner.tsx create mode 100644 frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx new file mode 100644 index 0000000000..513075d564 --- /dev/null +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx @@ -0,0 +1,222 @@ +"use client" + +import React, { useState, useRef, useEffect } from "react" +import { + Container, + styled, + theme, + Grid2, + Card, + Pagination, + PaginationItem, + LoadingSpinner, + Typography, +} from "ol-components" +import { Permission } from "api/hooks/user" +import { useArticleList } from "api/hooks/articles" +import type { WebsiteContent } from "api/v1" +import { LocalDate } from "ol-utilities" +import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" +import { extractFirstImageFromArticle } from "@/common/articleUtils" +import { websiteContentEditView, websiteContentCreateView } from "@/common/urls" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { ButtonLink } from "@mitodl/smoot-design" + +const PAGE_SIZE = 20 + +export const DEFAULT_BACKGROUND_IMAGE_URL = + "/images/backgrounds/banner_background.webp" + +const PageWrapper = styled.div` + background: ${theme.custom.colors.white}; + min-height: calc(100vh - 200px); + padding: 80px 0; + ${theme.breakpoints.down("md")} { + padding: 40px 0; + } +` + +const PageHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 40px; +` + +const DraftArticleCard = styled(Card)` + display: flex; + flex-direction: column; + height: 100%; +` + +const PaginationContainer = styled.div` + display: flex; + justify-content: center; + margin-top: 40px; +` + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; +` + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + gap: 16px; +` + +const DraftBadge = styled.span` + color: ${theme.custom.colors.silverGrayDark}; + font-weight: ${theme.typography.fontWeightMedium}; +` + +const CONTENT_TYPE_LABELS: Record = { + article: "Article", + news: "News", +} + +const DraftItem: React.FC<{ article: WebsiteContent; type: string }> = ({ + article, + type, +}) => { + const itemUrl = article.is_published + ? `/${type === "article" ? "articles" : type}/${article.slug || article.id}` + : websiteContentEditView(type, article.id) + + const imageUrl = extractFirstImageFromArticle(article.content) + + return ( + + + + {article.title} + + + + {!article.is_published && ( + <> + {" • "} + Draft + + )} + + + ) +} + +interface WebsiteContentDraftListingPageProps { + /** + * Content type to show drafts for (e.g. 'article', 'news'). + * Filtering by content_type requires the OpenAPI client to be regenerated + * after adding WebsiteContentFilter to the Django viewset. + */ + contentType?: string +} + +const WebsiteContentDraftListingPage: React.FC< + WebsiteContentDraftListingPageProps +> = ({ contentType }) => { + const [page, setPage] = useState(1) + const scrollRef = useRef(null) + const type = contentType || "article" + const label = CONTENT_TYPE_LABELS[type] ?? type + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const listParams: any = { + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + draft: true, + ...(contentType ? { content_type: contentType } : {}), + } + + const { data: articles, isLoading: isLoadingArticles } = + useArticleList(listParams) + + useEffect(() => { + if (page > 1 && scrollRef.current) { + scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" }) + } + }, [page]) + + const draftArticles = articles?.results + const totalPages = articles?.count ? Math.ceil(articles.count / PAGE_SIZE) : 0 + + if (isLoadingArticles) { + return + } + + return ( + + + + + {label} Drafts + + New {label} + + + + {isLoadingArticles ? ( + + + + ) : draftArticles && draftArticles.length > 0 ? ( + <> + + {draftArticles.map((article) => ( + + + + ))} + + + {totalPages > 1 && ( + + setPage(newPage)} + renderItem={(item) => ( + + )} + /> + + )} + + ) : ( + + No Draft {label}s + + You don't have any draft {label.toLowerCase()}s yet. + + + )} + + + + ) +} + +export { WebsiteContentDraftListingPage } diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx new file mode 100644 index 0000000000..3aae320b94 --- /dev/null +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx @@ -0,0 +1,96 @@ +"use client" + +import React from "react" +import { useRouter } from "next-nprogress-bar" +import { notFound } from "next/navigation" +import { Permission } from "api/hooks/user" +import { useArticleDetailRetrieve } from "api/hooks/articles" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { styled, LoadingSpinner } from "ol-components" +import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" +import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" +import { userArticlesView, websiteContentEditView } from "@/common/urls" +import invariant from "tiny-invariant" +import type { WebsiteContent } from "api/v1" + +const PageContainer = styled.div(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "flex", + height: "100%", +})) + +const Spinner = styled(LoadingSpinner)({ + margin: "auto", + position: "absolute", + top: "40%", + left: "50%", + transform: "translate(-50%, -50%)", +}) + +const PUBLISHED_VIEW_URL: Record string> = { + article: (slug) => userArticlesView(slug), + news: (slug) => `/news/${slug}`, +} + +const EDITORS: Record< + string, + React.ComponentType<{ + onSave?: (article: WebsiteContent) => void + readOnly?: boolean + article?: WebsiteContent + }> +> = { + article: ArticleEditor, + news: NewsEditor, +} + +interface WebsiteContentEditPageProps { + type: string + idOrSlug: string +} + +const WebsiteContentEditPage = ({ + type, + idOrSlug, +}: WebsiteContentEditPageProps) => { + const { + data: article, + isLoading, + isFetching, + } = useArticleDetailRetrieve(idOrSlug) + const router = useRouter() + + const Editor = EDITORS[type] + const viewUrl = PUBLISHED_VIEW_URL[type] + + if (!Editor || !viewUrl) { + notFound() + } + + if (isLoading || isFetching) { + return + } + if (!article) { + return notFound() + } + + return ( + + + { + if (saved.is_published) { + invariant(saved.slug, "Published content must have a slug") + return router.push(viewUrl(saved.slug)) + } else { + router.push(websiteContentEditView(type, saved.id)) + } + }} + /> + + + ) +} + +export { WebsiteContentEditPage } diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx new file mode 100644 index 0000000000..8ef76b2dc6 --- /dev/null +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx @@ -0,0 +1,71 @@ +"use client" + +import React from "react" +import { useRouter } from "next-nprogress-bar" +import { notFound } from "next/navigation" +import { Permission } from "api/hooks/user" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { styled } from "ol-components" +import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" +import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" +import { userArticlesView, websiteContentEditView } from "@/common/urls" +import invariant from "tiny-invariant" +import type { WebsiteContent } from "api/v1" + +const PageContainer = styled.div(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "flex", + height: "100%", +})) + +const PUBLISHED_VIEW_URL: Record string> = { + article: (slug) => userArticlesView(slug), + news: (slug) => `/news/${slug}`, +} + +const EDITORS: Record< + string, + React.ComponentType<{ + onSave?: (article: WebsiteContent) => void + readOnly?: boolean + article?: WebsiteContent + }> +> = { + article: ArticleEditor, + news: NewsEditor, +} + +interface WebsiteContentNewPageProps { + type: string +} + +const WebsiteContentNewPage: React.FC = ({ + type, +}) => { + const router = useRouter() + const Editor = EDITORS[type] + const viewUrl = PUBLISHED_VIEW_URL[type] + + if (!Editor || !viewUrl) { + notFound() + } + + return ( + + + { + if (article.is_published) { + invariant(article.slug, "Published content must have a slug") + return router.push(viewUrl(article.slug)) + } else { + router.push(websiteContentEditView(type, article.id)) + } + }} + /> + + + ) +} + +export { WebsiteContentNewPage } diff --git a/frontends/main/src/app/articles/[slugOrId]/edit/page.tsx b/frontends/main/src/app/articles/[slugOrId]/edit/page.tsx index 50010e8367..ccb262ba73 100644 --- a/frontends/main/src/app/articles/[slugOrId]/edit/page.tsx +++ b/frontends/main/src/app/articles/[slugOrId]/edit/page.tsx @@ -1,12 +1,8 @@ -import React from "react" -import { UserArticleEditPage } from "@/app-pages/UserArticles/UserArticleEditPage" +import { redirect } from "next/navigation" -const Page: React.FC> = async ( - props, -) => { +const Page = async (props: { params: Promise<{ slugOrId: string }> }) => { const { slugOrId } = await props.params - - return + redirect(`/website_content/article/${slugOrId}/edit`) } export default Page diff --git a/frontends/main/src/app/articles/draft/page.tsx b/frontends/main/src/app/articles/draft/page.tsx index a16b38a550..a51ac5703c 100644 --- a/frontends/main/src/app/articles/draft/page.tsx +++ b/frontends/main/src/app/articles/draft/page.tsx @@ -1,15 +1,7 @@ -import React from "react" -import { Metadata } from "next" -import { standardizeMetadata } from "@/common/metadata" -import { UserArticleDraftPage } from "@/app-pages/UserArticles/UserArticleDraftListingPage" +import { redirect } from "next/navigation" -export const metadata: Metadata = standardizeMetadata({ - title: "Article Drafts", - robots: "noindex, nofollow", -}) - -const Page: React.FC> = () => { - return +const Page = () => { + redirect("/website_content/drafts?content_type=article") } export default Page diff --git a/frontends/main/src/app/articles/new/page.tsx b/frontends/main/src/app/articles/new/page.tsx index 8bcd4029c2..62dfc58301 100644 --- a/frontends/main/src/app/articles/new/page.tsx +++ b/frontends/main/src/app/articles/new/page.tsx @@ -1,15 +1,7 @@ -import React from "react" -import { Metadata } from "next" -import { standardizeMetadata } from "@/common/metadata" -import { UserArticleNewPage } from "@/app-pages/UserArticles/UserArticleNewPage" +import { redirect } from "next/navigation" -export const metadata: Metadata = standardizeMetadata({ - title: "MIT Learn | New Article", - robots: "noindex, nofollow", -}) - -const Page: React.FC> = () => { - return +const Page = () => { + redirect("/website_content/article/new") } export default Page diff --git a/frontends/main/src/app/news/[slugOrId]/edit/page.tsx b/frontends/main/src/app/news/[slugOrId]/edit/page.tsx index 3837282c8e..4064bd021f 100644 --- a/frontends/main/src/app/news/[slugOrId]/edit/page.tsx +++ b/frontends/main/src/app/news/[slugOrId]/edit/page.tsx @@ -1,9 +1,8 @@ -import React from "react" -import { ArticleEditPage } from "@/app-pages/Articles/ArticleEditPage" +import { redirect } from "next/navigation" -const Page: React.FC> = async (props) => { +const Page = async (props: { params: Promise<{ slugOrId: string }> }) => { const { slugOrId } = await props.params - - return + redirect(`/website_content/news/${slugOrId}/edit`) } + export default Page diff --git a/frontends/main/src/app/news/draft/page.tsx b/frontends/main/src/app/news/draft/page.tsx index 56ad03090b..63cea3be9f 100644 --- a/frontends/main/src/app/news/draft/page.tsx +++ b/frontends/main/src/app/news/draft/page.tsx @@ -1,15 +1,7 @@ -import React from "react" -import { Metadata } from "next" -import { standardizeMetadata } from "@/common/metadata" -import { ArticleDraftPage } from "@/app-pages/Articles/ArticleDraftListingPage" +import { redirect } from "next/navigation" -export const metadata: Metadata = standardizeMetadata({ - title: "Articles Draft", - robots: "noindex, nofollow", -}) - -const Page: React.FC> = () => { - return +const Page = () => { + redirect("/website_content/drafts?content_type=news") } export default Page diff --git a/frontends/main/src/app/news/new/page.tsx b/frontends/main/src/app/news/new/page.tsx index 7040db5e9d..2309ec5ee8 100644 --- a/frontends/main/src/app/news/new/page.tsx +++ b/frontends/main/src/app/news/new/page.tsx @@ -1,15 +1,7 @@ -import React from "react" -import { Metadata } from "next" -import { standardizeMetadata } from "@/common/metadata" -import { ArticleNewPage } from "@/app-pages/Articles/ArticleNewPage" +import { redirect } from "next/navigation" -export const metadata: Metadata = standardizeMetadata({ - title: "MIT Learn| New", - robots: "noindex, nofollow", -}) - -const Page: React.FC> = () => { - return +const Page = () => { + redirect("/website_content/news/new") } export default Page diff --git a/frontends/main/src/app/website_content/[type]/[idOrSlug]/edit/page.tsx b/frontends/main/src/app/website_content/[type]/[idOrSlug]/edit/page.tsx new file mode 100644 index 0000000000..1df7bc1811 --- /dev/null +++ b/frontends/main/src/app/website_content/[type]/[idOrSlug]/edit/page.tsx @@ -0,0 +1,13 @@ +import React from "react" +import { WebsiteContentEditPage } from "@/app-pages/WebsiteContent/WebsiteContentEditPage" + +const Page = async ({ + params, +}: { + params: Promise<{ type: string; idOrSlug: string }> +}) => { + const { type, idOrSlug } = await params + return +} + +export default Page diff --git a/frontends/main/src/app/website_content/[type]/new/page.tsx b/frontends/main/src/app/website_content/[type]/new/page.tsx new file mode 100644 index 0000000000..9318402af2 --- /dev/null +++ b/frontends/main/src/app/website_content/[type]/new/page.tsx @@ -0,0 +1,16 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { WebsiteContentNewPage } from "@/app-pages/WebsiteContent/WebsiteContentNewPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "MIT Learn | New", + robots: "noindex, nofollow", +}) + +const Page = async ({ params }: { params: Promise<{ type: string }> }) => { + const { type } = await params + return +} + +export default Page diff --git a/frontends/main/src/app/website_content/drafts/page.tsx b/frontends/main/src/app/website_content/drafts/page.tsx new file mode 100644 index 0000000000..5d78090e16 --- /dev/null +++ b/frontends/main/src/app/website_content/drafts/page.tsx @@ -0,0 +1,20 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { WebsiteContentDraftListingPage } from "@/app-pages/WebsiteContent/WebsiteContentDraftListingPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "MIT Learn | Drafts", + robots: "noindex, nofollow", +}) + +const Page = async ({ + searchParams, +}: { + searchParams: Promise<{ content_type?: string }> +}) => { + const { content_type: contentType } = await searchParams + return +} + +export default Page diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index 2c47c3c2b2..0491e7e7f8 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -61,6 +61,21 @@ export const userArticlesDraftView = (id: string) => export const userArticlesEditView = (id: number) => generatePath(USER_ARTICLES_EDIT, { id: String(id) }) +// Generic website content editing routes +export const WEBSITE_CONTENT_CREATE = "/website_content/[type]/new" +export const WEBSITE_CONTENT_EDIT = "/website_content/[type]/[idOrSlug]/edit" +export const WEBSITE_CONTENT_DRAFTS = "/website_content/drafts" +export const websiteContentCreateView = (type: string) => + `/website_content/${type}/new` +export const websiteContentEditView = ( + type: string, + idOrSlug: string | number, +) => `/website_content/${type}/${idOrSlug}/edit` +export const websiteContentDraftsView = (contentType?: string) => + contentType + ? `${WEBSITE_CONTENT_DRAFTS}?content_type=${contentType}` + : WEBSITE_CONTENT_DRAFTS + export const DEPARTMENTS = "/departments/" export const TOPICS = "/topics/" diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx index 2dc29b1b72..990fa97dea 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx +++ b/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx @@ -1,14 +1,22 @@ import { createContext, useContext } from "react" import type { WebsiteContent } from "api/v1" -interface ArticleContextValue { +interface WebsiteContentContextValue { article?: WebsiteContent } -const ArticleContext = createContext({}) +const WebsiteContentContext = createContext({}) -export const ArticleProvider = ArticleContext.Provider +export const WebsiteContentProvider = WebsiteContentContext.Provider +export function useWebsiteContent() { + return useContext(WebsiteContentContext).article +} + +/** @deprecated Use WebsiteContentProvider */ +export const ArticleProvider = WebsiteContentProvider + +/** @deprecated Use useWebsiteContent */ export function useArticle() { - return useContext(ArticleContext).article + return useWebsiteContent() } diff --git a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx index e86152ed18..456d3b3659 100644 --- a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx @@ -49,7 +49,7 @@ import "./vendor/components/tiptap-templates/simple/simple-editor.scss" import "./TiptapEditor.styles.scss" import { BannerViewer } from "./extensions/node/Banner/BannerNode" -import { ArticleByLineInfoBarViewer } from "./extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer" +import { ByLineInfoBarViewer } from "./extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer" import { ImageWithCaptionViewer } from "./extensions/node/Image/ImageWithCaption" import { DividerViewer } from "./extensions/node/Divider/DividerNode" import { LearningResourceButton } from "./extensions/ui/LearningResource/LearningResourceButton" @@ -82,6 +82,9 @@ const Container = styled.div<{ }, } : {}), + "&& .tiptap.ProseMirror.abc > :nth-child(2)": { + paddingTop: "36px", + }, "&& .tiptap.ProseMirror, && .tiptap-viewer": { fontFamily: theme.typography.fontFamily, color: theme.custom.colors.darkGray2, @@ -290,13 +293,17 @@ const TiptapEditor = ({ editor, className }: TiptapEditorProps) => { const TipTapViewer = ({ content, extensions, + bannerViewer = BannerViewer, + bylineViewer = ByLineInfoBarViewer, }: { content: JSONContent extensions: Array + bannerViewer?: typeof BannerViewer + bylineViewer?: typeof BannerViewer }) => { return ( -
+
{renderToReactElement({ extensions, content, @@ -310,8 +317,8 @@ const TipTapViewer = ({ * See https://tiptap.dev/docs/editor/api/utilities/static-renderer#react-nodeviews */ nodeMapping: { - banner: BannerViewer, - byline: ArticleByLineInfoBarViewer, + banner: bannerViewer, + byline: bylineViewer, divider: DividerViewer, imageWithCaption: ImageWithCaptionViewer, learningResource: LearningResourceCardViewer, diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx index 1450e5ec5a..880e8748b3 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx @@ -6,11 +6,14 @@ import type { WebsiteContent } from "api/v1" import { ButtonLink } from "@mitodl/smoot-design" import { useArticleCreate, useArticlePartialUpdate } from "api/hooks/articles" import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer" -import { GenericEditor } from "../../core/GenericEditor" +import { WebsiteContentEditor } from "../../core/GenericEditor" import { createArticleExtensions, newArticleDocument, } from "./articleExtensions" +import { ArticleBannerViewer } from "../../extensions/node/Banner/ArticleBannerNode" + +const NullBylineViewer = () => <> // Article-specific: extract author name from the byline node const extractArticleExtraFields = (content: { @@ -50,13 +53,17 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { const updateMutation = useArticlePartialUpdate() const editUrl = article - ? `/articles/${article.is_published ? article.slug : article.id}/edit` - : "/articles/new" + ? `/website_content/article/${article.is_published ? article.slug : article.id}/edit` + : "/website_content/article/new" const toolbarSlot = readOnly ? ( <> - + Drafts @@ -66,7 +73,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { ) : null return ( - { onSave={onSave} readOnly={readOnly} article={article} + backgroundColor="lightGray1" + bannerViewer={ArticleBannerViewer} + bylineViewer={NullBylineViewer} /> ) } diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts index 3c5ba195fb..c50789639c 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts @@ -1,11 +1,49 @@ +import type { Extension, Node, Mark } from "@tiptap/core" +import Document from "@tiptap/extension-document" +import { ArticleBannerNode } from "../../extensions/node/Banner/ArticleBannerNode" +import { ByLineInfoBarNode } from "../../extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode" +import { createBaseExtensions } from "../../extensions/baseExtensions" +import type { CreateExtensionsFn } from "../../core/GenericEditor" + +export const ArticleDocument = Document.extend({ + content: "banner byline block+", +}) + +export const newArticleDocument = { + type: "doc", + content: [ + { + type: "banner", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [], + }, + { + type: "paragraph", + content: [], + }, + ], + }, + { + type: "byline", + attrs: { authorName: null }, + }, + { type: "paragraph", content: [] }, + ], +} + /** - * Article content type extensions. - * - * Currently mirrors the news content type. Extensions and document structure - * will diverge as the /articles feature evolves. + * Factory function that builds the full extensions list for the article content type. + * Pass to WebsiteContentEditor as `createExtensions`. */ -export { - createNewsExtensions as createArticleExtensions, - newNewsDocument as newArticleDocument, - NewsDocument as ArticleDocument, -} from "../news/newsExtensions" +export const createArticleExtensions: CreateExtensionsFn = ( + uploadHandler, + setUploadError, +): (Extension | Node | Mark)[] => [ + ArticleDocument, + ...createBaseExtensions(uploadHandler, setUploadError), + ArticleBannerNode, + ByLineInfoBarNode, +] diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx index ab380a4928..4266354292 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx @@ -6,7 +6,7 @@ import type { WebsiteContent } from "api/v1" import { ButtonLink } from "@mitodl/smoot-design" import { useArticleCreate, useArticlePartialUpdate } from "api/hooks/articles" import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer" -import { GenericEditor } from "../../core/GenericEditor" +import { WebsiteContentEditor } from "../../core/GenericEditor" import { createNewsExtensions, newNewsDocument } from "./newsExtensions" // News-specific: extract the author name from the byline node in the document @@ -41,13 +41,17 @@ const NewsEditor = ({ onSave, readOnly, article }: NewsEditorProps) => { const updateMutation = useArticlePartialUpdate() const editUrl = article - ? `/news/${article.is_published ? article.slug : article.id}/edit` - : "/news/new" + ? `/website_content/news/${article.is_published ? article.slug : article.id}/edit` + : "/website_content/news/new" const toolbarSlot = readOnly ? ( <> - + Drafts @@ -57,7 +61,7 @@ const NewsEditor = ({ onSave, readOnly, article }: NewsEditorProps) => { ) : null return ( - [ NewsDocument, - StarterKit.configure({ - document: false, - horizontalRule: false, - heading: false, - link: { - openOnClick: false, - enableClickSelection: true, - }, - trailingNode: { - node: "paragraph", - }, - }), - Heading.configure({ - levels: [1, 2, 3, 4, 5, 6], - }), - Placeholder.configure({ - showOnlyCurrent: false, - includeChildren: true, - placeholder: ({ node, editor }): string => { - let parentNode: typeof node | null = null - - editor.state.doc.descendants((n: ProseMirrorNode) => { - n.forEach((childNode: ProseMirrorNode) => { - if (childNode === node) { - parentNode = n - } - }) - if (parentNode) { - return false - } - return undefined - }) - - if (parentNode) { - const parentExtension = editor.extensionManager.extensions.find( - (ext) => ext.name === parentNode!.type.name, - ) - - if ( - parentExtension && - "config" in parentExtension && - parentExtension.config && - typeof (parentExtension.config as ExtendedNodeConfig) - .getPlaceholders === "function" - ) { - const placeholder = ( - parentExtension.config as ExtendedNodeConfig - ).getPlaceholders(node) - if (placeholder) { - return placeholder - } - } - } - - if (node.type.name === "heading") { - return "Add a heading" - } - return "Add some text" - }, - }), - HorizontalRule, - LearningResourceURLHandler, - LearningResourceNode, - LearningResourceInputNode, - TextAlign.configure({ types: ["heading", "paragraph"] }), - TaskList, - TaskItem.configure({ nested: true }), - TiptapTypography, - Superscript, - Subscript, - Selection, - Image, - MediaEmbedNode, - MediaEmbedInputNode, - DividerNode, - ArticleByLineInfoBarNode, - ImageWithCaptionNode, - MediaEmbedURLHandler, - ImageNode.configure({ - accept: "image/*", - maxSize: MAX_FILE_SIZE, - limit: 3, - upload: uploadHandler, - onError: (error) => setUploadError(error.message), - }), + ...createBaseExtensions(uploadHandler, setUploadError), BannerNode, + ByLineInfoBarNode, ] diff --git a/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx index 27a7be5bd3..ea7e753f7d 100644 --- a/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx @@ -19,9 +19,10 @@ import dynamic from "next/dynamic" import { Toolbar } from "../vendor/components/tiptap-ui-primitive/toolbar" import { TiptapEditor, MainToolbarContent, TipTapViewer } from "../TiptapEditor" +import { BannerViewer } from "../extensions/node/Banner/BannerNode" import { handleImageUpload } from "../vendor/lib/tiptap-utils" import { useSchema } from "../useSchema" -import { ArticleProvider } from "../ArticleContext" +import { WebsiteContentProvider } from "../ArticleContext" import { extractLearningResourceIds, contentsMatch } from "../extensions/utils" import { LearningResourceProvider } from "../extensions/node/LearningResource/LearningResourceDataProvider" @@ -33,13 +34,18 @@ const LearningResourceDrawer = dynamic( const TOOLBAR_HEIGHT = 43 -const ViewContainer = styled.div<{ toolbarVisible: boolean }>( - ({ toolbarVisible, theme }) => ({ - width: "100vw", - marginTop: toolbarVisible ? TOOLBAR_HEIGHT : 0, - backgroundColor: theme.custom.colors.white, - }), -) +const ViewContainer = styled.div<{ + toolbarVisible: boolean + backgroundColor?: string + readOnly?: boolean +}>(({ toolbarVisible, backgroundColor, readOnly, theme }) => ({ + width: "100vw", + marginTop: toolbarVisible ? TOOLBAR_HEIGHT : 0, + backgroundColor: + readOnly && backgroundColor + ? theme.custom.colors[backgroundColor as keyof typeof theme.custom.colors] + : theme.custom.colors.white, +})) const StyledToolbar = styled(Toolbar)(({ theme }) => ({ "&&": { @@ -125,7 +131,7 @@ export type CreateExtensionsFn = ( setUploadError: (error: string | null) => void, ) => (Extension | Node | Mark)[] -export interface GenericEditorProps { +export interface WebsiteContentEditorProps { /** * Factory that builds the full extensions list for this content type. * Must be a stable reference (module-level function or useCallback). @@ -154,9 +160,12 @@ export interface GenericEditorProps { onSave?: (article: WebsiteContent) => void readOnly?: boolean article?: WebsiteContent + backgroundColor?: string + bannerViewer?: typeof BannerViewer + bylineViewer?: typeof BannerViewer } -const GenericEditor = ({ +const WebsiteContentEditor = ({ createExtensions, initialDoc, toolbarSlot, @@ -166,7 +175,10 @@ const GenericEditor = ({ onSave, readOnly, article, -}: GenericEditorProps) => { + bannerViewer, + bylineViewer, + backgroundColor, +}: WebsiteContentEditorProps) => { const [isPublishing, setIsPublishing] = useState(false) const [uploadError, setUploadError] = useState(null) const [resetAttempted, setResetAttempted] = useState(false) @@ -349,8 +361,12 @@ const GenericEditor = ({ const resourceIds = extractLearningResourceIds(content) return ( - - + + {isArticleEditor ? ( @@ -434,16 +450,27 @@ const GenericEditor = ({ {readOnly ? ( <> - + ) : ( )} - + ) } -export { GenericEditor } +export { WebsiteContentEditor } + +/** @deprecated Use WebsiteContentEditor */ +export { WebsiteContentEditor as GenericEditor } + +/** @deprecated Use WebsiteContentEditorProps */ +export type { WebsiteContentEditorProps as GenericEditorProps } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts b/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts new file mode 100644 index 0000000000..129cf08f53 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts @@ -0,0 +1,121 @@ +/** + * Tier-1 base extensions: common to all website content types. + * Constructible from only (uploadHandler, setUploadError) — no type-specific args. + * + * Each content type's createExtensions factory collapses to: + * [TypeDocument, ...createBaseExtensions(deps), TypeBannerNode, ByLineInfoBarNode] + */ +import type { Extension, Node, Mark } from "@tiptap/core" +import { Placeholder, Selection } from "@tiptap/extensions" +import { StarterKit } from "@tiptap/starter-kit" +import { TaskItem, TaskList } from "@tiptap/extension-list" +import { Heading } from "@tiptap/extension-heading" +import { Image } from "@tiptap/extension-image" +import { TextAlign } from "@tiptap/extension-text-align" +import { Typography as TiptapTypography } from "@tiptap/extension-typography" +import { Subscript } from "@tiptap/extension-subscript" +import { Superscript } from "@tiptap/extension-superscript" +import type { Node as ProseMirrorNode } from "@tiptap/pm/model" +import { HorizontalRule } from "../vendor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension" +import { ImageNode } from "./node/Image/ImageNode" +import { ImageWithCaptionNode } from "./node/Image/ImageWithCaptionNode" +import { DividerNode } from "./node/Divider/DividerNode" +import { LearningResourceNode } from "./node/LearningResource/LearningResourceNode" +import { LearningResourceInputNode } from "./node/LearningResource/LearningResourceInputNode" +import { LearningResourceURLHandler } from "./node/LearningResource/LearningResourcePaste" +import { MediaEmbedURLHandler } from "./node/MediaEmbed/MediaEmbedURLHandler" +import { MediaEmbedNode } from "./node/MediaEmbed/MediaEmbedNode" +import { MediaEmbedInputNode } from "./node/MediaEmbed/MediaEmbedInputNode" +import type { ExtendedNodeConfig } from "./node/types" +import { MAX_FILE_SIZE } from "../vendor/lib/tiptap-utils" +import type { UploadHandler } from "../core/GenericEditor" + +export const createBaseExtensions = ( + uploadHandler: UploadHandler, + setUploadError: (error: string | null) => void, +): (Extension | Node | Mark)[] => [ + StarterKit.configure({ + document: false, + horizontalRule: false, + heading: false, + link: { + openOnClick: false, + enableClickSelection: true, + }, + trailingNode: { + node: "paragraph", + }, + }), + Heading.configure({ + levels: [1, 2, 3, 4, 5, 6], + }), + Placeholder.configure({ + showOnlyCurrent: false, + includeChildren: true, + placeholder: ({ node, editor }): string => { + let parentNode: typeof node | null = null + + editor.state.doc.descendants((n: ProseMirrorNode) => { + n.forEach((childNode: ProseMirrorNode) => { + if (childNode === node) { + parentNode = n + } + }) + if (parentNode) { + return false + } + return undefined + }) + + if (parentNode) { + const parentExtension = editor.extensionManager.extensions.find( + (ext) => ext.name === parentNode!.type.name, + ) + + if ( + parentExtension && + "config" in parentExtension && + parentExtension.config && + typeof (parentExtension.config as ExtendedNodeConfig) + .getPlaceholders === "function" + ) { + const placeholder = ( + parentExtension.config as ExtendedNodeConfig + ).getPlaceholders(node) + if (placeholder) { + return placeholder + } + } + } + + if (node.type.name === "heading") { + return "Add a heading" + } + return "Add some text" + }, + }), + HorizontalRule, + LearningResourceURLHandler, + LearningResourceNode, + LearningResourceInputNode, + TextAlign.configure({ types: ["heading", "paragraph"] }), + TaskList, + TaskItem.configure({ nested: true }), + TiptapTypography, + Superscript, + Subscript, + Selection, + Image, + MediaEmbedNode, + MediaEmbedInputNode, + DividerNode, + ImageWithCaptionNode, + MediaEmbedURLHandler, + ImageNode.configure({ + accept: "image/*", + maxSize: MAX_FILE_SIZE, + limit: 3, + upload: uploadHandler, + onError: (error) => setUploadError(error.message), + }), +] diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx index 92d32c516e..34883535d4 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx @@ -6,7 +6,7 @@ import { Container } from "ol-components" import { RiShareFill } from "@remixicon/react" import { ActionButton, TextField } from "@mitodl/smoot-design" import type { JSONContent } from "@tiptap/core" -import { useArticle } from "../../../ArticleContext" +import { useWebsiteContent } from "../../../ArticleContext" import { calculateReadTime } from "../../utils" import SharePopover from "@/components/SharePopover/SharePopover" @@ -93,7 +93,7 @@ interface ArticleByLineInfoBarContentProps { onAuthorNameChange?: (name: string) => void } -export const ArticleByLineInfoBarContent = ({ +export const ByLineInfoBarContent = ({ publishedDate, content, isEditable = false, @@ -103,7 +103,7 @@ export const ArticleByLineInfoBarContent = ({ const [shareOpen, setShareOpen] = useState(false) const shareButtonRef = useRef(null) - const article = useArticle() + const article = useWebsiteContent() const readTime = calculateReadTime(content) @@ -165,12 +165,12 @@ export const ArticleByLineInfoBarContent = ({ ) } -const ArticleByLineInfoBar = ({ +const ByLineInfoBar = ({ editor, node, updateAttributes, }: ReactNodeViewProps) => { - const article = useArticle() + const article = useWebsiteContent() const publishedDate = article?.is_published ? article?.created_on : null @@ -200,7 +200,7 @@ const ArticleByLineInfoBar = ({ return ( - ({ + ...theme.typography.body2, + color: theme.custom.colors.darkGray2, +})) + +const InfoText = styled.span(({ theme }) => ({ + ...theme.typography.body2, + color: theme.custom.colors.darkGray2, + opacity: 0.7, +})) + +interface ArticleByLineInBannerProps { + authorName?: string | null + publishedDate?: string | null + content?: JSONContent | null +} + +const ArticleByLineInBanner = ({ + authorName, + publishedDate, + content, +}: ArticleByLineInBannerProps) => { + const readTime = calculateReadTime(content) + const displayAuthorName = authorName || "" + + if (!displayAuthorName && !readTime && !publishedDate) { + return null + } + + return ( + + {displayAuthorName && By {displayAuthorName}} + {readTime ? {readTime} min read : null} + {readTime && publishedDate ? · : null} + + {publishedDate + ? new Date(publishedDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : "Draft"} + + + ) +} + +export { ArticleByLineInBanner } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts b/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts index f18ea019b7..a981514169 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts @@ -2,6 +2,7 @@ import { Node, mergeAttributes } from "@tiptap/core" import { ReactNodeViewRenderer } from "@tiptap/react" import ArticleByLineInfoBar from "./ArticleByLineInfoBar" +/** @deprecated Use ByLineInfoBarNode */ export const ArticleByLineInfoBarNode = Node.create({ name: "byline", atom: true, @@ -33,6 +34,10 @@ export const ArticleByLineInfoBarNode = Node.create({ }, addNodeView() { + // eslint-disable-next-line react-hooks/rules-of-hooks return ReactNodeViewRenderer(ArticleByLineInfoBar) }, }) + +/** Primary export — use this name in new code. */ +export { ArticleByLineInfoBarNode as ByLineInfoBarNode } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer.tsx index 6643d4fd7d..81da04434d 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer.tsx @@ -1,16 +1,17 @@ import React from "react" -import { useArticle } from "../../../ArticleContext" -import { ArticleByLineInfoBarContent } from "./ArticleByLineInfoBar" +import { useWebsiteContent } from "../../../ArticleContext" +import { ByLineInfoBarContent } from "./ArticleByLineInfoBar" +import { ArticleByLineInBanner } from "./ArticleByLineInfoBarInBanner" -const ArticleByLineInfoBarViewer = () => { - const article = useArticle() +const ByLineInfoBarViewer = () => { + const article = useWebsiteContent() const publishedDate = article?.is_published ? article?.created_on : null const content = article?.content const authorName = article?.author_name ?? null return ( - { ) } -export { ArticleByLineInfoBarViewer } +export { ByLineInfoBarViewer } + +/** @deprecated Use ByLineInfoBarViewer */ +export { ByLineInfoBarViewer as ArticleByLineInfoBarViewer } + +const ArticleByLineInBannerViewer = () => { + const article = useWebsiteContent() + + const publishedDate = article?.is_published ? article?.created_on : null + const content = article?.content + const authorName = article?.author_name ?? null + + return ( + + ) +} + +export { ArticleByLineInBannerViewer } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx new file mode 100644 index 0000000000..9aadfdd894 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx @@ -0,0 +1,215 @@ +import React from "react" +import { + ReactNodeViewRenderer, + Node, + mergeAttributes, + NodeViewWrapper, + NodeViewContent, + ReactNodeViewContentProvider, +} from "@tiptap/react" +import type { Node as ProseMirrorNode } from "@tiptap/pm/model" +import { Container, Breadcrumbs } from "ol-components" +import styled from "@emotion/styled" +import type { ExtendedNodeConfig } from "../types" +import { getTitle } from "../lib" + +const FullWidthContainer = styled.div({ + position: "relative", + left: "50%", + right: "50%", + marginLeft: "-50vw", + marginRight: "-50vw", + width: "100vw", +}) + +const InnerContainer = styled(Container)({ + "&&": { + maxWidth: "890px", + }, +}) + +const StyledNodeViewWrapper = styled(NodeViewWrapper)({ + "&&": { + position: "relative", + left: "50%", + right: "50%", + marginLeft: "-50vw", + marginRight: "-50vw", + width: "100vw", + borderBottom: "1px solid #DDE1E6", + }, +}) + +const BreadcrumContainer = styled(Container)(({ theme }) => ({ + maxWidth: "1080px !important", + padding: "0 !important", + [theme.breakpoints.down("lg")]: { + padding: "0 16px !important", + }, + [theme.breakpoints.down("md")]: { + padding: "0 16px !important", + }, + [theme.breakpoints.down("sm")]: { + padding: "0 16px !important", + }, +})) + +const BreadcrumbBar = styled.div(({ theme }) => ({ + position: "relative", + left: "50%", + right: "50%", + marginLeft: "-50vw", + marginRight: "-50vw", + width: "100vw", + padding: "18px 0 2px 0", + backgroundColor: theme.custom.colors.white, + borderBottom: `1px solid ${theme.custom.colors.red}`, + textDecoration: "none", + "&& .breadcrum span span a": { + textDecoration: "none !important", + }, + "&& .breadcrum span span a span": { + textDecoration: "none !important", + }, + [theme.breakpoints.down("sm")]: { + padding: "12px 0 0px 0", + }, +})) + +const ArticleBannerSection = styled.div(({ theme }) => ({ + padding: "64px 0", + backgroundColor: theme.custom.colors.white, + [theme.breakpoints.down("sm")]: { + padding: "32px 0", + }, +})) + +const StyledNodeViewContent = styled(NodeViewContent)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + "&&&&& h1": { + marginTop: 0, + marginBottom: "0px", + color: theme.custom.colors.darkGray2, + [theme.breakpoints.down("sm")]: { + ...theme.typography.h3, + }, + }, + "&&&&& p": { + position: "relative", + marginBottom: 0, + marginTop: "16px", + color: theme.custom.colors.darkGray2, + [theme.breakpoints.down("sm")]: { + ...theme.typography.body2, + marginTop: 0, + }, + }, + ".is-empty:not(.with-slash)[data-placeholder]:has(> .ProseMirror-trailingBreak:only-child)::before": + { + color: theme.custom.colors.silverGrayLight, + opacity: 0.4, + }, + '[contenteditable="true"] &': { + caretColor: theme.custom.colors.red, + }, +})) + +import { ArticleByLineInBannerViewer } from "../ArticleByLineInfoBar/ArticleByLineInfoBarViewer" + +const ArticleBannerViewer = ({ + children, + node, +}: { + children?: React.ReactNode + node?: ProseMirrorNode +}) => { + return ( + + + <> + + + + + + + + + + + + + + + + + + ) +} + +const ArticleBannerWrapper = (props?: { node?: ProseMirrorNode }) => { + return ( + + + + + + + + + + + ) +} + +const articleBannerNodeConfig: ExtendedNodeConfig = { + name: "banner", + + selectable: false, + + // Enforce that the node must contain exactly a title (heading) and subheading (paragraph) + content: "heading paragraph", + + isolating: false, + + parseHTML() { + return [{ tag: "banner" }] + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return ["banner", mergeAttributes(HTMLAttributes), 0] + }, + + addNodeView() { + return ReactNodeViewRenderer(ArticleBannerWrapper) + }, + + getPlaceholders: (childNode: ProseMirrorNode) => { + if (childNode.type.name === "heading") { + return "Add a title" + } + if (childNode.type.name === "paragraph") { + return "Add a subheading" + } + return null + }, +} + +const ArticleBannerNode = Node.create(articleBannerNodeConfig) + +export { ArticleBannerNode, ArticleBannerViewer } diff --git a/frontends/main/src/page-components/TiptapEditor/index.ts b/frontends/main/src/page-components/TiptapEditor/index.ts index ce26ad9bdb..1c946fe273 100644 --- a/frontends/main/src/page-components/TiptapEditor/index.ts +++ b/frontends/main/src/page-components/TiptapEditor/index.ts @@ -1,8 +1,10 @@ export { ArticleEditor } from "./ArticleEditor" export { NewsEditor } from "./contentTypes/news/NewsEditor" export { ArticleEditor as UserArticleEditor } from "./contentTypes/article/ArticleEditor" -export { GenericEditor } from "./core/GenericEditor" +export { WebsiteContentEditor } from "./core/GenericEditor" +export { WebsiteContentEditor as GenericEditor } from "./core/GenericEditor" export type { - GenericEditorProps, + WebsiteContentEditorProps, + WebsiteContentEditorProps as GenericEditorProps, CreateExtensionsFn, } from "./core/GenericEditor" From d55f62344f600ff9f6616c9bc3c2ee2c7429b00f Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Wed, 20 May 2026 15:48:41 +0500 Subject: [PATCH 08/17] address the feedback --- frontends/main/next.config.js | 12 -- .../app-pages/Articles/ArticleDetailPage.tsx | 4 +- .../app-pages/Articles/ArticleEditPage.tsx | 4 +- .../src/app-pages/Articles/ArticleNewPage.tsx | 4 +- .../articles/[slugOrId]/draft/page.tsx | 0 .../articles/[slugOrId]/edit/page.tsx | 0 .../{ => (site)}/articles/[slugOrId]/page.tsx | 0 .../app/{ => (site)}/articles/draft/page.tsx | 0 .../app/{ => (site)}/articles/new/page.tsx | 0 .../src/app/{ => (site)}/articles/page.tsx | 0 .../[type]/[idOrSlug]/edit/page.tsx | 0 .../website_content/[type]/new/page.tsx | 0 .../website_content/drafts/page.tsx | 0 .../TiptapEditor/ArticleEditor.tsx | 8 - .../TiptapEditor/ArticleViewer.test.tsx | 16 +- .../TiptapEditor/TiptapEditor.tsx | 2 +- ...eContext.tsx => WebsiteContentContext.tsx} | 8 - .../contentTypes/article/ArticleEditor.tsx | 6 +- .../contentTypes/article/articleExtensions.ts | 4 +- .../news/NewsEditor.happydom.test.tsx} | 19 +- .../contentTypes/news/NewsEditor.tsx | 4 +- .../contentTypes/news/newsExtensions.ts | 4 +- ...ricEditor.tsx => WebsiteContentEditor.tsx} | 18 +- .../TiptapEditor/extensions/baseExtensions.ts | 2 +- .../node/Banner/ArticleBannerNode.tsx | 2 +- .../ByLineInfoBar.tsx} | 11 +- .../ByLineInfoBarInBanner.tsx} | 4 +- .../ByLineInfoBarNode.ts} | 10 +- .../ByLineInfoBarViewer.tsx} | 18 +- .../src/page-components/TiptapEditor/index.ts | 7 +- .../TiptapEditor/useArticleSchema.test.tsx | 172 ------------------ .../TiptapEditor/useArticleSchema.ts | 50 ----- 32 files changed, 61 insertions(+), 328 deletions(-) rename frontends/main/src/app/{ => (site)}/articles/[slugOrId]/draft/page.tsx (100%) rename frontends/main/src/app/{ => (site)}/articles/[slugOrId]/edit/page.tsx (100%) rename frontends/main/src/app/{ => (site)}/articles/[slugOrId]/page.tsx (100%) rename frontends/main/src/app/{ => (site)}/articles/draft/page.tsx (100%) rename frontends/main/src/app/{ => (site)}/articles/new/page.tsx (100%) rename frontends/main/src/app/{ => (site)}/articles/page.tsx (100%) rename frontends/main/src/app/{ => (site)}/website_content/[type]/[idOrSlug]/edit/page.tsx (100%) rename frontends/main/src/app/{ => (site)}/website_content/[type]/new/page.tsx (100%) rename frontends/main/src/app/{ => (site)}/website_content/drafts/page.tsx (100%) delete mode 100644 frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx rename frontends/main/src/page-components/TiptapEditor/{ArticleContext.tsx => WebsiteContentContext.tsx} (66%) rename frontends/main/src/page-components/TiptapEditor/{ArticleEditor.happydom.test.tsx => contentTypes/news/NewsEditor.happydom.test.tsx} (98%) rename frontends/main/src/page-components/TiptapEditor/core/{GenericEditor.tsx => WebsiteContentEditor.tsx} (96%) rename frontends/main/src/page-components/TiptapEditor/extensions/node/{ArticleByLineInfoBar/ArticleByLineInfoBar.tsx => ByLineInfoBar/ByLineInfoBar.tsx} (94%) rename frontends/main/src/page-components/TiptapEditor/extensions/node/{ArticleByLineInfoBar/ArticleByLineInfoBarInBanner.tsx => ByLineInfoBar/ByLineInfoBarInBanner.tsx} (95%) rename frontends/main/src/page-components/TiptapEditor/extensions/node/{ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts => ByLineInfoBar/ByLineInfoBarNode.ts} (70%) rename frontends/main/src/page-components/TiptapEditor/extensions/node/{ArticleByLineInfoBar/ArticleByLineInfoBarViewer.tsx => ByLineInfoBar/ByLineInfoBarViewer.tsx} (65%) delete mode 100644 frontends/main/src/page-components/TiptapEditor/useArticleSchema.test.tsx delete mode 100644 frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index 303a8b29f4..ec6918ed40 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -57,18 +57,6 @@ const nextConfig = { destination: "/enrollmentcode/:code", permanent: true, }, - { - // can be removed once fastly redirect is in place - source: "/articles/:slug*", - destination: "/news/:slug*", - permanent: true, - }, - { - // can be removed once fastly redirect is in place - source: "/articles", - destination: "/news", - permanent: true, - }, ] }, diff --git a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx index 5a0bc49ed5..f09eadd5d7 100644 --- a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx @@ -3,7 +3,7 @@ import React from "react" import { useArticleDetailRetrieve } from "api/hooks/articles" import { LoadingSpinner, styled } from "ol-components" -import { ArticleEditor } from "@/page-components/TiptapEditor/ArticleEditor" +import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" import { notFound } from "next/navigation" import { LearningResourceProvider } from "@/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceDataProvider" @@ -43,7 +43,7 @@ export const ArticleDetailPage = ({ return ( - + ) diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx index aaab2df643..0ed3b1b31d 100644 --- a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx @@ -7,7 +7,7 @@ import { Permission } from "api/hooks/user" import { useArticleDetailRetrieve } from "api/hooks/articles" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import { styled, LoadingSpinner } from "ol-components" -import { ArticleEditor } from "@/page-components/TiptapEditor/ArticleEditor" +import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" import { articlesView, articlesDraftView } from "@/common/urls" import invariant from "tiny-invariant" @@ -43,7 +43,7 @@ const ArticleEditPage = ({ articleId }: { articleId: string }) => { return ( - { if (article.is_published) { diff --git a/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx b/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx index 8cbdd98489..5dd53fb0ba 100644 --- a/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next-nprogress-bar" import { Permission } from "api/hooks/user" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import { styled } from "ol-components" -import { ArticleEditor } from "@/page-components/TiptapEditor/ArticleEditor" +import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" import { articlesDraftView, articlesView } from "@/common/urls" import invariant from "tiny-invariant" @@ -21,7 +21,7 @@ const ArticleNewPage: React.FC = () => { return ( - { if (article.is_published) { invariant(article.slug, "Published article must have a slug") diff --git a/frontends/main/src/app/articles/[slugOrId]/draft/page.tsx b/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx similarity index 100% rename from frontends/main/src/app/articles/[slugOrId]/draft/page.tsx rename to frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx diff --git a/frontends/main/src/app/articles/[slugOrId]/edit/page.tsx b/frontends/main/src/app/(site)/articles/[slugOrId]/edit/page.tsx similarity index 100% rename from frontends/main/src/app/articles/[slugOrId]/edit/page.tsx rename to frontends/main/src/app/(site)/articles/[slugOrId]/edit/page.tsx diff --git a/frontends/main/src/app/articles/[slugOrId]/page.tsx b/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx similarity index 100% rename from frontends/main/src/app/articles/[slugOrId]/page.tsx rename to frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx diff --git a/frontends/main/src/app/articles/draft/page.tsx b/frontends/main/src/app/(site)/articles/draft/page.tsx similarity index 100% rename from frontends/main/src/app/articles/draft/page.tsx rename to frontends/main/src/app/(site)/articles/draft/page.tsx diff --git a/frontends/main/src/app/articles/new/page.tsx b/frontends/main/src/app/(site)/articles/new/page.tsx similarity index 100% rename from frontends/main/src/app/articles/new/page.tsx rename to frontends/main/src/app/(site)/articles/new/page.tsx diff --git a/frontends/main/src/app/articles/page.tsx b/frontends/main/src/app/(site)/articles/page.tsx similarity index 100% rename from frontends/main/src/app/articles/page.tsx rename to frontends/main/src/app/(site)/articles/page.tsx diff --git a/frontends/main/src/app/website_content/[type]/[idOrSlug]/edit/page.tsx b/frontends/main/src/app/(site)/website_content/[type]/[idOrSlug]/edit/page.tsx similarity index 100% rename from frontends/main/src/app/website_content/[type]/[idOrSlug]/edit/page.tsx rename to frontends/main/src/app/(site)/website_content/[type]/[idOrSlug]/edit/page.tsx diff --git a/frontends/main/src/app/website_content/[type]/new/page.tsx b/frontends/main/src/app/(site)/website_content/[type]/new/page.tsx similarity index 100% rename from frontends/main/src/app/website_content/[type]/new/page.tsx rename to frontends/main/src/app/(site)/website_content/[type]/new/page.tsx diff --git a/frontends/main/src/app/website_content/drafts/page.tsx b/frontends/main/src/app/(site)/website_content/drafts/page.tsx similarity index 100% rename from frontends/main/src/app/website_content/drafts/page.tsx rename to frontends/main/src/app/(site)/website_content/drafts/page.tsx diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx deleted file mode 100644 index f5c01fe11e..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.tsx +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Backward-compatible re-export. - * The news editor logic now lives in contentTypes/news/NewsEditor.tsx. - * All /news pages continue to import from this path without changes. - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export { NewsEditor as ArticleEditor } from "./contentTypes/news/NewsEditor" -export type { NewsEditorProps as ArticleEditorProps } from "./contentTypes/news/NewsEditor" diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx index 8ee3150d3b..d5a4a5b5b6 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx @@ -1,7 +1,7 @@ import React from "react" import { screen, renderWithProviders, setMockResponse } from "@/test-utils" import { factories, urls } from "api/test-utils" -import { ArticleEditor } from "./ArticleEditor" +import { NewsEditor } from "./contentTypes/news/NewsEditor" describe("ArticleViewer", () => { test("renders article content", async () => { @@ -64,7 +64,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() await screen.findByRole("heading", { name: "Test Title", level: 1 }) await screen.findByText("Test subheading") @@ -83,7 +83,7 @@ describe("ArticleViewer", () => { author_name: authorName, }) - renderWithProviders() + renderWithProviders() await screen.findByText(`By ${authorName}`) }) @@ -202,7 +202,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() await screen.findByRole("heading", { level: 1, name: "Heading Level 1" }) await screen.findByRole("heading", { level: 2, name: "Heading Level 2" }) @@ -294,7 +294,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const firstUnordered = await screen.findByText("First unordered item") const secondUnordered = await screen.findByText("Second unordered item") @@ -398,7 +398,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const boldText = await screen.findByText("bold text") expect(boldText).toBeInTheDocument() @@ -486,7 +486,7 @@ describe("ArticleViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const link = await screen.findByRole("link", { name: "example.com" }) expect(link).toBeInTheDocument() @@ -501,7 +501,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) const article = factories.articles.article() - renderWithProviders() + renderWithProviders() await screen.findByRole("link", { name: "Edit" }) }) diff --git a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx index 456d3b3659..2f19afc1d5 100644 --- a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx @@ -49,7 +49,7 @@ import "./vendor/components/tiptap-templates/simple/simple-editor.scss" import "./TiptapEditor.styles.scss" import { BannerViewer } from "./extensions/node/Banner/BannerNode" -import { ByLineInfoBarViewer } from "./extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer" +import { ByLineInfoBarViewer } from "./extensions/node/ByLineInfoBar/ByLineInfoBarViewer" import { ImageWithCaptionViewer } from "./extensions/node/Image/ImageWithCaption" import { DividerViewer } from "./extensions/node/Divider/DividerNode" import { LearningResourceButton } from "./extensions/ui/LearningResource/LearningResourceButton" diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx b/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx similarity index 66% rename from frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx rename to frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx index 990fa97dea..bfb8ba4347 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleContext.tsx +++ b/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx @@ -12,11 +12,3 @@ export const WebsiteContentProvider = WebsiteContentContext.Provider export function useWebsiteContent() { return useContext(WebsiteContentContext).article } - -/** @deprecated Use WebsiteContentProvider */ -export const ArticleProvider = WebsiteContentProvider - -/** @deprecated Use useWebsiteContent */ -export function useArticle() { - return useWebsiteContent() -} diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx index 880e8748b3..d900fced52 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx @@ -6,7 +6,7 @@ import type { WebsiteContent } from "api/v1" import { ButtonLink } from "@mitodl/smoot-design" import { useArticleCreate, useArticlePartialUpdate } from "api/hooks/articles" import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer" -import { WebsiteContentEditor } from "../../core/GenericEditor" +import { WebsiteContentEditor } from "../../core/WebsiteContentEditor" import { createArticleExtensions, newArticleDocument, @@ -37,7 +37,7 @@ interface ArticleEditorProps { /** * Editor shell configured for the article content type (served under /articles). - * Owns its own save mutations so GenericEditor stays API-agnostic. + * Owns its own save mutations so WebsiteContentEditor stays API-agnostic. * * Currently uses the same websiteContent API as the news editor. When /articles * gets its own Django model and viewset, swap in the new hooks here: @@ -45,7 +45,7 @@ interface ArticleEditorProps { * const createMutation = useUserArticleCreate() // future hook * const updateMutation = useUserArticlePartialUpdate() * - * GenericEditor does not need to change at all. + * WebsiteContentEditor does not need to change at all. */ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { // Swap these two lines when a dedicated UserArticle API exists. diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts index c50789639c..829468b6b5 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/articleExtensions.ts @@ -1,9 +1,9 @@ import type { Extension, Node, Mark } from "@tiptap/core" import Document from "@tiptap/extension-document" import { ArticleBannerNode } from "../../extensions/node/Banner/ArticleBannerNode" -import { ByLineInfoBarNode } from "../../extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode" +import { ByLineInfoBarNode } from "../../extensions/node/ByLineInfoBar/ByLineInfoBarNode" import { createBaseExtensions } from "../../extensions/baseExtensions" -import type { CreateExtensionsFn } from "../../core/GenericEditor" +import type { CreateExtensionsFn } from "../../core/WebsiteContentEditor" export const ArticleDocument = Document.extend({ content: "banner byline block+", diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx similarity index 98% rename from frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx rename to frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx index 95f0ef7026..3cdb2fdb6d 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx @@ -8,7 +8,7 @@ import React from "react" import { screen, waitFor, fireEvent } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { setMockResponse, factories, urls, makeRequest } from "api/test-utils" -import { ArticleEditor } from "./ArticleEditor" +import { NewsEditor } from "./NewsEditor" import type { JSONContent } from "@tiptap/react" import { renderWithProviders } from "@/test-utils" @@ -19,7 +19,7 @@ jest.mock("posthog-js/react", () => ({ const mockOnSave = jest.fn() -describe("ArticleEditor - Content Editing and Saving", () => { +describe("NewsEditor - Content Editing and Saving", () => { beforeEach(() => { mockOnSave.mockClear() jest.clearAllMocks() @@ -44,10 +44,9 @@ describe("ArticleEditor - Content Editing and Saving", () => { }) setMockResponse.get(urls.websiteContent.details(articleId), article) - renderWithProviders( - , - { user }, - ) + renderWithProviders(, { + user, + }) await screen.findByTestId("editor") return article @@ -521,7 +520,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { }) setMockResponse.post(urls.websiteContent.list(), createdArticle) - renderWithProviders(, { user }) + renderWithProviders(, { user }) await screen.findByTestId("editor") @@ -607,7 +606,7 @@ describe("ArticleEditor - Content Editing and Saving", () => { }) }) -describe("ArticleEditor - Document Rendering", () => { +describe("NewsEditor - Document Rendering", () => { let consoleErrorSpy: ReturnType beforeEach(() => { @@ -647,7 +646,7 @@ describe("ArticleEditor - Document Rendering", () => { setMockResponse.get(urls.websiteContent.details(articleId), article) renderWithProviders( - , + , { user }, ) @@ -662,7 +661,7 @@ describe("ArticleEditor - Document Rendering", () => { }) setMockResponse.get(urls.userMe.get(), user) - renderWithProviders(, { user }) + renderWithProviders(, { user }) await screen.findByTestId("editor") }) diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx index 4266354292..dcb48141cb 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx @@ -6,7 +6,7 @@ import type { WebsiteContent } from "api/v1" import { ButtonLink } from "@mitodl/smoot-design" import { useArticleCreate, useArticlePartialUpdate } from "api/hooks/articles" import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer" -import { WebsiteContentEditor } from "../../core/GenericEditor" +import { WebsiteContentEditor } from "../../core/WebsiteContentEditor" import { createNewsExtensions, newNewsDocument } from "./newsExtensions" // News-specific: extract the author name from the byline node in the document @@ -32,7 +32,7 @@ interface NewsEditorProps { /** * Editor shell configured for the news content type (served under /news). * Owns its own save mutations (websiteContent API) and passes them to - * GenericEditor — keeping the generic shell decoupled from any specific API. + * WebsiteContentEditor — keeping the generic shell decoupled from any specific API. */ const NewsEditor = ({ onSave, readOnly, article }: NewsEditorProps) => { // News content type uses the websiteContent (articles) API. diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts index a410469617..05a650b379 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/newsExtensions.ts @@ -1,9 +1,9 @@ import type { Extension, Node, Mark } from "@tiptap/core" import Document from "@tiptap/extension-document" import { BannerNode } from "../../extensions/node/Banner/BannerNode" -import { ByLineInfoBarNode } from "../../extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode" +import { ByLineInfoBarNode } from "../../extensions/node/ByLineInfoBar/ByLineInfoBarNode" import { createBaseExtensions } from "../../extensions/baseExtensions" -import type { CreateExtensionsFn } from "../../core/GenericEditor" +import type { CreateExtensionsFn } from "../../core/WebsiteContentEditor" export const NewsDocument = Document.extend({ content: "banner byline block+", diff --git a/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx similarity index 96% rename from frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx rename to frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx index ea7e753f7d..d39d43b92d 100644 --- a/frontends/main/src/page-components/TiptapEditor/core/GenericEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx @@ -22,7 +22,7 @@ import { TiptapEditor, MainToolbarContent, TipTapViewer } from "../TiptapEditor" import { BannerViewer } from "../extensions/node/Banner/BannerNode" import { handleImageUpload } from "../vendor/lib/tiptap-utils" import { useSchema } from "../useSchema" -import { WebsiteContentProvider } from "../ArticleContext" +import { WebsiteContentProvider } from "../WebsiteContentContext" import { extractLearningResourceIds, contentsMatch } from "../extensions/utils" import { LearningResourceProvider } from "../extensions/node/LearningResource/LearningResourceDataProvider" @@ -90,18 +90,18 @@ export interface SavePayload { /** * Per-type save mutations. Each content type owns its own API hooks and passes - * the resulting mutation objects here, so GenericEditor never imports a + * the resulting mutation objects here, so WebsiteContentEditor never imports a * specific API hook directly. * * Example — news type uses websiteContent API: * const create = useArticleCreate() * const update = useArticlePartialUpdate() - * + * * * A future user-article type could use a completely different API hook: - * const create = useUserArticleCreate() + * const create = useUserArticleCreate() // future hook * const update = useUserArticlePartialUpdate() - * + * */ export interface SaveMutations { create: { @@ -154,7 +154,7 @@ export interface WebsiteContentEditorProps { extractExtraFields?: (content: JSONContent) => Record /** * Mutations for create and update. Provided by the content-type wrapper so - * GenericEditor stays decoupled from any specific API endpoint. + * WebsiteContentEditor stays decoupled from any specific API endpoint. */ saveMutations: SaveMutations onSave?: (article: WebsiteContent) => void @@ -468,9 +468,3 @@ const WebsiteContentEditor = ({ } export { WebsiteContentEditor } - -/** @deprecated Use WebsiteContentEditor */ -export { WebsiteContentEditor as GenericEditor } - -/** @deprecated Use WebsiteContentEditorProps */ -export type { WebsiteContentEditorProps as GenericEditorProps } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts b/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts index 129cf08f53..3cf7de88a4 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts +++ b/frontends/main/src/page-components/TiptapEditor/extensions/baseExtensions.ts @@ -28,7 +28,7 @@ import { MediaEmbedNode } from "./node/MediaEmbed/MediaEmbedNode" import { MediaEmbedInputNode } from "./node/MediaEmbed/MediaEmbedInputNode" import type { ExtendedNodeConfig } from "./node/types" import { MAX_FILE_SIZE } from "../vendor/lib/tiptap-utils" -import type { UploadHandler } from "../core/GenericEditor" +import type { UploadHandler } from "../core/WebsiteContentEditor" export const createBaseExtensions = ( uploadHandler: UploadHandler, diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx index 9aadfdd894..7526c1881e 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx @@ -115,7 +115,7 @@ const StyledNodeViewContent = styled(NodeViewContent)(({ theme }) => ({ }, })) -import { ArticleByLineInBannerViewer } from "../ArticleByLineInfoBar/ArticleByLineInfoBarViewer" +import { ArticleByLineInBannerViewer } from "../ByLineInfoBar/ByLineInfoBarViewer" const ArticleBannerViewer = ({ children, diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx similarity index 94% rename from frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx rename to frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx index 34883535d4..3227dafd74 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx @@ -6,7 +6,7 @@ import { Container } from "ol-components" import { RiShareFill } from "@remixicon/react" import { ActionButton, TextField } from "@mitodl/smoot-design" import type { JSONContent } from "@tiptap/core" -import { useWebsiteContent } from "../../../ArticleContext" +import { useWebsiteContent } from "../../../WebsiteContentContext" import { calculateReadTime } from "../../utils" import SharePopover from "@/components/SharePopover/SharePopover" @@ -85,7 +85,7 @@ const AuthorInput = styled(TextField)(({ theme }) => ({ width: "300px", })) -interface ArticleByLineInfoBarContentProps { +interface ByLineInfoBarContentProps { publishedDate: string | null content: JSONContent | null | undefined isEditable?: boolean @@ -99,7 +99,7 @@ export const ByLineInfoBarContent = ({ isEditable = false, authorName, onAuthorNameChange, -}: ArticleByLineInfoBarContentProps) => { +}: ByLineInfoBarContentProps) => { const [shareOpen, setShareOpen] = useState(false) const shareButtonRef = useRef(null) @@ -117,7 +117,7 @@ export const ByLineInfoBarContent = ({ title={article?.title ?? ""} anchorEl={shareButtonRef.current} onClose={() => setShareOpen(false)} - pageUrl={`${NEXT_PUBLIC_ORIGIN}/news/${article?.slug}`} + pageUrl={`${NEXT_PUBLIC_ORIGIN}/${article?.content_type === "article" ? "articles" : "news"}/${article?.slug}`} /> {(displayAuthorName || isEditable) && ( @@ -212,6 +212,3 @@ const ByLineInfoBar = ({ } export default ByLineInfoBar - -/** @deprecated Use ByLineInfoBarContent */ -export { ByLineInfoBarContent as ArticleByLineInfoBarContent } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarInBanner.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarInBanner.tsx similarity index 95% rename from frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarInBanner.tsx rename to frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarInBanner.tsx index e2d825de36..2b9d9b7f72 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarInBanner.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarInBanner.tsx @@ -27,7 +27,7 @@ interface ArticleByLineInBannerProps { content?: JSONContent | null } -const ArticleByLineInBanner = ({ +const ByLineInBanner = ({ authorName, publishedDate, content, @@ -57,4 +57,4 @@ const ArticleByLineInBanner = ({ ) } -export { ArticleByLineInBanner } +export { ByLineInBanner } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarNode.ts similarity index 70% rename from frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts rename to frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarNode.ts index a981514169..b303972957 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarNode.ts +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarNode.ts @@ -1,9 +1,8 @@ import { Node, mergeAttributes } from "@tiptap/core" import { ReactNodeViewRenderer } from "@tiptap/react" -import ArticleByLineInfoBar from "./ArticleByLineInfoBar" +import ByLineInfoBar from "./ByLineInfoBar" -/** @deprecated Use ByLineInfoBarNode */ -export const ArticleByLineInfoBarNode = Node.create({ +export const ByLineInfoBarNode = Node.create({ name: "byline", atom: true, selectable: false, @@ -35,9 +34,6 @@ export const ArticleByLineInfoBarNode = Node.create({ addNodeView() { // eslint-disable-next-line react-hooks/rules-of-hooks - return ReactNodeViewRenderer(ArticleByLineInfoBar) + return ReactNodeViewRenderer(ByLineInfoBar) }, }) - -/** Primary export — use this name in new code. */ -export { ArticleByLineInfoBarNode as ByLineInfoBarNode } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarViewer.tsx similarity index 65% rename from frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer.tsx rename to frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarViewer.tsx index 81da04434d..f4d2e62145 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBarViewer.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarViewer.tsx @@ -1,7 +1,7 @@ import React from "react" -import { useWebsiteContent } from "../../../ArticleContext" -import { ByLineInfoBarContent } from "./ArticleByLineInfoBar" -import { ArticleByLineInBanner } from "./ArticleByLineInfoBarInBanner" +import { useWebsiteContent } from "../../../WebsiteContentContext" +import { ByLineInfoBarContent } from "./ByLineInfoBar" +import { ByLineInBanner } from "./ByLineInfoBarInBanner" const ByLineInfoBarViewer = () => { const article = useWebsiteContent() @@ -22,10 +22,7 @@ const ByLineInfoBarViewer = () => { export { ByLineInfoBarViewer } -/** @deprecated Use ByLineInfoBarViewer */ -export { ByLineInfoBarViewer as ArticleByLineInfoBarViewer } - -const ArticleByLineInBannerViewer = () => { +const ByLineInBannerViewer = () => { const article = useWebsiteContent() const publishedDate = article?.is_published ? article?.created_on : null @@ -33,7 +30,7 @@ const ArticleByLineInBannerViewer = () => { const authorName = article?.author_name ?? null return ( - { ) } -export { ArticleByLineInBannerViewer } +export { ByLineInBannerViewer } + +/** @deprecated Use ByLineInBannerViewer */ +export { ByLineInBannerViewer as ArticleByLineInBannerViewer } diff --git a/frontends/main/src/page-components/TiptapEditor/index.ts b/frontends/main/src/page-components/TiptapEditor/index.ts index 1c946fe273..9ad5ede13e 100644 --- a/frontends/main/src/page-components/TiptapEditor/index.ts +++ b/frontends/main/src/page-components/TiptapEditor/index.ts @@ -1,10 +1,7 @@ -export { ArticleEditor } from "./ArticleEditor" export { NewsEditor } from "./contentTypes/news/NewsEditor" export { ArticleEditor as UserArticleEditor } from "./contentTypes/article/ArticleEditor" -export { WebsiteContentEditor } from "./core/GenericEditor" -export { WebsiteContentEditor as GenericEditor } from "./core/GenericEditor" +export { WebsiteContentEditor } from "./core/WebsiteContentEditor" export type { WebsiteContentEditorProps, - WebsiteContentEditorProps as GenericEditorProps, CreateExtensionsFn, -} from "./core/GenericEditor" +} from "./core/WebsiteContentEditor" diff --git a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.test.tsx b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.test.tsx deleted file mode 100644 index 5837cab756..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.test.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/** - * @jest-environment @happy-dom/jest-environment - */ -import React from "react" -import { render, screen, waitFor } from "@testing-library/react" -import { useArticleSchema } from "./useArticleSchema" -import type { JSONContent } from "@tiptap/react" - -// Mock console methods to avoid noise in test output -const originalError = console.error -let consoleErrorSpy: ReturnType - -beforeEach(() => { - // Suppress expected validation error messages in console.error - consoleErrorSpy = jest - .spyOn(console, "error") - .mockImplementation((message) => { - // Suppress expected validation errors - if ( - typeof message === "string" && - message.includes("Document schema check failed") - ) { - return - } - originalError(message) - }) -}) - -afterEach(() => { - consoleErrorSpy.mockRestore() -}) - -const TestComponent = ({ - content, - enabled, -}: { - content: JSONContent - enabled: boolean -}) => { - const mockUploadHandler = jest - .fn() - .mockResolvedValue("http://example.com/image.jpg") - const mockSetUploadError = jest.fn() - - const { schemaError } = useArticleSchema({ - uploadHandler: mockUploadHandler, - setUploadError: mockSetUploadError, - enabled, - content, - }) - - return ( -
- {schemaError &&
{schemaError}
} - {!schemaError &&
No error
} -
- ) -} - -describe("useArticleSchema", () => { - describe("schema validation", () => { - test("show schema error when document is not valid ProseMirror content", async () => { - const content: JSONContent = { - some: "random", - } - render() - - await screen.findByText( - "Document schema check failed: Invalid content for node doc: content specification not satisfied", - ) - }) - - test("show schema error when document is not valid ProseMirror JSON", async () => { - const content: JSONContent = { - type: "doc", - content: [ - { - type: "invalid", - }, - ], - } - render() - - await screen.findByText( - 'Document schema check failed: Invalid content for node doc: node type "invalid" not found in schema', - ) - }) - - test("shows schema error when document does not confirm to content expression (missing banner and byline)", async () => { - // Content missing required banner and byline - const content: JSONContent = { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Content paragraph", - }, - ], - }, - ], - } - - render() - - await screen.findByText( - "Document schema check failed: Invalid content for node doc: paragraph is not allowed in this position", - ) - }) - - test("shows no error when content is valid", async () => { - const content: JSONContent = { - type: "doc", - content: [ - { - type: "banner", - content: [ - { - type: "heading", - attrs: { level: 1 }, - content: [{ type: "text", text: "Title" }], - }, - { - type: "paragraph", - content: [], - }, - ], - }, - { - type: "byline", - }, - { - type: "paragraph", - content: [{ type: "text", text: "Content paragraph" }], - }, - ], - } - - render() - - await waitFor(() => { - expect(screen.getByTestId("no-error")).toBeInTheDocument() - }) - }) - - test("shows no error when enabled is false", async () => { - // Invalid content, but validation should be skipped - const content: JSONContent = { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Content paragraph", - }, - ], - }, - ], - } - - render() - - await waitFor(() => { - expect(screen.getByTestId("no-error")).toBeInTheDocument() - }) - }) - }) -}) diff --git a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts deleted file mode 100644 index ddbe4c3f17..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts +++ /dev/null @@ -1,50 +0,0 @@ -"use client" - -import { useMemo } from "react" -import { getSchema } from "@tiptap/core" -import { useSchema } from "./useSchema" -import type { JSONContent } from "@tiptap/react" -import { - createNewsExtensions, - newNewsDocument, -} from "./contentTypes/news/newsExtensions" - -interface UseArticleSchemaOptions { - uploadHandler: ( - file: File, - onProgress?: (e: { progress: number }) => void, - abortSignal?: AbortSignal, - ) => Promise - setUploadError: (error: string | null) => void - enabled: boolean - content: JSONContent -} - -/** @deprecated Use newNewsDocument from contentTypes/news/newsExtensions */ -export const newArticleDocument = newNewsDocument - -export const useArticleSchema = ({ - uploadHandler, - setUploadError, - enabled, - content, -}: UseArticleSchemaOptions) => { - const extensions = useMemo( - () => createNewsExtensions(uploadHandler, setUploadError), - [uploadHandler, setUploadError], - ) - - const schema = useMemo(() => getSchema(extensions), [extensions]) - - const schemaError = useSchema({ - schema, - content, - enabled, - }) - - return { - extensions, - schema, - schemaError, - } -} From 454936e17444850cac873fa75b0a7b9fd1a432ff Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Thu, 21 May 2026 12:55:04 +0500 Subject: [PATCH 09/17] address the feedback --- frontends/api/src/hooks/articles/queries.ts | 42 - frontends/api/src/hooks/user/index.ts | 4 + .../index.test.ts | 54 +- .../{articles => website_content}/index.ts | 61 +- .../api/src/hooks/website_content/queries.ts | 45 ++ .../api/src/test-utils/factories/index.ts | 2 +- .../{articles.ts => websiteContent.ts} | 6 +- .../app-pages/Articles/ArticleDetailPage.tsx | 50 -- .../Articles/ArticleDraftListingPage.tsx | 184 ----- .../app-pages/Articles/ArticleEditPage.tsx | 62 -- .../app-pages/Articles/ArticleListingPage.tsx | 718 +++--------------- .../src/app-pages/Articles/ArticleNewPage.tsx | 39 - .../ArticleBanner.tsx => News/NewsBanner.tsx} | 6 +- .../NewsListingPage.test.tsx} | 58 +- .../src/app-pages/News/NewsListingPage.tsx | 681 +++++++++++++++++ .../UserArticleDraftListingPage.tsx | 182 ----- .../UserArticles/UserArticleEditPage.tsx | 62 -- .../UserArticles/UserArticleListingPage.tsx | 177 ----- .../UserArticles/UserArticleNewPage.tsx | 39 - .../WebsiteContentDetail.tsx} | 14 +- .../WebsiteContentDraftListingPage.tsx | 21 +- .../WebsiteContent/WebsiteContentEditPage.tsx | 4 +- .../WebsiteContentNewPage.test.tsx} | 6 +- .../(site)/articles/[slugOrId]/draft/page.tsx | 4 +- .../app/(site)/articles/[slugOrId]/page.tsx | 13 +- .../main/src/app/(site)/articles/page.tsx | 4 +- .../app/(site)/news/[slugOrId]/draft/page.tsx | 8 +- .../src/app/(site)/news/[slugOrId]/page.tsx | 27 +- frontends/main/src/app/(site)/news/page.tsx | 4 +- frontends/main/src/common/urls.ts | 22 +- .../TiptapEditor/ArticleViewer.test.tsx | 14 +- .../contentTypes/article/ArticleEditor.tsx | 21 +- .../news/NewsEditor.happydom.test.tsx | 6 +- .../contentTypes/news/NewsEditor.tsx | 21 +- .../core/WebsiteContentEditor.tsx | 6 +- .../node/Banner/ArticleBannerNode.tsx | 4 +- .../ByLineInfoBar/ByLineInfoBarViewer.tsx | 3 - .../src/page-components/TiptapEditor/index.ts | 1 - 38 files changed, 1037 insertions(+), 1638 deletions(-) delete mode 100644 frontends/api/src/hooks/articles/queries.ts rename frontends/api/src/hooks/{articles => website_content}/index.test.ts (66%) rename frontends/api/src/hooks/{articles => website_content}/index.ts (63%) create mode 100644 frontends/api/src/hooks/website_content/queries.ts rename frontends/api/src/test-utils/factories/{articles.ts => websiteContent.ts} (80%) delete mode 100644 frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx delete mode 100644 frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx delete mode 100644 frontends/main/src/app-pages/Articles/ArticleEditPage.tsx delete mode 100644 frontends/main/src/app-pages/Articles/ArticleNewPage.tsx rename frontends/main/src/app-pages/{Articles/ArticleBanner.tsx => News/NewsBanner.tsx} (94%) rename frontends/main/src/app-pages/{Articles/ArticleListingPage.test.tsx => News/NewsListingPage.test.tsx} (87%) create mode 100644 frontends/main/src/app-pages/News/NewsListingPage.tsx delete mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx delete mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx delete mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx delete mode 100644 frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx rename frontends/main/src/app-pages/{UserArticles/UserArticleDetailPage.tsx => WebsiteContent/WebsiteContentDetail.tsx} (71%) rename frontends/main/src/app-pages/{Articles/ArticleNewPage.test.tsx => WebsiteContent/WebsiteContentNewPage.test.tsx} (85%) diff --git a/frontends/api/src/hooks/articles/queries.ts b/frontends/api/src/hooks/articles/queries.ts deleted file mode 100644 index 38d7193479..0000000000 --- a/frontends/api/src/hooks/articles/queries.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { queryOptions } from "@tanstack/react-query" -import { websiteContentApi } from "../../clients" -import type { WebsiteContentApiWebsiteContentListRequest as ArticleListRequest } from "../../generated/v1" - -const articleKeys = { - root: ["articles"], - listRoot: () => [...articleKeys.root, "list"], - list: (params: ArticleListRequest) => [...articleKeys.listRoot(), params], - detailRoot: () => [...articleKeys.root, "detail"], - detail: (id: number) => [...articleKeys.detailRoot(), id], - articlesDetailRetrieve: (identifier: string) => [ - ...articleKeys.detailRoot(), - identifier, - ], -} - -const articleQueries = { - list: (params: ArticleListRequest) => - queryOptions({ - queryKey: articleKeys.list(params), - queryFn: () => - websiteContentApi.websiteContentList(params).then((res) => res.data), - }), - detail: (id: number) => - queryOptions({ - queryKey: articleKeys.detail(id), - queryFn: () => - websiteContentApi - .websiteContentRetrieve({ id }) - .then((res) => res.data), - }), - articlesDetailRetrieve: (identifier: string) => - queryOptions({ - queryKey: articleKeys.articlesDetailRetrieve(identifier), - queryFn: () => - websiteContentApi - .websiteContentDetailRetrieve({ identifier }) - .then((res) => res.data), - }), -} - -export { articleQueries, articleKeys } diff --git a/frontends/api/src/hooks/user/index.ts b/frontends/api/src/hooks/user/index.ts index 603246e399..be8c99e3ee 100644 --- a/frontends/api/src/hooks/user/index.ts +++ b/frontends/api/src/hooks/user/index.ts @@ -3,6 +3,10 @@ import type { User } from "../../generated/v0/api" import { userQueries } from "./queries" enum Permission { + /** + * Controls access to all website_content types (both "news" and "article" + * content_type). Despite the name, this is not limited to article content. + */ ArticleEditor = "is_article_editor", Authenticated = "is_authenticated", LearningPathEditor = "is_learning_path_editor", diff --git a/frontends/api/src/hooks/articles/index.test.ts b/frontends/api/src/hooks/website_content/index.test.ts similarity index 66% rename from frontends/api/src/hooks/articles/index.test.ts rename to frontends/api/src/hooks/website_content/index.test.ts index 43f8c6f037..7c031cfb85 100644 --- a/frontends/api/src/hooks/articles/index.test.ts +++ b/frontends/api/src/hooks/website_content/index.test.ts @@ -1,16 +1,16 @@ import { renderHook, waitFor } from "@testing-library/react" import { setupReactQueryTest } from "../test-utils" -import { articleKeys } from "./queries" +import { websiteContentKeys } from "./queries" import { setMockResponse, urls, makeRequest } from "../../test-utils" import { UseQueryResult } from "@tanstack/react-query" -import { articles as factory } from "../../test-utils/factories" +import { websiteContent as factory } from "../../test-utils/factories" import { - useArticleList, - useArticleDetail, - useArticleCreate, - useArticlePartialUpdate, - useArticleDestroy, + useWebsiteContentList, + useWebsiteContentDetail, + useWebsiteContentCreate, + useWebsiteContentPartialUpdate, + useWebsiteContentDestroy, } from "./index" /** @@ -28,86 +28,86 @@ const assertApiCalled = async ( expect(result.current.data).toEqual(data) } -describe("useArticleList", () => { +describe("useWebsiteContentList", () => { it.each([undefined, { limit: 5 }, { limit: 5, offset: 10 }])( "Calls the correct API", async (params) => { - const data = factory.articles({ count: 3 }) + const data = factory.websiteContents({ count: 3 }) const url = urls.websiteContent.list(params) const { wrapper } = setupReactQueryTest() setMockResponse.get(url, data) - const useTestHook = () => useArticleList(params) + const useTestHook = () => useWebsiteContentList(params) const { result } = renderHook(useTestHook, { wrapper }) assertApiCalled(result, url, "GET", data) }, ) }) -describe("useArticleDetail", () => { +describe("useWebsiteContentDetail", () => { it("Calls the correct API", async () => { - const data = factory.article() + const data = factory.websiteContent() const url = urls.websiteContent.details(data.id) const { wrapper } = setupReactQueryTest() setMockResponse.get(url, data) - const useTestHook = () => useArticleDetail(data.id) + const useTestHook = () => useWebsiteContentDetail(data.id) const { result } = renderHook(useTestHook, { wrapper }) assertApiCalled(result, url, "GET", data) }) }) -describe("Article CRUD", () => { - test("useArticleCreate calls correct API", async () => { +describe("Website Content CRUD", () => { + test("useWebsiteContentCreate calls correct API", async () => { const url = urls.websiteContent.list() - const data = factory.article() - const { id, ...requestData } = factory.article() + const data = factory.websiteContent() + const { id, ...requestData } = factory.websiteContent() setMockResponse.post(url, data) const { wrapper, queryClient } = setupReactQueryTest() jest.spyOn(queryClient, "invalidateQueries") - const { result } = renderHook(useArticleCreate, { wrapper }) + const { result } = renderHook(useWebsiteContentCreate, { wrapper }) result.current.mutate(requestData) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(makeRequest).toHaveBeenCalledWith("post", url, requestData) expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: articleKeys.listRoot(), + queryKey: websiteContentKeys.listRoot(), }) }) - test("useArticlePartialUpdate calls correct API", async () => { - const article = factory.article() + test("useWebsiteContentPartialUpdate calls correct API", async () => { + const article = factory.websiteContent() const url = urls.websiteContent.details(article.id) setMockResponse.patch(url, article) const { wrapper, queryClient } = setupReactQueryTest() jest.spyOn(queryClient, "invalidateQueries") - const { result } = renderHook(useArticlePartialUpdate, { wrapper }) + const { result } = renderHook(useWebsiteContentPartialUpdate, { wrapper }) result.current.mutate(article) await waitFor(() => expect(result.current.isSuccess).toBe(true)) const { id, ...patchData } = article expect(makeRequest).toHaveBeenCalledWith("patch", url, patchData) expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: articleKeys.detail(article.id), + queryKey: websiteContentKeys.detail(article.id), }) }) - test("useArticleDestroy calls correct API", async () => { - const { id } = factory.article() + test("useWebsiteContentDestroy calls correct API", async () => { + const { id } = factory.websiteContent() const url = urls.websiteContent.details(id) setMockResponse.delete(url, null) const { wrapper, queryClient } = setupReactQueryTest() jest.spyOn(queryClient, "invalidateQueries") - const { result } = renderHook(useArticleDestroy, { wrapper }) + const { result } = renderHook(useWebsiteContentDestroy, { wrapper }) result.current.mutate(id) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(makeRequest).toHaveBeenCalledWith("delete", url, undefined) expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: articleKeys.listRoot(), + queryKey: websiteContentKeys.listRoot(), }) }) }) diff --git a/frontends/api/src/hooks/articles/index.ts b/frontends/api/src/hooks/website_content/index.ts similarity index 63% rename from frontends/api/src/hooks/articles/index.ts rename to frontends/api/src/hooks/website_content/index.ts index 65b92ea258..adeedce1fb 100644 --- a/frontends/api/src/hooks/articles/index.ts +++ b/frontends/api/src/hooks/website_content/index.ts @@ -4,17 +4,17 @@ import type { AxiosProgressEvent } from "axios" import { websiteContentApi, mediaApi } from "../../clients" import type { - WebsiteContentApiWebsiteContentListRequest as ArticleListRequest, - WebsiteContent as Article, + WebsiteContentApiWebsiteContentListRequest as WebsiteContentListRequest, + WebsiteContent, } from "../../generated/v1" -import { articleQueries, articleKeys } from "./queries" +import { websiteContentQueries, websiteContentKeys } from "./queries" -const useArticleList = ( - params: ArticleListRequest = {}, +const useWebsiteContentList = ( + params: WebsiteContentListRequest = {}, opts?: { enabled?: boolean }, ) => { return useQuery({ - ...articleQueries.list(params), + ...websiteContentQueries.list(params), ...opts, }) } @@ -22,26 +22,26 @@ const useArticleList = ( /** * Query is disabled if id is undefined. */ -const useArticleDetail = (id: number | undefined) => { +const useWebsiteContentDetail = (id: number | undefined) => { return useQuery({ - ...articleQueries.detail(id ?? -1), + ...websiteContentQueries.detail(id ?? -1), enabled: id !== undefined, }) } -const useArticleDetailRetrieve = (identifier: string | undefined) => { +const useWebsiteContentDetailRetrieve = (identifier: string | undefined) => { return useQuery({ - ...articleQueries.articlesDetailRetrieve(identifier ?? ""), + ...websiteContentQueries.websiteContentDetailRetrieve(identifier ?? ""), enabled: identifier !== undefined, }) } -const useArticleCreate = () => { +const useWebsiteContentCreate = () => { const client = useQueryClient() return useMutation({ mutationFn: ( data: Omit< - Article, + WebsiteContent, "id" | "user" | "created_on" | "updated_on" | "publish_date" >, ) => @@ -49,7 +49,7 @@ const useArticleCreate = () => { .websiteContentCreate({ WebsiteContentRequest: data }) .then((response) => response.data), onSuccess: () => { - client.invalidateQueries({ queryKey: articleKeys.listRoot() }) + client.invalidateQueries({ queryKey: websiteContentKeys.listRoot() }) }, }) } @@ -97,41 +97,46 @@ export const useMediaUpload = () => { } } -const useArticleDestroy = () => { +const useWebsiteContentDestroy = () => { const client = useQueryClient() return useMutation({ mutationFn: (id: number) => websiteContentApi.websiteContentDestroy({ id }), onSuccess: () => { - client.invalidateQueries({ queryKey: articleKeys.listRoot() }) + client.invalidateQueries({ queryKey: websiteContentKeys.listRoot() }) }, }) } -const useArticlePartialUpdate = () => { +const useWebsiteContentPartialUpdate = () => { const client = useQueryClient() return useMutation({ - mutationFn: ({ id, ...data }: Partial
& Pick) => + mutationFn: ({ + id, + ...data + }: Partial & Pick) => websiteContentApi .websiteContentPartialUpdate({ id, PatchedWebsiteContentRequest: data, }) .then((response) => response.data), - onSuccess: (article: Article) => { - client.invalidateQueries({ queryKey: articleKeys.detail(article.id) }) - const identifier = article.slug || article.id.toString() + onSuccess: (websiteContent: WebsiteContent) => { client.invalidateQueries({ - queryKey: articleKeys.articlesDetailRetrieve(identifier), + queryKey: websiteContentKeys.detail(websiteContent.id), + }) + const identifier = websiteContent.slug || websiteContent.id.toString() + client.invalidateQueries({ + queryKey: websiteContentKeys.websiteContentDetailRetrieve(identifier), }) }, }) } export { - useArticleList, - useArticleDetail, - useArticleCreate, - useArticleDestroy, - useArticlePartialUpdate, - articleQueries, - useArticleDetailRetrieve, + useWebsiteContentList, + useWebsiteContentDetail, + useWebsiteContentCreate, + useWebsiteContentDestroy, + useWebsiteContentPartialUpdate, + websiteContentQueries, + useWebsiteContentDetailRetrieve, } diff --git a/frontends/api/src/hooks/website_content/queries.ts b/frontends/api/src/hooks/website_content/queries.ts new file mode 100644 index 0000000000..35085ab650 --- /dev/null +++ b/frontends/api/src/hooks/website_content/queries.ts @@ -0,0 +1,45 @@ +import { queryOptions } from "@tanstack/react-query" +import { websiteContentApi } from "../../clients" +import type { WebsiteContentApiWebsiteContentListRequest as WebsiteContentListRequest } from "../../generated/v1" + +const websiteContentKeys = { + root: ["website_content"], + listRoot: () => [...websiteContentKeys.root, "list"], + list: (params: WebsiteContentListRequest) => [ + ...websiteContentKeys.listRoot(), + params, + ], + detailRoot: () => [...websiteContentKeys.root, "detail"], + detail: (id: number) => [...websiteContentKeys.detailRoot(), id], + websiteContentDetailRetrieve: (identifier: string) => [ + ...websiteContentKeys.detailRoot(), + identifier, + ], +} + +const websiteContentQueries = { + list: (params: WebsiteContentListRequest) => + queryOptions({ + queryKey: websiteContentKeys.list(params), + queryFn: () => + websiteContentApi.websiteContentList(params).then((res) => res.data), + }), + detail: (id: number) => + queryOptions({ + queryKey: websiteContentKeys.detail(id), + queryFn: () => + websiteContentApi + .websiteContentRetrieve({ id }) + .then((res) => res.data), + }), + websiteContentDetailRetrieve: (identifier: string) => + queryOptions({ + queryKey: websiteContentKeys.websiteContentDetailRetrieve(identifier), + queryFn: () => + websiteContentApi + .websiteContentDetailRetrieve({ identifier }) + .then((res) => res.data), + }), +} + +export { websiteContentQueries, websiteContentKeys } diff --git a/frontends/api/src/test-utils/factories/index.ts b/frontends/api/src/test-utils/factories/index.ts index 53e0733990..d349f89087 100644 --- a/frontends/api/src/test-utils/factories/index.ts +++ b/frontends/api/src/test-utils/factories/index.ts @@ -1,4 +1,4 @@ -export * as articles from "./articles" +export * as websiteContent from "./websiteContent" export * as channels from "./channels" export * as hubspot from "./hubspot" export * as learningResources from "./learningResources" diff --git a/frontends/api/src/test-utils/factories/articles.ts b/frontends/api/src/test-utils/factories/websiteContent.ts similarity index 80% rename from frontends/api/src/test-utils/factories/articles.ts rename to frontends/api/src/test-utils/factories/websiteContent.ts index 2583690c91..9e19593357 100644 --- a/frontends/api/src/test-utils/factories/articles.ts +++ b/frontends/api/src/test-utils/factories/websiteContent.ts @@ -3,7 +3,7 @@ import { makePaginatedFactory } from "ol-test-utilities" import type { Factory } from "ol-test-utilities" import type { WebsiteContent } from "../../generated/v1" -const article: Factory = (overrides = {}) => ({ +const websiteContent: Factory = (overrides = {}) => ({ id: faker.number.int(), title: faker.lorem.sentence(), content: { @@ -25,6 +25,6 @@ const article: Factory = (overrides = {}) => ({ ...overrides, }) -const articles = makePaginatedFactory(article) +const websiteContents = makePaginatedFactory(websiteContent) -export { article, articles } +export { websiteContent, websiteContents } diff --git a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx deleted file mode 100644 index f09eadd5d7..0000000000 --- a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client" - -import React from "react" -import { useArticleDetailRetrieve } from "api/hooks/articles" -import { LoadingSpinner, styled } from "ol-components" -import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" -import { notFound } from "next/navigation" -import { LearningResourceProvider } from "@/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceDataProvider" - -const PageContainer = styled.div({ - display: "flex", - height: "100%", -}) - -const Spinner = styled(LoadingSpinner)({ - margin: "auto", - position: "absolute", - top: "40%", - left: "50%", - transform: "translate(-50%, -50%)", -}) - -export const ArticleDetailPage = ({ - articleId, - learningResourceIds = [], -}: { - articleId: string - learningResourceIds?: number[] -}) => { - const { data: article, isLoading } = useArticleDetailRetrieve(articleId) - - if (isLoading) { - return ( - - - - ) - } - if (!article) { - return notFound() - } - - return ( - - - - - - ) -} diff --git a/frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx b/frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx deleted file mode 100644 index 82ed4141f3..0000000000 --- a/frontends/main/src/app-pages/Articles/ArticleDraftListingPage.tsx +++ /dev/null @@ -1,184 +0,0 @@ -"use client" - -import React, { useState, useRef, useEffect } from "react" -import { - Container, - styled, - theme, - Grid2, - Card, - Pagination, - PaginationItem, - LoadingSpinner, - Typography, -} from "ol-components" -import { Permission } from "api/hooks/user" -import { useArticleList } from "api/hooks/articles" -import type { WebsiteContent } from "api/v1" -import { LocalDate } from "ol-utilities" -import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" -import { ArticleBanner, DEFAULT_BACKGROUND_IMAGE_URL } from "./ArticleBanner" -import { extractFirstImageFromArticle } from "@/common/articleUtils" -import { articlesDraftView, articlesView } from "@/common/urls" -import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" - -const PAGE_SIZE = 20 - -const PageWrapper = styled.div` - background: ${theme.custom.colors.white}; - min-height: calc(100vh - 200px); - padding: 80px 0; - ${theme.breakpoints.down("md")} { - padding: 40px 0; - } -` - -const DraftArticleCard = styled(Card)` - display: flex; - flex-direction: column; - height: 100%; -` - -const PaginationContainer = styled.div` - display: flex; - justify-content: center; - margin-top: 40px; -` - -const LoadingContainer = styled.div` - display: flex; - justify-content: center; - align-items: center; - min-height: 400px; -` - -const EmptyState = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 400px; - gap: 16px; -` - -const DraftBadge = styled.span` - color: ${theme.custom.colors.silverGrayDark}; - font-weight: ${theme.typography.fontWeightMedium}; -` - -export const DraftArticle: React.FC<{ article: WebsiteContent }> = ({ - article, -}) => { - const articleUrl = article.is_published - ? articlesView(article.slug || String(article.id)) - : articlesDraftView(String(article.id)) - - const imageUrl = extractFirstImageFromArticle(article.content) - - return ( - - { - - } - - {article.title} - - - - {!article.is_published && ( - <> - {" • "} - Draft - - )} - - - ) -} - -const ArticleDraftPage: React.FC = () => { - const [page, setPage] = useState(1) - const scrollRef = useRef(null) - - const { data: articles, isLoading: isLoadingArticles } = useArticleList({ - limit: PAGE_SIZE, - offset: (page - 1) * PAGE_SIZE, - draft: true, // Filter for drafts only on the backend - }) - - useEffect(() => { - if (page > 1 && scrollRef.current) { - scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" }) - } - }, [page]) - - const draftArticles = articles?.results - const totalPages = articles?.count ? Math.ceil(articles.count / PAGE_SIZE) : 0 - - if (isLoadingArticles) { - return - } - return ( - - - - - {isLoadingArticles ? ( - - - - ) : draftArticles && draftArticles.length > 0 ? ( - <> - - {draftArticles.map((article) => ( - - - - ))} - - - {totalPages > 1 && ( - - setPage(newPage)} - renderItem={(item) => ( - - )} - /> - - )} - - ) : ( - - No Draft Articles - - You don't have any draft articles yet. Create a new article to - get started. - - - )} - - - - ) -} - -export { ArticleDraftPage } diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx deleted file mode 100644 index 0ed3b1b31d..0000000000 --- a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client" - -import React from "react" -import { useRouter } from "next-nprogress-bar" -import { notFound } from "next/navigation" -import { Permission } from "api/hooks/user" -import { useArticleDetailRetrieve } from "api/hooks/articles" -import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" -import { styled, LoadingSpinner } from "ol-components" -import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" -import { articlesView, articlesDraftView } from "@/common/urls" -import invariant from "tiny-invariant" - -const PageContainer = styled.div(({ theme }) => ({ - color: theme.custom.colors.darkGray2, - display: "flex", - height: "100%", -})) - -const Spinner = styled(LoadingSpinner)({ - margin: "auto", - position: "absolute", - top: "40%", - left: "50%", - transform: "translate(-50%, -50%)", -}) - -const ArticleEditPage = ({ articleId }: { articleId: string }) => { - const { - data: article, - isLoading, - isFetching, - } = useArticleDetailRetrieve(articleId) - const router = useRouter() - - if (isLoading || isFetching) { - return - } - if (!article) { - return notFound() - } - - return ( - - - { - if (article.is_published) { - invariant(article.slug, "Published article must have a slug") - return router.push(articlesView(article.slug)) - } else { - router.push(articlesDraftView(String(article.id))) - } - }} - /> - - - ) -} - -export { ArticleEditPage } diff --git a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx index 4386640b6b..6bf38847c3 100644 --- a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx @@ -1,30 +1,28 @@ "use client" -import React from "react" -import Image from "next/image" -import { useSearchParams } from "@mitodl/course-search-utils/next" +import React, { useState, useRef, useEffect } from "react" import { Container, styled, theme, - Typography, Grid2, + Card, Pagination, PaginationItem, - css, LoadingSpinner, - PlainList, + Typography, } from "ol-components" -import Link from "next/link" +import { ButtonLink } from "@mitodl/smoot-design" +import { useWebsiteContentList } from "api/hooks/website_content" +import type { WebsiteContent } from "api/v1" +import { LocalDate } from "ol-utilities" import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" +import { extractFirstImageFromArticle } from "@/common/articleUtils" import { - useNewsEventsList, - NewsEventsListFeedTypeEnum, -} from "api/hooks/newsEvents" -import type { NewsFeedItem } from "api/v0" -import { LocalDate } from "ol-utilities" -import { linkifyText } from "@/common/utils" -import { ArticleBanner } from "./ArticleBanner" + userArticlesView, + USER_ARTICLES_CREATE, + USER_ARTICLES_LISTING, +} from "@/common/urls" const PAGE_SIZE = 20 const MAX_PAGE = 50 @@ -37,644 +35,142 @@ const getLastPage = (count: number): number => { return pages > MAX_PAGE ? MAX_PAGE : pages } -const Section = styled.section` - background: ${theme.custom.colors.white}; - padding: 80px 0; - ${theme.breakpoints.down("sm")} { - padding: 0; - } -` - -const FeaturedStorySection = styled.div` - max-width: 1000px; - margin: -290px auto 40px; - position: relative; - z-index: 10; - - ${theme.breakpoints.down("md")} { - margin: -250px auto 24px; - } - ${theme.breakpoints.down("sm")} { - margin: 24px auto; - max-width: 100%; - } -` - -const MainStoryCard = styled.div` +const PageHeader = styled.div` display: flex; - border-bottom: 1px solid ${theme.custom.colors.lightGray2}; - background: ${theme.custom.colors.darkGray2}; - border-top: 4px solid #a31f34; - border-radius: 10px; - - &:hover { - h2 { - text-decoration: underline; - } - } - - ${theme.breakpoints.down("sm")} { - flex-direction: column; - gap: 0; - } -` - -const MainStoryImage = styled.div` - width: 50%; - min-height: 400px; - background-color: ${theme.custom.colors.darkGray1}; - border-radius: 10px 0 0 10px; - overflow: hidden; - position: relative; - - ${theme.breakpoints.down("md")} { - min-height: 300px; - } - - ${theme.breakpoints.down("sm")} { - width: 100%; - aspect-ratio: 16 / 9; - min-height: auto; - border-radius: 10px 10px 0 0; - } -` - -const MainStoryContent = styled.div` - width: 50%; - display: flex; - flex-direction: column; - gap: 16px; - padding: 40px; - color: white; - justify-content: space-between; - - ${theme.breakpoints.down("md")} { - padding: 24px; - } - - ${theme.breakpoints.down("sm")} { - width: 100%; - padding: 24px; - } -` -const RegularStoryTitleWrapper = styled.div` - display: flex; - flex-direction: column; - gap: 10px; - - ${theme.breakpoints.down("sm")} { - gap: 8px; - } -` - -const MainStoryContentContainer = styled.div` - display: flex; - flex-direction: column; - gap: 24px; - ${theme.breakpoints.down("md")} { - gap: 16px; - } -` - -const MainStoryTitle = styled.h2` - color: ${theme.custom.colors.white}; - ${{ ...theme.typography.h3 }} - margin: 0; - - a { - color: ${theme.custom.colors.white}; - text-decoration: none; - cursor: pointer; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - - &:hover { - color: ${theme.custom.colors.white}; - } - } - - ${theme.breakpoints.down("md")} { - ${{ ...theme.typography.h5 }} - } - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.h5 }} - } -` - -const MainStorySummary = styled.p` - color: ${theme.custom.colors.white}; - ${{ ...theme.typography.body1 }} - margin: 0; - line-height: 22px; - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; - overflow-wrap: break-word; - - a { - color: ${theme.custom.colors.white}; - text-decoration: underline; - - &:hover { - opacity: 0.8; - } - } - - ${theme.breakpoints.down("md")} { - ${{ ...theme.typography.body1 }} - } - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.body2 }} - } -` - -const MainStoryDate = styled(Typography)` - color: ${theme.custom.colors.white}; - ${{ ...theme.typography.body3 }} - margin: 0; -` - -// Regular story card for grid -const StoryCard = styled.div` - display: flex; - flex-direction: row; - gap: 24px; - background: white; - border-radius: 8px; - padding: 16px 16px 16px 24px; - overflow: hidden; - border: 1px solid transparent; - - &:hover { - border-radius: 8px; - border: 1px solid ${theme.custom.colors.lightGray2}; - background: ${theme.custom.colors.white}; - box-shadow: 0 8px 20px 0 rgb(120 147 172 / 10%); - - h2 { - color: ${theme.custom.colors.red}; - } - } - - ${theme.breakpoints.down("sm")} { - flex-direction: row; - gap: 12px; - padding: 16px 0; - background: transparent; - border: none; - border-bottom: 1px solid ${theme.custom.colors.lightGray2}; - border-radius: 0; - - &:hover { - border: none; - border-bottom: 1px solid ${theme.custom.colors.lightGray2}; - box-shadow: none; - } - } -` - -const StoryImage = styled.div` - width: 280px; - min-width: 280px; - max-width: 280px; - height: 180px; - flex-shrink: 0; - background-color: ${theme.custom.colors.lightGray1}; - border-radius: 8px; - order: 2; - align-self: flex-end; - overflow: hidden; - position: relative; - - ${theme.breakpoints.down("sm")} { - width: 100px; - min-width: 100px; - max-width: 100px; - height: 80px; - order: 2; - align-self: flex-start; - border-radius: 4px; - } -` - -const StoryContent = styled.div` - display: flex; - flex-direction: column; + align-items: center; justify-content: space-between; - flex: 1; - order: 1; - min-height: 180px; - min-width: 0; - overflow: hidden; - - ${theme.breakpoints.down("sm")} { - order: 1; - min-height: auto; - min-width: 0; - justify-content: space-between; - gap: 8px; - } -` - -const StoryTitle = styled.h2` - color: ${theme.custom.colors.darkGray2}; - ${{ ...theme.typography.h5 }} - margin: 0; - margin-top: 16px; - - a { - color: ${theme.custom.colors.darkGray2}; - text-decoration: none; - cursor: pointer; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - - &:hover { - color: ${theme.custom.colors.red}; - } - } - - ${theme.breakpoints.down("md")} { - ${{ ...theme.typography.subtitle1 }} - } - - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.subtitle2 }} - margin-top: 0; - -webkit-line-clamp: 3; - } + margin-bottom: 32px; ` -const StorySummary = styled.p` - color: ${theme.custom.colors.darkGray2}; - ${{ ...theme.typography.body2 }} - margin: 0; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.5; - overflow-wrap: break-word; - - a { - color: ${theme.custom.colors.red}; - text-decoration: underline; - - &:hover { - opacity: 0.8; - } - } - - ${theme.breakpoints.down("sm")} { - ${{ ...theme.typography.body3 }} - -webkit-line-clamp: 2; - margin-top: 0; - color: ${theme.custom.colors.black}; - } -` - -const StoryDate = styled(Typography)` - color: ${theme.custom.colors.silverGrayDark}; - ${{ ...theme.typography.body3 }} - margin-bottom: 16px; - - ${theme.breakpoints.down("sm")} { - margin-bottom: 0; - margin-top: 0; - } -` - -const StyledSection = styled(Section)` - background: ${theme.custom.colors.lightGray1}; - - ul { - list-style: none; - } -` - -const GridContainer = styled.div` - max-width: 800px; - margin: 0 auto; - +const Section = styled.section` + background: ${theme.custom.colors.white}; + padding: 80px 0; ${theme.breakpoints.down("sm")} { - max-width: 100%; + padding: 40px 0; } ` -const MobileContent = styled.div` +const ArticleCardWrapper = styled(Card)` display: flex; - align-items: center; flex-direction: column; - gap: 40px; - margin: 40px 0; - - ${theme.breakpoints.down("sm")} { - margin: 0; - } + height: 100%; ` -const MobileContainer = styled.section` - width: 100%; - margin: 0 -16px; - - h3 { - margin: 0 16px 12px; - } -` - -const AboveMdOnly = styled.div(({ theme }) => ({ - [theme.breakpoints.down("sm")]: { - display: "none", - }, -})) - -const BelowMdOnly = styled.div(({ theme }) => ({ - [theme.breakpoints.up("sm")]: { - display: "none", - }, -})) - const PaginationContainer = styled.div` display: flex; justify-content: center; - margin-top: 24px; - - ${({ theme }) => theme.breakpoints.down("md")} { - margin-top: 16px; - margin-bottom: 24px; - } - - ul li button.Mui-selected { - ${({ theme }) => css({ ...theme.typography.subtitle1 })} - background-color: inherit; - } - - ul li button svg { - background-color: ${({ theme }) => theme.custom.colors.lightGray2}; - border-radius: 4px; - width: 1.5em; - height: 1.5em; - padding: 0.25em; - } -` - -const LoadingContainer = styled.div` - display: flex; - justify-content: center; - align-items: center; - min-height: 400px; - width: 100%; + margin-top: 40px; ` const EmptyState = styled.div` display: flex; flex-direction: column; - justify-content: center; align-items: center; + justify-content: center; min-height: 400px; - width: 100%; gap: 16px; - text-align: center; - padding: 40px 20px; - - ${theme.breakpoints.down("sm")} { - min-height: 300px; - padding: 24px 16px; - } ` -const ArticleBannerStyled = styled(ArticleBanner)<{ page: number }>( - ({ page, theme }) => ({ - padding: "48px 0", - paddingBottom: page === 1 ? "250px" : undefined, - position: "relative", - backgroundSize: "150% !important", - backgroundPosition: "center !important", - - "&::before": { - content: '""', - position: "absolute", - inset: 0, - background: "rgb(0 0 0 / 85%)", - zIndex: 1, - }, - - "& > *": { - position: "relative", - zIndex: 2, - }, - - [theme.breakpoints.down("md")]: { - paddingBottom: page === 1 ? "198px" : undefined, - }, - - [theme.breakpoints.down("sm")]: { - padding: "32px 0", - marginBottom: 0, - }, - }), -) - -const MainStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { - const [imageError, setImageError] = React.useState(false) - - return ( - - - {item.image?.url && !imageError && ( - - {item.image.alt setImageError(true)} - /> - - )} - - - - - {item.title} - - {item.summary && ( - - )} - - - - - - - ) -} +const UserArticleCard: React.FC<{ article: WebsiteContent }> = ({ + article, +}) => { + const articleUrl = article.is_published + ? userArticlesView(article.slug || String(article.id)) + : `${USER_ARTICLES_LISTING}${article.id}/draft` -const RegularStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { - const [imageError, setImageError] = React.useState(false) + const imageUrl = extractFirstImageFromArticle(article.content) return ( - - - - - {item.title} - - {item.summary && ( - - )} - - - - - - - - {item.image?.url && !imageError && ( - {item.image.alt setImageError(true)} - /> - )} - - - + + + + {article.title} + + + + + ) } const ArticleListingPage: React.FC = () => { - const [searchParams, setSearchParams] = useSearchParams() - const page = parseInt(searchParams.get("page") ?? "1", 10) + const [page, setPage] = useState(1) + const scrollRef = useRef(null) - const { data: news, isLoading } = useNewsEventsList({ - feed_type: [NewsEventsListFeedTypeEnum.News], + const { data: articles, isLoading } = useWebsiteContentList({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, - sortby: "-news_date", }) - const stories = news?.results ?? [] + useEffect(() => { + if (page > 1 && scrollRef.current) { + scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" }) + } + }, [page]) - // On page 1, first story is featured, rest are grid stories - // On other pages, all stories are grid stories - const mainStory = - page === 1 && stories.length > 0 ? (stories[0] as NewsFeedItem) : null - const gridStories = page === 1 ? stories.slice(1) : stories + const results = articles?.results + const totalPages = articles?.count ? getLastPage(articles.count) : 0 return ( - <> - - - - - {isLoading ? ( - - - - ) : stories.length === 0 ? ( - - No News Available - - There are no news to display at this time. Please check back - later. - - - ) : ( - <> - - - - {page === 1 && mainStory && ( - - - - )} - - {gridStories.map((item) => ( -
  • - -
  • - ))} -
    -
    -
    -
    - - - {/* Main Story Section: Only visible on page 1 */} - {page === 1 && mainStory && ( - - - - )} - - {/* Grid Section: Other articles */} - {gridStories.length > 0 ? ( - - - {gridStories.map((item) => ( - - - - ))} - - - ) : null} - - - )} -
    - - {!isLoading && gridStories.length > 0 && ( - - - { - setSearchParams((current) => { - const copy = new URLSearchParams(current) - if (newPage === 1) { - copy.delete("page") - } else { - copy.set("page", newPage.toString()) - } - return copy - }) - }} - renderItem={(item) => ( - - )} - /> - - +
    + + + Articles + + New Article + + + + {isLoading ? ( + + ) : results && results.length > 0 ? ( + <> + + {results.map((article) => ( + + + + ))} + + + {totalPages > 1 && ( + + setPage(newPage)} + renderItem={(item) => ( + + )} + /> + + )} + + ) : ( + + No Articles Yet + + Get started by creating your first article. + + + New Article + + )} - - + +
    ) } diff --git a/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx b/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx deleted file mode 100644 index 5dd53fb0ba..0000000000 --- a/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client" - -import React from "react" -import { useRouter } from "next-nprogress-bar" -import { Permission } from "api/hooks/user" -import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" -import { styled } from "ol-components" -import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" -import { articlesDraftView, articlesView } from "@/common/urls" -import invariant from "tiny-invariant" - -const PageContainer = styled.div(({ theme }) => ({ - color: theme.custom.colors.darkGray2, - display: "flex", - height: "100%", -})) - -const ArticleNewPage: React.FC = () => { - const router = useRouter() - - return ( - - - { - if (article.is_published) { - invariant(article.slug, "Published article must have a slug") - return router.push(articlesView(article.slug)) - } else { - router.push(articlesDraftView(String(article.id))) - } - }} - /> - - - ) -} - -export { ArticleNewPage } diff --git a/frontends/main/src/app-pages/Articles/ArticleBanner.tsx b/frontends/main/src/app-pages/News/NewsBanner.tsx similarity index 94% rename from frontends/main/src/app-pages/Articles/ArticleBanner.tsx rename to frontends/main/src/app-pages/News/NewsBanner.tsx index 3d13a333b3..cf3acc519f 100644 --- a/frontends/main/src/app-pages/Articles/ArticleBanner.tsx +++ b/frontends/main/src/app-pages/News/NewsBanner.tsx @@ -50,7 +50,7 @@ const BannerDescription = styled(Typography)` } ` -interface ArticleBannerProps { +interface NewsBannerProps { title: string description: string currentBreadcrumb?: string @@ -58,7 +58,7 @@ interface ArticleBannerProps { className?: string } -const ArticleBanner: React.FC = ({ +const NewsBanner: React.FC = ({ title, description, currentBreadcrumb = "MIT News", @@ -87,4 +87,4 @@ const ArticleBanner: React.FC = ({ ) } -export { ArticleBanner } +export { NewsBanner } diff --git a/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx b/frontends/main/src/app-pages/News/NewsListingPage.test.tsx similarity index 87% rename from frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx rename to frontends/main/src/app-pages/News/NewsListingPage.test.tsx index b6f6d3cf4e..ef1140b62e 100644 --- a/frontends/main/src/app-pages/Articles/ArticleListingPage.test.tsx +++ b/frontends/main/src/app-pages/News/NewsListingPage.test.tsx @@ -1,5 +1,5 @@ import React from "react" -import { ArticleListingPage } from "./ArticleListingPage" +import { NewsListingPage } from "./NewsListingPage" import { urls, setMockResponse } from "api/test-utils" import type { NewsFeedItem } from "api/v0" import { newsEvents } from "api/test-utils/factories" @@ -19,7 +19,7 @@ jest.mock("@/common/useFeatureFlagsLoaded") const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) const mockedUseFeatureFlagsLoaded = jest.mocked(useFeatureFlagsLoaded) -describe("ArticleListingPage", () => { +describe("NewsListingPage", () => { beforeEach(() => { mockedUseFeatureFlagEnabled.mockReturnValue(true) mockedUseFeatureFlagsLoaded.mockReturnValue(true) @@ -36,13 +36,13 @@ describe("ArticleListingPage", () => { test("displays loading spinner on initial load", () => { setupAPI(0) - renderWithProviders() + renderWithProviders() expect(screen.getByRole("progressbar")).toBeInTheDocument() }) test("displays empty state when no articles are available", async () => { setupAPI(0) - renderWithProviders() + renderWithProviders() await screen.findByText("No News Available") @@ -55,7 +55,7 @@ describe("ArticleListingPage", () => { test("displays main news and grid stories on desktop", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -73,18 +73,18 @@ describe("ArticleListingPage", () => { ) }) - test("displays article banner with correct title and description", async () => { + test("displays News banner with correct title and description", async () => { setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByRole("heading", { name: "News" })).toBeInTheDocument() }) }) - test("displays article images when available", async () => { + test("displays News images when available", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -95,38 +95,38 @@ describe("ArticleListingPage", () => { expect(images.length).toBeGreaterThan(0) }) - test("displays article publish dates", async () => { + test("displays News publish dates", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() }) // Verify that LocalDate component renders dates for articles - // Check that there are multiple date elements rendered (one per article) + // Check that there are multiple date elements rendered (one per news) const listItems = screen.getAllByRole("listitem") expect(listItems.length).toBeGreaterThan(0) // Verify that dates are present in the document - // The first article should have a publish date - const firstArticle = news.results[0] as NewsFeedItem - const firstArticleDate = firstArticle.news_details?.publish_date - if (firstArticleDate) { + // The first news should have a publish date + const firstNews = news.results[0] as NewsFeedItem + const firstNewsDate = firstNews.news_details?.publish_date + if (firstNewsDate) { // LocalDate component will format the date, so check for parts of the date const dateElements = screen.getAllByText((content, element) => { return ( element?.tagName.toLowerCase() === "time" || - content.includes(new Date(firstArticleDate).getFullYear().toString()) + content.includes(new Date(firstNewsDate).getFullYear().toString()) ) }) expect(dateElements.length).toBeGreaterThan(0) } }) - test("links to article URLs correctly", async () => { + test("links to News URLs correctly", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -141,7 +141,7 @@ describe("ArticleListingPage", () => { expect(firstArticleLink).toHaveAttribute("href", news.results[0].url) }) - test("displays article summaries with HTML stripped", async () => { + test("displays News summaries with HTML stripped", async () => { const newsWithHtml = newsEvents.newsItems({ count: 1 }) // Summaries are now cleaned by the backend, so they come without HTML newsWithHtml.results[0].summary = "This is a test summary" @@ -151,7 +151,7 @@ describe("ArticleListingPage", () => { newsWithHtml, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -195,7 +195,7 @@ describe("ArticleListingPage", () => { secondPage, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -216,7 +216,7 @@ describe("ArticleListingPage", () => { const news = newsEvents.newsItems({ count: 100 }) setMockResponse.get(expect.stringContaining(urls.newsEvents.list()), news) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -233,7 +233,7 @@ describe("ArticleListingPage", () => { news.count = 1500 // Override to simulate 1500 total items setMockResponse.get(expect.stringContaining(urls.newsEvents.list()), news) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -274,7 +274,7 @@ describe("ArticleListingPage", () => { secondPage, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -300,7 +300,7 @@ describe("ArticleListingPage", () => { test("hides pagination when no articles", async () => { setupAPI(0) - renderWithProviders() + renderWithProviders() await screen.findByText("No News Available") @@ -330,7 +330,7 @@ describe("ArticleListingPage", () => { secondPage, ) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -344,7 +344,7 @@ describe("ArticleListingPage", () => { test("renders responsive mobile layout", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() @@ -358,7 +358,7 @@ describe("ArticleListingPage", () => { test("calculates grid stories correctly for page 1", async () => { const news = setupAPI(21) - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.queryByRole("progressbar")).not.toBeInTheDocument() diff --git a/frontends/main/src/app-pages/News/NewsListingPage.tsx b/frontends/main/src/app-pages/News/NewsListingPage.tsx new file mode 100644 index 0000000000..abd33ec626 --- /dev/null +++ b/frontends/main/src/app-pages/News/NewsListingPage.tsx @@ -0,0 +1,681 @@ +"use client" + +import React from "react" +import Image from "next/image" +import { useSearchParams } from "@mitodl/course-search-utils/next" +import { + Container, + styled, + theme, + Typography, + Grid2, + Pagination, + PaginationItem, + css, + LoadingSpinner, + PlainList, +} from "ol-components" +import Link from "next/link" +import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" +import { + useNewsEventsList, + NewsEventsListFeedTypeEnum, +} from "api/hooks/newsEvents" +import type { NewsFeedItem } from "api/v0" +import { LocalDate } from "ol-utilities" +import { linkifyText } from "@/common/utils" +import { NewsBanner } from "./NewsBanner" + +const PAGE_SIZE = 20 +const MAX_PAGE = 50 + +export const DEFAULT_BACKGROUND_IMAGE_URL = + "/images/backgrounds/banner_background.webp" + +const getLastPage = (count: number): number => { + const pages = Math.ceil(count / PAGE_SIZE) + return pages > MAX_PAGE ? MAX_PAGE : pages +} + +const Section = styled.section` + background: ${theme.custom.colors.white}; + padding: 80px 0; + ${theme.breakpoints.down("sm")} { + padding: 0; + } +` + +const FeaturedStorySection = styled.div` + max-width: 1000px; + margin: -290px auto 40px; + position: relative; + z-index: 10; + + ${theme.breakpoints.down("md")} { + margin: -250px auto 24px; + } + ${theme.breakpoints.down("sm")} { + margin: 24px auto; + max-width: 100%; + } +` + +const MainStoryCard = styled.div` + display: flex; + border-bottom: 1px solid ${theme.custom.colors.lightGray2}; + background: ${theme.custom.colors.darkGray2}; + border-top: 4px solid #a31f34; + border-radius: 10px; + + &:hover { + h2 { + text-decoration: underline; + } + } + + ${theme.breakpoints.down("sm")} { + flex-direction: column; + gap: 0; + } +` + +const MainStoryImage = styled.div` + width: 50%; + min-height: 400px; + background-color: ${theme.custom.colors.darkGray1}; + border-radius: 10px 0 0 10px; + overflow: hidden; + position: relative; + + ${theme.breakpoints.down("md")} { + min-height: 300px; + } + + ${theme.breakpoints.down("sm")} { + width: 100%; + aspect-ratio: 16 / 9; + min-height: auto; + border-radius: 10px 10px 0 0; + } +` + +const MainStoryContent = styled.div` + width: 50%; + display: flex; + flex-direction: column; + gap: 16px; + padding: 40px; + color: white; + justify-content: space-between; + + ${theme.breakpoints.down("md")} { + padding: 24px; + } + + ${theme.breakpoints.down("sm")} { + width: 100%; + padding: 24px; + } +` +const RegularStoryTitleWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + + ${theme.breakpoints.down("sm")} { + gap: 8px; + } +` + +const MainStoryContentContainer = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + ${theme.breakpoints.down("md")} { + gap: 16px; + } +` + +const MainStoryTitle = styled.h2` + color: ${theme.custom.colors.white}; + ${{ ...theme.typography.h3 }} + margin: 0; + + a { + color: ${theme.custom.colors.white}; + text-decoration: none; + cursor: pointer; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + + &:hover { + color: ${theme.custom.colors.white}; + } + } + + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.h5 }} + } + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.h5 }} + } +` + +const MainStorySummary = styled.p` + color: ${theme.custom.colors.white}; + ${{ ...theme.typography.body1 }} + margin: 0; + line-height: 22px; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: break-word; + + a { + color: ${theme.custom.colors.white}; + text-decoration: underline; + + &:hover { + opacity: 0.8; + } + } + + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.body1 }} + } + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.body2 }} + } +` + +const MainStoryDate = styled(Typography)` + color: ${theme.custom.colors.white}; + ${{ ...theme.typography.body3 }} + margin: 0; +` + +// Regular story card for grid +const StoryCard = styled.div` + display: flex; + flex-direction: row; + gap: 24px; + background: white; + border-radius: 8px; + padding: 16px 16px 16px 24px; + overflow: hidden; + border: 1px solid transparent; + + &:hover { + border-radius: 8px; + border: 1px solid ${theme.custom.colors.lightGray2}; + background: ${theme.custom.colors.white}; + box-shadow: 0 8px 20px 0 rgb(120 147 172 / 10%); + + h2 { + color: ${theme.custom.colors.red}; + } + } + + ${theme.breakpoints.down("sm")} { + flex-direction: row; + gap: 12px; + padding: 16px 0; + background: transparent; + border: none; + border-bottom: 1px solid ${theme.custom.colors.lightGray2}; + border-radius: 0; + + &:hover { + border: none; + border-bottom: 1px solid ${theme.custom.colors.lightGray2}; + box-shadow: none; + } + } +` + +const StoryImage = styled.div` + width: 280px; + min-width: 280px; + max-width: 280px; + height: 180px; + flex-shrink: 0; + background-color: ${theme.custom.colors.lightGray1}; + border-radius: 8px; + order: 2; + align-self: flex-end; + overflow: hidden; + position: relative; + + ${theme.breakpoints.down("sm")} { + width: 100px; + min-width: 100px; + max-width: 100px; + height: 80px; + order: 2; + align-self: flex-start; + border-radius: 4px; + } +` + +const StoryContent = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + order: 1; + min-height: 180px; + min-width: 0; + overflow: hidden; + + ${theme.breakpoints.down("sm")} { + order: 1; + min-height: auto; + min-width: 0; + justify-content: space-between; + gap: 8px; + } +` + +const StoryTitle = styled.h2` + color: ${theme.custom.colors.darkGray2}; + ${{ ...theme.typography.h5 }} + margin: 0; + margin-top: 16px; + + a { + color: ${theme.custom.colors.darkGray2}; + text-decoration: none; + cursor: pointer; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + + &:hover { + color: ${theme.custom.colors.red}; + } + } + + ${theme.breakpoints.down("md")} { + ${{ ...theme.typography.subtitle1 }} + } + + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.subtitle2 }} + margin-top: 0; + -webkit-line-clamp: 3; + } +` + +const StorySummary = styled.p` + color: ${theme.custom.colors.darkGray2}; + ${{ ...theme.typography.body2 }} + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.5; + overflow-wrap: break-word; + + a { + color: ${theme.custom.colors.red}; + text-decoration: underline; + + &:hover { + opacity: 0.8; + } + } + + ${theme.breakpoints.down("sm")} { + ${{ ...theme.typography.body3 }} + -webkit-line-clamp: 2; + margin-top: 0; + color: ${theme.custom.colors.black}; + } +` + +const StoryDate = styled(Typography)` + color: ${theme.custom.colors.silverGrayDark}; + ${{ ...theme.typography.body3 }} + margin-bottom: 16px; + + ${theme.breakpoints.down("sm")} { + margin-bottom: 0; + margin-top: 0; + } +` + +const StyledSection = styled(Section)` + background: ${theme.custom.colors.lightGray1}; + + ul { + list-style: none; + } +` + +const GridContainer = styled.div` + max-width: 800px; + margin: 0 auto; + + ${theme.breakpoints.down("sm")} { + max-width: 100%; + } +` + +const MobileContent = styled.div` + display: flex; + align-items: center; + flex-direction: column; + gap: 40px; + margin: 40px 0; + + ${theme.breakpoints.down("sm")} { + margin: 0; + } +` + +const MobileContainer = styled.section` + width: 100%; + margin: 0 -16px; + + h3 { + margin: 0 16px 12px; + } +` + +const AboveMdOnly = styled.div(({ theme }) => ({ + [theme.breakpoints.down("sm")]: { + display: "none", + }, +})) + +const BelowMdOnly = styled.div(({ theme }) => ({ + [theme.breakpoints.up("sm")]: { + display: "none", + }, +})) + +const PaginationContainer = styled.div` + display: flex; + justify-content: center; + margin-top: 24px; + + ${({ theme }) => theme.breakpoints.down("md")} { + margin-top: 16px; + margin-bottom: 24px; + } + + ul li button.Mui-selected { + ${({ theme }) => css({ ...theme.typography.subtitle1 })} + background-color: inherit; + } + + ul li button svg { + background-color: ${({ theme }) => theme.custom.colors.lightGray2}; + border-radius: 4px; + width: 1.5em; + height: 1.5em; + padding: 0.25em; + } +` + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + width: 100%; +` + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 400px; + width: 100%; + gap: 16px; + text-align: center; + padding: 40px 20px; + + ${theme.breakpoints.down("sm")} { + min-height: 300px; + padding: 24px 16px; + } +` +const NewsBannerStyled = styled(NewsBanner)<{ page: number }>( + ({ page, theme }) => ({ + padding: "48px 0", + paddingBottom: page === 1 ? "250px" : undefined, + position: "relative", + backgroundSize: "150% !important", + backgroundPosition: "center !important", + + "&::before": { + content: '""', + position: "absolute", + inset: 0, + background: "rgb(0 0 0 / 85%)", + zIndex: 1, + }, + + "& > *": { + position: "relative", + zIndex: 2, + }, + + [theme.breakpoints.down("md")]: { + paddingBottom: page === 1 ? "198px" : undefined, + }, + + [theme.breakpoints.down("sm")]: { + padding: "32px 0", + marginBottom: 0, + }, + }), +) + +const MainStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { + const [imageError, setImageError] = React.useState(false) + + return ( + + + {item.image?.url && !imageError && ( + + {item.image.alt setImageError(true)} + /> + + )} + + + + + + {item.title} + + {item.summary && ( + + )} + + + + + + + ) +} + +const RegularStory: React.FC<{ item: NewsFeedItem }> = ({ item }) => { + const [imageError, setImageError] = React.useState(false) + + return ( + + + + + {item.title} + + {item.summary && ( + + )} + + + + + + + + {item.image?.url && !imageError && ( + {item.image.alt setImageError(true)} + /> + )} + + + + ) +} + +const NewsListingPage: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams() + const page = parseInt(searchParams.get("page") ?? "1", 10) + + const { data: news, isLoading } = useNewsEventsList({ + feed_type: [NewsEventsListFeedTypeEnum.News], + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + sortby: "-news_date", + }) + + const stories = news?.results ?? [] + + // On page 1, first story is featured, rest are grid stories + // On other pages, all stories are grid stories + const mainStory = + page === 1 && stories.length > 0 ? (stories[0] as NewsFeedItem) : null + const gridStories = page === 1 ? stories.slice(1) : stories + + return ( + <> + + + + + {isLoading ? ( + + + + ) : stories.length === 0 ? ( + + No News Available + + There are no news to display at this time. Please check back + later. + + + ) : ( + <> + + + + {page === 1 && mainStory && ( + + + + )} + + {gridStories.map((item) => ( +
  • + +
  • + ))} +
    +
    +
    +
    + + + {/* Main Story Section: Only visible on page 1 */} + {page === 1 && mainStory && ( + + + + )} + + {/* Grid Section: Other news */} + {gridStories.length > 0 ? ( + + + {gridStories.map((item) => ( + + + + ))} + + + ) : null} + + + )} +
    + + {!isLoading && gridStories.length > 0 && ( + + + { + setSearchParams((current) => { + const copy = new URLSearchParams(current) + if (newPage === 1) { + copy.delete("page") + } else { + copy.set("page", newPage.toString()) + } + return copy + }) + }} + renderItem={(item) => ( + + )} + /> + + + )} +
    + + ) +} + +export { NewsListingPage } diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx deleted file mode 100644 index bc8b162509..0000000000 --- a/frontends/main/src/app-pages/UserArticles/UserArticleDraftListingPage.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client" - -import React, { useState, useRef, useEffect } from "react" -import { - Container, - styled, - theme, - Grid2, - Card, - Pagination, - PaginationItem, - LoadingSpinner, - Typography, -} from "ol-components" -import { Permission } from "api/hooks/user" -import { useArticleList } from "api/hooks/articles" -import type { WebsiteContent } from "api/v1" -import { LocalDate } from "ol-utilities" -import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" -import { extractFirstImageFromArticle } from "@/common/articleUtils" -import { userArticlesDraftView, userArticlesView } from "@/common/urls" -import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" - -const PAGE_SIZE = 20 - -export const DEFAULT_BACKGROUND_IMAGE_URL = - "/images/backgrounds/banner_background.webp" - -const PageWrapper = styled.div` - background: ${theme.custom.colors.white}; - min-height: calc(100vh - 200px); - padding: 80px 0; - ${theme.breakpoints.down("md")} { - padding: 40px 0; - } -` - -const DraftArticleCard = styled(Card)` - display: flex; - flex-direction: column; - height: 100%; -` - -const PaginationContainer = styled.div` - display: flex; - justify-content: center; - margin-top: 40px; -` - -const LoadingContainer = styled.div` - display: flex; - justify-content: center; - align-items: center; - min-height: 400px; -` - -const EmptyState = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 400px; - gap: 16px; -` - -const DraftBadge = styled.span` - color: ${theme.custom.colors.silverGrayDark}; - font-weight: ${theme.typography.fontWeightMedium}; -` - -const DraftUserArticle: React.FC<{ article: WebsiteContent }> = ({ - article, -}) => { - const articleUrl = article.is_published - ? userArticlesView(article.slug || String(article.id)) - : userArticlesDraftView(String(article.id)) - - const imageUrl = extractFirstImageFromArticle(article.content) - - return ( - - { - - } - - {article.title} - - - - {!article.is_published && ( - <> - {" • "} - Draft - - )} - - - ) -} - -const UserArticleDraftPage: React.FC = () => { - const [page, setPage] = useState(1) - const scrollRef = useRef(null) - - const { data: articles, isLoading: isLoadingArticles } = useArticleList({ - limit: PAGE_SIZE, - offset: (page - 1) * PAGE_SIZE, - draft: true, - }) - - useEffect(() => { - if (page > 1 && scrollRef.current) { - scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" }) - } - }, [page]) - - const draftArticles = articles?.results - const totalPages = articles?.count ? Math.ceil(articles.count / PAGE_SIZE) : 0 - - if (isLoadingArticles) { - return - } - - return ( - - - - {isLoadingArticles ? ( - - - - ) : draftArticles && draftArticles.length > 0 ? ( - <> - - {draftArticles.map((article) => ( - - - - ))} - - - {totalPages > 1 && ( - - setPage(newPage)} - renderItem={(item) => ( - - )} - /> - - )} - - ) : ( - - No Draft Articles - - You don't have any draft articles yet. Create a new article - to get started. - - - )} - - - - ) -} - -export { UserArticleDraftPage } diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx deleted file mode 100644 index 95f3ab0902..0000000000 --- a/frontends/main/src/app-pages/UserArticles/UserArticleEditPage.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client" - -import React from "react" -import { useRouter } from "next-nprogress-bar" -import { notFound } from "next/navigation" -import { Permission } from "api/hooks/user" -import { useArticleDetailRetrieve } from "api/hooks/articles" -import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" -import { styled, LoadingSpinner } from "ol-components" -import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" -import { userArticlesDraftView, userArticlesView } from "@/common/urls" -import invariant from "tiny-invariant" - -const PageContainer = styled.div(({ theme }) => ({ - color: theme.custom.colors.darkGray2, - display: "flex", - height: "100%", -})) - -const Spinner = styled(LoadingSpinner)({ - margin: "auto", - position: "absolute", - top: "40%", - left: "50%", - transform: "translate(-50%, -50%)", -}) - -const UserArticleEditPage = ({ articleId }: { articleId: string }) => { - const { - data: article, - isLoading, - isFetching, - } = useArticleDetailRetrieve(articleId) - const router = useRouter() - - if (isLoading || isFetching) { - return - } - if (!article) { - return notFound() - } - - return ( - - - { - if (saved.is_published) { - invariant(saved.slug, "Published article must have a slug") - return router.push(userArticlesView(saved.slug)) - } else { - router.push(userArticlesDraftView(String(saved.id))) - } - }} - /> - - - ) -} - -export { UserArticleEditPage } diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx deleted file mode 100644 index f23e45117b..0000000000 --- a/frontends/main/src/app-pages/UserArticles/UserArticleListingPage.tsx +++ /dev/null @@ -1,177 +0,0 @@ -"use client" - -import React, { useState, useRef, useEffect } from "react" -import { - Container, - styled, - theme, - Grid2, - Card, - Pagination, - PaginationItem, - LoadingSpinner, - Typography, -} from "ol-components" -import { ButtonLink } from "@mitodl/smoot-design" -import { useArticleList } from "api/hooks/articles" -import type { WebsiteContent } from "api/v1" -import { LocalDate } from "ol-utilities" -import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" -import { extractFirstImageFromArticle } from "@/common/articleUtils" -import { - userArticlesView, - USER_ARTICLES_CREATE, - USER_ARTICLES_LISTING, -} from "@/common/urls" - -const PAGE_SIZE = 20 -const MAX_PAGE = 50 - -export const DEFAULT_BACKGROUND_IMAGE_URL = - "/images/backgrounds/banner_background.webp" - -const getLastPage = (count: number): number => { - const pages = Math.ceil(count / PAGE_SIZE) - return pages > MAX_PAGE ? MAX_PAGE : pages -} - -const PageHeader = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 32px; -` - -const Section = styled.section` - background: ${theme.custom.colors.white}; - padding: 80px 0; - ${theme.breakpoints.down("sm")} { - padding: 40px 0; - } -` - -const ArticleCardWrapper = styled(Card)` - display: flex; - flex-direction: column; - height: 100%; -` - -const PaginationContainer = styled.div` - display: flex; - justify-content: center; - margin-top: 40px; -` - -const EmptyState = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 400px; - gap: 16px; -` - -const UserArticleCard: React.FC<{ article: WebsiteContent }> = ({ - article, -}) => { - const articleUrl = article.is_published - ? userArticlesView(article.slug || String(article.id)) - : `${USER_ARTICLES_LISTING}${article.id}/draft` - - const imageUrl = extractFirstImageFromArticle(article.content) - - return ( - - - - {article.title} - - - - - - ) -} - -const UserArticleListingPage: React.FC = () => { - const [page, setPage] = useState(1) - const scrollRef = useRef(null) - - const { data: articles, isLoading } = useArticleList({ - limit: PAGE_SIZE, - offset: (page - 1) * PAGE_SIZE, - }) - - useEffect(() => { - if (page > 1 && scrollRef.current) { - scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" }) - } - }, [page]) - - const results = articles?.results - const totalPages = articles?.count ? getLastPage(articles.count) : 0 - - return ( -
    - - - Articles - - New Article - - - - {isLoading ? ( - - ) : results && results.length > 0 ? ( - <> - - {results.map((article) => ( - - - - ))} - - - {totalPages > 1 && ( - - setPage(newPage)} - renderItem={(item) => ( - - )} - /> - - )} - - ) : ( - - No Articles Yet - - Get started by creating your first article. - - - New Article - - - )} - -
    - ) -} - -export { UserArticleListingPage } diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx b/frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx deleted file mode 100644 index bb03defc55..0000000000 --- a/frontends/main/src/app-pages/UserArticles/UserArticleNewPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client" - -import React from "react" -import { useRouter } from "next-nprogress-bar" -import { Permission } from "api/hooks/user" -import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" -import { styled } from "ol-components" -import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" -import { userArticlesDraftView, userArticlesView } from "@/common/urls" -import invariant from "tiny-invariant" - -const PageContainer = styled.div(({ theme }) => ({ - color: theme.custom.colors.darkGray2, - display: "flex", - height: "100%", -})) - -const UserArticleNewPage: React.FC = () => { - const router = useRouter() - - return ( - - - { - if (article.is_published) { - invariant(article.slug, "Published article must have a slug") - return router.push(userArticlesView(article.slug)) - } else { - router.push(userArticlesDraftView(String(article.id))) - } - }} - /> - - - ) -} - -export { UserArticleNewPage } diff --git a/frontends/main/src/app-pages/UserArticles/UserArticleDetailPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx similarity index 71% rename from frontends/main/src/app-pages/UserArticles/UserArticleDetailPage.tsx rename to frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx index 592546c9dd..d25c858801 100644 --- a/frontends/main/src/app-pages/UserArticles/UserArticleDetailPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx @@ -1,8 +1,9 @@ "use client" import React from "react" -import { useArticleDetailRetrieve } from "api/hooks/articles" +import { useWebsiteContentDetailRetrieve } from "api/hooks/website_content" import { LoadingSpinner, styled } from "ol-components" +import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" import { LearningResourceProvider } from "@/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceDataProvider" import { notFound } from "next/navigation" @@ -20,14 +21,15 @@ const Spinner = styled(LoadingSpinner)({ transform: "translate(-50%, -50%)", }) -const UserArticleDetailPage = ({ +const WebsiteContentDetail = ({ articleId, learningResourceIds = [], }: { articleId: string learningResourceIds?: number[] }) => { - const { data: article, isLoading } = useArticleDetailRetrieve(articleId) + const { data: article, isLoading } = + useWebsiteContentDetailRetrieve(articleId) if (isLoading) { return ( @@ -40,13 +42,15 @@ const UserArticleDetailPage = ({ return notFound() } + const Editor = article.content_type === "article" ? ArticleEditor : NewsEditor + return ( - + ) } -export { UserArticleDetailPage } +export { WebsiteContentDetail } diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx index 513075d564..8870bad7e3 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx @@ -13,8 +13,11 @@ import { Typography, } from "ol-components" import { Permission } from "api/hooks/user" -import { useArticleList } from "api/hooks/articles" -import type { WebsiteContent } from "api/v1" +import { useWebsiteContentList } from "api/hooks/website_content" +import type { + WebsiteContent, + WebsiteContentApiWebsiteContentListRequest as WebsiteContentListRequest, +} from "api/v1" import { LocalDate } from "ol-utilities" import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" import { extractFirstImageFromArticle } from "@/common/articleUtils" @@ -116,8 +119,6 @@ const DraftItem: React.FC<{ article: WebsiteContent; type: string }> = ({ interface WebsiteContentDraftListingPageProps { /** * Content type to show drafts for (e.g. 'article', 'news'). - * Filtering by content_type requires the OpenAPI client to be regenerated - * after adding WebsiteContentFilter to the Django viewset. */ contentType?: string } @@ -130,16 +131,20 @@ const WebsiteContentDraftListingPage: React.FC< const type = contentType || "article" const label = CONTENT_TYPE_LABELS[type] ?? type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const listParams: any = { + const listParams: WebsiteContentListRequest = { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, draft: true, - ...(contentType ? { content_type: contentType } : {}), + ...(contentType + ? { + content_type: + contentType as WebsiteContentListRequest["content_type"], + } + : {}), } const { data: articles, isLoading: isLoadingArticles } = - useArticleList(listParams) + useWebsiteContentList(listParams) useEffect(() => { if (page > 1 && scrollRef.current) { diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx index 3aae320b94..c7e9d33b51 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx @@ -4,7 +4,7 @@ import React from "react" import { useRouter } from "next-nprogress-bar" import { notFound } from "next/navigation" import { Permission } from "api/hooks/user" -import { useArticleDetailRetrieve } from "api/hooks/articles" +import { useWebsiteContentDetailRetrieve } from "api/hooks/website_content" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import { styled, LoadingSpinner } from "ol-components" import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" @@ -57,7 +57,7 @@ const WebsiteContentEditPage = ({ data: article, isLoading, isFetching, - } = useArticleDetailRetrieve(idOrSlug) + } = useWebsiteContentDetailRetrieve(idOrSlug) const router = useRouter() const Editor = EDITORS[type] diff --git a/frontends/main/src/app-pages/Articles/ArticleNewPage.test.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.test.tsx similarity index 85% rename from frontends/main/src/app-pages/Articles/ArticleNewPage.test.tsx rename to frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.test.tsx index 38f8323e1f..9c1db13ff2 100644 --- a/frontends/main/src/app-pages/Articles/ArticleNewPage.test.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.test.tsx @@ -6,7 +6,7 @@ import { } from "@/test-utils" import { waitFor } from "@testing-library/react" import { factories, urls } from "api/test-utils" -import { ArticleNewPage } from "./ArticleNewPage" +import { WebsiteContentNewPage } from "./WebsiteContentNewPage" const mockPush = jest.fn() jest.mock("next/navigation", () => ({ @@ -15,7 +15,7 @@ jest.mock("next/navigation", () => ({ }), })) -describe("ArticleNewPage", () => { +describe("WebsiteContentNewPage", () => { test("throws ForbiddenError when user lacks ArticleEditor permission", async () => { const user = factories.user.user({ is_authenticated: true, @@ -28,7 +28,7 @@ describe("ArticleNewPage", () => { renderWithProviders( - + , ) diff --git a/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx b/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx index a375abaa8f..5cc1f16b54 100644 --- a/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx +++ b/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx @@ -1,6 +1,6 @@ import React from "react" import { standardizeMetadata } from "@/common/metadata" -import { UserArticleDetailPage } from "@/app-pages/UserArticles/UserArticleDetailPage" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import { Permission } from "api/hooks/user" @@ -17,7 +17,7 @@ const Page: React.FC> = async ( return ( - + ) } diff --git a/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx b/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx index 635bbc6e1d..d898aef160 100644 --- a/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx +++ b/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx @@ -1,7 +1,7 @@ import React from "react" import { HydrationBoundary, dehydrate } from "@tanstack/react-query" -import { articleQueries } from "api/hooks/articles/queries" -import { UserArticleDetailPage } from "@/app-pages/UserArticles/UserArticleDetailPage" +import { websiteContentQueries } from "api/hooks/website_content/queries" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" import { getQueryClient } from "@/app/getQueryClient" import { learningResourceQueries } from "api/hooks/learningResources" import { extractLearningResourceIds } from "@/page-components/TiptapEditor/extensions/utils" @@ -44,7 +44,7 @@ export const generateMetadata = async ( return safeGenerateMetadata(async () => { const article = await queryClient.fetchQuery( - articleQueries.articlesDetailRetrieve(slugOrId), + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), ) const description = extractArticleDescription(article) @@ -65,10 +65,11 @@ const Page: React.FC> = async (props) => { const queryClient = getQueryClient() await queryClient.fetchQueryOr404( - articleQueries.articlesDetailRetrieve(slugOrId), + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), ) - const queryKey = articleQueries.articlesDetailRetrieve(slugOrId).queryKey + const queryKey = + websiteContentQueries.websiteContentDetailRetrieve(slugOrId).queryKey const cacheData = queryClient.getQueryData(queryKey) const learningResourceIds = cacheData?.content @@ -84,7 +85,7 @@ const Page: React.FC> = async (props) => { return ( - diff --git a/frontends/main/src/app/(site)/articles/page.tsx b/frontends/main/src/app/(site)/articles/page.tsx index 1a2061323b..498bb7c361 100644 --- a/frontends/main/src/app/(site)/articles/page.tsx +++ b/frontends/main/src/app/(site)/articles/page.tsx @@ -1,7 +1,7 @@ import React from "react" import { Metadata } from "next" import { standardizeMetadata } from "@/common/metadata" -import { UserArticleListingPage } from "@/app-pages/UserArticles/UserArticleListingPage" +import { ArticleListingPage } from "@/app-pages/Articles/ArticleListingPage" export const metadata: Metadata = standardizeMetadata({ title: "MIT Learn | Articles", @@ -9,7 +9,7 @@ export const metadata: Metadata = standardizeMetadata({ }) const Page: React.FC> = () => { - return + return } export default Page diff --git a/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx b/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx index 1f4dc0b464..334ad8d68e 100644 --- a/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx +++ b/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx @@ -1,24 +1,24 @@ import React from "react" -import { ArticleDetailPage } from "@/app-pages/Articles/ArticleDetailPage" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" import { standardizeMetadata } from "@/common/metadata" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import { Permission } from "api/hooks/user" export const generateMetadata = async () => { return standardizeMetadata({ - title: "Draft Article", + title: "Draft News", }) } const Page: React.FC> = async (props) => { const { slugOrId } = await props.params - // No prefetching for draft articles - the client-side component + // No prefetching for draft News - the client-side component // will fetch with user authentication return ( - + ) } diff --git a/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx b/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx index 460f3ea80f..7d6639bb60 100644 --- a/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx +++ b/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx @@ -1,7 +1,7 @@ import React from "react" import { HydrationBoundary, dehydrate } from "@tanstack/react-query" -import { articleQueries } from "api/hooks/articles/queries" -import { ArticleDetailPage } from "@/app-pages/Articles/ArticleDetailPage" +import { websiteContentQueries } from "api/hooks/website_content/queries" +import { WebsiteContentDetail } from "@/app-pages/WebsiteContent/WebsiteContentDetail" import { getQueryClient } from "@/app/getQueryClient" import { learningResourceQueries } from "api/hooks/learningResources" import { extractLearningResourceIds } from "@/page-components/TiptapEditor/extensions/utils" @@ -10,7 +10,7 @@ import type { WebsiteContent } from "api/v1" import type { JSONContent } from "@tiptap/react" // Extracts the banner subheading paragraph at known location -const extractArticleDescription = ( +const extractWebsiteContentDescription = ( article: WebsiteContent, ): string | undefined => { const banner = article.content?.content?.[0] @@ -20,9 +20,9 @@ const extractArticleDescription = ( } const extractImageMetadata = ( - article: WebsiteContent, + news: WebsiteContent, ): { src: string; alt: string } | null => { - const imageWithCaption = article.content?.content?.find( + const imageWithCaption = news.content?.content?.find( (node: JSONContent) => node.type === "imageWithCaption", ) if (!imageWithCaption) { @@ -44,15 +44,15 @@ export const generateMetadata = async ( const queryClient = getQueryClient() return safeGenerateMetadata(async () => { - const article = await queryClient.fetchQuery( - articleQueries.articlesDetailRetrieve(slugOrId), + const news = await queryClient.fetchQuery( + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), ) - const description = extractArticleDescription(article) - const leadImage = extractImageMetadata(article) + const description = extractWebsiteContentDescription(news) + const leadImage = extractImageMetadata(news) return standardizeMetadata({ - title: article.title, + title: news.title, description, image: leadImage?.src, imageAlt: leadImage?.alt, @@ -66,10 +66,11 @@ const Page: React.FC> = async (props) => { const queryClient = getQueryClient() await queryClient.fetchQueryOr404( - articleQueries.articlesDetailRetrieve(slugOrId), + websiteContentQueries.websiteContentDetailRetrieve(slugOrId), ) - const queryKey = articleQueries.articlesDetailRetrieve(slugOrId).queryKey + const queryKey = + websiteContentQueries.websiteContentDetailRetrieve(slugOrId).queryKey const cacheData = queryClient.getQueryData(queryKey) const learningResourceIds = cacheData?.content @@ -85,7 +86,7 @@ const Page: React.FC> = async (props) => { return ( - diff --git a/frontends/main/src/app/(site)/news/page.tsx b/frontends/main/src/app/(site)/news/page.tsx index 436c014d4d..942bd20a09 100644 --- a/frontends/main/src/app/(site)/news/page.tsx +++ b/frontends/main/src/app/(site)/news/page.tsx @@ -1,7 +1,7 @@ import React from "react" import { Metadata } from "next" import { standardizeMetadata } from "@/common/metadata" -import { ArticleListingPage } from "@/app-pages/Articles/ArticleListingPage" +import { NewsListingPage } from "@/app-pages/News/NewsListingPage" export const metadata: Metadata = standardizeMetadata({ title: "MIT Learn | News", @@ -9,7 +9,7 @@ export const metadata: Metadata = standardizeMetadata({ }) const Page: React.FC> = () => { - return + return } export default Page diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index 55ec0b8700..09aaec2f7b 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -36,17 +36,17 @@ export const learningPathsView = (id: number) => export const PROGRAMLETTER_VIEW = "/program_letter/[id]/view/" export const programLetterView = (id: string) => generatePath(PROGRAMLETTER_VIEW, { id: String(id) }) -export const ARTICLES_LISTING = "/news/" -export const ARTICLES_VIEW = "/news/[id]" -export const ARTICLES_DRAFT_VIEW = "/news/[id]/draft" -export const ARTICLES_EDIT = "/news/[id]/edit" -export const ARTICLES_CREATE = "/news/new" -export const articlesView = (id: string) => - generatePath(ARTICLES_VIEW, { id: String(id) }) -export const articlesDraftView = (id: string) => - generatePath(ARTICLES_DRAFT_VIEW, { id: String(id) }) -export const articlesEditView = (id: number) => - generatePath(ARTICLES_EDIT, { id: String(id) }) +export const NEWS_LISTING = "/news/" +export const NEWS_VIEW = "/news/[id]" +export const NEWS_DRAFT_VIEW = "/news/[id]/draft" +export const NEWS_EDIT = "/news/[id]/edit" +export const NEWS_CREATE = "/news/new" +export const newsView = (id: string) => + generatePath(NEWS_VIEW, { id: String(id) }) +export const newsDraftView = (id: string) => + generatePath(NEWS_DRAFT_VIEW, { id: String(id) }) +export const newsEditView = (id: number) => + generatePath(NEWS_EDIT, { id: String(id) }) // User-created articles (served under /articles) export const USER_ARTICLES_LISTING = "/articles/" diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx index d5a4a5b5b6..ee88990e4d 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx @@ -11,7 +11,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const article = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -78,7 +78,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) const authorName = `${user.first_name} ${user.last_name}` - const article = factories.articles.article({ + const article = factories.websiteContent.websiteContent({ user, author_name: authorName, }) @@ -95,7 +95,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const article = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -219,7 +219,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const article = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -321,7 +321,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const article = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -428,7 +428,7 @@ describe("ArticleViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const article = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -500,7 +500,7 @@ describe("ArticleViewer", () => { is_article_editor: true, }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article() + const article = factories.websiteContent.websiteContent() renderWithProviders() await screen.findByRole("link", { name: "Edit" }) diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx index d900fced52..5c80bd995c 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx @@ -1,10 +1,12 @@ "use client" import React from "react" -import type { ChangeEventHandler } from "react" import type { WebsiteContent } from "api/v1" import { ButtonLink } from "@mitodl/smoot-design" -import { useArticleCreate, useArticlePartialUpdate } from "api/hooks/articles" +import { + useWebsiteContentCreate, + useWebsiteContentPartialUpdate, +} from "api/hooks/website_content" import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer" import { WebsiteContentEditor } from "../../core/WebsiteContentEditor" import { @@ -20,18 +22,15 @@ const extractArticleExtraFields = (content: { content?: Array<{ type?: string; attrs?: Record }> }): Record => { const bylineNode = content.content?.find((node) => node.type === "byline") - return { author_name: bylineNode?.attrs?.authorName || "" } + return { + author_name: bylineNode?.attrs?.authorName || "", + content_type: "article", + } } interface ArticleEditorProps { - /** @deprecated unused, kept for API compatibility */ - value?: object onSave?: (article: WebsiteContent) => void readOnly?: boolean - /** @deprecated unused, kept for API compatibility */ - title?: string - /** @deprecated unused, kept for API compatibility */ - setTitle?: ChangeEventHandler article?: WebsiteContent } @@ -49,8 +48,8 @@ interface ArticleEditorProps { */ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { // Swap these two lines when a dedicated UserArticle API exists. - const createMutation = useArticleCreate() - const updateMutation = useArticlePartialUpdate() + const createMutation = useWebsiteContentCreate() + const updateMutation = useWebsiteContentPartialUpdate() const editUrl = article ? `/website_content/article/${article.is_published ? article.slug : article.id}/edit` diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx index 3cdb2fdb6d..e7574aad52 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx @@ -36,7 +36,7 @@ describe("NewsEditor - Content Editing and Saving", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const article = factories.websiteContent.websiteContent({ id: articleId, title, content, @@ -513,7 +513,7 @@ describe("NewsEditor - Content Editing and Saving", () => { }) setMockResponse.get(urls.userMe.get(), user) - const createdArticle = factories.articles.article({ + const createdArticle = factories.websiteContent.websiteContent({ id: 101, title: "My Article", is_published: true, @@ -638,7 +638,7 @@ describe("NewsEditor - Document Rendering", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.articles.article({ + const article = factories.websiteContent.websiteContent({ id: 1, title: "Test Article", content, diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx index dcb48141cb..5f71d4cd2f 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx @@ -1,10 +1,12 @@ "use client" import React from "react" -import type { ChangeEventHandler } from "react" import type { WebsiteContent } from "api/v1" import { ButtonLink } from "@mitodl/smoot-design" -import { useArticleCreate, useArticlePartialUpdate } from "api/hooks/articles" +import { + useWebsiteContentCreate, + useWebsiteContentPartialUpdate, +} from "api/hooks/website_content" import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer" import { WebsiteContentEditor } from "../../core/WebsiteContentEditor" import { createNewsExtensions, newNewsDocument } from "./newsExtensions" @@ -14,18 +16,15 @@ const extractNewsExtraFields = (content: { content?: Array<{ type?: string; attrs?: Record }> }): Record => { const bylineNode = content.content?.find((node) => node.type === "byline") - return { author_name: bylineNode?.attrs?.authorName || "" } + return { + author_name: bylineNode?.attrs?.authorName || "", + content_type: "news", + } } interface NewsEditorProps { - /** @deprecated unused, kept for API compatibility */ - value?: object onSave?: (article: WebsiteContent) => void readOnly?: boolean - /** @deprecated unused, kept for API compatibility */ - title?: string - /** @deprecated unused, kept for API compatibility */ - setTitle?: ChangeEventHandler article?: WebsiteContent } @@ -37,8 +36,8 @@ interface NewsEditorProps { const NewsEditor = ({ onSave, readOnly, article }: NewsEditorProps) => { // News content type uses the websiteContent (articles) API. // A different content type would call different hooks here. - const createMutation = useArticleCreate() - const updateMutation = useArticlePartialUpdate() + const createMutation = useWebsiteContentCreate() + const updateMutation = useWebsiteContentPartialUpdate() const editUrl = article ? `/website_content/news/${article.is_published ? article.slug : article.id}/edit` diff --git a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx index d39d43b92d..3b06af784a 100644 --- a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx @@ -14,7 +14,7 @@ import { } from "ol-components" import { Alert, Button } from "@mitodl/smoot-design" import { useUserHasPermission, Permission } from "api/hooks/user" -import { useMediaUpload } from "api/hooks/articles" +import { useMediaUpload } from "api/hooks/website_content" import dynamic from "next/dynamic" import { Toolbar } from "../vendor/components/tiptap-ui-primitive/toolbar" @@ -94,8 +94,8 @@ export interface SavePayload { * specific API hook directly. * * Example — news type uses websiteContent API: - * const create = useArticleCreate() - * const update = useArticlePartialUpdate() + * const create = useWebsiteContentCreate() + * const update = useWebsiteContentPartialUpdate() * * * A future user-article type could use a completely different API hook: diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx index 7526c1881e..44222efdf5 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx @@ -115,7 +115,7 @@ const StyledNodeViewContent = styled(NodeViewContent)(({ theme }) => ({ }, })) -import { ArticleByLineInBannerViewer } from "../ByLineInfoBar/ByLineInfoBarViewer" +import { ByLineInBannerViewer } from "../ByLineInfoBar/ByLineInfoBarViewer" const ArticleBannerViewer = ({ children, @@ -145,7 +145,7 @@ const ArticleBannerViewer = ({ - + diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarViewer.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarViewer.tsx index f4d2e62145..b23d6ba198 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarViewer.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarViewer.tsx @@ -39,6 +39,3 @@ const ByLineInBannerViewer = () => { } export { ByLineInBannerViewer } - -/** @deprecated Use ByLineInBannerViewer */ -export { ByLineInBannerViewer as ArticleByLineInBannerViewer } diff --git a/frontends/main/src/page-components/TiptapEditor/index.ts b/frontends/main/src/page-components/TiptapEditor/index.ts index 9ad5ede13e..36d8e0a99d 100644 --- a/frontends/main/src/page-components/TiptapEditor/index.ts +++ b/frontends/main/src/page-components/TiptapEditor/index.ts @@ -1,5 +1,4 @@ export { NewsEditor } from "./contentTypes/news/NewsEditor" -export { ArticleEditor as UserArticleEditor } from "./contentTypes/article/ArticleEditor" export { WebsiteContentEditor } from "./core/WebsiteContentEditor" export type { WebsiteContentEditorProps, From 5e29ed57cbfeeba055796a5c10bceea1c3449453 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Thu, 21 May 2026 22:26:43 +0500 Subject: [PATCH 10/17] address the feedback --- .../app-pages/Articles/ArticleListingPage.tsx | 25 ++++++++----------- .../WebsiteContentDraftListingPage.tsx | 4 +-- .../WebsiteContent/WebsiteContentEditPage.tsx | 4 +-- .../WebsiteContent/WebsiteContentNewPage.tsx | 4 +-- frontends/main/src/common/urls.ts | 24 +++++++++--------- ...articleUtils.ts => websiteContentUtils.ts} | 6 ++--- ...cleViewer.test.tsx => NewsViewer.test.tsx} | 4 +-- .../TiptapEditor/TiptapEditor.tsx | 6 ++--- .../core/WebsiteContentEditor.tsx | 5 ++-- .../node/ByLineInfoBar/ByLineInfoBarNode.ts | 1 - 10 files changed, 39 insertions(+), 44 deletions(-) rename frontends/main/src/common/{articleUtils.ts => websiteContentUtils.ts} (77%) rename frontends/main/src/page-components/TiptapEditor/{ArticleViewer.test.tsx => NewsViewer.test.tsx} (99%) diff --git a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx index 6bf38847c3..3aa76d355a 100644 --- a/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleListingPage.tsx @@ -17,12 +17,8 @@ import { useWebsiteContentList } from "api/hooks/website_content" import type { WebsiteContent } from "api/v1" import { LocalDate } from "ol-utilities" import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" -import { extractFirstImageFromArticle } from "@/common/articleUtils" -import { - userArticlesView, - USER_ARTICLES_CREATE, - USER_ARTICLES_LISTING, -} from "@/common/urls" +import { extractFirstImage } from "@/common/websiteContentUtils" +import { articleView, ARTICLES_CREATE, ARTICLES_LISTING } from "@/common/urls" const PAGE_SIZE = 20 const MAX_PAGE = 50 @@ -71,14 +67,12 @@ const EmptyState = styled.div` gap: 16px; ` -const UserArticleCard: React.FC<{ article: WebsiteContent }> = ({ - article, -}) => { +const ArticleCard: React.FC<{ article: WebsiteContent }> = ({ article }) => { const articleUrl = article.is_published - ? userArticlesView(article.slug || String(article.id)) - : `${USER_ARTICLES_LISTING}${article.id}/draft` + ? articleView(article.slug || String(article.id)) + : `${ARTICLES_LISTING}${article.id}/draft` - const imageUrl = extractFirstImageFromArticle(article.content) + const imageUrl = extractFirstImage(article.content) return ( @@ -103,6 +97,7 @@ const ArticleListingPage: React.FC = () => { const { data: articles, isLoading } = useWebsiteContentList({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, + content_type: "article", }) useEffect(() => { @@ -119,7 +114,7 @@ const ArticleListingPage: React.FC = () => { Articles - + New Article @@ -134,7 +129,7 @@ const ArticleListingPage: React.FC = () => { key={article.id} size={{ xs: 12, sm: 6, md: 4, lg: 3, xl: 3 }} > - + ))} @@ -164,7 +159,7 @@ const ArticleListingPage: React.FC = () => { Get started by creating your first article. - + New Article diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx index 8870bad7e3..146fcf7c4a 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx @@ -20,7 +20,7 @@ import type { } from "api/v1" import { LocalDate } from "ol-utilities" import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react" -import { extractFirstImageFromArticle } from "@/common/articleUtils" +import { extractFirstImage } from "@/common/websiteContentUtils" import { websiteContentEditView, websiteContentCreateView } from "@/common/urls" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import { ButtonLink } from "@mitodl/smoot-design" @@ -92,7 +92,7 @@ const DraftItem: React.FC<{ article: WebsiteContent; type: string }> = ({ ? `/${type === "article" ? "articles" : type}/${article.slug || article.id}` : websiteContentEditView(type, article.id) - const imageUrl = extractFirstImageFromArticle(article.content) + const imageUrl = extractFirstImage(article.content) return ( diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx index c7e9d33b51..f5bc00c118 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx @@ -9,7 +9,7 @@ import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import { styled, LoadingSpinner } from "ol-components" import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" -import { userArticlesView, websiteContentEditView } from "@/common/urls" +import { articleView, websiteContentEditView } from "@/common/urls" import invariant from "tiny-invariant" import type { WebsiteContent } from "api/v1" @@ -28,7 +28,7 @@ const Spinner = styled(LoadingSpinner)({ }) const PUBLISHED_VIEW_URL: Record string> = { - article: (slug) => userArticlesView(slug), + article: (slug) => articleView(slug), news: (slug) => `/news/${slug}`, } diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx index 8ef76b2dc6..c9943bd03f 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx @@ -8,7 +8,7 @@ import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import { styled } from "ol-components" import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/article/ArticleEditor" import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" -import { userArticlesView, websiteContentEditView } from "@/common/urls" +import { articleView, websiteContentEditView } from "@/common/urls" import invariant from "tiny-invariant" import type { WebsiteContent } from "api/v1" @@ -19,7 +19,7 @@ const PageContainer = styled.div(({ theme }) => ({ })) const PUBLISHED_VIEW_URL: Record string> = { - article: (slug) => userArticlesView(slug), + article: (slug) => articleView(slug), news: (slug) => `/news/${slug}`, } diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index 09aaec2f7b..bc244f91b6 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -48,18 +48,18 @@ export const newsDraftView = (id: string) => export const newsEditView = (id: number) => generatePath(NEWS_EDIT, { id: String(id) }) -// User-created articles (served under /articles) -export const USER_ARTICLES_LISTING = "/articles/" -export const USER_ARTICLES_VIEW = "/articles/[id]" -export const USER_ARTICLES_DRAFT_VIEW = "/articles/[id]/draft" -export const USER_ARTICLES_EDIT = "/articles/[id]/edit" -export const USER_ARTICLES_CREATE = "/articles/new" -export const userArticlesView = (id: string) => - generatePath(USER_ARTICLES_VIEW, { id: String(id) }) -export const userArticlesDraftView = (id: string) => - generatePath(USER_ARTICLES_DRAFT_VIEW, { id: String(id) }) -export const userArticlesEditView = (id: number) => - generatePath(USER_ARTICLES_EDIT, { id: String(id) }) +// Articles (served under /articles) +export const ARTICLES_LISTING = "/articles/" +export const ARTICLES_VIEW = "/articles/[id]" +export const ARTICLES_DRAFT_VIEW = "/articles/[id]/draft" +export const ARTICLES_EDIT = "/articles/[id]/edit" +export const ARTICLES_CREATE = "/articles/new" +export const articleView = (id: string) => + generatePath(ARTICLES_VIEW, { id: String(id) }) +export const articleDraftView = (id: string) => + generatePath(ARTICLES_DRAFT_VIEW, { id: String(id) }) +export const articleEditView = (id: number) => + generatePath(ARTICLES_EDIT, { id: String(id) }) // Generic website content editing routes export const WEBSITE_CONTENT_CREATE = "/website_content/[type]/new" diff --git a/frontends/main/src/common/articleUtils.ts b/frontends/main/src/common/websiteContentUtils.ts similarity index 77% rename from frontends/main/src/common/articleUtils.ts rename to frontends/main/src/common/websiteContentUtils.ts index c4af58b1e4..493021cf51 100644 --- a/frontends/main/src/common/articleUtils.ts +++ b/frontends/main/src/common/websiteContentUtils.ts @@ -1,10 +1,10 @@ /** * Recursively traverses a ProseMirror JSON content structure to find the first image. * - * @param content - The ProseMirror JSON content object from an article + * @param content - The ProseMirror JSON content object * @returns The URL of the first image found, or null if no image exists */ -export function extractFirstImageFromArticle(content: unknown): string | null { +export function extractFirstImage(content: unknown): string | null { if (!content || typeof content !== "object") return null const node = content as Record @@ -21,7 +21,7 @@ export function extractFirstImageFromArticle(content: unknown): string | null { // Recursively check content array if (Array.isArray(node.content)) { for (const childNode of node.content) { - const imageUrl = extractFirstImageFromArticle(childNode) + const imageUrl = extractFirstImage(childNode) if (imageUrl) { return imageUrl } diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx similarity index 99% rename from frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx rename to frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx index ee88990e4d..c07883d07c 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleViewer.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx @@ -3,8 +3,8 @@ import { screen, renderWithProviders, setMockResponse } from "@/test-utils" import { factories, urls } from "api/test-utils" import { NewsEditor } from "./contentTypes/news/NewsEditor" -describe("ArticleViewer", () => { - test("renders article content", async () => { +describe("NewsViewer", () => { + test("renders content", async () => { const user = factories.user.user({ is_authenticated: true, is_article_editor: true, diff --git a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx index 2f19afc1d5..f5d70d1248 100644 --- a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx @@ -82,7 +82,7 @@ const Container = styled.div<{ }, } : {}), - "&& .tiptap.ProseMirror.abc > :nth-child(2)": { + "&& .tiptap.ProseMirror.tiptap-viewer > :nth-child(2)": { paddingTop: "36px", }, "&& .tiptap.ProseMirror, && .tiptap-viewer": { @@ -299,11 +299,11 @@ const TipTapViewer = ({ content: JSONContent extensions: Array bannerViewer?: typeof BannerViewer - bylineViewer?: typeof BannerViewer + bylineViewer?: typeof ByLineInfoBarViewer }) => { return ( -
    +
    {renderToReactElement({ extensions, content, diff --git a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx index 3b06af784a..d032ca510e 100644 --- a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx @@ -20,6 +20,7 @@ import dynamic from "next/dynamic" import { Toolbar } from "../vendor/components/tiptap-ui-primitive/toolbar" import { TiptapEditor, MainToolbarContent, TipTapViewer } from "../TiptapEditor" import { BannerViewer } from "../extensions/node/Banner/BannerNode" +import { ByLineInfoBarViewer } from "../extensions/node/ByLineInfoBar/ByLineInfoBarViewer" import { handleImageUpload } from "../vendor/lib/tiptap-utils" import { useSchema } from "../useSchema" import { WebsiteContentProvider } from "../WebsiteContentContext" @@ -162,7 +163,7 @@ export interface WebsiteContentEditorProps { article?: WebsiteContent backgroundColor?: string bannerViewer?: typeof BannerViewer - bylineViewer?: typeof BannerViewer + bylineViewer?: typeof ByLineInfoBarViewer } const WebsiteContentEditor = ({ @@ -454,7 +455,7 @@ const WebsiteContentEditor = ({ content={content} extensions={extensions} bannerViewer={bannerViewer} - bylineViewer={bylineViewer} + bylineViewer={bylineViewer ?? ByLineInfoBarViewer} /> ) : ( diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarNode.ts b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarNode.ts index b303972957..6d5186f416 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarNode.ts +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarNode.ts @@ -33,7 +33,6 @@ export const ByLineInfoBarNode = Node.create({ }, addNodeView() { - // eslint-disable-next-line react-hooks/rules-of-hooks return ReactNodeViewRenderer(ByLineInfoBar) }, }) From 5ecbb03d56d9e0186898e78499eac862ca2663f0 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Thu, 21 May 2026 22:53:44 +0500 Subject: [PATCH 11/17] chatgpt modal review --- .../WebsiteContentDraftListingPage.tsx | 48 ++++++++++--------- .../TiptapEditor/NewsViewer.test.tsx | 28 +++++------ .../news/NewsEditor.happydom.test.tsx | 40 ++++++++-------- 3 files changed, 59 insertions(+), 57 deletions(-) diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx index 146fcf7c4a..d0afd041df 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx @@ -46,7 +46,7 @@ const PageHeader = styled.div` margin-bottom: 40px; ` -const DraftArticleCard = styled(Card)` +const DraftContentCard = styled(Card)` display: flex; flex-direction: column; height: 100%; @@ -84,35 +84,35 @@ const CONTENT_TYPE_LABELS: Record = { news: "News", } -const DraftItem: React.FC<{ article: WebsiteContent; type: string }> = ({ - article, +const DraftItem: React.FC<{ contentItem: WebsiteContent; type: string }> = ({ + contentItem, type, }) => { - const itemUrl = article.is_published - ? `/${type === "article" ? "articles" : type}/${article.slug || article.id}` - : websiteContentEditView(type, article.id) + const itemUrl = contentItem.is_published + ? `/${type === "article" ? "articles" : type}/${contentItem.slug || contentItem.id}` + : websiteContentEditView(type, contentItem.id) - const imageUrl = extractFirstImage(article.content) + const imageUrl = extractFirstImage(contentItem.content) return ( - + - {article.title} + {contentItem.title} - - {!article.is_published && ( + + {!contentItem.is_published && ( <> {" • "} Draft )} - + ) } @@ -143,7 +143,7 @@ const WebsiteContentDraftListingPage: React.FC< : {}), } - const { data: articles, isLoading: isLoadingArticles } = + const { data: contentItems, isLoading: isLoadingContentItems } = useWebsiteContentList(listParams) useEffect(() => { @@ -152,11 +152,13 @@ const WebsiteContentDraftListingPage: React.FC< } }, [page]) - const draftArticles = articles?.results - const totalPages = articles?.count ? Math.ceil(articles.count / PAGE_SIZE) : 0 + const draftItems = contentItems?.results + const totalPages = contentItems?.count + ? Math.ceil(contentItems.count / PAGE_SIZE) + : 0 - if (isLoadingArticles) { - return + if (isLoadingContentItems) { + return } return ( @@ -174,19 +176,19 @@ const WebsiteContentDraftListingPage: React.FC< - {isLoadingArticles ? ( + {isLoadingContentItems ? ( - ) : draftArticles && draftArticles.length > 0 ? ( + ) : draftItems && draftItems.length > 0 ? ( <> - {draftArticles.map((article) => ( + {draftItems.map((contentItem) => ( - + ))} diff --git a/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx index c07883d07c..6bf53b82cb 100644 --- a/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx @@ -11,7 +11,7 @@ describe("NewsViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.websiteContent.websiteContent({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -64,7 +64,7 @@ describe("NewsViewer", () => { }, }) - renderWithProviders() + renderWithProviders() await screen.findByRole("heading", { name: "Test Title", level: 1 }) await screen.findByText("Test subheading") @@ -78,12 +78,12 @@ describe("NewsViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) const authorName = `${user.first_name} ${user.last_name}` - const article = factories.websiteContent.websiteContent({ + const newsItem = factories.websiteContent.websiteContent({ user, author_name: authorName, }) - renderWithProviders() + renderWithProviders() await screen.findByText(`By ${authorName}`) }) @@ -95,7 +95,7 @@ describe("NewsViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.websiteContent.websiteContent({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -202,7 +202,7 @@ describe("NewsViewer", () => { }, }) - renderWithProviders() + renderWithProviders() await screen.findByRole("heading", { level: 1, name: "Heading Level 1" }) await screen.findByRole("heading", { level: 2, name: "Heading Level 2" }) @@ -219,7 +219,7 @@ describe("NewsViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.websiteContent.websiteContent({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -294,7 +294,7 @@ describe("NewsViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const firstUnordered = await screen.findByText("First unordered item") const secondUnordered = await screen.findByText("Second unordered item") @@ -321,7 +321,7 @@ describe("NewsViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.websiteContent.websiteContent({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -398,7 +398,7 @@ describe("NewsViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const boldText = await screen.findByText("bold text") expect(boldText).toBeInTheDocument() @@ -428,7 +428,7 @@ describe("NewsViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.websiteContent.websiteContent({ + const newsItem = factories.websiteContent.websiteContent({ content: { type: "doc", content: [ @@ -486,7 +486,7 @@ describe("NewsViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const link = await screen.findByRole("link", { name: "example.com" }) expect(link).toBeInTheDocument() @@ -500,8 +500,8 @@ describe("NewsViewer", () => { is_article_editor: true, }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.websiteContent.websiteContent() - renderWithProviders() + const newsItem = factories.websiteContent.websiteContent() + renderWithProviders() await screen.findByRole("link", { name: "Edit" }) }) diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx index e7574aad52..2167fa6374 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx @@ -36,20 +36,20 @@ describe("NewsEditor - Content Editing and Saving", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.websiteContent.websiteContent({ + const newsItem = factories.websiteContent.websiteContent({ id: articleId, title, content, is_published: false, }) - setMockResponse.get(urls.websiteContent.details(articleId), article) + setMockResponse.get(urls.websiteContent.details(articleId), newsItem) - renderWithProviders(, { + renderWithProviders(, { user, }) await screen.findByTestId("editor") - return article + return newsItem } describe("Editing title in banner heading", () => { @@ -376,7 +376,7 @@ describe("NewsEditor - Content Editing and Saving", () => { }) describe("Save as Draft functionality", () => { - test("can save article as draft", async () => { + test("can save news as draft", async () => { const initialContent: JSONContent = { type: "doc", content: [ @@ -404,23 +404,23 @@ describe("NewsEditor - Content Editing and Saving", () => { ], } - const article = await setupEditor(initialContent, 208, "Title") + const newsItem = await setupEditor(initialContent, 208, "Title") const paragraph = screen.getByText("Content") await userEvent.click(paragraph) await userEvent.keyboard("{Control>}a{/Control}") await userEvent.type(paragraph, "Updated content") - const updatedArticle = { - ...article, + const updatedNewsItem = { + ...newsItem, content: expect.objectContaining({ type: "doc", }), is_published: false, } setMockResponse.patch( - urls.websiteContent.details(article.id), - updatedArticle, + urls.websiteContent.details(newsItem.id), + updatedNewsItem, ) const saveDraftButton = await screen.findByRole("button", { @@ -433,7 +433,7 @@ describe("NewsEditor - Content Editing and Saving", () => { expect(makeRequest).toHaveBeenCalledWith( "patch", - urls.websiteContent.details(article.id), + urls.websiteContent.details(newsItem.id), expect.objectContaining({ is_published: false, author_name: "", @@ -505,20 +505,20 @@ describe("NewsEditor - Content Editing and Saving", () => { }) }) - describe("Creating new articles", () => { - test("submits article successfully", async () => { + describe("Creating news", () => { + test("submits news successfully", async () => { const user = factories.user.user({ is_authenticated: true, is_article_editor: true, }) setMockResponse.get(urls.userMe.get(), user) - const createdArticle = factories.websiteContent.websiteContent({ + const createdNewsItem = factories.websiteContent.websiteContent({ id: 101, title: "My Article", is_published: true, }) - setMockResponse.post(urls.websiteContent.list(), createdArticle) + setMockResponse.post(urls.websiteContent.list(), createdNewsItem) renderWithProviders(, { user }) @@ -598,7 +598,7 @@ describe("NewsEditor - Content Editing and Saving", () => { expect(savedData).toBeDefined() expect(savedData).toMatchObject({ - id: createdArticle.id, + id: createdNewsItem.id, title: "My Article", is_published: true, }) @@ -638,20 +638,20 @@ describe("NewsEditor - Document Rendering", () => { }) setMockResponse.get(urls.userMe.get(), user) - const article = factories.websiteContent.websiteContent({ + const newsItem = factories.websiteContent.websiteContent({ id: 1, title: "Test Article", content, }) - setMockResponse.get(urls.websiteContent.details(articleId), article) + setMockResponse.get(urls.websiteContent.details(articleId), newsItem) renderWithProviders( - , + , { user }, ) await screen.findByTestId("editor") - return article + return newsItem } test("renders editor when user has ArticleEditor permission", async () => { From fe59476be376b91b197a6fbdb98c09a20e6c95a1 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Thu, 21 May 2026 23:26:19 +0500 Subject: [PATCH 12/17] another review of chatgpt --- .../WebsiteContent/WebsiteContentDetail.tsx | 8 ++++--- .../WebsiteContent/WebsiteContentEditPage.tsx | 14 ++++++++----- .../WebsiteContent/WebsiteContentNewPage.tsx | 10 +++++---- .../TiptapEditor/NewsViewer.test.tsx | 14 ++++++------- .../contentTypes/article/ArticleEditor.tsx | 3 +++ .../news/NewsEditor.happydom.test.tsx | 11 ++++++---- .../contentTypes/news/NewsEditor.tsx | 17 ++++++++------- .../core/WebsiteContentEditor.tsx | 21 +++++++++++++++++-- 8 files changed, 66 insertions(+), 32 deletions(-) diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx index d25c858801..0ae6cbf476 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx @@ -42,12 +42,14 @@ const WebsiteContentDetail = ({ return notFound() } - const Editor = article.content_type === "article" ? ArticleEditor : NewsEditor - return ( - + {article.content_type === "article" ? ( + + ) : ( + + )} ) diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx index f5bc00c118..b4bee695ed 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx @@ -35,13 +35,17 @@ const PUBLISHED_VIEW_URL: Record string> = { const EDITORS: Record< string, React.ComponentType<{ - onSave?: (article: WebsiteContent) => void + onSave?: (savedContent: WebsiteContent) => void readOnly?: boolean - article?: WebsiteContent + contentItem?: WebsiteContent }> > = { - article: ArticleEditor, - news: NewsEditor, + article: ({ contentItem, ...props }) => ( + + ), + news: ({ contentItem, ...props }) => ( + + ), } interface WebsiteContentEditPageProps { @@ -78,7 +82,7 @@ const WebsiteContentEditPage = ({ { if (saved.is_published) { invariant(saved.slug, "Published content must have a slug") diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx index c9943bd03f..ed648a5b94 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentNewPage.tsx @@ -26,13 +26,15 @@ const PUBLISHED_VIEW_URL: Record string> = { const EDITORS: Record< string, React.ComponentType<{ - onSave?: (article: WebsiteContent) => void + onSave?: (savedContent: WebsiteContent) => void readOnly?: boolean - article?: WebsiteContent + contentItem?: WebsiteContent }> > = { - article: ArticleEditor, - news: NewsEditor, + article: ({ contentItem, ...props }) => ( + + ), + news: ({ contentItem: _contentItem, ...props }) => , } interface WebsiteContentNewPageProps { diff --git a/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx index 6bf53b82cb..92c13d5b9a 100644 --- a/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx @@ -64,7 +64,7 @@ describe("NewsViewer", () => { }, }) - renderWithProviders() + renderWithProviders() await screen.findByRole("heading", { name: "Test Title", level: 1 }) await screen.findByText("Test subheading") @@ -83,7 +83,7 @@ describe("NewsViewer", () => { author_name: authorName, }) - renderWithProviders() + renderWithProviders() await screen.findByText(`By ${authorName}`) }) @@ -202,7 +202,7 @@ describe("NewsViewer", () => { }, }) - renderWithProviders() + renderWithProviders() await screen.findByRole("heading", { level: 1, name: "Heading Level 1" }) await screen.findByRole("heading", { level: 2, name: "Heading Level 2" }) @@ -294,7 +294,7 @@ describe("NewsViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const firstUnordered = await screen.findByText("First unordered item") const secondUnordered = await screen.findByText("Second unordered item") @@ -398,7 +398,7 @@ describe("NewsViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const boldText = await screen.findByText("bold text") expect(boldText).toBeInTheDocument() @@ -486,7 +486,7 @@ describe("NewsViewer", () => { }, }) - renderWithProviders() + renderWithProviders() const link = await screen.findByRole("link", { name: "example.com" }) expect(link).toBeInTheDocument() @@ -501,7 +501,7 @@ describe("NewsViewer", () => { }) setMockResponse.get(urls.userMe.get(), user) const newsItem = factories.websiteContent.websiteContent() - renderWithProviders() + renderWithProviders() await screen.findByRole("link", { name: "Edit" }) }) diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx index 5c80bd995c..3f7ec7f3a4 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx @@ -6,6 +6,7 @@ import { ButtonLink } from "@mitodl/smoot-design" import { useWebsiteContentCreate, useWebsiteContentPartialUpdate, + useMediaUpload, } from "api/hooks/website_content" import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer" import { WebsiteContentEditor } from "../../core/WebsiteContentEditor" @@ -50,6 +51,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { // Swap these two lines when a dedicated UserArticle API exists. const createMutation = useWebsiteContentCreate() const updateMutation = useWebsiteContentPartialUpdate() + const uploadImage = useMediaUpload() const editUrl = article ? `/website_content/article/${article.is_published ? article.slug : article.id}/edit` @@ -78,6 +80,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { toolbarSlot={toolbarSlot} extractExtraFields={extractArticleExtraFields} saveMutations={{ create: createMutation, update: updateMutation }} + uploadImage={uploadImage} onSave={onSave} readOnly={readOnly} article={article} diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx index 2167fa6374..0730eec432 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.happydom.test.tsx @@ -44,9 +44,12 @@ describe("NewsEditor - Content Editing and Saving", () => { }) setMockResponse.get(urls.websiteContent.details(articleId), newsItem) - renderWithProviders(, { - user, - }) + renderWithProviders( + , + { + user, + }, + ) await screen.findByTestId("editor") return newsItem @@ -646,7 +649,7 @@ describe("NewsEditor - Document Rendering", () => { setMockResponse.get(urls.websiteContent.details(articleId), newsItem) renderWithProviders( - , + , { user }, ) diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx index 5f71d4cd2f..8c5db3115f 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx @@ -6,6 +6,7 @@ import { ButtonLink } from "@mitodl/smoot-design" import { useWebsiteContentCreate, useWebsiteContentPartialUpdate, + useMediaUpload, } from "api/hooks/website_content" import { Spacer } from "../../vendor/components/tiptap-ui-primitive/spacer" import { WebsiteContentEditor } from "../../core/WebsiteContentEditor" @@ -23,9 +24,9 @@ const extractNewsExtraFields = (content: { } interface NewsEditorProps { - onSave?: (article: WebsiteContent) => void + onSave?: (savedContent: WebsiteContent) => void readOnly?: boolean - article?: WebsiteContent + newsItem?: WebsiteContent } /** @@ -33,14 +34,15 @@ interface NewsEditorProps { * Owns its own save mutations (websiteContent API) and passes them to * WebsiteContentEditor — keeping the generic shell decoupled from any specific API. */ -const NewsEditor = ({ onSave, readOnly, article }: NewsEditorProps) => { - // News content type uses the websiteContent (articles) API. +const NewsEditor = ({ onSave, readOnly, newsItem }: NewsEditorProps) => { + // News content type uses the websiteContent API. // A different content type would call different hooks here. const createMutation = useWebsiteContentCreate() const updateMutation = useWebsiteContentPartialUpdate() + const uploadImage = useMediaUpload() - const editUrl = article - ? `/website_content/news/${article.is_published ? article.slug : article.id}/edit` + const editUrl = newsItem + ? `/website_content/news/${newsItem.is_published ? newsItem.slug : newsItem.id}/edit` : "/website_content/news/new" const toolbarSlot = readOnly ? ( @@ -66,9 +68,10 @@ const NewsEditor = ({ onSave, readOnly, article }: NewsEditorProps) => { toolbarSlot={toolbarSlot} extractExtraFields={extractNewsExtraFields} saveMutations={{ create: createMutation, update: updateMutation }} + uploadImage={uploadImage} onSave={onSave} readOnly={readOnly} - article={article} + article={newsItem} /> ) } diff --git a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx index d032ca510e..537885262b 100644 --- a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx @@ -14,7 +14,6 @@ import { } from "ol-components" import { Alert, Button } from "@mitodl/smoot-design" import { useUserHasPermission, Permission } from "api/hooks/user" -import { useMediaUpload } from "api/hooks/website_content" import dynamic from "next/dynamic" import { Toolbar } from "../vendor/components/tiptap-ui-primitive/toolbar" @@ -78,6 +77,18 @@ export type UploadHandler = ( abortSignal?: AbortSignal, ) => Promise +/** + * The minimal interface expected from a media upload mutation. + * Matches the shape returned by `useMediaUpload` from `api/hooks/website_content`, + * but callers may supply any compatible implementation. + */ +export interface MediaUpload { + mutateAsync: (data: { file: File }) => Promise<{ url?: string }> + setNextProgressCallback: ( + callback: ((percent: number) => void) | undefined, + ) => void +} + /** * The data shape sent to the create/update API. * `[key: string]: unknown` allows per-type extra fields (e.g. author_name). @@ -158,6 +169,12 @@ export interface WebsiteContentEditorProps { * WebsiteContentEditor stays decoupled from any specific API endpoint. */ saveMutations: SaveMutations + /** + * Upload mutation provided by the content-type wrapper. + * Pass the return value of `useMediaUpload()` (or a compatible implementation) + * so WebsiteContentEditor stays decoupled from any specific upload endpoint. + */ + uploadImage: MediaUpload onSave?: (article: WebsiteContent) => void readOnly?: boolean article?: WebsiteContent @@ -173,6 +190,7 @@ const WebsiteContentEditor = ({ className, extractExtraFields, saveMutations, + uploadImage, onSave, readOnly, article, @@ -193,7 +211,6 @@ const WebsiteContentEditor = ({ const isPending = createMutation.isPending || updateMutation.isPending const saveError = createMutation.error || updateMutation.error - const uploadImage = useMediaUpload() // Keep a ref so the stable uploadHandler callback always calls the latest mutation. const uploadImageRef = useRef(uploadImage) uploadImageRef.current = uploadImage From 23c604bafbd27363cf9271c01efb0ad7e9d8092e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 12:47:03 +0000 Subject: [PATCH 13/17] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- news_events/etl/articles_news.py | 1 - website_content/api.py | 2 -- website_content/api_test.py | 1 - website_content/factories.py | 1 - website_content/models.py | 1 - website_content/models_test.py | 1 - website_content/views.py | 2 -- 7 files changed, 9 deletions(-) diff --git a/news_events/etl/articles_news.py b/news_events/etl/articles_news.py index c4d3204b6a..219947cb31 100644 --- a/news_events/etl/articles_news.py +++ b/news_events/etl/articles_news.py @@ -4,7 +4,6 @@ from news_events.constants import FeedType from news_events.etl import loaders - from website_content.constants import WebsiteContentType from website_content.models import WebsiteContent diff --git a/website_content/api.py b/website_content/api.py index 7110e3c568..0fdfe4f0a9 100644 --- a/website_content/api.py +++ b/website_content/api.py @@ -2,9 +2,7 @@ import logging - from website_content.constants import WebsiteContentType - from website_content.hooks import get_plugin_manager from website_content.tasks import ( PURGE_TIMEOUT_SECONDS, diff --git a/website_content/api_test.py b/website_content/api_test.py index 305d88a64b..5ff5398be7 100644 --- a/website_content/api_test.py +++ b/website_content/api_test.py @@ -2,7 +2,6 @@ import pytest - from website_content.api import purge_content_on_save from website_content.constants import WebsiteContentType from website_content.factories import WebsiteContentFactory diff --git a/website_content/factories.py b/website_content/factories.py index 6d6859cff3..93c478d4b8 100644 --- a/website_content/factories.py +++ b/website_content/factories.py @@ -4,7 +4,6 @@ from factory.django import DjangoModelFactory from website_content import models - from website_content.constants import WebsiteContentType diff --git a/website_content/models.py b/website_content/models.py index 9c8394ef27..2ad3198582 100644 --- a/website_content/models.py +++ b/website_content/models.py @@ -6,7 +6,6 @@ from django.utils.text import slugify from main.models import TimestampedModel - from website_content.constants import WebsiteContentType from website_content.utils import website_content_image_upload_uri diff --git a/website_content/models_test.py b/website_content/models_test.py index 5866f52227..7610432994 100644 --- a/website_content/models_test.py +++ b/website_content/models_test.py @@ -5,7 +5,6 @@ import pytest from django.contrib.auth import get_user_model - from website_content.constants import WebsiteContentType from website_content.models import WebsiteContent diff --git a/website_content/views.py b/website_content/views.py index 85d34329f1..fb8bce5e86 100644 --- a/website_content/views.py +++ b/website_content/views.py @@ -1,9 +1,7 @@ from django.conf import settings from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator - from django_filters.rest_framework import DjangoFilterBackend - from drf_spectacular.utils import ( OpenApiParameter, OpenApiResponse, From 35f6bf906fa6eeb75b1d581bff6fead7605f4a5b Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Fri, 22 May 2026 16:09:28 -0400 Subject: [PATCH 14/17] Unify TipTap byline + banner; move per-type styling to wrappers Make the byline and article banner render identically in edit and read-only modes, and move per-content-type styling out of the generic nodes into the content-type wrappers. Byline: - One `byline` node + one component (ByLineInfoBarContent) in every mode for both content types. Drop NullBylineViewer, the bylineViewer prop, and the in-banner reconstruction (ByLineInfoBarInBanner); this restores the share button in article read-only. - The node ships a default (news bar) look plus stable hook classes (byline-info-bar / byline-info-bar__separator). ArticleEditor customizes via styled(WebsiteContentEditor), so the node carries no content-type branches. Banner: - ArticleBannerViewer now reuses the editor's ArticleBannerWrapper (via ReactNodeViewContentProvider), mirroring news's BannerViewer/BannerWrapper, so the breadcrumb bar is identical in edit and read-only. Editor shell: - WebsiteContentEditor forwards className to its root container and drops the contentType and backgroundColor props; content types theme it via styled(WebsiteContentEditor). The article's gray page now applies in edit mode too (transparent content card), matching the published view. - Remove the :nth-child(2) /news spacing regression and dead Container code. Tests: article read-only byline+share regression test (explicit byline node) and an ArticleEditor happydom smoke test for the live banner node view. Co-Authored-By: Claude Opus 4.7 --- .../TiptapEditor/NewsViewer.test.tsx | 34 +++++++++++ .../TiptapEditor/TiptapEditor.tsx | 37 +++--------- .../article/ArticleEditor.happydom.test.tsx | 56 +++++++++++++++++ .../contentTypes/article/ArticleEditor.tsx | 26 ++++++-- .../core/WebsiteContentEditor.tsx | 29 +++------ .../node/Banner/ArticleBannerNode.tsx | 53 +++++----------- .../node/ByLineInfoBar/ByLineInfoBar.tsx | 12 +++- .../ByLineInfoBar/ByLineInfoBarInBanner.tsx | 60 ------------------- .../ByLineInfoBar/ByLineInfoBarViewer.tsx | 19 ------ 9 files changed, 153 insertions(+), 173 deletions(-) create mode 100644 frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx delete mode 100644 frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarInBanner.tsx diff --git a/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx index 92c13d5b9a..abf5b69a07 100644 --- a/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/NewsViewer.test.tsx @@ -2,6 +2,7 @@ import React from "react" import { screen, renderWithProviders, setMockResponse } from "@/test-utils" import { factories, urls } from "api/test-utils" import { NewsEditor } from "./contentTypes/news/NewsEditor" +import { ArticleEditor } from "./contentTypes/article/ArticleEditor" describe("NewsViewer", () => { test("renders content", async () => { @@ -88,6 +89,39 @@ describe("NewsViewer", () => { await screen.findByText(`By ${authorName}`) }) + test("article read-only renders the byline with a share button", async () => { + const user = factories.user.user({ + is_authenticated: true, + is_article_editor: true, + }) + setMockResponse.get(urls.userMe.get(), user) + const authorName = `${user.first_name} ${user.last_name}` + const article = factories.websiteContent.websiteContent({ + author_name: authorName, + content: { + type: "doc", + content: [ + { + type: "banner", + content: [ + { type: "heading", attrs: { level: 1 }, content: [] }, + { type: "paragraph", content: [] }, + ], + }, + { type: "byline" }, + { type: "paragraph", content: [] }, + ], + }, + }) + + renderWithProviders() + + await screen.findByText(`By ${authorName}`) + expect( + screen.getByRole("button", { name: /share this article/i }), + ).toBeInTheDocument() + }) + test("renders headings levels 1-6", async () => { const user = factories.user.user({ is_authenticated: true, diff --git a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx index f5d70d1248..a8cf99cc3f 100644 --- a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx @@ -56,12 +56,10 @@ import { LearningResourceButton } from "./extensions/ui/LearningResource/Learnin import { LearningResourceCardViewer } from "./extensions/node/LearningResource/LearningResourceNode" import { MediaEmbedViewer } from "./extensions/node/MediaEmbed/MediaEmbedViewer" -const Container = styled.div<{ - readOnly: boolean -}>(({ theme, readOnly }) => ({ +const Container = styled.div(({ theme }) => ({ maxWidth: "890px", minHeight: "calc(100vh - 350px)", - backgroundColor: theme.custom.colors.white, + backgroundColor: "transparent", borderRadius: "10px", margin: "0 auto", @@ -71,20 +69,6 @@ const Container = styled.div<{ padding: "0 16px", }, }, - ...(readOnly - ? { - backgroundColor: "transparent", - ".tiptap.ProseMirror.simple-editor": { - padding: "0 24px", - [theme.breakpoints.down("sm")]: { - padding: "0 16px", - }, - }, - } - : {}), - "&& .tiptap.ProseMirror.tiptap-viewer > :nth-child(2)": { - paddingTop: "36px", - }, "&& .tiptap.ProseMirror, && .tiptap-viewer": { fontFamily: theme.typography.fontFamily, color: theme.custom.colors.darkGray2, @@ -275,17 +259,12 @@ interface TiptapEditorProps { editor: Editor readOnly?: boolean fullWidth?: boolean - className?: string } -const TiptapEditor = ({ editor, className }: TiptapEditorProps) => { +const TiptapEditor = ({ editor }: TiptapEditorProps) => { return ( - - + + ) } @@ -294,15 +273,13 @@ const TipTapViewer = ({ content, extensions, bannerViewer = BannerViewer, - bylineViewer = ByLineInfoBarViewer, }: { content: JSONContent extensions: Array bannerViewer?: typeof BannerViewer - bylineViewer?: typeof ByLineInfoBarViewer }) => { return ( - +
    {renderToReactElement({ extensions, @@ -318,7 +295,7 @@ const TipTapViewer = ({ */ nodeMapping: { banner: bannerViewer, - byline: bylineViewer, + byline: ByLineInfoBarViewer, divider: DividerViewer, imageWithCaption: ImageWithCaptionViewer, learningResource: LearningResourceCardViewer, diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx new file mode 100644 index 0000000000..7f1a1d6797 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx @@ -0,0 +1,56 @@ +/** + * @jest-environment @happy-dom/jest-environment + * + * Using the Happy DOM environment as the editor accesses DOM APIs and uses + * contenteditable elements not supported by JSDOM, the default Jest environment. + */ +import React from "react" +import { screen } from "@testing-library/react" +import { setMockResponse, factories, urls } from "api/test-utils" +import type { JSONContent } from "@tiptap/react" +import { ArticleEditor } from "./ArticleEditor" +import { renderWithProviders } from "@/test-utils" + +const content: JSONContent = { + type: "doc", + content: [ + { + type: "banner", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Article Title" }], + }, + { type: "paragraph", content: [] }, + ], + }, + { type: "byline" }, + { type: "paragraph", content: [] }, + ], +} + +const renderArticleEditor = () => { + const user = factories.user.user({ + is_authenticated: true, + is_article_editor: true, + }) + setMockResponse.get(urls.userMe.get(), user) + const article = factories.websiteContent.websiteContent({ content }) + renderWithProviders(, { user }) +} + +describe("ArticleEditor", () => { + test("mounts the live editor with an editable banner heading", async () => { + renderArticleEditor() + + await screen.findByTestId("editor") + await screen.findByRole("heading", { level: 1, name: "Article Title" }) + }) + + test("renders the article breadcrumb bar in edit mode", async () => { + renderArticleEditor() + + await screen.findByText("MIT Learn Articles") + }) +}) diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx index 3f7ec7f3a4..1fceff0080 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx @@ -1,6 +1,7 @@ "use client" import React from "react" +import styled from "@emotion/styled" import type { WebsiteContent } from "api/v1" import { ButtonLink } from "@mitodl/smoot-design" import { @@ -16,7 +17,26 @@ import { } from "./articleExtensions" import { ArticleBannerViewer } from "../../extensions/node/Banner/ArticleBannerNode" -const NullBylineViewer = () => <> +/** + * Article-specific byline look: merged into the white banner (no bar chrome) with + * a "·" separator. Styling lives here (not in the byline node) by targeting the + * node's published hook classes, so the node stays content-type-agnostic. + */ +const StyledWebsiteContentEditor = styled(WebsiteContentEditor)( + ({ theme }) => ({ + // Article sits on a gray page; the banner + byline render as white islands. + backgroundColor: theme.custom.colors.lightGray1, + ".byline-info-bar": { + boxShadow: "none", + border: "none", + marginBottom: "40px", + paddingTop: 0, + }, + ".byline-info-bar__separator::before": { + content: '"·"', + }, + }), +) // Article-specific: extract author name from the byline node const extractArticleExtraFields = (content: { @@ -74,7 +94,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { ) : null return ( - { onSave={onSave} readOnly={readOnly} article={article} - backgroundColor="lightGray1" bannerViewer={ArticleBannerViewer} - bylineViewer={NullBylineViewer} /> ) } diff --git a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx index 537885262b..b8d1832918 100644 --- a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx @@ -19,7 +19,6 @@ import dynamic from "next/dynamic" import { Toolbar } from "../vendor/components/tiptap-ui-primitive/toolbar" import { TiptapEditor, MainToolbarContent, TipTapViewer } from "../TiptapEditor" import { BannerViewer } from "../extensions/node/Banner/BannerNode" -import { ByLineInfoBarViewer } from "../extensions/node/ByLineInfoBar/ByLineInfoBarViewer" import { handleImageUpload } from "../vendor/lib/tiptap-utils" import { useSchema } from "../useSchema" import { WebsiteContentProvider } from "../WebsiteContentContext" @@ -36,15 +35,10 @@ const TOOLBAR_HEIGHT = 43 const ViewContainer = styled.div<{ toolbarVisible: boolean - backgroundColor?: string - readOnly?: boolean -}>(({ toolbarVisible, backgroundColor, readOnly, theme }) => ({ +}>(({ toolbarVisible, theme }) => ({ width: "100vw", marginTop: toolbarVisible ? TOOLBAR_HEIGHT : 0, - backgroundColor: - readOnly && backgroundColor - ? theme.custom.colors[backgroundColor as keyof typeof theme.custom.colors] - : theme.custom.colors.white, + backgroundColor: theme.custom.colors.white, })) const StyledToolbar = styled(Toolbar)(({ theme }) => ({ @@ -157,7 +151,11 @@ export interface WebsiteContentEditorProps { * - In edit mode this slot is appended after the Publish button. */ toolbarSlot?: React.ReactNode - /** Optional CSS class forwarded to the editor container for per-type theming. */ + /** + * Optional CSS class applied to the editor root container (covers both edit + * and read-only). Used by content-type wrappers via `styled(WebsiteContentEditor)` + * to theme nodes through their hook classes. + */ className?: string /** * Extract additional fields to include in the save payload. @@ -178,9 +176,7 @@ export interface WebsiteContentEditorProps { onSave?: (article: WebsiteContent) => void readOnly?: boolean article?: WebsiteContent - backgroundColor?: string bannerViewer?: typeof BannerViewer - bylineViewer?: typeof ByLineInfoBarViewer } const WebsiteContentEditor = ({ @@ -195,8 +191,6 @@ const WebsiteContentEditor = ({ readOnly, article, bannerViewer, - bylineViewer, - backgroundColor, }: WebsiteContentEditorProps) => { const [isPublishing, setIsPublishing] = useState(false) const [uploadError, setUploadError] = useState(null) @@ -379,11 +373,7 @@ const WebsiteContentEditor = ({ const resourceIds = extractLearningResourceIds(content) return ( - + @@ -472,11 +462,10 @@ const WebsiteContentEditor = ({ content={content} extensions={extensions} bannerViewer={bannerViewer} - bylineViewer={bylineViewer ?? ByLineInfoBarViewer} /> ) : ( - + )} diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx index 44222efdf5..e504ba60ac 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx @@ -36,7 +36,6 @@ const StyledNodeViewWrapper = styled(NodeViewWrapper)({ marginLeft: "-50vw", marginRight: "-50vw", width: "100vw", - borderBottom: "1px solid #DDE1E6", }, }) @@ -115,8 +114,6 @@ const StyledNodeViewContent = styled(NodeViewContent)(({ theme }) => ({ }, })) -import { ByLineInBannerViewer } from "../ByLineInfoBar/ByLineInfoBarViewer" - const ArticleBannerViewer = ({ children, node, @@ -126,54 +123,34 @@ const ArticleBannerViewer = ({ }) => { return ( - - <> - - - - - - - - - - - - - - - - + ) } const ArticleBannerWrapper = (props?: { node?: ProseMirrorNode }) => { return ( - + + + + + + - - + ) } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx index 3227dafd74..84c2b6f6a9 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBar.tsx @@ -56,6 +56,12 @@ const InfoText = styled.span(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, })) +// Separator between read time and date. Default "-"; content-type owners may +// override the glyph by targeting the "byline-info-bar__separator" hook class. +const Separator = styled(InfoText)({ + "&::before": { content: '"-"' }, +}) + const AuthorInput = styled(TextField)(({ theme }) => ({ "& .MuiInputBase-root": { ...theme.typography.body2, @@ -111,7 +117,7 @@ export const ByLineInfoBarContent = ({ const displayAuthorName = authorName || "" return ( - + By {displayAuthorName} )} {readTime ? {readTime} min read : null} - {readTime && publishedDate ? - : null} + {readTime && publishedDate ? ( + + ) : null} {publishedDate ? new Date(publishedDate).toLocaleDateString("en-US", { diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarInBanner.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarInBanner.tsx deleted file mode 100644 index 2b9d9b7f72..0000000000 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarInBanner.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react" -import styled from "@emotion/styled" -import type { JSONContent } from "@tiptap/core" -import { calculateReadTime } from "../../utils" - -const Wrapper = styled.div({ - display: "flex", - alignItems: "center", - gap: "16px", - marginTop: "24px", -}) - -const NameText = styled.span(({ theme }) => ({ - ...theme.typography.body2, - color: theme.custom.colors.darkGray2, -})) - -const InfoText = styled.span(({ theme }) => ({ - ...theme.typography.body2, - color: theme.custom.colors.darkGray2, - opacity: 0.7, -})) - -interface ArticleByLineInBannerProps { - authorName?: string | null - publishedDate?: string | null - content?: JSONContent | null -} - -const ByLineInBanner = ({ - authorName, - publishedDate, - content, -}: ArticleByLineInBannerProps) => { - const readTime = calculateReadTime(content) - const displayAuthorName = authorName || "" - - if (!displayAuthorName && !readTime && !publishedDate) { - return null - } - - return ( - - {displayAuthorName && By {displayAuthorName}} - {readTime ? {readTime} min read : null} - {readTime && publishedDate ? · : null} - - {publishedDate - ? new Date(publishedDate).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }) - : "Draft"} - - - ) -} - -export { ByLineInBanner } diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarViewer.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarViewer.tsx index b23d6ba198..85468e89b0 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarViewer.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ByLineInfoBar/ByLineInfoBarViewer.tsx @@ -1,7 +1,6 @@ import React from "react" import { useWebsiteContent } from "../../../WebsiteContentContext" import { ByLineInfoBarContent } from "./ByLineInfoBar" -import { ByLineInBanner } from "./ByLineInfoBarInBanner" const ByLineInfoBarViewer = () => { const article = useWebsiteContent() @@ -21,21 +20,3 @@ const ByLineInfoBarViewer = () => { } export { ByLineInfoBarViewer } - -const ByLineInBannerViewer = () => { - const article = useWebsiteContent() - - const publishedDate = article?.is_published ? article?.created_on : null - const content = article?.content - const authorName = article?.author_name ?? null - - return ( - - ) -} - -export { ByLineInBannerViewer } From 43ec52bd14dd989a9e07e79ccc95630418cdd547 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Mon, 25 May 2026 12:29:45 +0500 Subject: [PATCH 15/17] address feedback of Chris --- .../WebsiteContent/WebsiteContentDetail.tsx | 33 +++++++---- .../WebsiteContentDraftListingPage.tsx | 9 +-- .../(site)/articles/[slugOrId]/draft/page.tsx | 2 +- .../app/(site)/articles/[slugOrId]/page.tsx | 54 ++++++------------ .../app/(site)/news/[slugOrId]/draft/page.tsx | 2 +- .../src/app/(site)/news/[slugOrId]/page.tsx | 54 ++++++------------ frontends/main/src/common/website_content.ts | 32 +++++++++++ .../TiptapEditor/TiptapEditor.tsx | 21 +++++-- .../TiptapEditor/WebsiteContentContext.tsx | 4 +- .../contentTypes/article/ArticleEditor.tsx | 13 +++-- .../contentTypes/news/NewsEditor.tsx | 2 +- .../core/WebsiteContentEditor.tsx | 56 ++++++++++--------- .../node/Banner/ArticleBannerNode.tsx | 2 +- 13 files changed, 152 insertions(+), 132 deletions(-) create mode 100644 frontends/main/src/common/website_content.ts diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx index 0ae6cbf476..fea109d342 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDetail.tsx @@ -1,6 +1,7 @@ "use client" import React from "react" +import type { WebsiteContent } from "api/v1" import { useWebsiteContentDetailRetrieve } from "api/hooks/website_content" import { LoadingSpinner, styled } from "ol-components" import { NewsEditor } from "@/page-components/TiptapEditor/contentTypes/news/NewsEditor" @@ -8,6 +9,16 @@ import { ArticleEditor } from "@/page-components/TiptapEditor/contentTypes/artic import { LearningResourceProvider } from "@/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceDataProvider" import { notFound } from "next/navigation" +const DETAIL_EDITORS: Record< + string, + React.ComponentType<{ contentItem: WebsiteContent }> +> = { + article: ({ contentItem }) => ( + + ), + news: ({ contentItem }) => , +} + const PageContainer = styled.div({ display: "flex", height: "100%", @@ -22,14 +33,14 @@ const Spinner = styled(LoadingSpinner)({ }) const WebsiteContentDetail = ({ - articleId, + contentId, learningResourceIds = [], }: { - articleId: string + contentId: string learningResourceIds?: number[] }) => { - const { data: article, isLoading } = - useWebsiteContentDetailRetrieve(articleId) + const { data: contentItem, isLoading } = + useWebsiteContentDetailRetrieve(contentId) if (isLoading) { return ( @@ -38,18 +49,20 @@ const WebsiteContentDetail = ({ ) } - if (!article) { + if (!contentItem) { + return notFound() + } + + const contentType = contentItem.content_type ?? "" + const Editor = DETAIL_EDITORS[contentType] + if (!Editor) { return notFound() } return ( - {article.content_type === "article" ? ( - - ) : ( - - )} + ) diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx index d0afd041df..5a08eb4841 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentDraftListingPage.tsx @@ -128,19 +128,14 @@ const WebsiteContentDraftListingPage: React.FC< > = ({ contentType }) => { const [page, setPage] = useState(1) const scrollRef = useRef(null) - const type = contentType || "article" + const type = contentType || "news" const label = CONTENT_TYPE_LABELS[type] ?? type const listParams: WebsiteContentListRequest = { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, draft: true, - ...(contentType - ? { - content_type: - contentType as WebsiteContentListRequest["content_type"], - } - : {}), + content_type: type as WebsiteContentListRequest["content_type"], } const { data: contentItems, isLoading: isLoadingContentItems } = diff --git a/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx b/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx index 5cc1f16b54..64a79f3363 100644 --- a/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx +++ b/frontends/main/src/app/(site)/articles/[slugOrId]/draft/page.tsx @@ -17,7 +17,7 @@ const Page: React.FC> = async ( return ( - + ) } diff --git a/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx b/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx index d898aef160..919d303442 100644 --- a/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx +++ b/frontends/main/src/app/(site)/articles/[slugOrId]/page.tsx @@ -6,33 +6,11 @@ import { getQueryClient } from "@/app/getQueryClient" import { learningResourceQueries } from "api/hooks/learningResources" import { extractLearningResourceIds } from "@/page-components/TiptapEditor/extensions/utils" import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata" -import type { WebsiteContent } from "api/v1" -import type { JSONContent } from "@tiptap/react" - -// Extracts the banner subheading paragraph at known location -const extractArticleDescription = ( - article: WebsiteContent, -): string | undefined => { - const banner = article.content?.content?.[0] - const subheading = banner?.content?.[1] - const textNode = subheading?.content?.[0] - return textNode?.text -} - -const extractImageMetadata = ( - article: WebsiteContent, -): { src: string; alt: string } | null => { - const imageWithCaption = article.content?.content?.find( - (node: JSONContent) => node.type === "imageWithCaption", - ) - if (!imageWithCaption) { - return null - } - return { - src: imageWithCaption.attrs.src, - alt: imageWithCaption.attrs.caption || imageWithCaption.attrs.alt, - } -} +import { + extractImageMetadata, + extractWebsiteContentDescription, +} from "@/common/website_content" +import { notFound } from "next/navigation" export const generateMetadata = async ( props: PageProps<"/articles/[slugOrId]">, @@ -43,15 +21,18 @@ export const generateMetadata = async ( const queryClient = getQueryClient() return safeGenerateMetadata(async () => { - const article = await queryClient.fetchQuery( + const content = await queryClient.fetchQuery( websiteContentQueries.websiteContentDetailRetrieve(slugOrId), ) + if (content.content_type !== "article") { + return notFound() + } - const description = extractArticleDescription(article) - const leadImage = extractImageMetadata(article) + const description = extractWebsiteContentDescription(content) + const leadImage = extractImageMetadata(content) return standardizeMetadata({ - title: article.title, + title: content.title, description, image: leadImage?.src, imageAlt: leadImage?.alt, @@ -70,11 +51,12 @@ const Page: React.FC> = async (props) => { const queryKey = websiteContentQueries.websiteContentDetailRetrieve(slugOrId).queryKey - const cacheData = queryClient.getQueryData(queryKey) + const content = queryClient.getQueryData(queryKey) + if (!content || content.content_type !== "article") { + return notFound() + } - const learningResourceIds = cacheData?.content - ? extractLearningResourceIds(cacheData.content) - : [] + const learningResourceIds = extractLearningResourceIds(content.content) if (learningResourceIds.length > 0) { const bulkQuery = learningResourceQueries.list({ @@ -86,7 +68,7 @@ const Page: React.FC> = async (props) => { return ( diff --git a/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx b/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx index 334ad8d68e..5e67a9bb04 100644 --- a/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx +++ b/frontends/main/src/app/(site)/news/[slugOrId]/draft/page.tsx @@ -18,7 +18,7 @@ const Page: React.FC> = async (props) => { return ( - + ) } diff --git a/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx b/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx index 7d6639bb60..b668bfbaf5 100644 --- a/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx +++ b/frontends/main/src/app/(site)/news/[slugOrId]/page.tsx @@ -6,33 +6,11 @@ import { getQueryClient } from "@/app/getQueryClient" import { learningResourceQueries } from "api/hooks/learningResources" import { extractLearningResourceIds } from "@/page-components/TiptapEditor/extensions/utils" import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata" -import type { WebsiteContent } from "api/v1" -import type { JSONContent } from "@tiptap/react" - -// Extracts the banner subheading paragraph at known location -const extractWebsiteContentDescription = ( - article: WebsiteContent, -): string | undefined => { - const banner = article.content?.content?.[0] - const subheading = banner?.content?.[1] - const textNode = subheading?.content?.[0] - return textNode?.text -} - -const extractImageMetadata = ( - news: WebsiteContent, -): { src: string; alt: string } | null => { - const imageWithCaption = news.content?.content?.find( - (node: JSONContent) => node.type === "imageWithCaption", - ) - if (!imageWithCaption) { - return null - } - return { - src: imageWithCaption.attrs.src, - alt: imageWithCaption.attrs.caption || imageWithCaption.attrs.alt, - } -} +import { + extractImageMetadata, + extractWebsiteContentDescription, +} from "@/common/website_content" +import { notFound } from "next/navigation" export const generateMetadata = async ( props: PageProps<"/news/[slugOrId]">, @@ -44,15 +22,18 @@ export const generateMetadata = async ( const queryClient = getQueryClient() return safeGenerateMetadata(async () => { - const news = await queryClient.fetchQuery( + const content = await queryClient.fetchQuery( websiteContentQueries.websiteContentDetailRetrieve(slugOrId), ) + if (content.content_type !== "news") { + return notFound() + } - const description = extractWebsiteContentDescription(news) - const leadImage = extractImageMetadata(news) + const description = extractWebsiteContentDescription(content) + const leadImage = extractImageMetadata(content) return standardizeMetadata({ - title: news.title, + title: content.title, description, image: leadImage?.src, imageAlt: leadImage?.alt, @@ -71,11 +52,12 @@ const Page: React.FC> = async (props) => { const queryKey = websiteContentQueries.websiteContentDetailRetrieve(slugOrId).queryKey - const cacheData = queryClient.getQueryData(queryKey) + const content = queryClient.getQueryData(queryKey) + if (!content || content.content_type !== "news") { + return notFound() + } - const learningResourceIds = cacheData?.content - ? extractLearningResourceIds(cacheData.content) - : [] + const learningResourceIds = extractLearningResourceIds(content.content) if (learningResourceIds.length > 0) { const bulkQuery = learningResourceQueries.list({ @@ -87,7 +69,7 @@ const Page: React.FC> = async (props) => { return ( diff --git a/frontends/main/src/common/website_content.ts b/frontends/main/src/common/website_content.ts new file mode 100644 index 0000000000..028e4de043 --- /dev/null +++ b/frontends/main/src/common/website_content.ts @@ -0,0 +1,32 @@ +import type { JSONContent } from "@tiptap/react" +import type { WebsiteContent } from "api/v1" + +export const extractWebsiteContentDescription = ( + content: WebsiteContent, +): string | undefined => { + const banner = content.content?.content?.[0] + const subheading = banner?.content?.[1] + const textNode = subheading?.content?.[0] + return textNode?.text +} + +export const extractImageMetadata = ( + content: WebsiteContent, +): { src: string; alt: string } | null => { + const imageWithCaption = content.content?.content?.find( + (node: JSONContent) => node.type === "imageWithCaption", + ) + + const attrs = imageWithCaption?.attrs as + | { src?: string; alt?: string; caption?: string } + | undefined + + if (!attrs?.src) { + return null + } + + return { + src: attrs.src, + alt: attrs.caption || attrs.alt || "", + } +} diff --git a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx index f5d70d1248..d689f3667c 100644 --- a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx @@ -58,7 +58,8 @@ import { MediaEmbedViewer } from "./extensions/node/MediaEmbed/MediaEmbedViewer" const Container = styled.div<{ readOnly: boolean -}>(({ theme, readOnly }) => ({ + applyViewerTopSpacing?: boolean +}>(({ theme, readOnly, applyViewerTopSpacing }) => ({ maxWidth: "890px", minHeight: "calc(100vh - 350px)", backgroundColor: theme.custom.colors.white, @@ -82,9 +83,13 @@ const Container = styled.div<{ }, } : {}), - "&& .tiptap.ProseMirror.tiptap-viewer > :nth-child(2)": { - paddingTop: "36px", - }, + ...(applyViewerTopSpacing + ? { + "&& .tiptap.ProseMirror.tiptap-viewer > :nth-child(2)": { + paddingTop: "36px", + }, + } + : {}), "&& .tiptap.ProseMirror, && .tiptap-viewer": { fontFamily: theme.typography.fontFamily, color: theme.custom.colors.darkGray2, @@ -295,14 +300,20 @@ const TipTapViewer = ({ extensions, bannerViewer = BannerViewer, bylineViewer = ByLineInfoBarViewer, + applyViewerTopSpacing = false, }: { content: JSONContent extensions: Array bannerViewer?: typeof BannerViewer bylineViewer?: typeof ByLineInfoBarViewer + applyViewerTopSpacing?: boolean }) => { return ( - +
    {renderToReactElement({ extensions, diff --git a/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx b/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx index bfb8ba4347..9c71fd4eb4 100644 --- a/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx +++ b/frontends/main/src/page-components/TiptapEditor/WebsiteContentContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext } from "react" import type { WebsiteContent } from "api/v1" interface WebsiteContentContextValue { - article?: WebsiteContent + contentItem?: WebsiteContent } const WebsiteContentContext = createContext({}) @@ -10,5 +10,5 @@ const WebsiteContentContext = createContext({}) export const WebsiteContentProvider = WebsiteContentContext.Provider export function useWebsiteContent() { - return useContext(WebsiteContentContext).article + return useContext(WebsiteContentContext).contentItem } diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx index 3f7ec7f3a4..3a617608f2 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx @@ -39,16 +39,16 @@ interface ArticleEditorProps { * Editor shell configured for the article content type (served under /articles). * Owns its own save mutations so WebsiteContentEditor stays API-agnostic. * - * Currently uses the same websiteContent API as the news editor. When /articles - * gets its own Django model and viewset, swap in the new hooks here: + * Currently uses the same websiteContent API as the news editor. If /articles + * later needs a dedicated endpoint, swap in the new hooks here: * - * const createMutation = useUserArticleCreate() // future hook - * const updateMutation = useUserArticlePartialUpdate() + * const createMutation = useArticleCreate() // future hook + * const updateMutation = useArticlePartialUpdate() * * WebsiteContentEditor does not need to change at all. */ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { - // Swap these two lines when a dedicated UserArticle API exists. + // Swap these hooks when a dedicated article API exists. const createMutation = useWebsiteContentCreate() const updateMutation = useWebsiteContentPartialUpdate() const uploadImage = useMediaUpload() @@ -83,8 +83,9 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { uploadImage={uploadImage} onSave={onSave} readOnly={readOnly} - article={article} + contentItem={article} backgroundColor="lightGray1" + applyViewerTopSpacing bannerViewer={ArticleBannerViewer} bylineViewer={NullBylineViewer} /> diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx index 8c5db3115f..ec04e5be3d 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/news/NewsEditor.tsx @@ -71,7 +71,7 @@ const NewsEditor = ({ onSave, readOnly, newsItem }: NewsEditorProps) => { uploadImage={uploadImage} onSave={onSave} readOnly={readOnly} - article={newsItem} + contentItem={newsItem} /> ) } diff --git a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx index 537885262b..eead8b07a6 100644 --- a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx @@ -110,9 +110,9 @@ export interface SavePayload { * const update = useWebsiteContentPartialUpdate() * * - * A future user-article type could use a completely different API hook: - * const create = useUserArticleCreate() // future hook - * const update = useUserArticlePartialUpdate() + * A future content type could use a different API hook: + * const create = useSpecializedContentCreate() // future hook + * const update = useSpecializedContentPartialUpdate() * */ export interface SaveMutations { @@ -149,7 +149,7 @@ export interface WebsiteContentEditorProps { * Must be a stable reference (module-level function or useCallback). */ createExtensions: CreateExtensionsFn - /** Initial document structure when no article is provided. */ + /** Initial document structure when no content item is provided. */ initialDoc: JSONContent /** * Content-type-specific toolbar content. @@ -175,12 +175,13 @@ export interface WebsiteContentEditorProps { * so WebsiteContentEditor stays decoupled from any specific upload endpoint. */ uploadImage: MediaUpload - onSave?: (article: WebsiteContent) => void + onSave?: (contentItem: WebsiteContent) => void readOnly?: boolean - article?: WebsiteContent + contentItem?: WebsiteContent backgroundColor?: string bannerViewer?: typeof BannerViewer bylineViewer?: typeof ByLineInfoBarViewer + applyViewerTopSpacing?: boolean } const WebsiteContentEditor = ({ @@ -193,18 +194,19 @@ const WebsiteContentEditor = ({ uploadImage, onSave, readOnly, - article, + contentItem, bannerViewer, bylineViewer, + applyViewerTopSpacing, backgroundColor, }: WebsiteContentEditorProps) => { const [isPublishing, setIsPublishing] = useState(false) const [uploadError, setUploadError] = useState(null) const [resetAttempted, setResetAttempted] = useState(false) const [content, setContent] = useState( - article?.content || initialDoc, + contentItem?.content || initialDoc, ) - const [title, setTitle] = useState(article?.title) + const [title, setTitle] = useState(contentItem?.title) const [touched, setTouched] = useState(false) const { create: createMutation, update: updateMutation } = saveMutations @@ -263,10 +265,10 @@ const WebsiteContentEditor = ({ const handleSave = (publish: boolean) => { if (!title) return const extraFields = extractExtraFields?.(content) ?? {} - if (article) { + if (contentItem) { updateMutation.mutate( { - id: article.id, + id: contentItem.id, title: title.trim(), content, is_published: publish, @@ -321,23 +323,23 @@ const WebsiteContentEditor = ({ extensions, }) - // Sync incoming article changes (e.g., after a refetch) + // Sync incoming content changes (e.g., after a refetch) useEffect(() => { - if (!article || !editor) return + if (!contentItem || !editor) return - if (article.content) { + if (contentItem.content) { const currentContent = editor.getJSON() - if (!contentsMatch(article.content, currentContent)) { - setContent(article.content) + if (!contentsMatch(contentItem.content, currentContent)) { + setContent(contentItem.content) setTouched(true) - editor.commands.setContent(article.content) + editor.commands.setContent(contentItem.content) } } - if (article.title !== undefined) { - setTitle(article.title) + if (contentItem.title !== undefined) { + setTitle(contentItem.title) } - }, [article, editor]) + }, [contentItem, editor]) // Keep title in sync with the h1 heading inside the editor useEffect(() => { @@ -384,7 +386,7 @@ const WebsiteContentEditor = ({ backgroundColor={backgroundColor} readOnly={readOnly} > - + {isArticleEditor ? ( @@ -393,7 +395,7 @@ const WebsiteContentEditor = ({ ) : ( - {!article?.is_published ? ( + {!contentItem?.is_published ? ( - {toolbarSlot} ) ) : null} From fa8104965ad398814e63ff7fb425df4713bc4a85 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Tue, 26 May 2026 18:18:02 +0500 Subject: [PATCH 17/17] address the feedback --- .../WebsiteContent/WebsiteContentEditPage.tsx | 8 ++---- .../TiptapEditor/TiptapEditor.tsx | 20 ++------------- .../article/ArticleEditor.happydom.test.tsx | 2 +- .../contentTypes/article/ArticleEditor.tsx | 2 -- .../core/WebsiteContentEditor.tsx | 25 +++---------------- .../node/Banner/ArticleBannerNode.tsx | 4 +-- 6 files changed, 10 insertions(+), 51 deletions(-) diff --git a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx index b4bee695ed..5d877a5a5c 100644 --- a/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx +++ b/frontends/main/src/app-pages/WebsiteContent/WebsiteContentEditPage.tsx @@ -57,11 +57,7 @@ const WebsiteContentEditPage = ({ type, idOrSlug, }: WebsiteContentEditPageProps) => { - const { - data: article, - isLoading, - isFetching, - } = useWebsiteContentDetailRetrieve(idOrSlug) + const { data: article, isLoading } = useWebsiteContentDetailRetrieve(idOrSlug) const router = useRouter() const Editor = EDITORS[type] @@ -71,7 +67,7 @@ const WebsiteContentEditPage = ({ notFound() } - if (isLoading || isFetching) { + if (isLoading) { return } if (!article) { diff --git a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx index cda972187e..47b7613a3e 100644 --- a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx @@ -58,8 +58,7 @@ import { MediaEmbedViewer } from "./extensions/node/MediaEmbed/MediaEmbedViewer" const Container = styled.div<{ readOnly: boolean - applyViewerTopSpacing?: boolean -}>(({ theme, readOnly, applyViewerTopSpacing }) => ({ +}>(({ theme, readOnly }) => ({ maxWidth: "890px", minHeight: "calc(100vh - 350px)", backgroundColor: "transparent", @@ -84,14 +83,6 @@ const Container = styled.div<{ }, } : {}), - ...(applyViewerTopSpacing - ? { - "&& .tiptap.ProseMirror.tiptap-viewer > :nth-child(2)": { - paddingTop: "36px", - }, - } - : {}), - "&& .tiptap.ProseMirror, && .tiptap-viewer": { fontFamily: theme.typography.fontFamily, color: theme.custom.colors.darkGray2, @@ -296,20 +287,13 @@ const TipTapViewer = ({ content, extensions, bannerViewer = BannerViewer, - applyViewerTopSpacing = false, }: { content: JSONContent extensions: Array bannerViewer?: typeof BannerViewer - bylineViewer?: typeof ByLineInfoBarViewer - applyViewerTopSpacing?: boolean }) => { return ( - +
    {renderToReactElement({ extensions, diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx index 7f1a1d6797..d216f99b41 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.happydom.test.tsx @@ -51,6 +51,6 @@ describe("ArticleEditor", () => { test("renders the article breadcrumb bar in edit mode", async () => { renderArticleEditor() - await screen.findByText("MIT Learn Articles") + await screen.findByText("Articles") }) }) diff --git a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx index f3f91def75..c7ced70591 100644 --- a/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/contentTypes/article/ArticleEditor.tsx @@ -82,8 +82,6 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => { onSave={onSave} readOnly={readOnly} contentItem={article} - backgroundColor="lightGray1" - applyViewerTopSpacing bannerViewer={ArticleBannerViewer} /> ) diff --git a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx index 5e1bf204a7..a6fc8a5ad8 100644 --- a/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/core/WebsiteContentEditor.tsx @@ -19,7 +19,6 @@ import dynamic from "next/dynamic" import { Toolbar } from "../vendor/components/tiptap-ui-primitive/toolbar" import { TiptapEditor, MainToolbarContent, TipTapViewer } from "../TiptapEditor" import { BannerViewer } from "../extensions/node/Banner/BannerNode" -import { ByLineInfoBarViewer } from "../extensions/node/ByLineInfoBar/ByLineInfoBarViewer" import { Spacer } from "../vendor/components/tiptap-ui-primitive/spacer" import { handleImageUpload } from "../vendor/lib/tiptap-utils" import { useSchema } from "../useSchema" @@ -38,15 +37,10 @@ const TOOLBAR_HEIGHT = 43 const ViewContainer = styled.div<{ toolbarVisible: boolean - backgroundColor?: string - readOnly?: boolean -}>(({ toolbarVisible, backgroundColor, readOnly, theme }) => ({ +}>(({ toolbarVisible, theme }) => ({ width: "100vw", marginTop: toolbarVisible ? TOOLBAR_HEIGHT : 0, - backgroundColor: - readOnly && backgroundColor - ? theme.custom.colors[backgroundColor as keyof typeof theme.custom.colors] - : theme.custom.colors.white, + backgroundColor: theme.custom.colors.white, })) const StyledToolbar = styled(Toolbar)(({ theme }) => ({ @@ -180,10 +174,7 @@ export interface WebsiteContentEditorProps { onSave?: (contentItem: WebsiteContent) => void readOnly?: boolean contentItem?: WebsiteContent - backgroundColor?: string bannerViewer?: typeof BannerViewer - bylineViewer?: typeof ByLineInfoBarViewer - applyViewerTopSpacing?: boolean } const WebsiteContentEditor = ({ @@ -198,9 +189,6 @@ const WebsiteContentEditor = ({ readOnly, contentItem, bannerViewer, - bylineViewer, - applyViewerTopSpacing, - backgroundColor, }: WebsiteContentEditorProps) => { const [isPublishing, setIsPublishing] = useState(false) const [uploadError, setUploadError] = useState(null) @@ -407,12 +395,7 @@ const WebsiteContentEditor = ({ ) return ( - + @@ -501,8 +484,6 @@ const WebsiteContentEditor = ({ content={content} extensions={extensions} bannerViewer={bannerViewer} - bylineViewer={bylineViewer} - applyViewerTopSpacing={applyViewerTopSpacing} /> ) : ( diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx index e504ba60ac..d5282d2fc0 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/Banner/ArticleBannerNode.tsx @@ -134,10 +134,10 @@ const ArticleBannerWrapper = (props?: { node?: ProseMirrorNode }) => {