How to create streams of lessons in LearnDash

For my Making Pro Plugins course, I’m adding some additional videos for another platform students could use to sell their plugins with. But I wanted to create a way for them to pick which one they wanted to go with, and only see videos related to that one to avoid confusion.

Here’s how I did it by creating “LearnDash streams” students can pick from!

Ability to pick streams for a lesson

I decided to create a new taxonomy.  This way I get the interface to create, edit, select and delete streams out of the box (thanks WordPress!)

function ldss_add_taxonomy() {
   $labels = array(
      'name' => _x( 'Add New Stream', 'taxonomy general name', 'learndash-streams' ),
      'singular_name' => _x( 'Stream', 'taxonomy singular name', 'learndash-streams' ),
      'search_items' => __( 'Search Streams', 'learndash-streams' ),
      'popular_items' => __( 'Popular Streams', 'learndash-streams' ),
      'all_items'=> __( 'All Streams', 'learndash-streams' ),
      'parent_item' => null,
      'parent_item_colon' => null,
      'edit_item' => __( 'Edit Stream', 'learndash-streams' ),
      'update_item' => __( 'Update Stream', 'learndash-streams' ),
      'add_new_item' => __( 'Add New Stream', 'learndash-streams' ),
      'new_item_name' => __( 'New Stream Name', 'learndash-streams' ),
      'separate_items_with_commas' => __( 'Separate streams with commas', 'learndash-streams' ),
      'add_or_remove_items' => __( 'Add or remove streams', 'learndash-streams' ),
      'choose_from_most_used' => __( 'Choose from the most used streams', 'learndash-streams' ),
      'not_found' => __( 'No streams found.', 'learndash-streams' ),
      'menu_name' => __( 'Streams', 'learndash-streams' ),
   );
	
   $args = array(
      'hierarchical'          => false,
      'labels'                => $labels,
      'show_ui'               => true,
      'show_admin_column'     => true,
      'update_count_callback' => '_update_post_term_count',
      'query_var'             => false,
      'rewrite'               => array( 'slug' => 'ld-streams' ),
   );
	
   register_taxonomy( 'ld-streams', array( 'sfwd-lessons', 'sfwd-topic' ), $args );

   // Recommended by the docs for register_taxonomy
   register_taxonomy_for_object_type( 'ld-streams', 'sfwd-lessons' );
   register_taxonomy_for_object_type( 'ld-streams', 'sfwd-topic' );
}
add_action( 'init', 'ldss_add_taxonomy', 99 );

I added it for both Topics and Lessons, though in my case I’ll be keeping the Lessons the same but changing the Topics (videos) that will appear based on their stream.

I also added them as a tag-like taxonomy since students will only be able to select one, and there’s no need for a parent-child relationship.

Add a menu item to edit the streams

I wanted to be able to easily edit the streams, so I can add a description students will see when they pick their stream.  The screen is already visible if you go to /edit-tags.php?taxonomy=ld-streams so I can edit a stream:

We can add it to LearnDash menu itself with:

function ldss_add_submenu( $submenus ) {
   $submenus['streams'] = array(
      'name' => _x( 'Streams', 'Streams menu label', 'learndash-streams' ),
         'cap' => 'edit_courses',
         'link' => 'edit-tags.php?taxonomy=ld-streams'
   );
   return $submenus;
}
add_filter( 'learndash_submenu', 'ldss_add_submenu' );

Add appropriate streams to course topics

Now you can go into the topics and mark them with the appropriate stream(s). For topics that work in any stream, I’m leaving them blank.

Let students pick their stream

To do this I register a widget to show a dropdown to pick a stream, which you can add in the place that’s best for your course/theme:

class LearnDashStreamsWidget extends WP_Widget {
   function __construct() {
      // Instantiate the parent object
      parent::__construct( false, _x( 'Learndash Stream Picker', 'learndash-streams' ) );
   }

   function widget( $args, $instance ) {
      $course_id = learndash_get_course_id( get_the_ID() );
      if ( empty( $course_id ) )
         return;

      $terms = get_terms( array(
         'taxonomy' => 'ld-streams',
         'hide_empty' => false,
      ) );
      ?>
      <= __( 'Streams', 'learndash-streams' ); ?>

      <form id="ldss_stream_picker">
      <select>
      <option value=""></option>
      <?php foreach ( $terms as $term ): ?>
         <option value="<?= intval( $term->term_id ) ?>"<?php selected( get_user_meta( get_current_user_id(), 'ldss_stream_' . $course_id, true ), $term->term_id ); ?>><?= esc_html( $term->name ) ?></option>
      <?php endforeach; ?>
      </select>
      </form>
      <?php
   }
}

