diff --git a/RELEASE.rst b/RELEASE.rst index aa2abcddae..0bbae685fe 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,16 @@ Release Notes ============= +Version 0.13.8 +-------------- + +- add is_learning_material filter show courses and programs first in default sort (#1104) +- dashboard my lists style fixes (#1107) +- Updates to learning resource price display (#1108) +- Add profile edit page (#1029) +- Append `/static` to the front of the testimonial marketing card image (#1115) +- two separate search inputs (#1111) + Version 0.13.7 (Released June 18, 2024) -------------- diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index 0c8107a283..dd6a8fb2dc 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -40,7 +40,7 @@ import { } from "./base" /** - * * `resource_type` - resource_type * `certification` - certification * `certification_type` - certification_type * `offered_by` - offered_by * `platform` - platform * `topic` - topic * `department` - department * `level` - level * `course_feature` - course_feature * `professional` - professional * `free` - free * `learning_format` - learning_format + * * `resource_type` - resource_type * `certification` - certification * `certification_type` - certification_type * `offered_by` - offered_by * `platform` - platform * `topic` - topic * `department` - department * `level` - level * `course_feature` - course_feature * `professional` - professional * `free` - free * `learning_format` - learning_format * `is_learning_material` - is_learning_material * @export * @enum {string} */ @@ -58,6 +58,7 @@ export const AggregationsEnumDescriptions = { professional: "professional", free: "free", learning_format: "learning_format", + is_learning_material: "is_learning_material", } as const export const AggregationsEnum = { @@ -109,6 +110,10 @@ export const AggregationsEnum = { * learning_format */ LearningFormat: "learning_format", + /** + * is_learning_material + */ + IsLearningMaterial: "is_learning_material", } as const export type AggregationsEnum = @@ -3352,6 +3357,12 @@ export interface PercolateQuerySubscriptionRequestRequest { * @memberof PercolateQuerySubscriptionRequestRequest */ certification?: boolean | null + /** + * True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path + * @type {boolean} + * @memberof PercolateQuerySubscriptionRequestRequest + */ + is_learning_material?: boolean | null /** * The type of certificate * `micromasters` - Micromasters Credential * `professional` - Professional Certificate * `completion` - Certificate of Completion * `none` - No Certificate * @type {Array} @@ -11350,6 +11361,7 @@ export const LearningResourcesSearchApiAxiosParamCreator = function ( * @param {Array} [department] The department that offers the learning resource * `1` - Civil and Environmental Engineering * `2` - Mechanical Engineering * `3` - Materials Science and Engineering * `4` - Architecture * `5` - Chemistry * `6` - Electrical Engineering and Computer Science * `7` - Biology * `8` - Physics * `9` - Brain and Cognitive Sciences * `10` - Chemical Engineering * `11` - Urban Studies and Planning * `12` - Earth, Atmospheric, and Planetary Sciences * `14` - Economics * `15` - Sloan School of Management * `16` - Aeronautics and Astronautics * `17` - Political Science * `18` - Mathematics * `20` - Biological Engineering * `21A` - Anthropology * `21G` - Global Studies and Languages * `21H` - History * `21L` - Literature * `21M` - Music and Theater Arts * `22` - Nuclear Science and Engineering * `24` - Linguistics and Philosophy * `CC` - Concourse * `CMS-W` - Comparative Media Studies/Writing * `EC` - Edgerton Center * `ES` - Experimental Study Group * `ESD` - Engineering Systems Division * `HST` - Health Sciences and Technology * `IDS` - Institute for Data, Systems, and Society * `MAS` - Media Arts and Sciences * `PE` - Athletics, Physical Education and Recreation * `RES` - Supplemental Resources * `STS` - Science, Technology, and Society * `WGS` - Women\'s and Gender Studies * @param {boolean | null} [free] * @param {Array} [id] The id value for the learning resource + * @param {boolean | null} [is_learning_material] True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path * @param {Array} [learning_format] The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @param {Array} [level] * @param {number} [limit] Number of results to return per page @@ -11372,6 +11384,7 @@ export const LearningResourcesSearchApiAxiosParamCreator = function ( department?: Array, free?: boolean | null, id?: Array, + is_learning_material?: boolean | null, learning_format?: Array, level?: Array, limit?: number, @@ -11429,6 +11442,10 @@ export const LearningResourcesSearchApiAxiosParamCreator = function ( localVarQueryParameter["id"] = id } + if (is_learning_material !== undefined) { + localVarQueryParameter["is_learning_material"] = is_learning_material + } + if (learning_format) { localVarQueryParameter["learning_format"] = learning_format } @@ -11510,6 +11527,7 @@ export const LearningResourcesSearchApiFp = function ( * @param {Array} [department] The department that offers the learning resource * `1` - Civil and Environmental Engineering * `2` - Mechanical Engineering * `3` - Materials Science and Engineering * `4` - Architecture * `5` - Chemistry * `6` - Electrical Engineering and Computer Science * `7` - Biology * `8` - Physics * `9` - Brain and Cognitive Sciences * `10` - Chemical Engineering * `11` - Urban Studies and Planning * `12` - Earth, Atmospheric, and Planetary Sciences * `14` - Economics * `15` - Sloan School of Management * `16` - Aeronautics and Astronautics * `17` - Political Science * `18` - Mathematics * `20` - Biological Engineering * `21A` - Anthropology * `21G` - Global Studies and Languages * `21H` - History * `21L` - Literature * `21M` - Music and Theater Arts * `22` - Nuclear Science and Engineering * `24` - Linguistics and Philosophy * `CC` - Concourse * `CMS-W` - Comparative Media Studies/Writing * `EC` - Edgerton Center * `ES` - Experimental Study Group * `ESD` - Engineering Systems Division * `HST` - Health Sciences and Technology * `IDS` - Institute for Data, Systems, and Society * `MAS` - Media Arts and Sciences * `PE` - Athletics, Physical Education and Recreation * `RES` - Supplemental Resources * `STS` - Science, Technology, and Society * `WGS` - Women\'s and Gender Studies * @param {boolean | null} [free] * @param {Array} [id] The id value for the learning resource + * @param {boolean | null} [is_learning_material] True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path * @param {Array} [learning_format] The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @param {Array} [level] * @param {number} [limit] Number of results to return per page @@ -11532,6 +11550,7 @@ export const LearningResourcesSearchApiFp = function ( department?: Array, free?: boolean | null, id?: Array, + is_learning_material?: boolean | null, learning_format?: Array, level?: Array, limit?: number, @@ -11559,6 +11578,7 @@ export const LearningResourcesSearchApiFp = function ( department, free, id, + is_learning_material, learning_format, level, limit, @@ -11619,6 +11639,7 @@ export const LearningResourcesSearchApiFactory = function ( requestParameters.department, requestParameters.free, requestParameters.id, + requestParameters.is_learning_material, requestParameters.learning_format, requestParameters.level, requestParameters.limit, @@ -11645,7 +11666,7 @@ export const LearningResourcesSearchApiFactory = function ( export interface LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest { /** * Show resource counts by category - * @type {Array<'resource_type' | 'certification' | 'certification_type' | 'offered_by' | 'platform' | 'topic' | 'department' | 'level' | 'course_feature' | 'professional' | 'free' | 'learning_format'>} + * @type {Array<'resource_type' | 'certification' | 'certification_type' | 'offered_by' | 'platform' | 'topic' | 'department' | 'level' | 'course_feature' | 'professional' | 'free' | 'learning_format' | 'is_learning_material'>} * @memberof LearningResourcesSearchApiLearningResourcesSearchRetrieve */ readonly aggregations?: Array @@ -11692,6 +11713,13 @@ export interface LearningResourcesSearchApiLearningResourcesSearchRetrieveReques */ readonly id?: Array + /** + * True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path + * @type {boolean} + * @memberof LearningResourcesSearchApiLearningResourcesSearchRetrieve + */ + readonly is_learning_material?: boolean | null + /** * The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @type {Array<'online' | 'hybrid' | 'in_person'>} @@ -11798,6 +11826,7 @@ export class LearningResourcesSearchApi extends BaseAPI { requestParameters.department, requestParameters.free, requestParameters.id, + requestParameters.is_learning_material, requestParameters.learning_format, requestParameters.level, requestParameters.limit, @@ -11831,6 +11860,7 @@ export const LearningResourcesSearchRetrieveAggregationsEnum = { Professional: "professional", Free: "free", LearningFormat: "learning_format", + IsLearningMaterial: "is_learning_material", } as const export type LearningResourcesSearchRetrieveAggregationsEnum = (typeof LearningResourcesSearchRetrieveAggregationsEnum)[keyof typeof LearningResourcesSearchRetrieveAggregationsEnum] @@ -12005,6 +12035,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( * @param {Array} [department] The department that offers the learning resource * `1` - Civil and Environmental Engineering * `2` - Mechanical Engineering * `3` - Materials Science and Engineering * `4` - Architecture * `5` - Chemistry * `6` - Electrical Engineering and Computer Science * `7` - Biology * `8` - Physics * `9` - Brain and Cognitive Sciences * `10` - Chemical Engineering * `11` - Urban Studies and Planning * `12` - Earth, Atmospheric, and Planetary Sciences * `14` - Economics * `15` - Sloan School of Management * `16` - Aeronautics and Astronautics * `17` - Political Science * `18` - Mathematics * `20` - Biological Engineering * `21A` - Anthropology * `21G` - Global Studies and Languages * `21H` - History * `21L` - Literature * `21M` - Music and Theater Arts * `22` - Nuclear Science and Engineering * `24` - Linguistics and Philosophy * `CC` - Concourse * `CMS-W` - Comparative Media Studies/Writing * `EC` - Edgerton Center * `ES` - Experimental Study Group * `ESD` - Engineering Systems Division * `HST` - Health Sciences and Technology * `IDS` - Institute for Data, Systems, and Society * `MAS` - Media Arts and Sciences * `PE` - Athletics, Physical Education and Recreation * `RES` - Supplemental Resources * `STS` - Science, Technology, and Society * `WGS` - Women\'s and Gender Studies * @param {boolean | null} [free] * @param {Array} [id] The id value for the learning resource + * @param {boolean | null} [is_learning_material] True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path * @param {Array} [learning_format] The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @param {Array} [level] * @param {number} [limit] Number of results to return per page @@ -12028,6 +12059,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( department?: Array, free?: boolean | null, id?: Array, + is_learning_material?: boolean | null, learning_format?: Array, level?: Array, limit?: number, @@ -12086,6 +12118,10 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( localVarQueryParameter["id"] = id } + if (is_learning_material !== undefined) { + localVarQueryParameter["is_learning_material"] = is_learning_material + } + if (learning_format) { localVarQueryParameter["learning_format"] = learning_format } @@ -12158,6 +12194,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( * @param {Array} [department] The department that offers the learning resource * `1` - Civil and Environmental Engineering * `2` - Mechanical Engineering * `3` - Materials Science and Engineering * `4` - Architecture * `5` - Chemistry * `6` - Electrical Engineering and Computer Science * `7` - Biology * `8` - Physics * `9` - Brain and Cognitive Sciences * `10` - Chemical Engineering * `11` - Urban Studies and Planning * `12` - Earth, Atmospheric, and Planetary Sciences * `14` - Economics * `15` - Sloan School of Management * `16` - Aeronautics and Astronautics * `17` - Political Science * `18` - Mathematics * `20` - Biological Engineering * `21A` - Anthropology * `21G` - Global Studies and Languages * `21H` - History * `21L` - Literature * `21M` - Music and Theater Arts * `22` - Nuclear Science and Engineering * `24` - Linguistics and Philosophy * `CC` - Concourse * `CMS-W` - Comparative Media Studies/Writing * `EC` - Edgerton Center * `ES` - Experimental Study Group * `ESD` - Engineering Systems Division * `HST` - Health Sciences and Technology * `IDS` - Institute for Data, Systems, and Society * `MAS` - Media Arts and Sciences * `PE` - Athletics, Physical Education and Recreation * `RES` - Supplemental Resources * `STS` - Science, Technology, and Society * `WGS` - Women\'s and Gender Studies * @param {boolean | null} [free] * @param {Array} [id] The id value for the learning resource + * @param {boolean | null} [is_learning_material] True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path * @param {Array} [learning_format] The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @param {Array} [level] * @param {number} [limit] Number of results to return per page @@ -12180,6 +12217,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( department?: Array, free?: boolean | null, id?: Array, + is_learning_material?: boolean | null, learning_format?: Array, level?: Array, limit?: number, @@ -12237,6 +12275,10 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( localVarQueryParameter["id"] = id } + if (is_learning_material !== undefined) { + localVarQueryParameter["is_learning_material"] = is_learning_material + } + if (learning_format) { localVarQueryParameter["learning_format"] = learning_format } @@ -12305,6 +12347,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( * @param {Array} [department] The department that offers the learning resource * `1` - Civil and Environmental Engineering * `2` - Mechanical Engineering * `3` - Materials Science and Engineering * `4` - Architecture * `5` - Chemistry * `6` - Electrical Engineering and Computer Science * `7` - Biology * `8` - Physics * `9` - Brain and Cognitive Sciences * `10` - Chemical Engineering * `11` - Urban Studies and Planning * `12` - Earth, Atmospheric, and Planetary Sciences * `14` - Economics * `15` - Sloan School of Management * `16` - Aeronautics and Astronautics * `17` - Political Science * `18` - Mathematics * `20` - Biological Engineering * `21A` - Anthropology * `21G` - Global Studies and Languages * `21H` - History * `21L` - Literature * `21M` - Music and Theater Arts * `22` - Nuclear Science and Engineering * `24` - Linguistics and Philosophy * `CC` - Concourse * `CMS-W` - Comparative Media Studies/Writing * `EC` - Edgerton Center * `ES` - Experimental Study Group * `ESD` - Engineering Systems Division * `HST` - Health Sciences and Technology * `IDS` - Institute for Data, Systems, and Society * `MAS` - Media Arts and Sciences * `PE` - Athletics, Physical Education and Recreation * `RES` - Supplemental Resources * `STS` - Science, Technology, and Society * `WGS` - Women\'s and Gender Studies * @param {boolean | null} [free] * @param {Array} [id] The id value for the learning resource + * @param {boolean | null} [is_learning_material] True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path * @param {Array} [learning_format] The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @param {Array} [level] * @param {number} [limit] Number of results to return per page @@ -12329,6 +12372,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( department?: Array, free?: boolean | null, id?: Array, + is_learning_material?: boolean | null, learning_format?: Array, level?: Array, limit?: number, @@ -12388,6 +12432,10 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( localVarQueryParameter["id"] = id } + if (is_learning_material !== undefined) { + localVarQueryParameter["is_learning_material"] = is_learning_material + } + if (learning_format) { localVarQueryParameter["learning_format"] = learning_format } @@ -12531,6 +12579,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( * @param {Array} [department] The department that offers the learning resource * `1` - Civil and Environmental Engineering * `2` - Mechanical Engineering * `3` - Materials Science and Engineering * `4` - Architecture * `5` - Chemistry * `6` - Electrical Engineering and Computer Science * `7` - Biology * `8` - Physics * `9` - Brain and Cognitive Sciences * `10` - Chemical Engineering * `11` - Urban Studies and Planning * `12` - Earth, Atmospheric, and Planetary Sciences * `14` - Economics * `15` - Sloan School of Management * `16` - Aeronautics and Astronautics * `17` - Political Science * `18` - Mathematics * `20` - Biological Engineering * `21A` - Anthropology * `21G` - Global Studies and Languages * `21H` - History * `21L` - Literature * `21M` - Music and Theater Arts * `22` - Nuclear Science and Engineering * `24` - Linguistics and Philosophy * `CC` - Concourse * `CMS-W` - Comparative Media Studies/Writing * `EC` - Edgerton Center * `ES` - Experimental Study Group * `ESD` - Engineering Systems Division * `HST` - Health Sciences and Technology * `IDS` - Institute for Data, Systems, and Society * `MAS` - Media Arts and Sciences * `PE` - Athletics, Physical Education and Recreation * `RES` - Supplemental Resources * `STS` - Science, Technology, and Society * `WGS` - Women\'s and Gender Studies * @param {boolean | null} [free] * @param {Array} [id] The id value for the learning resource + * @param {boolean | null} [is_learning_material] True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path * @param {Array} [learning_format] The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @param {Array} [level] * @param {number} [limit] Number of results to return per page @@ -12554,6 +12603,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( department?: Array, free?: boolean | null, id?: Array, + is_learning_material?: boolean | null, learning_format?: Array, level?: Array, limit?: number, @@ -12582,6 +12632,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( department, free, id, + is_learning_material, learning_format, level, limit, @@ -12619,6 +12670,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( * @param {Array} [department] The department that offers the learning resource * `1` - Civil and Environmental Engineering * `2` - Mechanical Engineering * `3` - Materials Science and Engineering * `4` - Architecture * `5` - Chemistry * `6` - Electrical Engineering and Computer Science * `7` - Biology * `8` - Physics * `9` - Brain and Cognitive Sciences * `10` - Chemical Engineering * `11` - Urban Studies and Planning * `12` - Earth, Atmospheric, and Planetary Sciences * `14` - Economics * `15` - Sloan School of Management * `16` - Aeronautics and Astronautics * `17` - Political Science * `18` - Mathematics * `20` - Biological Engineering * `21A` - Anthropology * `21G` - Global Studies and Languages * `21H` - History * `21L` - Literature * `21M` - Music and Theater Arts * `22` - Nuclear Science and Engineering * `24` - Linguistics and Philosophy * `CC` - Concourse * `CMS-W` - Comparative Media Studies/Writing * `EC` - Edgerton Center * `ES` - Experimental Study Group * `ESD` - Engineering Systems Division * `HST` - Health Sciences and Technology * `IDS` - Institute for Data, Systems, and Society * `MAS` - Media Arts and Sciences * `PE` - Athletics, Physical Education and Recreation * `RES` - Supplemental Resources * `STS` - Science, Technology, and Society * `WGS` - Women\'s and Gender Studies * @param {boolean | null} [free] * @param {Array} [id] The id value for the learning resource + * @param {boolean | null} [is_learning_material] True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path * @param {Array} [learning_format] The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @param {Array} [level] * @param {number} [limit] Number of results to return per page @@ -12641,6 +12693,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( department?: Array, free?: boolean | null, id?: Array, + is_learning_material?: boolean | null, learning_format?: Array, level?: Array, limit?: number, @@ -12668,6 +12721,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( department, free, id, + is_learning_material, learning_format, level, limit, @@ -12704,6 +12758,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( * @param {Array} [department] The department that offers the learning resource * `1` - Civil and Environmental Engineering * `2` - Mechanical Engineering * `3` - Materials Science and Engineering * `4` - Architecture * `5` - Chemistry * `6` - Electrical Engineering and Computer Science * `7` - Biology * `8` - Physics * `9` - Brain and Cognitive Sciences * `10` - Chemical Engineering * `11` - Urban Studies and Planning * `12` - Earth, Atmospheric, and Planetary Sciences * `14` - Economics * `15` - Sloan School of Management * `16` - Aeronautics and Astronautics * `17` - Political Science * `18` - Mathematics * `20` - Biological Engineering * `21A` - Anthropology * `21G` - Global Studies and Languages * `21H` - History * `21L` - Literature * `21M` - Music and Theater Arts * `22` - Nuclear Science and Engineering * `24` - Linguistics and Philosophy * `CC` - Concourse * `CMS-W` - Comparative Media Studies/Writing * `EC` - Edgerton Center * `ES` - Experimental Study Group * `ESD` - Engineering Systems Division * `HST` - Health Sciences and Technology * `IDS` - Institute for Data, Systems, and Society * `MAS` - Media Arts and Sciences * `PE` - Athletics, Physical Education and Recreation * `RES` - Supplemental Resources * `STS` - Science, Technology, and Society * `WGS` - Women\'s and Gender Studies * @param {boolean | null} [free] * @param {Array} [id] The id value for the learning resource + * @param {boolean | null} [is_learning_material] True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path * @param {Array} [learning_format] The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @param {Array} [level] * @param {number} [limit] Number of results to return per page @@ -12728,6 +12783,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( department?: Array, free?: boolean | null, id?: Array, + is_learning_material?: boolean | null, learning_format?: Array, level?: Array, limit?: number, @@ -12754,6 +12810,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( department, free, id, + is_learning_material, learning_format, level, limit, @@ -12847,6 +12904,7 @@ export const LearningResourcesUserSubscriptionApiFactory = function ( requestParameters.department, requestParameters.free, requestParameters.id, + requestParameters.is_learning_material, requestParameters.learning_format, requestParameters.level, requestParameters.limit, @@ -12883,6 +12941,7 @@ export const LearningResourcesUserSubscriptionApiFactory = function ( requestParameters.department, requestParameters.free, requestParameters.id, + requestParameters.is_learning_material, requestParameters.learning_format, requestParameters.level, requestParameters.limit, @@ -12918,6 +12977,7 @@ export const LearningResourcesUserSubscriptionApiFactory = function ( requestParameters.department, requestParameters.free, requestParameters.id, + requestParameters.is_learning_material, requestParameters.learning_format, requestParameters.level, requestParameters.limit, @@ -12964,7 +13024,7 @@ export const LearningResourcesUserSubscriptionApiFactory = function ( export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionCheckListRequest { /** * Show resource counts by category - * @type {Array<'resource_type' | 'certification' | 'certification_type' | 'offered_by' | 'platform' | 'topic' | 'department' | 'level' | 'course_feature' | 'professional' | 'free' | 'learning_format'>} + * @type {Array<'resource_type' | 'certification' | 'certification_type' | 'offered_by' | 'platform' | 'topic' | 'department' | 'level' | 'course_feature' | 'professional' | 'free' | 'learning_format' | 'is_learning_material'>} * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionCheckList */ readonly aggregations?: Array @@ -13011,6 +13071,13 @@ export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscr */ readonly id?: Array + /** + * True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path + * @type {boolean} + * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionCheckList + */ + readonly is_learning_material?: boolean | null + /** * The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @type {Array<'online' | 'hybrid' | 'in_person'>} @@ -13104,7 +13171,7 @@ export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscr export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionListRequest { /** * Show resource counts by category - * @type {Array<'resource_type' | 'certification' | 'certification_type' | 'offered_by' | 'platform' | 'topic' | 'department' | 'level' | 'course_feature' | 'professional' | 'free' | 'learning_format'>} + * @type {Array<'resource_type' | 'certification' | 'certification_type' | 'offered_by' | 'platform' | 'topic' | 'department' | 'level' | 'course_feature' | 'professional' | 'free' | 'learning_format' | 'is_learning_material'>} * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionList */ readonly aggregations?: Array @@ -13151,6 +13218,13 @@ export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscr */ readonly id?: Array + /** + * True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path + * @type {boolean} + * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionList + */ + readonly is_learning_material?: boolean | null + /** * The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @type {Array<'online' | 'hybrid' | 'in_person'>} @@ -13237,7 +13311,7 @@ export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscr export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionSubscribeCreateRequest { /** * Show resource counts by category - * @type {Array<'resource_type' | 'certification' | 'certification_type' | 'offered_by' | 'platform' | 'topic' | 'department' | 'level' | 'course_feature' | 'professional' | 'free' | 'learning_format'>} + * @type {Array<'resource_type' | 'certification' | 'certification_type' | 'offered_by' | 'platform' | 'topic' | 'department' | 'level' | 'course_feature' | 'professional' | 'free' | 'learning_format' | 'is_learning_material'>} * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionSubscribeCreate */ readonly aggregations?: Array @@ -13284,6 +13358,13 @@ export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscr */ readonly id?: Array + /** + * True if the learning resource is a podcast, podcast episode, video, video playlist, or learning path + * @type {boolean} + * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionSubscribeCreate + */ + readonly is_learning_material?: boolean | null + /** * The format(s) in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * @type {Array<'online' | 'hybrid' | 'in_person'>} @@ -13418,6 +13499,7 @@ export class LearningResourcesUserSubscriptionApi extends BaseAPI { requestParameters.department, requestParameters.free, requestParameters.id, + requestParameters.is_learning_material, requestParameters.learning_format, requestParameters.level, requestParameters.limit, @@ -13456,6 +13538,7 @@ export class LearningResourcesUserSubscriptionApi extends BaseAPI { requestParameters.department, requestParameters.free, requestParameters.id, + requestParameters.is_learning_material, requestParameters.learning_format, requestParameters.level, requestParameters.limit, @@ -13493,6 +13576,7 @@ export class LearningResourcesUserSubscriptionApi extends BaseAPI { requestParameters.department, requestParameters.free, requestParameters.id, + requestParameters.is_learning_material, requestParameters.learning_format, requestParameters.level, requestParameters.limit, @@ -13548,6 +13632,7 @@ export const LearningResourcesUserSubscriptionCheckListAggregationsEnum = { Professional: "professional", Free: "free", LearningFormat: "learning_format", + IsLearningMaterial: "is_learning_material", } as const export type LearningResourcesUserSubscriptionCheckListAggregationsEnum = (typeof LearningResourcesUserSubscriptionCheckListAggregationsEnum)[keyof typeof LearningResourcesUserSubscriptionCheckListAggregationsEnum] @@ -13728,6 +13813,7 @@ export const LearningResourcesUserSubscriptionListAggregationsEnum = { Professional: "professional", Free: "free", LearningFormat: "learning_format", + IsLearningMaterial: "is_learning_material", } as const export type LearningResourcesUserSubscriptionListAggregationsEnum = (typeof LearningResourcesUserSubscriptionListAggregationsEnum)[keyof typeof LearningResourcesUserSubscriptionListAggregationsEnum] @@ -13900,6 +13986,7 @@ export const LearningResourcesUserSubscriptionSubscribeCreateAggregationsEnum = Professional: "professional", Free: "free", LearningFormat: "learning_format", + IsLearningMaterial: "is_learning_material", } as const export type LearningResourcesUserSubscriptionSubscribeCreateAggregationsEnum = (typeof LearningResourcesUserSubscriptionSubscribeCreateAggregationsEnum)[keyof typeof LearningResourcesUserSubscriptionSubscribeCreateAggregationsEnum] diff --git a/frontends/api/src/test-utils/factories/learningResources.ts b/frontends/api/src/test-utils/factories/learningResources.ts index fb45aaf3ee..26376b54c6 100644 --- a/frontends/api/src/test-utils/factories/learningResources.ts +++ b/frontends/api/src/test-utils/factories/learningResources.ts @@ -224,6 +224,7 @@ const learningResourceCourseNumber: Factory = ( const _learningResourceShared = (): Partial< Omit > => { + const free = Math.random() < 0.5 return { id: uniqueEnforcerId.enforce(() => faker.number.int()), professional: faker.datatype.boolean(), @@ -233,7 +234,8 @@ const _learningResourceShared = (): Partial< image: learningResourceImage(), offered_by: maybe(learningResourceOfferor) ?? null, platform: maybe(learningResourcePlatform) ?? null, - prices: ["0.00"], + free, + prices: free ? ["0"] : [faker.finance.amount({ min: 0, max: 100 })], readable_id: faker.lorem.slug(), course_feature: repeat(faker.lorem.word), runs: [], diff --git a/frontends/mit-open/src/page-components/Profile/CertificateChoice.tsx b/frontends/mit-open/src/page-components/Profile/CertificateChoice.tsx new file mode 100644 index 0000000000..8d2a491f21 --- /dev/null +++ b/frontends/mit-open/src/page-components/Profile/CertificateChoice.tsx @@ -0,0 +1,123 @@ +import React from "react" + +import { + styled, + Grid, + Container, + ChoiceBox, + RadioChoiceField, +} from "ol-components" +import { + CertificateDesiredEnum, + CertificateDesiredEnumDescriptions, +} from "api/v0" + +import type { ProfileFieldUpdateProps, ProfileFieldStateHook } from "./types" + +const CHOICES = [ + CertificateDesiredEnum.Yes, + CertificateDesiredEnum.No, + CertificateDesiredEnum.NotSureYet, +].map((value) => ({ + value, + label: CertificateDesiredEnumDescriptions[value], +})) + +type State = CertificateDesiredEnum | "" +type Props = ProfileFieldUpdateProps<"certificate_desired"> + +const useCertificateChoiceState: ProfileFieldStateHook< + "certificate_desired" +> = (value, onUpdate) => { + const [certificateDesired, setCertificateDesired] = React.useState( + value || "", + ) + + const handleChange = (event: React.ChangeEvent) => { + setCertificateDesired(() => { + return event.target.value as CertificateDesiredEnum + }) + } + + React.useEffect(() => { + onUpdate("certificate_desired", certificateDesired) + }, [certificateDesired, onUpdate]) + + return [certificateDesired, handleChange] +} + +const CertificateChoiceBoxField: React.FC = ({ + value, + label, + onUpdate, +}) => { + const [certificateDesired, handleChange] = useCertificateChoiceState( + value, + onUpdate, + ) + + return ( + <> + {label} + + + {Object.values(CertificateDesiredEnum).map((value, index) => { + const checked = value === certificateDesired + return ( + + + + ) + })} + + + + ) +} + +const RadioContainer = styled.div(({ theme }) => ({ + [theme.breakpoints.up("md")]: { + "& .MuiFormGroup-root": { + flexDirection: "row", + }, + }, +})) + +const CertificateRadioChoiceField: React.FC = ({ + value, + label, + onUpdate, +}) => { + const [certificateDesired, handleChange] = useCertificateChoiceState( + value, + onUpdate, + ) + + return ( + + + + ) +} + +export { CertificateChoiceBoxField, CertificateRadioChoiceField } diff --git a/frontends/mit-open/src/page-components/Profile/EducationLevelChoice.tsx b/frontends/mit-open/src/page-components/Profile/EducationLevelChoice.tsx new file mode 100644 index 0000000000..3a3b345c61 --- /dev/null +++ b/frontends/mit-open/src/page-components/Profile/EducationLevelChoice.tsx @@ -0,0 +1,48 @@ +import React from "react" + +import { + Select, + MenuItem, + FormControl, + FormLabel, + SelectChangeEvent, +} from "ol-components" +import { CurrentEducationEnum, CurrentEducationEnumDescriptions } from "api/v0" + +import { ProfileFieldUpdateProps } from "./types" + +const EducationLevelSelect: React.FC< + ProfileFieldUpdateProps<"current_education"> +> = ({ label, value, onUpdate }) => { + const [educationLevel, setEducationLevel] = React.useState< + CurrentEducationEnum | "" + >(value || "") + + const handleChange = (event: SelectChangeEvent) => { + setEducationLevel(event.target.value as CurrentEducationEnum) + } + + React.useEffect(() => { + onUpdate("current_education", educationLevel) + }, [educationLevel, onUpdate]) + + return ( + + {label} + + + ) +} + +export { EducationLevelSelect } diff --git a/frontends/mit-open/src/page-components/Profile/GoalsChoice.tsx b/frontends/mit-open/src/page-components/Profile/GoalsChoice.tsx new file mode 100644 index 0000000000..36fc27d46b --- /dev/null +++ b/frontends/mit-open/src/page-components/Profile/GoalsChoice.tsx @@ -0,0 +1,137 @@ +import React from "react" +import { + styled, + Checkbox, + CheckboxChoiceBoxField, + ChoiceBoxGridProps, + FormLabel, + FormControl, +} from "ol-components" +import { GoalsEnum, GoalsEnumDescriptions, PatchedProfileRequest } from "api/v0" + +import type { ProfileFieldUpdateProps, ProfileFieldUpdateFunc } from "./types" + +const CHOICES = [ + { + value: GoalsEnum.CareerGrowth, + label: GoalsEnumDescriptions[GoalsEnum.CareerGrowth], + description: "Looking for career growth through new skills & certification", + }, + { + value: GoalsEnum.SupplementalLearning, + label: GoalsEnumDescriptions[GoalsEnum.SupplementalLearning], + description: "Additional learning to integrate with degree work", + }, + { + value: GoalsEnum.JustToLearn, + label: GoalsEnumDescriptions[GoalsEnum.JustToLearn], + description: "I just want more knowledge", + }, +] + +type State = GoalsEnum[] + +const useGoalsChoice = ( + value: PatchedProfileRequest["goals"], + onUpdate: ProfileFieldUpdateFunc<"goals">, +): [State, React.ChangeEventHandler] => { + const [goals, setGoals] = React.useState(value || []) + + const handleToggle = (event: React.SyntheticEvent) => { + setGoals((prevGoals) => { + const target = event.target as HTMLInputElement + if (target.checked) { + return [...prevGoals, target.value as GoalsEnum] + } else { + return prevGoals.filter((goal) => goal !== target.value) + } + }) + } + + React.useEffect(() => { + onUpdate("goals", goals) + }, [goals, onUpdate]) + + return [goals, handleToggle] +} + +function GoalsChoiceBoxField({ + value, + label, + gridProps, + gridItemProps, + onUpdate, +}: ProfileFieldUpdateProps<"goals"> & ChoiceBoxGridProps) { + const [goals, handleToggle] = useGoalsChoice(value, onUpdate) + + return ( + + ) +} + +const CheckboxContainer = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + flexWrap: "wrap", + "& label": { + flexShrink: 0, + }, + [theme.breakpoints.up("md")]: { + display: "flex", + flexDirection: "row", + "& label": { + marginRight: theme.spacing(3), + }, + }, +})) + +function GoalsCheckboxChoiceField({ + label, + value, + onUpdate, +}: ProfileFieldUpdateProps<"goals">) { + const [goals, handleToggle] = useGoalsChoice(value, onUpdate) + + return ( + + + {label} + + + {CHOICES.map((choice) => { + return ( + + ) + })} + + + ) +} +export { GoalsChoiceBoxField, GoalsCheckboxChoiceField } diff --git a/frontends/mit-open/src/page-components/Profile/LearningFormatChoice.tsx b/frontends/mit-open/src/page-components/Profile/LearningFormatChoice.tsx new file mode 100644 index 0000000000..15bfa49134 --- /dev/null +++ b/frontends/mit-open/src/page-components/Profile/LearningFormatChoice.tsx @@ -0,0 +1,96 @@ +import React from "react" +import { + RadioChoiceBoxField, + Select, + SelectChangeEvent, + MenuItem, + FormControl, + FormLabel, +} from "ol-components" +import { LearningFormatEnum, LearningFormatEnumDescriptions } from "api/v0" + +import { ProfileFieldUpdateProps, ProfileFieldStateHook } from "./types" + +const CHOICES = [ + LearningFormatEnum.InPerson, + LearningFormatEnum.Online, + LearningFormatEnum.Hybrid, +].map((value) => ({ + value, + label: LearningFormatEnumDescriptions[value], +})) + +type Props = ProfileFieldUpdateProps<"learning_format"> +type State = LearningFormatEnum | "" + +const useLearningFormatChoice: ProfileFieldStateHook<"learning_format"> = ( + value, + onUpdate, +): [State, React.ChangeEventHandler] => { + const [learningFormat, setLearningFormat] = React.useState(value || "") + + const handleChange = (event: React.SyntheticEvent) => { + setLearningFormat(() => { + const target = event.target as HTMLInputElement + return target.value as LearningFormatEnum + }) + } + + React.useEffect(() => { + onUpdate("learning_format", learningFormat) + }, [learningFormat, onUpdate]) + + return [learningFormat, handleChange] +} + +const LearningFormatChoiceBoxField: React.FC = ({ + label, + value, + onUpdate, +}) => { + const [learningFormat, handleChange] = useLearningFormatChoice( + value, + onUpdate, + ) + + return ( + + ) +} +const LearningFormatSelect: React.FC = ({ label, value, onUpdate }) => { + const [learningFormat, setLearningFormat] = React.useState(value || "") + + const handleChange = (event: SelectChangeEvent) => { + setLearningFormat(() => { + const target = event.target as HTMLInputElement + return target.value as LearningFormatEnum + }) + } + React.useEffect(() => { + onUpdate("learning_format", learningFormat) + }, [learningFormat, onUpdate]) + + return ( + + {label} + + + ) +} + +export { LearningFormatChoiceBoxField, LearningFormatSelect } diff --git a/frontends/mit-open/src/page-components/Profile/TimeCommitmentChoice.tsx b/frontends/mit-open/src/page-components/Profile/TimeCommitmentChoice.tsx new file mode 100644 index 0000000000..26248d1bc7 --- /dev/null +++ b/frontends/mit-open/src/page-components/Profile/TimeCommitmentChoice.tsx @@ -0,0 +1,87 @@ +import React from "react" +import { + FormControl, + FormLabel, + Select, + SelectChangeEvent, + MenuItem, + RadioChoiceBoxField, +} from "ol-components" +import { TimeCommitmentEnum, TimeCommitmentEnumDescriptions } from "api/v0" + +import { ProfileFieldUpdateProps } from "./types" + +const CHOICES = [ + TimeCommitmentEnum._0To5Hours, + TimeCommitmentEnum._5To10Hours, + TimeCommitmentEnum._10To20Hours, + TimeCommitmentEnum._20To30Hours, + TimeCommitmentEnum._30PlusHours, +].map((value) => ({ + value, + label: TimeCommitmentEnumDescriptions[value], +})) + +type Props = ProfileFieldUpdateProps<"time_commitment"> +type State = TimeCommitmentEnum | "" + +const TimeCommitmentRadioChoiceBoxField: React.FC = ({ + label, + value, + onUpdate, +}) => { + const [timeCommitment, setTimeCommitment] = React.useState(value || "") + + const handleChange = (event: React.SyntheticEvent) => { + setTimeCommitment(() => { + const target = event.target as HTMLInputElement + return target.value as TimeCommitmentEnum + }) + } + + React.useEffect(() => { + onUpdate("time_commitment", timeCommitment) + }, [timeCommitment, onUpdate]) + + return ( + + ) +} + +const TimeCommitmentSelect: React.FC = ({ label, value, onUpdate }) => { + const [timeCommitment, setTimeCommitment] = React.useState(value || "") + + const handleChange = (event: SelectChangeEvent) => { + setTimeCommitment(() => { + const target = event.target as HTMLInputElement + return target.value as TimeCommitmentEnum + }) + } + React.useEffect(() => { + onUpdate("time_commitment", timeCommitment) + }, [timeCommitment, onUpdate]) + + return ( + + {label} + + + ) +} + +export { TimeCommitmentRadioChoiceBoxField, TimeCommitmentSelect } diff --git a/frontends/mit-open/src/page-components/Profile/TopicInterestsChoice.tsx b/frontends/mit-open/src/page-components/Profile/TopicInterestsChoice.tsx new file mode 100644 index 0000000000..4d9b5fbf34 --- /dev/null +++ b/frontends/mit-open/src/page-components/Profile/TopicInterestsChoice.tsx @@ -0,0 +1,75 @@ +import React from "react" +import { + CheckboxChoiceBoxField, + ChoiceBoxChoice, + ChoiceBoxGridProps, +} from "ol-components" + +import { useLearningResourceTopics } from "api/hooks/learningResources" +import { ProfileFieldUpdateProps } from "./types" + +type Props = ProfileFieldUpdateProps<"topic_interests"> & ChoiceBoxGridProps + +const TopicInterestsChoiceBoxField: React.FC = ({ + value, + label, + gridProps, + gridItemProps, + onUpdate, +}) => { + const { data: topics } = useLearningResourceTopics({ is_toplevel: true }) + const [choices, setChoices] = React.useState([]) + const [topicInterests, setTopicInterests] = React.useState( + value?.map((topic) => topic.id.toString()) || [], + ) + + React.useEffect(() => { + const choices = topics?.results?.map((topic) => ({ + label: topic.name, + value: topic.id.toString(), + })) + setChoices(choices || []) + }, [topics, setChoices]) + + const handleToggle: React.ChangeEventHandler = (event) => { + const value = event.target.value + setTopicInterests((prevTopicInterests) => { + if (event.target.checked) { + return [...prevTopicInterests, value] + } else { + const update = prevTopicInterests.filter( + (interest) => interest !== value, + ) + return update + } + }) + } + + React.useEffect(() => { + onUpdate("topic_interests", topicInterests.map(Number)) + }, [topicInterests, onUpdate]) + + return ( + + ) +} + +export { TopicInterestsChoiceBoxField } diff --git a/frontends/mit-open/src/page-components/Profile/types.ts b/frontends/mit-open/src/page-components/Profile/types.ts new file mode 100644 index 0000000000..16558a9c47 --- /dev/null +++ b/frontends/mit-open/src/page-components/Profile/types.ts @@ -0,0 +1,22 @@ +import React from "react" + +import type { Profile, PatchedProfileRequest } from "api/v0" + +export type ProfileFieldUpdateable = keyof PatchedProfileRequest & keyof Profile + +export type ProfileFieldUpdateFunc< + FieldName extends ProfileFieldUpdateable = ProfileFieldUpdateable, +> = (name: FieldName, value: PatchedProfileRequest[FieldName]) => void + +export interface ProfileFieldUpdateProps< + FieldName extends ProfileFieldUpdateable, +> { + onUpdate: ProfileFieldUpdateFunc + value?: Profile[FieldName] + label: React.ReactNode +} + +export type ProfileFieldStateHook< + T extends ProfileFieldUpdateable, + E = React.ChangeEventHandler, +> = (value: Profile[T], onUpdate: ProfileFieldUpdateFunc) => [Profile[T], E] diff --git a/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx b/frontends/mit-open/src/page-components/SearchDisplay/SearchInput.test.tsx similarity index 97% rename from frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx rename to frontends/mit-open/src/page-components/SearchDisplay/SearchInput.test.tsx index 4bba5403d4..19c16f95f5 100644 --- a/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx +++ b/frontends/mit-open/src/page-components/SearchDisplay/SearchInput.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event" import { SearchInput } from "./SearchInput" import type { SearchInputProps } from "./SearchInput" import invariant from "tiny-invariant" -import { ThemeProvider } from "../ThemeProvider/ThemeProvider" +import { ThemeProvider } from "ol-components" const getSearchInput = () => { const element = screen.getByLabelText("Search for") diff --git a/frontends/ol-components/src/components/SearchInput/SearchInput.tsx b/frontends/mit-open/src/page-components/SearchDisplay/SearchInput.tsx similarity index 91% rename from frontends/ol-components/src/components/SearchInput/SearchInput.tsx rename to frontends/mit-open/src/page-components/SearchDisplay/SearchInput.tsx index 1c5b520abf..1de61d93d9 100644 --- a/frontends/ol-components/src/components/SearchInput/SearchInput.tsx +++ b/frontends/mit-open/src/page-components/SearchDisplay/SearchInput.tsx @@ -1,13 +1,16 @@ import React, { useCallback } from "react" import ClearIcon from "@mui/icons-material/Clear" -import { Input, AdornmentButton } from "../Input/Input" -import type { InputProps } from "../Input/Input" -import FormGroup from "@mui/material/FormGroup" -import Button from "@mui/material/Button" +import { + Input, + AdornmentButton, + FormGroup, + Button, + styled, + css, +} from "ol-components" +import type { InputProps } from "ol-components" import { RiSearch2Line } from "@remixicon/react" -import styled from "@emotion/styled" -import { css } from "@emotion/react" export interface SearchSubmissionEvent { target: { @@ -45,6 +48,7 @@ const StyledButton = styled(Button)` ${({ theme }) => theme.breakpoints.up("md")} { ${({ theme }) => css({ ...theme.typography.body2 })}; + min-width: 64px; height: 48px; padding: 8px 16px; border-top-right-radius: 8px; @@ -138,8 +142,8 @@ const SearchInput: React.FC = (props) => { } /> ({ const TabPanelStyled = styled(TabPanel)({ padding: "0", + width: "100%", }) const TitleText = styled(Typography)(({ theme }) => ({ @@ -409,13 +411,6 @@ const DashboardPage: React.FC = () => { ) - const contentComingSoon = ( - <> -
- Coming soon... - - ) - return ( @@ -517,7 +512,13 @@ const DashboardPage: React.FC = () => { value={TabValues.PROFILE} > Profile - {contentComingSoon} + {isLoadingProfile || typeof profile === "undefined" ? ( + + ) : ( +
+ +
+ )} diff --git a/frontends/mit-open/src/pages/DashboardPage/ProfileEditForm.tsx b/frontends/mit-open/src/pages/DashboardPage/ProfileEditForm.tsx new file mode 100644 index 0000000000..c37ff080b4 --- /dev/null +++ b/frontends/mit-open/src/pages/DashboardPage/ProfileEditForm.tsx @@ -0,0 +1,143 @@ +import React from "react" +import isEqual from "lodash/isEqual" +import { Profile, useProfileMeMutation } from "api/hooks/profile" +import type { PatchedProfileRequest } from "api/v0" +import { + styled, + Typography, + Grid, + Button, + CircularProgress, +} from "ol-components" + +import { LearningFormatSelect } from "@/page-components/Profile/LearningFormatChoice" +import { GoalsCheckboxChoiceField } from "@/page-components/Profile/GoalsChoice" +import { TopicInterestsChoiceBoxField } from "@/page-components/Profile/TopicInterestsChoice" +import { TimeCommitmentSelect } from "@/page-components/Profile/TimeCommitmentChoice" +import { EducationLevelSelect } from "@/page-components/Profile/EducationLevelChoice" +import { CertificateRadioChoiceField } from "@/page-components/Profile/CertificateChoice" + +import type { ProfileFieldUpdateFunc } from "@/page-components/Profile/types" + +type Props = { + profile: Profile +} + +const FieldLabel = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + ...theme.typography.body1, + marginTop: theme.spacing(3), + marginBottom: theme.spacing(1), + [theme.breakpoints.down("md")]: { + ...theme.typography.subtitle3, + }, +})) + +const ButtonContainer = styled.div(({ theme }) => ({ + marginTop: theme.spacing(3), +})) + +const ProfileEditForm: React.FC = ({ profile }) => { + const [updates, setUpdates] = React.useState({ + learning_format: profile.learning_format, + time_commitment: profile.time_commitment, + goals: profile.goals, + topic_interests: profile.topic_interests?.map((topic) => topic.id) || [], + certificate_desired: profile.certificate_desired, + current_education: profile.current_education, + }) + const { isLoading: isSaving, mutateAsync } = useProfileMeMutation() + const [hasChanges, setHasChanges] = React.useState(false) + + const handleUpdate: ProfileFieldUpdateFunc = < + T extends keyof PatchedProfileRequest, + >( + name: T, + value: PatchedProfileRequest[T], + ) => { + if (!isEqual(updates[name], value)) { + setUpdates((prevUpdates) => ({ + ...prevUpdates, + [name]: value, + })) + setHasChanges(true) + } + } + + const handleSave = () => { + mutateAsync(updates).then(() => { + setHasChanges(false) + }) + } + + return ( + <> + What are you interested in learning about? + } + value={profile.topic_interests} + onUpdate={handleUpdate} + gridProps={{ + columns: { + xl: 12, + lg: 9, + md: 6, + xs: 3, + }, + }} + /> + What do you want to reach?} + value={profile.goals} + onUpdate={handleUpdate} + /> + Are you seeking a certificate?} + value={profile.certificate_desired} + onUpdate={handleUpdate} + /> + + + What is your current level of education? + } + value={profile.current_education} + onUpdate={handleUpdate} + /> + + + + + How much time per week do you want to commit to learning? + + } + value={profile.time_commitment} + onUpdate={handleUpdate} + /> + + + What format are you interested in?} + value={profile.learning_format} + onUpdate={handleUpdate} + /> + + + + + + + ) +} + +export { ProfileEditForm } diff --git a/frontends/mit-open/src/pages/HomePage/HeroSearch.tsx b/frontends/mit-open/src/pages/HomePage/HeroSearch.tsx index 057c1a3e5e..a7ff529819 100644 --- a/frontends/mit-open/src/pages/HomePage/HeroSearch.tsx +++ b/frontends/mit-open/src/pages/HomePage/HeroSearch.tsx @@ -1,13 +1,8 @@ import React, { useState, useCallback } from "react" import { useNavigate } from "react-router" -import { - Typography, - SearchInput, - SearchInputProps, - styled, - ChipLink, -} from "ol-components" +import { Typography, styled, ChipLink } from "ol-components" import type { ChipLinkProps } from "ol-components" +import { SearchInput, SearchInputProps } from "./SearchInput" type SearchChip = { label: string @@ -113,6 +108,11 @@ const ControlsContainer = styled.div(({ theme }) => ({ padding: "12px", gap: "16px", }, + [theme.breakpoints.up("sm")]: { + input: { + paddingLeft: "5px", + }, + }, })) const LinksContainer = styled.div(({ theme }) => ({ width: "100%", diff --git a/frontends/mit-open/src/pages/HomePage/SearchInput.test.tsx b/frontends/mit-open/src/pages/HomePage/SearchInput.test.tsx new file mode 100644 index 0000000000..7d8b757ab0 --- /dev/null +++ b/frontends/mit-open/src/pages/HomePage/SearchInput.test.tsx @@ -0,0 +1,78 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { SearchInput } from "./SearchInput" +import type { SearchInputProps } from "./SearchInput" +import invariant from "tiny-invariant" +import { ThemeProvider } from "ol-components" +const getSearchInput = () => { + const element = screen.getByLabelText("Search for") + invariant(element instanceof HTMLInputElement) + return element +} + +const getSearchButton = (): HTMLButtonElement => { + const button = screen.getByLabelText("Search") + invariant(button instanceof HTMLButtonElement) + return button +} + +/** + * This actually returns an icon (inside a button) + */ +const getClearButton = (): HTMLButtonElement => { + const button = screen.getByLabelText("Clear search text") + invariant(button instanceof HTMLButtonElement) + return button +} + +const searchEvent = (value: string) => + expect.objectContaining({ target: { value } }) + +describe("SearchInput", () => { + const renderSearchInput = (props: Partial = {}) => { + const { value = "", ...otherProps } = props + const onSubmit = jest.fn() + const onChange = jest.fn((e) => e.persist()) + const onClear = jest.fn() + render( + , + { wrapper: ThemeProvider }, + ) + const user = userEvent.setup() + const spies = { onClear, onChange, onSubmit } + return { user, spies } + } + + it("Renders the given value in input", () => { + renderSearchInput({ value: "math" }) + expect(getSearchInput().value).toBe("math") + }) + + it("Calls onChange when text is typed", async () => { + const { user, spies } = renderSearchInput({ value: "math" }) + const input = getSearchInput() + await user.type(getSearchInput(), "s") + expect(spies.onChange).toHaveBeenCalledWith( + expect.objectContaining({ target: input }), + ) + }) + + it("Calls onSubmit when search is clicked", async () => { + const { user, spies } = renderSearchInput({ value: "chemistry" }) + await user.click(getSearchButton()) + expect(spies.onSubmit).toHaveBeenCalledWith(searchEvent("chemistry")) + }) + + it("Calls onClear clear is clicked", async () => { + const { user, spies } = renderSearchInput({ value: "biology" }) + await user.click(getClearButton()) + expect(spies.onClear).toHaveBeenCalled() + }) +}) diff --git a/frontends/mit-open/src/pages/HomePage/SearchInput.tsx b/frontends/mit-open/src/pages/HomePage/SearchInput.tsx new file mode 100644 index 0000000000..74f7f92fb4 --- /dev/null +++ b/frontends/mit-open/src/pages/HomePage/SearchInput.tsx @@ -0,0 +1,89 @@ +import React, { useCallback } from "react" +import { RiSearch2Line } from "@remixicon/react" +import ClearIcon from "@mui/icons-material/Clear" +import { Input, AdornmentButton } from "ol-components" +import type { InputProps } from "ol-components" + +export interface SearchSubmissionEvent { + target: { + value: string + } + /** + * Deprecated. course-search-utils calls unnecessarily. + */ + preventDefault: () => void +} + +type SearchSubmitHandler = (event: SearchSubmissionEvent) => void + +interface SearchInputProps { + className?: string + classNameClear?: string + classNameSearch?: string + value: string + placeholder?: string + autoFocus?: boolean + onChange: React.ChangeEventHandler + onClear: React.MouseEventHandler + onSubmit: SearchSubmitHandler + size?: InputProps["size"] + fullWidth?: boolean +} + +const muiInputProps = { "aria-label": "Search for" } + +const SearchInput: React.FC = (props) => { + const { onSubmit, value } = props + const handleSubmit = useCallback(() => { + const event = { + target: { value }, + preventDefault: () => null, + } + onSubmit(event) + }, [onSubmit, value]) + const onInputKeyDown: React.KeyboardEventHandler = + useCallback( + (e) => { + if (e.key !== "Enter") return + handleSubmit() + }, + [handleSubmit], + ) + + return ( + + + + } + endAdornment={ + props.value && ( + + + + ) + } + /> + ) +} + +export { SearchInput } +export type { SearchInputProps } diff --git a/frontends/mit-open/src/pages/HomePage/TestimonialsSection.tsx b/frontends/mit-open/src/pages/HomePage/TestimonialsSection.tsx index a40b2bc043..c76eeb3c58 100644 --- a/frontends/mit-open/src/pages/HomePage/TestimonialsSection.tsx +++ b/frontends/mit-open/src/pages/HomePage/TestimonialsSection.tsx @@ -220,7 +220,7 @@ const SlickCarousel = () => { > diff --git a/frontends/mit-open/src/pages/OnboardingPage/CertificateStep.tsx b/frontends/mit-open/src/pages/OnboardingPage/CertificateStep.tsx deleted file mode 100644 index db10b1e6ac..0000000000 --- a/frontends/mit-open/src/pages/OnboardingPage/CertificateStep.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from "react" - -import { Grid, Container, ChoiceBox } from "ol-components" -import { - CertificateDesiredEnum, - CertificateDesiredEnumDescriptions, -} from "api/v0" - -import { StepProps } from "./types" - -import Prompt from "./Prompt" - -function CertificateStep({ profile, onUpdate }: StepProps) { - const [certificateDesired, setCertificateDesired] = React.useState< - CertificateDesiredEnum | "" - >(profile.certificate_desired || "") - - const handleToggle = (event: React.SyntheticEvent) => { - setCertificateDesired(() => { - const target = event.target as HTMLInputElement - return target.value as CertificateDesiredEnum - }) - } - - React.useEffect(() => { - onUpdate({ certificate_desired: certificateDesired }) - }, [certificateDesired, onUpdate]) - - return ( - <> -

Are you seeking to receive a certificate?

- Select one: - - - {Object.values(CertificateDesiredEnum).map((value, index) => { - const checked = value === certificateDesired - return ( - - - - ) - })} - - - - ) -} - -export default CertificateStep diff --git a/frontends/mit-open/src/pages/OnboardingPage/EducationLevelStep.tsx b/frontends/mit-open/src/pages/OnboardingPage/EducationLevelStep.tsx deleted file mode 100644 index e508940f34..0000000000 --- a/frontends/mit-open/src/pages/OnboardingPage/EducationLevelStep.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from "react" - -import { - Select, - MenuItem, - SelectChangeEvent, - Container, - FormControl, -} from "ol-components" -import { CurrentEducationEnum, CurrentEducationEnumDescriptions } from "api/v0" - -import { StepProps } from "./types" - -function EducationLevelStep({ profile, onUpdate }: StepProps) { - const [educationLevel, setEducationLevel] = React.useState< - CurrentEducationEnum | "" - >(profile.current_education || "") - - const onChange = ( - event: SelectChangeEvent, - _: React.ReactNode, - ) => { - setEducationLevel(() => { - const target = event.target as HTMLInputElement - return target.value as CurrentEducationEnum - }) - } - - React.useEffect(() => { - onUpdate({ current_education: educationLevel }) - }, [educationLevel, onUpdate]) - - return ( - <> -

What is your current level of education?

- - - - - - - ) -} - -export default EducationLevelStep diff --git a/frontends/mit-open/src/pages/OnboardingPage/GoalsStep.tsx b/frontends/mit-open/src/pages/OnboardingPage/GoalsStep.tsx deleted file mode 100644 index afee2af6a2..0000000000 --- a/frontends/mit-open/src/pages/OnboardingPage/GoalsStep.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from "react" -import { Grid, Container, ChoiceBox } from "ol-components" -import { GoalsEnum, GoalsEnumDescriptions } from "api/v0" - -import Prompt from "./Prompt" -import type { StepProps } from "./types" - -const GoalsDescriptions: Record = { - [GoalsEnum.CareerGrowth]: - "Looking for career growth through new skills & certification", - [GoalsEnum.SupplementalLearning]: - "Additional learning to integrate with degree work", - [GoalsEnum.JustToLearn]: "I just want more knowledge", -} - -function GoalsStep({ profile, onUpdate }: StepProps) { - const [goals, setGoals] = React.useState(profile.goals || []) - - const handleToggle = (event: React.SyntheticEvent) => { - setGoals((prevGoals) => { - const target = event.target as HTMLInputElement - if (target.checked) { - return [...prevGoals, target.value as GoalsEnum] - } else { - return prevGoals.filter((goal) => goal !== target.value) - } - }) - } - - React.useEffect(() => { - onUpdate({ goals }) - }, [goals, onUpdate]) - - return ( - <> -

What do you want MIT online education to help you reach?

- Select all that apply: - - - {Object.values(GoalsEnum).map((value, index) => { - const checked = goals.includes(value) - return ( - - - - ) - })} - - - - ) -} - -export default GoalsStep diff --git a/frontends/mit-open/src/pages/OnboardingPage/LearningFormatStep.tsx b/frontends/mit-open/src/pages/OnboardingPage/LearningFormatStep.tsx deleted file mode 100644 index 566329d892..0000000000 --- a/frontends/mit-open/src/pages/OnboardingPage/LearningFormatStep.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from "react" -import { Grid, Container, ChoiceBox } from "ol-components" -import { LearningFormatEnum, LearningFormatEnumDescriptions } from "api/v0" - -import Prompt from "./Prompt" -import { StepProps } from "./types" - -function LearningFormatStep({ onUpdate, profile }: StepProps) { - const [learningFormat, setLearningFormat] = React.useState< - LearningFormatEnum | "" - >(profile.learning_format || "") - - const handleToggle = (event: React.SyntheticEvent) => { - setLearningFormat(() => { - const target = event.target as HTMLInputElement - return target.value as LearningFormatEnum - }) - } - - React.useEffect(() => { - onUpdate({ learning_format: learningFormat }) - }, [learningFormat, onUpdate]) - - return profile ? ( - <> -

What course format are you interested in?

- Select one: - - - {Object.values(LearningFormatEnum).map((value, index) => { - const checked = learningFormat === value - return ( - - - - ) - })} - - - - ) : null -} -export default LearningFormatStep diff --git a/frontends/mit-open/src/pages/OnboardingPage/OnboardingPage.tsx b/frontends/mit-open/src/pages/OnboardingPage/OnboardingPage.tsx index 15c8265d1e..a694cef1e2 100644 --- a/frontends/mit-open/src/pages/OnboardingPage/OnboardingPage.tsx +++ b/frontends/mit-open/src/pages/OnboardingPage/OnboardingPage.tsx @@ -1,7 +1,7 @@ import React from "react" import { useNavigate } from "react-router-dom" -import isEmpty from "lodash/isEmpty" -import isMatch from "lodash/isMatch" +import isEqual from "lodash/isEqual" +import range from "lodash/range" import { styled, Step, @@ -12,48 +12,24 @@ import { Button, LoadingSpinner, CircularProgress, + Typography, } from "ol-components" import { MetaTags } from "ol-utilities" import { RiArrowRightLine, RiArrowLeftLine } from "@remixicon/react" import { PatchedProfileRequest } from "api/v0" -import LearningFormatStep from "./LearningFormatStep" -import GoalsStep from "./GoalsStep" -import TopicInterestsStep from "./TopicInterestsStep" -import TimeCommitmentStep from "./TimeCommitmentStep" -import EducationLevelStep from "./EducationLevelStep" -import CertificateStep from "./CertificateStep" +import { LearningFormatChoiceBoxField } from "@/page-components/Profile/LearningFormatChoice" +import { GoalsChoiceBoxField } from "@/page-components/Profile/GoalsChoice" +import { TopicInterestsChoiceBoxField } from "@/page-components/Profile/TopicInterestsChoice" +import { TimeCommitmentRadioChoiceBoxField } from "@/page-components/Profile/TimeCommitmentChoice" +import { EducationLevelSelect } from "@/page-components/Profile/EducationLevelChoice" +import { CertificateChoiceBoxField } from "@/page-components/Profile/CertificateChoice" import { useProfileMeMutation, useProfileMeQuery } from "api/hooks/profile" -import { DASHBOARD } from "../../common/urls" +import { DASHBOARD } from "@/common/urls" -import type { StepProps, StepUpdateFunc } from "./types" +import type { ProfileFieldUpdateFunc } from "@/page-components/Profile/types" -const StepNames = { - TopicInterests: "topic_interests", - Goals: "goals", - Certificate: "certificate", - EducationLevel: "education_level", - TimeCommitment: "time_commitment", - LearningFormat: "course_format", -} - -const STEPS = [ - StepNames.TopicInterests, - StepNames.Goals, - StepNames.Certificate, - StepNames.EducationLevel, - StepNames.TimeCommitment, - StepNames.LearningFormat, -] - -const STEP_COMPONENTS: Record> = { - [StepNames.TopicInterests]: TopicInterestsStep, - [StepNames.Goals]: GoalsStep, - [StepNames.Certificate]: CertificateStep, - [StepNames.EducationLevel]: EducationLevelStep, - [StepNames.TimeCommitment]: TimeCommitmentStep, - [StepNames.LearningFormat]: LearningFormatStep, -} +const NUM_STEPS = 6 const FlexContainer = styled(Container)({ display: "flex", @@ -71,7 +47,7 @@ const StepContainer = styled(Container)({ "& .MuiStep-root": { padding: 0, }, - // the two following rules work in concert to cetner the + // the two following rules work in concert to center the // this makes the empty div take up all the left space "& > div:first-child": { flex: 1, @@ -123,22 +99,34 @@ function StepIcon(props: StepIconProps) { return } +const Title = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.black, +})) as typeof Typography + +const Prompt = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const Label = styled.div({ + textAlign: "center", + margin: "0 0 40px", +}) + const OnboardingPage: React.FC = () => { const [updates, setUpdates] = React.useState({}) - const profile = useProfileMeQuery() - const profileMutation = useProfileMeMutation() + const { data: profile, isLoading: isLoadingProfile } = useProfileMeQuery() + const { isLoading: isSaving, mutateAsync } = useProfileMeMutation() const [activeStep, setActiveStep] = React.useState(0) - const [isStepValid, setisStepValid] = React.useState(false) const navigate = useNavigate() const handleNext = () => { // TODO: handle this error - profileMutation.mutateAsync(updates).then(() => { + mutateAsync(updates).then(() => { setActiveStep((prevActiveStep) => prevActiveStep + 1) }) } const handleFinish = () => { - profileMutation.mutateAsync(updates).then(() => { + mutateAsync(updates).then(() => { navigate(DASHBOARD) }) } @@ -147,18 +135,23 @@ const OnboardingPage: React.FC = () => { setActiveStep((prevActiveStep) => prevActiveStep - 1) } - const handleUpdate: StepUpdateFunc = (newUpdates) => { - if (!isMatch(updates, newUpdates)) { - setUpdates(newUpdates) - setisStepValid( - Object.values(newUpdates).every((value) => !isEmpty(value)), - ) + const handleUpdate: ProfileFieldUpdateFunc = < + T extends keyof PatchedProfileRequest, + >( + name: T, + value: PatchedProfileRequest[T], + ) => { + if (!isEqual(updates[name], value)) { + setUpdates({ + [name]: value, + }) } } + if (typeof profile === "undefined") { + return null + } - const StepComponent = STEP_COMPONENTS[STEPS[activeStep]] - - return activeStep < STEPS.length ? ( + return activeStep < NUM_STEPS ? ( Onboarding @@ -166,9 +159,9 @@ const OnboardingPage: React.FC = () => {
- {STEPS.map((name, index) => ( + {range(NUM_STEPS).map((index) => ( index} active={activeStep === index} > @@ -177,49 +170,135 @@ const OnboardingPage: React.FC = () => { ))} - {activeStep + 1}/{STEPS.length} + {activeStep + 1}/{NUM_STEPS} - {profile.isLoading ? : null} - {profile.isSuccess ? ( - - ) : null} + {isLoadingProfile ? ( + + ) : ( + <> + {activeStep === 0 ? ( + + + + Welcome{profile.name ? `, ${profile.name}` : ""}! What are + you interested in learning about? + + Select all that apply: + + } + value={profile.topic_interests} + onUpdate={handleUpdate} + /> + + ) : null} + {activeStep === 1 ? ( + + + + What do you want MIT online education to help you reach? + + Select all that apply: + + } + value={profile.goals} + onUpdate={handleUpdate} + /> + + ) : null} + {activeStep === 2 ? ( + + + + Are you seeking to receive a certificate? + + Select one: + + } + value={profile.certificate_desired} + onUpdate={handleUpdate} + /> + + ) : null} + {activeStep === 3 ? ( + + + + What is your current level of education? + + + } + value={profile.current_education} + onUpdate={handleUpdate} + /> + + ) : null} + {activeStep === 4 ? ( + + + + How much time per week do you want to commit to learning? + + Select one: + + } + value={profile.time_commitment} + onUpdate={handleUpdate} + /> + + ) : null} + {activeStep === 5 ? ( + + + + What course format are you interested in? + + Select one: + + } + value={profile.learning_format} + onUpdate={handleUpdate} + /> + + ) : null} + + )} {activeStep > 0 ? ( ) : null} - {activeStep < STEPS.length - 1 ? ( + {activeStep < NUM_STEPS - 1 ? ( ) : ( diff --git a/frontends/mit-open/src/pages/OnboardingPage/Prompt.tsx b/frontends/mit-open/src/pages/OnboardingPage/Prompt.tsx deleted file mode 100644 index 822aaeb0fb..0000000000 --- a/frontends/mit-open/src/pages/OnboardingPage/Prompt.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { styled } from "ol-components" - -const Prompt = styled.p(({ theme }) => ({ - color: theme.custom.colors.silverGrayDark, - margin: "0 0 40px", -})) - -export default Prompt diff --git a/frontends/mit-open/src/pages/OnboardingPage/TimeCommitmentStep.tsx b/frontends/mit-open/src/pages/OnboardingPage/TimeCommitmentStep.tsx deleted file mode 100644 index 138e2f49f7..0000000000 --- a/frontends/mit-open/src/pages/OnboardingPage/TimeCommitmentStep.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from "react" -import { Grid, Container, ChoiceBox } from "ol-components" -import { TimeCommitmentEnum, TimeCommitmentEnumDescriptions } from "api/v0" - -import Prompt from "./Prompt" -import { StepProps } from "./types" - -function TimeCommitmentStep({ onUpdate, profile }: StepProps) { - const [timeCommitment, setTimeCommitment] = React.useState< - TimeCommitmentEnum | "" - >(profile.time_commitment || "") - - const handleToggle = (event: React.SyntheticEvent) => { - setTimeCommitment(() => { - const target = event.target as HTMLInputElement - return target.value as TimeCommitmentEnum - }) - } - - React.useEffect(() => { - onUpdate({ time_commitment: timeCommitment }) - }, [timeCommitment, onUpdate]) - - return profile ? ( - <> -

How much time per week do you want to commit to learning?

- Select one: - - - {Object.values(TimeCommitmentEnum).map((value, index) => { - const checked = timeCommitment === value - return ( - - - - ) - })} - - - - ) : null -} -export default TimeCommitmentStep diff --git a/frontends/mit-open/src/pages/OnboardingPage/TopicInterestsStep.tsx b/frontends/mit-open/src/pages/OnboardingPage/TopicInterestsStep.tsx deleted file mode 100644 index 86757bf69e..0000000000 --- a/frontends/mit-open/src/pages/OnboardingPage/TopicInterestsStep.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from "react" -import { Grid, Container, ChoiceBox } from "ol-components" - -import { useLearningResourceTopics } from "api/hooks/learningResources" -import Prompt from "./Prompt" -import { StepProps } from "./types" - -function TopicInterestsStep({ onUpdate, profile }: StepProps) { - const { data: topics } = useLearningResourceTopics({ is_toplevel: true }) - const [topicInterests, setTopicInterests] = React.useState( - profile.topic_interests?.map((topic) => topic.id) || [], - ) - - const handleToggle = (value: number, checked: boolean) => { - setTopicInterests((prevTopicInterests) => { - if (checked) { - return [...prevTopicInterests, value] - } else { - return prevTopicInterests.filter((interest) => interest !== value) - } - }) - } - - React.useEffect(() => { - onUpdate({ topic_interests: topicInterests }) - }, [topicInterests, onUpdate]) - - return profile ? ( - <> -

- Welcome{profile.name ? `, ${profile.name}` : ""}! What are you - interested in learning about? -

- Select all that apply: - - - {topics?.results?.map((topic, index: number) => { - const value = topic.id - const checked = topicInterests.includes(value) - return ( - - - handleToggle(value, event.target.checked) - } - checked={checked} - /> - - ) - })} - - - - ) : null -} -export default TopicInterestsStep diff --git a/frontends/mit-open/src/pages/OnboardingPage/types.ts b/frontends/mit-open/src/pages/OnboardingPage/types.ts deleted file mode 100644 index 554ec4c00d..0000000000 --- a/frontends/mit-open/src/pages/OnboardingPage/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PatchedProfileRequest } from "api/v0" -import { Profile } from "api/hooks/profile" - -export type StepUpdateFunc = (fields: PatchedProfileRequest) => void - -export interface StepProps { - onUpdate: StepUpdateFunc - profile: Profile -} diff --git a/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx index f8ffcd382f..4c0becb5e1 100644 --- a/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx +++ b/frontends/mit-open/src/pages/SearchPage/SearchPage.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useMemo } from "react" -import { styled, Container, SearchInput, Grid } from "ol-components" +import { styled, Container, Grid } from "ol-components" import { MetaTags, capitalize } from "ol-utilities" import SearchDisplay from "@/page-components/SearchDisplay/SearchDisplay" +import { SearchInput } from "@/page-components/SearchDisplay/SearchInput" import type { LearningResourceOfferor } from "api" import { useOfferorsList } from "api/hooks/learningResources" diff --git a/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.tsx b/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.tsx index afb3e9a755..249bd03057 100644 --- a/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.tsx +++ b/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.tsx @@ -28,10 +28,13 @@ import { useNavigate } from "react-router" import * as urls from "@/common/urls" import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" -const ListHeaderGrid = styled(Grid)` - margin-top: 1rem; - margin-bottom: 1rem; -` +const PageContainer = styled(Container)({ + marginTop: "1rem", +}) + +const ListHeaderGrid = styled(Grid)({ + marginBottom: "1rem", +}) type EditUserListMenuProps = { userList: UserList @@ -164,12 +167,12 @@ const UserListListingPage: React.FC = () => { User Lists - + - + ) } diff --git a/frontends/ol-components/src/components/ChoiceBox/ChoiceBox.tsx b/frontends/ol-components/src/components/ChoiceBox/ChoiceBox.tsx index 6454dd4ed9..095b3c8dc8 100644 --- a/frontends/ol-components/src/components/ChoiceBox/ChoiceBox.tsx +++ b/frontends/ol-components/src/components/ChoiceBox/ChoiceBox.tsx @@ -7,6 +7,11 @@ import { RiCheckboxBlankCircleLine, } from "@remixicon/react" +import FormControl from "@mui/material/FormControl" +import FormGroup from "@mui/material/FormGroup" +import FormLabel from "@mui/material/FormLabel" +import Grid, { type GridProps } from "@mui/material/Grid" + const Label = styled.label(({ theme }) => { const colors = theme.custom.colors return { @@ -123,5 +128,119 @@ const ChoiceBox = ({ ) } -export { ChoiceBox } -export type { ChoiceBoxProps } +interface ChoiceBoxChoice { + value: string + label: string + description?: string +} + +type FieldGridProps = Omit + +interface ChoiceBoxGridProps { + gridProps?: FieldGridProps + gridItemProps?: FieldGridProps +} + +interface BaseChoiceBoxFieldProps extends ChoiceBoxGridProps { + label: React.ReactNode + choices: ChoiceBoxChoice[] + onChange: ChoiceBoxProps["onChange"] + className?: string +} + +interface ChoiceBoxFieldProps extends BaseChoiceBoxFieldProps { + type: ChoiceBoxProps["type"] + isChecked: (choice: ChoiceBoxChoice) => boolean +} + +const ChoiceBoxField: React.FC = ({ + label, + choices, + type, + isChecked, + onChange, + className, + gridProps, + gridItemProps, +}: ChoiceBoxFieldProps) => { + const fieldGridProps: GridProps = { + spacing: 2, + justifyContent: "center", + columns: { + lg: 12, + xs: 4, + }, + ...gridProps, + } + const fieldGridItemProps = { + ...gridItemProps, + } + return ( + + + {label} + + + + {choices.map((choice, index) => ( + + + + ))} + + + + ) +} + +interface CheckboxChoiceBoxFieldProps extends BaseChoiceBoxFieldProps { + values?: string[] +} + +const CheckboxChoiceBoxField: React.FC = ({ + values, + ...props +}) => { + return ( + values?.indexOf(choice.value) !== -1} + {...props} + /> + ) +} + +interface RadioChoiceBoxFieldProps extends BaseChoiceBoxFieldProps { + value?: string +} + +const RadioChoiceBoxField: React.FC = ({ + value, + ...props +}) => { + return ( + choice.value === value} + {...props} + /> + ) +} + +export { ChoiceBox, CheckboxChoiceBoxField, RadioChoiceBoxField } +export type { + ChoiceBoxProps, + ChoiceBoxChoice, + ChoiceBoxGridProps, + CheckboxChoiceBoxFieldProps, + RadioChoiceBoxFieldProps, +} diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx index 716fce9c09..ce142c091f 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.stories.tsx @@ -73,11 +73,26 @@ export default meta type Story = StoryObj -export const Course: Story = { +export const PaidCourse: Story = { args: { resource: makeResource({ resource_type: ResourceTypeEnum.Course, runs: [factories.learningResources.run()], + free: false, + certification: true, + prices: ["999"], + }), + }, +} + +export const FreeCourse: Story = { + args: { + resource: makeResource({ + resource_type: ResourceTypeEnum.Course, + runs: [factories.learningResources.run()], + free: true, + certification: true, + prices: ["0", "400"], }), }, } @@ -96,13 +111,19 @@ export const Program: Story = { export const Podcast: Story = { args: { - resource: makeResource({ resource_type: ResourceTypeEnum.Podcast }), + resource: makeResource({ + resource_type: ResourceTypeEnum.Podcast, + free: true, + }), }, } export const PodcastEpisode: Story = { args: { - resource: makeResource({ resource_type: ResourceTypeEnum.PodcastEpisode }), + resource: makeResource({ + resource_type: ResourceTypeEnum.PodcastEpisode, + free: true, + }), }, } @@ -111,6 +132,7 @@ export const Video: Story = { resource: makeResource({ resource_type: ResourceTypeEnum.Video, url: "https://www.youtube.com/watch?v=4A9bGL-_ilA", + free: true, }), }, } @@ -119,6 +141,7 @@ export const VideoPlaylist: Story = { args: { resource: makeResource({ resource_type: ResourceTypeEnum.VideoPlaylist, + free: true, }), }, } diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx index 9e9171c0ea..3ccac0b182 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.test.tsx @@ -169,4 +169,78 @@ describe("Learning Resource List Card", () => { expect(matching.length).toBe(1) expect(matching[0]).toHaveAttribute("alt", expected.alt) }) + + describe("Price display", () => { + test('Free course without certificate option displays "Free"', () => { + const resource = factories.learningResources.resource({ + certification: false, + free: true, + prices: ["0"], + }) + setup(resource) + screen.getByText("Free") + }) + + test('Free course with paid certificate option displays the certificate price and "Free"', () => { + const resource = factories.learningResources.resource({ + certification: true, + free: true, + prices: ["0", "49"], + }) + setup(resource) + screen.getByText("Certificate: $49") + screen.getByText("Free") + }) + + test('Free course with paid certificate option range displays the certificate price range and "Free". Prices are sorted correctly', () => { + const resource = factories.learningResources.resource({ + certification: true, + free: true, + prices: ["0", "99", "49"], + }) + setup(resource) + screen.getByText("Certificate: $49 - $99") + screen.getByText("Free") + }) + + test("Paid course without certificate option displays the course price", () => { + const resource = factories.learningResources.resource({ + certification: false, + free: false, + prices: ["49"], + }) + setup(resource) + screen.getByText("$49") + }) + + test("Amount with currency subunits are displayed to 2 decimal places", () => { + const resource = factories.learningResources.resource({ + certification: false, + free: false, + prices: ["49.50"], + }) + setup(resource) + screen.getByText("$49.50") + }) + + test('Free course with empty prices array displays "Free"', () => { + const resource = factories.learningResources.resource({ + certification: false, + free: true, + prices: [], + }) + setup(resource) + screen.getByText("Free") + }) + + test('Paid course that has zero price (prices not ingested) displays "Paid"', () => { + const resource = factories.learningResources.resource({ + certification: false, + free: false, + prices: ["0"], + }) + setup(resource) + screen.getByText("Paid") + }) + }) }) diff --git a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx index d476927895..bafa231907 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx +++ b/frontends/ol-components/src/components/LearningResourceCard/LearningResourceListCard.tsx @@ -94,29 +94,142 @@ const getEmbedlyUrl = (url: string, isMobile: boolean) => { }) } -const getPrice = (resource: LearningResource) => { +type Prices = { + course: null | number + certificate: null | number +} + +const getPrices = (resource: LearningResource) => { + const prices: Prices = { + course: null, + certificate: null, + } + if (!resource) { + return prices + } + + const resourcePrices = resource.prices + .map((price) => Number(price)) + .sort((a, b) => a - b) + + if (resourcePrices.length > 1) { + /* The resource is free and offers a paid certificate option, e.g. + * { prices: [0, 49], free: true, certification: true } + */ + if (resource.certification && resource.free) { + const certificatedPrices = resourcePrices.filter((price) => price > 0) + return { + course: 0, + certificate: + certificatedPrices.length === 1 + ? certificatedPrices[0] + : [ + certificatedPrices[0], + certificatedPrices[certificatedPrices.length - 1], + ], + } + } + + /* The resource is not free and has a range of prices, e.g. + * { prices: [950, 999], free: false, certification: true|false } + */ + if (resource.certification && !resource.free && Number(resourcePrices[0])) { + return { + course: [resourcePrices[0], resourcePrices[resourcePrices.length - 1]], + certificate: null, + } + } + + /* The resource is not free but has a zero price option (prices not ingested correctly) + * { prices: [0, 999], free: false, certification: true|false } + */ + if (!resource.free && !Number(resourcePrices[0])) { + return { + course: +Infinity, + certificate: null, + } + } + + /* We are not expecting multiple prices for courses with no certificate option. + * For resourses always certificated, there is one price that includes the certificate. + */ + } else if (resourcePrices.length === 1) { + if (!Number(resourcePrices[0])) { + /* Sometimes price info is missing, but the free flag is reliable. + */ + if (!resource.free) { + return { + course: +Infinity, + certificate: null, + } + } + + return { + course: 0, + certificate: null, + } + } else { + /* If the course has no free option, the price of the certificate + * is included in the price of the course. + */ + return { + course: Number(resourcePrices[0]), + certificate: null, + } + } + } else if (resourcePrices.length === 0) { + return { + course: resource.free ? 0 : +Infinity, + certificate: null, + } + } + + return prices +} + +const getDisplayPrecision = (price: number) => { + if (Number.isInteger(price)) { + return price.toFixed(0) + } + return price.toFixed(2) +} + +const getDisplayPrice = (price: number | number[] | null) => { + if (price === null) { return null } - const price = resource.prices?.[0] - if (resource.free) { + if (price === 0) { return "Free" } - return price ? `$${price}` : null + if (price === +Infinity) { + return "Paid" + } + if (Array.isArray(price)) { + return `$${getDisplayPrecision(price[0])} - $${getDisplayPrecision(price[1])}` + } + return `$${getDisplayPrecision(price)}` } +/* This displays a single price for courses with no free option + * (price includes the certificate). For free courses with the + * option of a paid certificate, the certificate price displayed + * in the certificate badge alongside the course "Free" price. + */ const Info = ({ resource }: { resource: LearningResource }) => { - const price = getPrice(resource) + const prices = getPrices(resource) + getDisplayPrice(+Infinity) return ( <> {getReadableResourceType(resource.resource_type)} {resource.certification && ( - Certificate + Certificate{prices?.certificate ? ":" : ""}{" "} + {getDisplayPrice(prices?.certificate)} )} - {price && {price}} + {getDisplayPrice(prices?.course)} ) } diff --git a/frontends/ol-components/src/components/RadioChoiceField/RadioChoiceField.tsx b/frontends/ol-components/src/components/RadioChoiceField/RadioChoiceField.tsx index 380f8acdac..e643477b0e 100644 --- a/frontends/ol-components/src/components/RadioChoiceField/RadioChoiceField.tsx +++ b/frontends/ol-components/src/components/RadioChoiceField/RadioChoiceField.tsx @@ -13,7 +13,7 @@ interface RadioChoiceProps { } interface RadioChoiceFieldProps { - label: string // We could make this optional, but we should demand one of (label, aria-label, aria-labelledby) + label: React.ReactNode // We could make this optional, but we should demand one of (label, aria-label, aria-labelledby) value?: string defaultValue?: string name: string diff --git a/frontends/ol-components/src/components/SearchInput/SearchInput.stories.tsx b/frontends/ol-components/src/components/SearchInput/SearchInput.stories.tsx deleted file mode 100644 index 963cee28da..0000000000 --- a/frontends/ol-components/src/components/SearchInput/SearchInput.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { useState } from "react" -import type { Meta, StoryObj } from "@storybook/react" -import { - SearchInput, - SearchInputProps, - SearchSubmissionEvent, -} from "./SearchInput" - -function StateWrapper(props: SearchInputProps) { - const [value, setValue] = useState(props.value) - const onChange = (event: React.ChangeEvent) => { - props.onChange?.(event) - setValue(event.currentTarget.value) - } - const onClear = (event: React.MouseEvent) => { - props.onClear(event) - setValue("") - } - const onSubmit = (event: SearchSubmissionEvent) => { - props.onSubmit(event) - setValue("") - } - return ( - - ) -} - -const meta: Meta = { - title: "ol-components/SearchInput", - component: StateWrapper, - argTypes: { - onChange: { - action: "changed", - }, - onClear: { - action: "cleared", - }, - onSubmit: { - action: "submitted", - }, - }, -} - -export default meta - -type Story = StoryObj - -export const Simple: Story = { - args: { - className: "", - classNameClear: "", - classNameSearch: "", - placeholder: "Placeholder", - autoFocus: true, - }, -} diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts index 28258947bb..ee117c05d4 100644 --- a/frontends/ol-components/src/index.ts +++ b/frontends/ol-components/src/index.ts @@ -153,6 +153,7 @@ export { default as StepLabel } from "@mui/material/StepLabel" export type { StepIconProps } from "@mui/material/StepIcon" export { default as CircularProgress } from "@mui/material/CircularProgress" +export { default as FormGroup } from "@mui/material/FormGroup" export * from "./components/Alert/Alert" export * from "./components/BasicDialog/BasicDialog" @@ -170,7 +171,6 @@ export * from "./components/LoadingSpinner/LoadingSpinner" export * from "./components/Logo/Logo" export * from "./components/RoutedDrawer/RoutedDrawer" export * from "./components/NavDrawer/NavDrawer" -export * from "./components/SearchInput/SearchInput" export * from "./components/SimpleMenu/SimpleMenu" export * from "./components/SortableList/SortableList" export * from "./components/ShareTooltip/ShareTooltip" diff --git a/learning_resources_search/api.py b/learning_resources_search/api.py index 8147900ce1..2f7fe833ce 100644 --- a/learning_resources_search/api.py +++ b/learning_resources_search/api.py @@ -38,7 +38,7 @@ LEARN_SUGGEST_FIELDS = ["title.trigram", "description.trigram"] COURSENUM_SORT_FIELD = "course.course_numbers.sort_coursenum" -DEFAULT_SORT = "-created_on" +DEFAULT_SORT = ["is_learning_material", "-created_on"] def gen_content_file_id(content_file_id): @@ -551,7 +551,7 @@ def construct_search(search_params): sort = generate_sort_clause(search_params) search = search.sort(sort) elif not search_params.get("q"): - search = search.sort(DEFAULT_SORT) + search = search.sort(*DEFAULT_SORT) if search_params.get("endpoint") == CONTENT_FILE_TYPE: query_type_query = {"exists": {"field": "content_type"}} @@ -598,7 +598,6 @@ def execute_learn_search(search_params): """ search = construct_search(search_params) - return search.execute().to_dict() diff --git a/learning_resources_search/api_test.py b/learning_resources_search/api_test.py index 5498ab2544..144b4b721d 100644 --- a/learning_resources_search/api_test.py +++ b/learning_resources_search/api_test.py @@ -1428,6 +1428,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "course.course_numbers.sort_coursenum", "course.course_numbers.primary", "resource_relations", + "is_learning_material", ] }, } @@ -1630,6 +1631,7 @@ def test_execute_learn_search_for_content_file_query(opensearch): "course.course_numbers.sort_coursenum", "course.course_numbers.primary", "resource_relations", + "is_learning_material", ] }, } @@ -1760,7 +1762,7 @@ def test_document_percolation(opensearch, mocker): [ ("-views", None, [{"views": {"order": "desc"}}]), ("-views", "text", [{"views": {"order": "desc"}}]), - (None, None, [{"created_on": {"order": "desc"}}]), + (None, None, ["is_learning_material", {"created_on": {"order": "desc"}}]), (None, "text", None), ], ) diff --git a/learning_resources_search/constants.py b/learning_resources_search/constants.py index 79d2f1e4b6..b136ae9b92 100644 --- a/learning_resources_search/constants.py +++ b/learning_resources_search/constants.py @@ -74,6 +74,7 @@ class FilterConfig: "platform": FilterConfig("platform.code"), "offered_by": FilterConfig("offered_by.code"), "learning_format": FilterConfig("learning_format.code"), + "is_learning_material": FilterConfig("is_learning_material"), } SEARCH_NESTED_FILTERS = { @@ -368,4 +369,5 @@ class FilterConfig: "course.course_numbers.sort_coursenum", "course.course_numbers.primary", "resource_relations", + "is_learning_material", ] diff --git a/learning_resources_search/serializers.py b/learning_resources_search/serializers.py index 3c09c2006c..92734b85d0 100644 --- a/learning_resources_search/serializers.py +++ b/learning_resources_search/serializers.py @@ -38,6 +38,8 @@ from learning_resources_search.api import gen_content_file_id from learning_resources_search.constants import ( CONTENT_FILE_TYPE, + COURSE_TYPE, + PROGRAM_TYPE, ) from learning_resources_search.models import PercolateQuery from learning_resources_search.utils import remove_child_queries @@ -79,6 +81,8 @@ def serialize_learning_resource_for_update( return { "resource_relations": {"name": "resource"}, "created_on": learning_resource_obj.created_on, + "is_learning_material": learning_resource_obj.resource_type + not in [COURSE_TYPE, PROGRAM_TYPE], **serialized_data, } @@ -175,6 +179,7 @@ def to_representation(self, obj): "professional", "free", "learning_format", + "is_learning_material", ] CONTENT_FILE_AGGREGATIONS = ["topic", "content_feature_type", "platform", "offered_by"] @@ -261,6 +266,13 @@ class LearningResourcesSearchRequestSerializer(SearchRequestSerializer): default=None, help_text="True if the learning resource offers a certificate", ) + is_learning_material = ArrayWrappedBoolean( + required=False, + allow_null=True, + default=None, + help_text="True if the learning resource is a podcast, podcast episode, video, " + "video playlist, or learning path", + ) certification_choices = CertificationType.as_tuple() certification_type = StringArrayField( required=False, diff --git a/learning_resources_search/serializers_test.py b/learning_resources_search/serializers_test.py index 7411de8710..5817bd19a1 100644 --- a/learning_resources_search/serializers_test.py +++ b/learning_resources_search/serializers_test.py @@ -142,6 +142,7 @@ "url": "http://xpro.mit.edu/courses/course-v1:xPRO+MCPO+R1/", "resource_type": "course", "platform": "globalalumni", + "is_learning_material": False, }, } ], @@ -278,6 +279,7 @@ "url": "http://xpro.mit.edu/courses/course-v1:xPRO+MCPO+R1/", "resource_type": "course", "platform": "globalalumni", + "is_learning_material": False, } ], "metadata": { @@ -344,6 +346,7 @@ "last_modified": None, "runs": [], "course_feature": [], + "is_learning_material": True, "user_list_parents": [], }, } @@ -507,6 +510,7 @@ "last_modified": None, "runs": [], "course_feature": [], + "is_learning_material": True, "user_list_parents": [], } ], @@ -589,6 +593,7 @@ def test_serialize_learning_resource_for_bulk(resource_type, is_professional, no "_id": resource.id, "resource_relations": {"name": "resource"}, "created_on": resource.created_on, + "is_learning_material": resource.resource_type not in ["course", "program"], **free_dict, **LearningResourceSerializer(resource).data, } @@ -635,6 +640,7 @@ def test_serialize_course_numbers_for_bulk( "resource_relations": {"name": "resource"}, "created_on": resource.created_on, "free": False, + "is_learning_material": False, **LearningResourceSerializer(resource).data, } expected_data["course"]["course_numbers"][0] = { @@ -713,6 +719,7 @@ def test_learning_resources_search_request_serializer(): "certification": "false", "certification_type": CertificationType.none.name, "free": True, + "is_learning_material": True, "offered_by": "xpro,ocw", "platform": "xpro,edx,ocw", "topic": "Math", @@ -730,6 +737,7 @@ def test_learning_resources_search_request_serializer(): "sortby": "-start_date", "professional": [True], "certification": [False], + "is_learning_material": [True], "certification_type": [CertificationType.none.name], "free": [True], "offered_by": ["xpro", "ocw"], diff --git a/main/settings.py b/main/settings.py index 907ae30c5f..ffa3574854 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.13.7" +VERSION = "0.13.8" log = logging.getLogger() diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index b0ef46d0ce..1fc9a1b160 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -2093,6 +2093,7 @@ paths: - professional - free - learning_format + - is_learning_material type: string description: |- * `resource_type` - resource_type @@ -2107,6 +2108,7 @@ paths: * `professional` - professional * `free` - free * `learning_format` - learning_format + * `is_learning_material` - is_learning_material description: Show resource counts by category - in: query name: certification @@ -2254,6 +2256,13 @@ paths: items: type: integer description: The id value for the learning resource + - in: query + name: is_learning_material + schema: + type: boolean + nullable: true + description: True if the learning resource is a podcast, podcast episode, + video, video playlist, or learning path - in: query name: learning_format schema: @@ -2494,6 +2503,7 @@ paths: - professional - free - learning_format + - is_learning_material type: string description: |- * `resource_type` - resource_type @@ -2508,6 +2518,7 @@ paths: * `professional` - professional * `free` - free * `learning_format` - learning_format + * `is_learning_material` - is_learning_material description: Show resource counts by category - in: query name: certification @@ -2655,6 +2666,13 @@ paths: items: type: integer description: The id value for the learning resource + - in: query + name: is_learning_material + schema: + type: boolean + nullable: true + description: True if the learning resource is a podcast, podcast episode, + video, video playlist, or learning path - in: query name: learning_format schema: @@ -2920,6 +2938,7 @@ paths: - professional - free - learning_format + - is_learning_material type: string description: |- * `resource_type` - resource_type @@ -2934,6 +2953,7 @@ paths: * `professional` - professional * `free` - free * `learning_format` - learning_format + * `is_learning_material` - is_learning_material description: Show resource counts by category - in: query name: certification @@ -3081,6 +3101,13 @@ paths: items: type: integer description: The id value for the learning resource + - in: query + name: is_learning_material + schema: + type: boolean + nullable: true + description: True if the learning resource is a podcast, podcast episode, + video, video playlist, or learning path - in: query name: learning_format schema: @@ -3337,6 +3364,7 @@ paths: - professional - free - learning_format + - is_learning_material type: string description: |- * `resource_type` - resource_type @@ -3351,6 +3379,7 @@ paths: * `professional` - professional * `free` - free * `learning_format` - learning_format + * `is_learning_material` - is_learning_material description: Show resource counts by category - in: query name: certification @@ -3498,6 +3527,13 @@ paths: items: type: integer description: The id value for the learning resource + - in: query + name: is_learning_material + schema: + type: boolean + nullable: true + description: True if the learning resource is a podcast, podcast episode, + video, video playlist, or learning path - in: query name: learning_format schema: @@ -6873,6 +6909,7 @@ components: - professional - free - learning_format + - is_learning_material type: string description: |- * `resource_type` - resource_type @@ -6887,6 +6924,7 @@ components: * `professional` - professional * `free` - free * `learning_format` - learning_format + * `is_learning_material` - is_learning_material x-enum-descriptions: - resource_type - certification @@ -6900,6 +6938,7 @@ components: - professional - free - learning_format + - is_learning_material Article: type: object description: Serializer for LearningResourceInstructor model @@ -9218,6 +9257,11 @@ components: type: boolean nullable: true description: True if the learning resource offers a certificate + is_learning_material: + type: boolean + nullable: true + description: True if the learning resource is a podcast, podcast episode, + video, video playlist, or learning path certification_type: type: array items: