diff --git a/RELEASE.rst b/RELEASE.rst index 17206684bf..e01250e5af 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,26 @@ Release Notes ============= +Version 0.47.13 +--------------- + +- making sure ingestion succeeds even without xml (#2704) +- Ab/hybrid search (#2663) +- replacing underscore with dashes for bootstrap featureflags (#2703) +- add error message for enrollment code issues (#2685) +- Fix hydration error, remove prefetch helper (#2697) +- rename views cache (#2700) +- more dashboard CTA adjustments (#2701) +- Ensure re-indexing and re-embedding operations include all non-course learning resource types (#2695) +- Article editor refactor for reuse and layout updates (#2699) +- feat: incorporating the tiptap in articles CRUD operations (#2693) +- fix(deps): update dependency litellm to v1.79.3 (#2618) +- limit offered by facet to specific offerors (#2692) +- chore(deps): update dependency ruff to v0.14.4 (#2666) +- Avoid n+1 queries on video.playlists serializer field (#2662) +- chore(deps): update nginx docker tag to v1.29.3 (#2667) +- fix(deps): update dependency django to v4.2.26 [security] (#2678) + Version 0.47.12 (Released November 17, 2025) --------------- diff --git a/articles/views.py b/articles/views.py index 4649090d73..3747678050 100644 --- a/articles/views.py +++ b/articles/views.py @@ -8,7 +8,7 @@ from articles.models import Article from articles.serializers import RichTextArticleSerializer from main.constants import VALID_HTTP_METHODS -from main.utils import cache_page_for_all_users, clear_search_cache +from main.utils import cache_page_for_all_users, clear_views_cache # Create your views here. @@ -43,16 +43,16 @@ class ArticleViewSet(viewsets.ModelViewSet): @method_decorator( cache_page_for_all_users( - settings.SEARCH_PAGE_CACHE_DURATION, cache="redis", key_prefix="search" + settings.REDIS_VIEW_CACHE_DURATION, cache="redis", key_prefix="articles" ) ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) def create(self, request, *args, **kwargs): - clear_search_cache() + clear_views_cache() return super().create(request, *args, **kwargs) def destroy(self, request, *args, **kwargs): - clear_search_cache() + clear_views_cache() return super().destroy(request, *args, **kwargs) diff --git a/channels/views.py b/channels/views.py index 331fbe84e2..419f3b471c 100644 --- a/channels/views.py +++ b/channels/views.py @@ -223,7 +223,7 @@ def get_queryset(self): @method_decorator( cache_page_for_all_users( - settings.SEARCH_PAGE_CACHE_DURATION, cache="redis", key_prefix="search" + settings.REDIS_VIEW_CACHE_DURATION, cache="redis", key_prefix="channels" ) ) def list(self, request, *args, **kwargs): diff --git a/env/shared.env b/env/shared.env index 2f90e49c18..178503bf27 100644 --- a/env/shared.env +++ b/env/shared.env @@ -12,3 +12,4 @@ NGINX_PORT=8063 APISIX_PORT=8065 KEYCLOAK_PORT=8066 KEYCLOAK_SSL_PORT=8067 +REDIS_VIEW_CACHE_DURATION=0 diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts index 49f4ba2a50..fd4558e580 100644 --- a/frontends/api/src/generated/v0/api.ts +++ b/frontends/api/src/generated/v0/api.ts @@ -2714,6 +2714,12 @@ export interface LearningResourceOfferor { * @memberof LearningResourceOfferor */ channel_url: string | null + /** + * + * @type {boolean} + * @memberof LearningResourceOfferor + */ + display_facet?: boolean } /** * Serializer for LearningResourceOfferor with all details @@ -2793,6 +2799,12 @@ export interface LearningResourceOfferorDetail { * @memberof LearningResourceOfferorDetail */ value_prop?: string + /** + * + * @type {boolean} + * @memberof LearningResourceOfferorDetail + */ + display_facet?: boolean } /** * Serializer for LearningResourceOfferor with all details @@ -2866,6 +2878,12 @@ export interface LearningResourceOfferorDetailRequest { * @memberof LearningResourceOfferorDetailRequest */ value_prop?: string + /** + * + * @type {boolean} + * @memberof LearningResourceOfferorDetailRequest + */ + display_facet?: boolean } /** * Serializer for LearningResourcePlatform diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index a19401073e..1886393d3c 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -2911,6 +2911,12 @@ export interface LearningResourceOfferor { * @memberof LearningResourceOfferor */ channel_url: string | null + /** + * + * @type {boolean} + * @memberof LearningResourceOfferor + */ + display_facet?: boolean } /** * Serializer for LearningResourceOfferor with all details @@ -2990,6 +2996,12 @@ export interface LearningResourceOfferorDetail { * @memberof LearningResourceOfferorDetail */ value_prop?: string + /** + * + * @type {boolean} + * @memberof LearningResourceOfferorDetail + */ + display_facet?: boolean } /** * Serializer for LearningResourceOfferor with basic details @@ -3009,6 +3021,12 @@ export interface LearningResourceOfferorRequest { * @memberof LearningResourceOfferorRequest */ name: string + /** + * + * @type {boolean} + * @memberof LearningResourceOfferorRequest + */ + display_facet?: boolean } /** * Serializer for LearningResourcePlatform @@ -7194,18 +7212,23 @@ export interface RichTextArticleRequest { title: string } /** - * * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase + * * `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 * @enum {string} */ export const SearchModeEnumDescriptions = { + phrase: "phrase", best_fields: "best_fields", most_fields: "most_fields", - phrase: "phrase", + hybrid: "hybrid", } as const export const SearchModeEnum = { + /** + * phrase + */ + Phrase: "phrase", /** * best_fields */ @@ -7215,9 +7238,9 @@ export const SearchModeEnum = { */ MostFields: "most_fields", /** - * phrase + * hybrid */ - Phrase: "phrase", + Hybrid: "hybrid", } as const export type SearchModeEnum = @@ -17416,7 +17439,7 @@ export const LearningResourcesSearchApiAxiosParamCreator = function ( * @param {string} [q] The search text * @param {Array} [resource_category] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `article` - article - * @param {LearningResourcesSearchRetrieveSearchModeEnum} [search_mode] The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase + * @param {LearningResourcesSearchRetrieveSearchModeEnum} [search_mode] The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * @param {number | null} [slop] Allowed distance for phrase search * @param {LearningResourcesSearchRetrieveSortbyEnum} [sortby] If the parameter starts with \'-\' the sort is in descending order * `featured` - Featured * `id` - Object ID ascending * `-id` - Object ID descending * `readable_id` - Readable ID ascending * `-readable_id` - Readable ID descending * `last_modified` - Last Modified Date ascending * `-last_modified` - Last Modified Date descending * `new` - Newest resources first * `start_date` - Start Date ascending * `-start_date` - Start Date descending * `mitcoursenumber` - MIT course number ascending * `-mitcoursenumber` - MIT course number descending * `views` - Popularity ascending * `-views` - Popularity descending * `upcoming` - Next start date ascending * @param {Array} [topic] The topic name. To see a list of options go to api/v1/topics/ @@ -17632,7 +17655,7 @@ export const LearningResourcesSearchApiFp = function ( * @param {string} [q] The search text * @param {Array} [resource_category] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `article` - article - * @param {LearningResourcesSearchRetrieveSearchModeEnum} [search_mode] The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase + * @param {LearningResourcesSearchRetrieveSearchModeEnum} [search_mode] The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * @param {number | null} [slop] Allowed distance for phrase search * @param {LearningResourcesSearchRetrieveSortbyEnum} [sortby] If the parameter starts with \'-\' the sort is in descending order * `featured` - Featured * `id` - Object ID ascending * `-id` - Object ID descending * `readable_id` - Readable ID ascending * `-readable_id` - Readable ID descending * `last_modified` - Last Modified Date ascending * `-last_modified` - Last Modified Date descending * `new` - Newest resources first * `start_date` - Start Date ascending * `-start_date` - Start Date descending * `mitcoursenumber` - MIT course number ascending * `-mitcoursenumber` - MIT course number descending * `views` - Popularity ascending * `-views` - Popularity descending * `upcoming` - Next start date ascending * @param {Array} [topic] The topic name. To see a list of options go to api/v1/topics/ @@ -17941,8 +17964,8 @@ export interface LearningResourcesSearchApiLearningResourcesSearchRetrieveReques readonly resource_type?: Array /** - * The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase - * @type {'best_fields' | 'most_fields' | 'phrase'} + * The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid + * @type {'phrase' | 'best_fields' | 'most_fields' | 'hybrid'} * @memberof LearningResourcesSearchApiLearningResourcesSearchRetrieve */ readonly search_mode?: LearningResourcesSearchRetrieveSearchModeEnum @@ -18200,9 +18223,10 @@ export type LearningResourcesSearchRetrieveResourceTypeEnum = * @export */ export const LearningResourcesSearchRetrieveSearchModeEnum = { + Phrase: "phrase", BestFields: "best_fields", MostFields: "most_fields", - Phrase: "phrase", + Hybrid: "hybrid", } as const export type LearningResourcesSearchRetrieveSearchModeEnum = (typeof LearningResourcesSearchRetrieveSearchModeEnum)[keyof typeof LearningResourcesSearchRetrieveSearchModeEnum] @@ -18262,7 +18286,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( * @param {string} [q] The search text * @param {Array} [resource_category] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `article` - article - * @param {LearningResourcesUserSubscriptionCheckListSearchModeEnum} [search_mode] The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase + * @param {LearningResourcesUserSubscriptionCheckListSearchModeEnum} [search_mode] The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * @param {number | null} [slop] Allowed distance for phrase search * @param {LearningResourcesUserSubscriptionCheckListSortbyEnum} [sortby] If the parameter starts with \'-\' the sort is in descending order * `featured` - Featured * `id` - Object ID ascending * `-id` - Object ID descending * `readable_id` - Readable ID ascending * `-readable_id` - Readable ID descending * `last_modified` - Last Modified Date ascending * `-last_modified` - Last Modified Date descending * `new` - Newest resources first * `start_date` - Start Date ascending * `-start_date` - Start Date descending * `mitcoursenumber` - MIT course number ascending * `-mitcoursenumber` - MIT course number descending * `views` - Popularity ascending * `-views` - Popularity descending * `upcoming` - Next start date ascending * @param {LearningResourcesUserSubscriptionCheckListSourceTypeEnum} [source_type] The subscription type * `search_subscription_type` - search_subscription_type * `channel_subscription_type` - channel_subscription_type @@ -18471,7 +18495,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( * @param {string} [q] The search text * @param {Array} [resource_category] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `article` - article - * @param {LearningResourcesUserSubscriptionListSearchModeEnum} [search_mode] The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase + * @param {LearningResourcesUserSubscriptionListSearchModeEnum} [search_mode] The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * @param {number | null} [slop] Allowed distance for phrase search * @param {LearningResourcesUserSubscriptionListSortbyEnum} [sortby] If the parameter starts with \'-\' the sort is in descending order * `featured` - Featured * `id` - Object ID ascending * `-id` - Object ID descending * `readable_id` - Readable ID ascending * `-readable_id` - Readable ID descending * `last_modified` - Last Modified Date ascending * `-last_modified` - Last Modified Date descending * `new` - Newest resources first * `start_date` - Start Date ascending * `-start_date` - Start Date descending * `mitcoursenumber` - MIT course number ascending * `-mitcoursenumber` - MIT course number descending * `views` - Popularity ascending * `-views` - Popularity descending * `upcoming` - Next start date ascending * @param {Array} [topic] The topic name. To see a list of options go to api/v1/topics/ @@ -18674,7 +18698,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( * @param {string} [q] The search text * @param {Array} [resource_category] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `article` - article - * @param {LearningResourcesUserSubscriptionSubscribeCreateSearchModeEnum} [search_mode] The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase + * @param {LearningResourcesUserSubscriptionSubscribeCreateSearchModeEnum} [search_mode] The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * @param {number | null} [slop] Allowed distance for phrase search * @param {LearningResourcesUserSubscriptionSubscribeCreateSortbyEnum} [sortby] If the parameter starts with \'-\' the sort is in descending order * `featured` - Featured * `id` - Object ID ascending * `-id` - Object ID descending * `readable_id` - Readable ID ascending * `-readable_id` - Readable ID descending * `last_modified` - Last Modified Date ascending * `-last_modified` - Last Modified Date descending * `new` - Newest resources first * `start_date` - Start Date ascending * `-start_date` - Start Date descending * `mitcoursenumber` - MIT course number ascending * `-mitcoursenumber` - MIT course number descending * `views` - Popularity ascending * `-views` - Popularity descending * `upcoming` - Next start date ascending * @param {LearningResourcesUserSubscriptionSubscribeCreateSourceTypeEnum} [source_type] The subscription type * `search_subscription_type` - search_subscription_type * `channel_subscription_type` - channel_subscription_type @@ -18956,7 +18980,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( * @param {string} [q] The search text * @param {Array} [resource_category] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `article` - article - * @param {LearningResourcesUserSubscriptionCheckListSearchModeEnum} [search_mode] The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase + * @param {LearningResourcesUserSubscriptionCheckListSearchModeEnum} [search_mode] The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * @param {number | null} [slop] Allowed distance for phrase search * @param {LearningResourcesUserSubscriptionCheckListSortbyEnum} [sortby] If the parameter starts with \'-\' the sort is in descending order * `featured` - Featured * `id` - Object ID ascending * `-id` - Object ID descending * `readable_id` - Readable ID ascending * `-readable_id` - Readable ID descending * `last_modified` - Last Modified Date ascending * `-last_modified` - Last Modified Date descending * `new` - Newest resources first * `start_date` - Start Date ascending * `-start_date` - Start Date descending * `mitcoursenumber` - MIT course number ascending * `-mitcoursenumber` - MIT course number descending * `views` - Popularity ascending * `-views` - Popularity descending * `upcoming` - Next start date ascending * @param {LearningResourcesUserSubscriptionCheckListSourceTypeEnum} [source_type] The subscription type * `search_subscription_type` - search_subscription_type * `channel_subscription_type` - channel_subscription_type @@ -19071,7 +19095,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( * @param {string} [q] The search text * @param {Array} [resource_category] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `article` - article - * @param {LearningResourcesUserSubscriptionListSearchModeEnum} [search_mode] The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase + * @param {LearningResourcesUserSubscriptionListSearchModeEnum} [search_mode] The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * @param {number | null} [slop] Allowed distance for phrase search * @param {LearningResourcesUserSubscriptionListSortbyEnum} [sortby] If the parameter starts with \'-\' the sort is in descending order * `featured` - Featured * `id` - Object ID ascending * `-id` - Object ID descending * `readable_id` - Readable ID ascending * `-readable_id` - Readable ID descending * `last_modified` - Last Modified Date ascending * `-last_modified` - Last Modified Date descending * `new` - Newest resources first * `start_date` - Start Date ascending * `-start_date` - Start Date descending * `mitcoursenumber` - MIT course number ascending * `-mitcoursenumber` - MIT course number descending * `views` - Popularity ascending * `-views` - Popularity descending * `upcoming` - Next start date ascending * @param {Array} [topic] The topic name. To see a list of options go to api/v1/topics/ @@ -19183,7 +19207,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( * @param {string} [q] The search text * @param {Array} [resource_category] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `article` - article - * @param {LearningResourcesUserSubscriptionSubscribeCreateSearchModeEnum} [search_mode] The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase + * @param {LearningResourcesUserSubscriptionSubscribeCreateSearchModeEnum} [search_mode] The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * @param {number | null} [slop] Allowed distance for phrase search * @param {LearningResourcesUserSubscriptionSubscribeCreateSortbyEnum} [sortby] If the parameter starts with \'-\' the sort is in descending order * `featured` - Featured * `id` - Object ID ascending * `-id` - Object ID descending * `readable_id` - Readable ID ascending * `-readable_id` - Readable ID descending * `last_modified` - Last Modified Date ascending * `-last_modified` - Last Modified Date descending * `new` - Newest resources first * `start_date` - Start Date ascending * `-start_date` - Start Date descending * `mitcoursenumber` - MIT course number ascending * `-mitcoursenumber` - MIT course number descending * `views` - Popularity ascending * `-views` - Popularity descending * `upcoming` - Next start date ascending * @param {LearningResourcesUserSubscriptionSubscribeCreateSourceTypeEnum} [source_type] The subscription type * `search_subscription_type` - search_subscription_type * `channel_subscription_type` - channel_subscription_type @@ -19635,8 +19659,8 @@ export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscr readonly resource_type?: Array /** - * The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase - * @type {'best_fields' | 'most_fields' | 'phrase'} + * The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid + * @type {'phrase' | 'best_fields' | 'most_fields' | 'hybrid'} * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionCheckList */ readonly search_mode?: LearningResourcesUserSubscriptionCheckListSearchModeEnum @@ -19838,8 +19862,8 @@ export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscr readonly resource_type?: Array /** - * The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase - * @type {'best_fields' | 'most_fields' | 'phrase'} + * The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid + * @type {'phrase' | 'best_fields' | 'most_fields' | 'hybrid'} * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionList */ readonly search_mode?: LearningResourcesUserSubscriptionListSearchModeEnum @@ -20034,8 +20058,8 @@ export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscr readonly resource_type?: Array /** - * The open search search type for text queries * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `phrase` - phrase - * @type {'best_fields' | 'most_fields' | 'phrase'} + * The open search search type for text queries * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid * `phrase` - phrase * `best_fields` - best_fields * `most_fields` - most_fields * `hybrid` - hybrid + * @type {'phrase' | 'best_fields' | 'most_fields' | 'hybrid'} * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionSubscribeCreate */ readonly search_mode?: LearningResourcesUserSubscriptionSubscribeCreateSearchModeEnum @@ -20436,9 +20460,10 @@ export type LearningResourcesUserSubscriptionCheckListResourceTypeEnum = * @export */ export const LearningResourcesUserSubscriptionCheckListSearchModeEnum = { + Phrase: "phrase", BestFields: "best_fields", MostFields: "most_fields", - Phrase: "phrase", + Hybrid: "hybrid", } as const export type LearningResourcesUserSubscriptionCheckListSearchModeEnum = (typeof LearningResourcesUserSubscriptionCheckListSearchModeEnum)[keyof typeof LearningResourcesUserSubscriptionCheckListSearchModeEnum] @@ -20643,9 +20668,10 @@ export type LearningResourcesUserSubscriptionListResourceTypeEnum = * @export */ export const LearningResourcesUserSubscriptionListSearchModeEnum = { + Phrase: "phrase", BestFields: "best_fields", MostFields: "most_fields", - Phrase: "phrase", + Hybrid: "hybrid", } as const export type LearningResourcesUserSubscriptionListSearchModeEnum = (typeof LearningResourcesUserSubscriptionListSearchModeEnum)[keyof typeof LearningResourcesUserSubscriptionListSearchModeEnum] @@ -20845,9 +20871,10 @@ export type LearningResourcesUserSubscriptionSubscribeCreateResourceTypeEnum = * @export */ export const LearningResourcesUserSubscriptionSubscribeCreateSearchModeEnum = { + Phrase: "phrase", BestFields: "best_fields", MostFields: "most_fields", - Phrase: "phrase", + Hybrid: "hybrid", } as const export type LearningResourcesUserSubscriptionSubscribeCreateSearchModeEnum = (typeof LearningResourcesUserSubscriptionSubscribeCreateSearchModeEnum)[keyof typeof LearningResourcesUserSubscriptionSubscribeCreateSearchModeEnum] diff --git a/frontends/api/src/hooks/articles/index.test.ts b/frontends/api/src/hooks/articles/index.test.ts index a186aa30e9..3f8a65d9d0 100644 --- a/frontends/api/src/hooks/articles/index.test.ts +++ b/frontends/api/src/hooks/articles/index.test.ts @@ -90,7 +90,7 @@ describe("Article CRUD", () => { const { id, ...patchData } = article expect(makeRequest).toHaveBeenCalledWith("patch", url, patchData) expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: articleKeys.root, + queryKey: articleKeys.detail(article.id), }) }) diff --git a/frontends/api/src/hooks/articles/index.ts b/frontends/api/src/hooks/articles/index.ts index 202ba0fe7d..a55a122d74 100644 --- a/frontends/api/src/hooks/articles/index.ts +++ b/frontends/api/src/hooks/articles/index.ts @@ -18,7 +18,7 @@ const useArticleList = ( } /** - * Query is diabled if id is undefined. + * Query is disabled if id is undefined. */ const useArticleDetail = (id: number | undefined) => { return useQuery({ @@ -58,8 +58,8 @@ const useArticlePartialUpdate = () => { PatchedRichTextArticleRequest: data, }) .then((response) => response.data), - onSuccess: (_data) => { - client.invalidateQueries({ queryKey: articleKeys.root }) + onSuccess: (article: Article) => { + client.invalidateQueries({ queryKey: articleKeys.detail(article.id) }) }, }) } diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index b6173bcdbe..01862ed6c2 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -32,7 +32,13 @@ const organizationQueries = { const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => { const queryClient = useQueryClient() return useMutation({ - mutationFn: () => b2bApi.b2bAttachCreate(opts), + mutationFn: async () => { + const response = await b2bApi.b2bAttachCreate(opts) + // 200 (already attached) indicates user already attached to all contracts + // 201 (successfully attached) is success + // 404 (invalid or expired code) will be thrown as error by axios + return response + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["mitxonline"] }) }, diff --git a/frontends/api/src/ssr/prefetch.ts b/frontends/api/src/ssr/prefetch.ts deleted file mode 100644 index 3cd719911c..0000000000 --- a/frontends/api/src/ssr/prefetch.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { dehydrate } from "@tanstack/react-query" -import { getServerQueryClient } from "./serverQueryClient" -import type { Query } from "@tanstack/react-query" - -/** - * Utility to avoid repetition in server components - * Optionally pass the queryClient returned from a previous prefetch - * where queries are dependent on previous results - */ -export const prefetch = async ( - queries: (Query | unknown)[], - - /** - * Unless passed, the SSR QueryClient uses React's cache() for reuse for the duration of the request. - * - * The QueryClient is garbage collected once the dehydrated state is produced and - * sent to the client and the request is complete. - */ - queryClient = getServerQueryClient(), -) => { - await Promise.all( - queries.filter(Boolean).map((query) => { - return queryClient.prefetchQuery(query as Query) - }), - ) - - return { - dehydratedState: dehydrate(queryClient), - queryClient, - } -} diff --git a/frontends/api/src/ssr/serverQueryClient.ts b/frontends/api/src/ssr/serverQueryClient.ts deleted file mode 100644 index daa745891f..0000000000 --- a/frontends/api/src/ssr/serverQueryClient.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { cache } from "react" -import { QueryClient } from "@tanstack/react-query" -import { AxiosError } from "axios" - -const MAX_RETRIES = 3 -const NO_RETRY_CODES = [400, 401, 403, 404, 405, 409, 422] - -/** - * Get or create a server-side QueryClient for consistent retry behavior. - * The server QueryClient should be used for all server-side API calls. - * - * Uses React's cache() to ensure the same QueryClient instance is reused - * throughout a single HTTP request, enabling: - * - * - Server API calls share the same QueryClient: - * - Prefetch runs in page server components - * - generateMetadata() - * - No duplicate API calls within the same request - * - Automatic cleanup when the request completes - * - Isolation between different HTTP requests - * - * The QueryClientProvider runs (during SSR) in a separate render pass as it's a - * client component and so the instance is not reused. On the server this does not - * make API calls and only sets up the hydration boundary and registers hooks in - * readiness for the dehydrated state to be sent to the client. - */ -const getServerQueryClient = cache(() => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - /** - * React Query's default retry logic is only active in the browser. - * Here we explicitly configure it to retry MAX_RETRIES times on - * the server, with an exclusion list of statuses that we expect not - * to succeed on retry. - * - * Includes status undefined as we want to retry on network errors - */ - retry: (failureCount, error) => { - const axiosError = error as AxiosError - console.info("Retrying failed request", { - failureCount, - error: { - message: axiosError.message, - name: axiosError.name, - status: axiosError?.status, - code: axiosError.code, - method: axiosError.request?.method, - url: axiosError.request?.url, - }, - }) - const status = (error as AxiosError)?.response?.status - const isNetworkError = status === undefined || status === 0 - - if (isNetworkError || !NO_RETRY_CODES.includes(status)) { - return failureCount < MAX_RETRIES - } - return false - }, - - /** - * By default, React Query gradually applies a backoff delay, though it is - * preferable that we do not significantly delay initial page renders (or - * indeed pages that are Statically Rendered during the build process) and - * instead allow the request to fail quickly so it can be subsequently - * fetched on the client. - */ - retryDelay: 1000, - }, - }, - }) - - return queryClient -}) - -export { getServerQueryClient } diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index 240d63e645..5dc44cefb3 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -15,7 +15,7 @@ const processFeatureFlags = () => { for (const [key, value] of Object.entries(process.env)) { if (key.startsWith(`NEXT_PUBLIC_${featureFlagPrefix}`)) { bootstrapFeatureFlags[ - key.replace(`NEXT_PUBLIC_${featureFlagPrefix}`, "") + key.replace(`NEXT_PUBLIC_${featureFlagPrefix}`, "").replaceAll("_", "-") ] = value === "True" ? true : JSON.stringify(value) } } diff --git a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx deleted file mode 100644 index 6832e53b57..0000000000 --- a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client" - -import React from "react" -import { TiptapEditor, theme, styled, HEADER_HEIGHT } from "ol-components" -import { Permission } from "api/hooks/user" -import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" - -const PageContainer = styled.div({ - color: theme.custom.colors.darkGray2, - display: "flex", - height: `calc(100vh - ${HEADER_HEIGHT}px - 132px)`, -}) - -const EditorContainer = styled.div({ - minHeight: 0, -}) - -const StyledTiptapEditor = styled(TiptapEditor)({ - width: "70vw", - height: `calc(100% - ${HEADER_HEIGHT}px - 132px)`, - overscrollBehavior: "contain", -}) - -const NewArticlePage: React.FC = () => { - return ( - - - - - - - - ) -} - -export { NewArticlePage } diff --git a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx index e8f73d53d8..4bbad45876 100644 --- a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx @@ -2,64 +2,27 @@ import React from "react" import { useArticleDetail } from "api/hooks/articles" -import { Container, LoadingSpinner, styled, Typography } from "ol-components" -import { ButtonLink } from "@mitodl/smoot-design" +import { LoadingSpinner, ArticleEditor } from "ol-components" import { notFound } from "next/navigation" import { Permission } from "api/hooks/user" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" -import { articlesEditView } from "@/common/urls" - -const Page = styled(Container)({ - marginTop: "40px", - marginBottom: "40px", -}) - -const ControlsContainer = styled.div({ - display: "flex", - justifyContent: "flex-end", - margin: "10px", -}) -const WrapperContainer = styled.div({ - borderBottom: "1px solid rgb(222, 208, 208)", - paddingBottom: "10px", -}) - -const PreTag = styled.pre({ - background: "#f6f6f6", - padding: "16px", - borderRadius: "8px", - fontSize: "14px", - overflowX: "auto", -}) export const ArticleDetailPage = ({ articleId }: { articleId: number }) => { - const id = Number(articleId) - const { data, isLoading } = useArticleDetail(id) - - const editUrl = articlesEditView(id) - - if (isLoading) { - return + const { + data: article, + isLoading, + isFetching, + } = useArticleDetail(Number(articleId)) + + if (isLoading || isFetching) { + return } - if (!data) { + if (!article) { return notFound() } return ( - - - - {data?.title} - - - - - Edit - - - - {JSON.stringify(data.content, null, 2)} - + ) } diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx index 2f1bf9b013..f0677a99c9 100644 --- a/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx @@ -2,14 +2,14 @@ import React from "react" import { screen, renderWithProviders, setMockResponse } from "@/test-utils" import { waitFor, fireEvent } from "@testing-library/react" import userEvent from "@testing-library/user-event" -import { factories, urls } from "api/test-utils" +import { factories, urls, makeRequest } from "api/test-utils" import { ArticleEditPage } from "./ArticleEditPage" -const pushMock = jest.fn() +const mockPush = jest.fn() jest.mock("next-nprogress-bar", () => ({ useRouter: () => ({ - push: pushMock, + push: mockPush, }), })) @@ -24,18 +24,26 @@ describe("ArticleEditPage", () => { const article = factories.articles.article({ id: 42, title: "Existing Title", - content: { id: 1, content: "Existing content" }, + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Existing Title" }], + }, + ], + }, }) setMockResponse.get(urls.articles.details(article.id), article) renderWithProviders() - expect(await screen.findByText("Edit Article")).toBeInTheDocument() - expect(screen.getByTestId("editor")).toBeInTheDocument() + await screen.findByTestId("editor") + expect(screen.getByDisplayValue("Existing Title")).toBeInTheDocument() }) - test("submits article successfully and redirects", async () => { + test("submits article successfully", async () => { const user = factories.user.user({ is_authenticated: true, is_article_editor: true, @@ -45,26 +53,42 @@ describe("ArticleEditPage", () => { const article = factories.articles.article({ id: 123, title: "Existing Title", - content: { id: 1, content: "Existing content" }, + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Existing Title" }], + }, + ], + }, }) setMockResponse.get(urls.articles.details(article.id), article) - // ✅ Mock successful update response const updated = { ...article, title: "Updated Title" } setMockResponse.patch(urls.articles.details(article.id), updated) renderWithProviders() - const titleInput = await screen.findByPlaceholderText("Enter article title") + await screen.findByTestId("editor") + + const titleInput = await screen.findByPlaceholderText("Article title") fireEvent.change(titleInput, { target: { value: "Updated Title" } }) await waitFor(() => expect(titleInput).toHaveValue("Updated Title")) - await userEvent.click(screen.getByText(/save article/i)) + await userEvent.click(screen.getByRole("button", { name: "Save" })) - // ✅ Wait for redirect after update success - await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/articles/123")) + await waitFor(() => + expect(makeRequest).toHaveBeenCalledWith( + "patch", + urls.articles.details(article.id), + expect.objectContaining({ title: "Updated Title" }), + ), + ) + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/articles/123")) }) test("shows error alert on failure", async () => { @@ -77,11 +101,18 @@ describe("ArticleEditPage", () => { const article = factories.articles.article({ id: 7, title: "Old Title", - content: { id: 1, content: "Bad content" }, + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Existing Title" }], + }, + ], + }, }) setMockResponse.get(urls.articles.details(article.id), article) - // ✅ Mock failed update (500) setMockResponse.patch( urls.articles.details(article.id), { detail: "Server Error" }, @@ -90,10 +121,12 @@ describe("ArticleEditPage", () => { renderWithProviders() - const titleInput = await screen.findByPlaceholderText("Enter article title") + await screen.findByTestId("editor") + + const titleInput = await screen.findByPlaceholderText("Article title") fireEvent.change(titleInput, { target: { value: "Bad Article" } }) - await userEvent.click(screen.getByText(/save article/i)) + await userEvent.click(screen.getByRole("button", { name: "Save" })) expect(await screen.findByText(/Mock Error/i)).toBeInTheDocument() }) diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx index dc4c8a5a35..9ee00c088f 100644 --- a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx +++ b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx @@ -1,136 +1,50 @@ "use client" -import React, { useEffect, useState, ChangeEvent } from "react" -import { Permission } from "api/hooks/user" + +import React from "react" import { useRouter } from "next-nprogress-bar" -import { useArticleDetail, useArticlePartialUpdate } from "api/hooks/articles" -import { Button, Input, Alert } from "@mitodl/smoot-design" -import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" -import { Container, Typography, styled, LoadingSpinner } from "ol-components" import { notFound } from "next/navigation" +import { Permission } from "api/hooks/user" +import { useArticleDetail } from "api/hooks/articles" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { + styled, + LoadingSpinner, + ArticleEditor, + HEADER_HEIGHT, +} from "ol-components" import { articlesView } from "@/common/urls" -const SaveButton = styled.div({ - textAlign: "right", - margin: "10px", -}) - -const ClientContainer = styled.div({ - width: "100%", - margin: "10px 0", -}) - -const TitleInput = styled(Input)({ - width: "100%", - margin: "10px 0", -}) +const PageContainer = styled.div(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "flex", + height: `calc(100vh - ${HEADER_HEIGHT}px - 132px)`, +})) const ArticleEditPage = ({ articleId }: { articleId: string }) => { + const { + data: article, + isLoading, + isFetching, + } = useArticleDetail(Number(articleId)) const router = useRouter() - const id = Number(articleId) - const { data: article, isLoading } = useArticleDetail(id) - - const [title, setTitle] = useState("") - const [text, setText] = useState("") - const [json, setJson] = useState({}) - const [alertText, setAlertText] = useState("") - - const { mutate: updateArticle, isPending } = useArticlePartialUpdate() - - const handleSave = () => { - const payload = { - id: id, - title: title.trim(), - content: json, - } - - updateArticle(payload, { - onSuccess: (article) => { - router.push(articlesView(article.id)) - }, - onError: (error) => { - setAlertText(`❌ ${error.message}`) - }, - }) - } - - useEffect(() => { - if (article && !title) { - setTitle(article.title) - setText(article.content ? JSON.stringify(article.content, null, 2) : "") - setJson(article.content) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [article]) - - if (isLoading) { + if (isLoading || isFetching) { return } if (!article) { return notFound() } - const handleChange = (e: ChangeEvent) => { - const value = e.target.value - setText(value) - - try { - const parsed = JSON.parse(value) - setJson(parsed) - } catch { - setJson({}) - } - } return ( - - - Edit Article - - {alertText && ( - - - {alertText} - - - )} - { - console.log("Title input changed:", e.target.value) - setTitle(e.target.value) - setAlertText("") + + { + router.push(articlesView(article.id)) }} - placeholder="Enter article title" - className="input-field" /> - - -