function ldss_register_widgets() {
   register_widget( 'LearnDashStreamsWidget' );
}
add_action( 'widgets_init', 'ldss_register_widgets' );

Which will show your streams in a dropdown like this when you add it to a widget area:

Saving the selected option

This will show a dropdown but not actually save anything when the value is changed. To do that we’ll use a bit of JavaScript and create our own custom WP REST API endpoint (I go through this in more detail in my free-to-watch Working with WordPress and JavaScript course).

First we create the endpoint to save the selected stream:

add_action( 'rest_api_init', function () {
	register_rest_route( 'ld-streams/v1', '/stream', array(
		'methods' => 'POST',
		'callback' => 'ldss_save_stream',
        'args' => array(
            'post_id' => array(
                'required' => true,
                'validate_callback' => function( $param, $request, $key ) {
                    return is_numeric( $param );
                }
            ),
            'stream_id' => array(
                'validate_callback' => function( $param, $request, $key ) {
                    return is_numeric( $param );
                }
            )
        ),
        'permissions_callback' => function() {
	   return is_user_logged_in();
        }
   ) );
} );

function ldss_save_stream( WP_REST_Request $request ) {
   $course_id = learndash_get_course_id( $request->get_param( 'post_id' ) );
   if ( empty( $course_id ) )
      return;

   update_user_meta( get_current_user_id(), 'ldss_stream_' . $course_id, $request->get_param( 'stream_id' ) );
   return true;
}

This registers our endpoint and validates that the post ID (where we currently are, used to grab the course ID) and the stream ID are passed in and valid. It also checks that the user is logged into the course.

We can then use a bit of JavaScript to save the value when the dropdown is changed:

(function($){
    $(document).ready(function(){
        $('#ldss_stream_picker select').on('change', function(){
            var data = {
                stream_id: parseInt($(this).val()),
                post_id: LD_STREAM.post_id
            };
            $.ajax({
                method: "POST",
                url: LD_STREAM.root + 'ld-streams/v1/stream',
                data: data,
                beforeSend: function ( xhr ) {
                    xhr.setRequestHeader( 'X-WP-Nonce', LD_STREAM.nonce );
                },
                success: function( response ) {
                    window.location.reload(true);
                },
                fail: function( response ) {
                    alert( LD_STREAM.failure_msg );
                }
            });
        });
    });
})(jQuery);

This will save the new stream value then reload the page.

To load this script only when our dropdown stream-picker widget is shown, we can add this inside the widget function of our widget class:

wp_enqueue_script( 'ld-stream-js', plugins_url( 'js/learndash-stream-switcher.js', __FILE__ ), array( 'jquery' ), LD_STREAMS_VERSION, true );
wp_localize_script( 'ld-stream-js', 'LD_STREAM', array(
    'root' => esc_url_raw( rest_url() ),
    'post_id' => get_the_ID(),
    'nonce' => wp_create_nonce( 'wp_rest' ),
    'failure_msg' => __( 'Unable to update stream', 'learndash-streams' ),
) );

While this eventually works, there’s a few caches on the topics listing we can clear inside the ldss_save_stream function so the correct topic list appears right away:

$lessons = learndash_get_lesson_list( $course_id );
foreach ( $lessons as $lesson ) {
    delete_transient( 'learndash_lesson_topics_' . $lesson->ID );
}
delete_transient( 'learndash_lesson_topics_all' );

Filtering the topics by the selected stream

I couldn’t find a filter within LearnDash itself, so instead we can use pre_get_posts to do it:

function ldss_filter_by_stream( $query ) {
   if ( is_admin() or ! in_array( $query->get( 'post_type' ), array( 'sfwd-topic', 'sfwd-lessons' ) ) )
      return;

   $course_id = learndash_get_course_id( get_the_ID() );
   if ( empty( $course_id ) )
      return;

   $stream_id = get_user_meta( get_current_user_id(), 'ldss_stream_' . $course_id, true );
   if ( empty( $stream_id ) )
      return;

   $tax_query = $query->get( 'tax_query' );
   if ( ! is_array( $tax_query ) )
      $tax_query = array();
   $tax_query[] = array(
        'relation' => 'OR',
        array(
            'taxonomy' => 'ld-streams',
            'field' => 'term_id',
            'terms' => array( intval( $stream_id ) ),
        ),
        array(
            'taxonomy' => 'ld-streams',
            'operator' => 'NOT EXISTS',
        )
   );
   $query->set( 'tax_query', $tax_query );
}
add_action( 'pre_get_posts', 'ldss_filter_by_stream' );

The pre_get_posts action passes the current query by reference, so we don’t have to return anything.

Conclusion

Now students can pick the stream they want, and see the topics and lessons appropriate for it. Things like the course progress dots, widget for listing the topics, and more all work as expected.