Creating Custom Tabs

Get Started

Creating Custom Tabs

Instructor content tabs use a Template Method pattern introduced in version 7.5.0. The LD<em>Dashboard</em>Tab_Base abstract class defines the rendering algorithm; subclasses implement the content-specific steps.


LDDashboardTab_Base

Located at includes/tabs/class-ld-dashboard-tab-base.php.

All custom tabs must extend this class. The render() method is declared final — you cannot override it. Instead, implement the abstract methods that render() calls internally.

Required Properties

protected $post_type;     // WordPress post type for this tab (e.g., 'sfwd-lessons')
protected $tab_name;      // Tab identifier string

Abstract Methods (required)

MethodReturnDescription
get_id(): stringstringUnique tab ID (e.g., gradebook).
get<em>learndash</em>label_key(): stringstringLearnDash custom label key (e.g., lesson, quiz). Used by LearnDash<em>Custom</em>Label::get_label().
get<em>accessible</em>ids( array $params ): arrayarrayReturn an array of post IDs the current user can see. Return empty array for admins (no restriction).
get<em>filter</em>args( array $args, array $params ): arrayarrayModify the WP_Query args array with tab-specific filters (status, course, etc.).
render<em>item( WP</em>Post $post, array $params ): stringstringRender a single list item’s HTML. Must return a string.

render() — Template Method

The final public function render( array $params ): array method executes the tab rendering algorithm:

render( $params )
  ├── get_accessible_ids( $params )   → restrict posts to user's scope
  ├── build_query_args( $params, $ids )  → WP_Query base args
  ├── get_filter_args( $args, $params )  → tab-specific filters
  ├── apply_filters('ld_dashboard_{id}_content_args', $args)  → developer hook
  ├── new WP_Query( $args )           → execute query
  ├── render_items( $query, $params ) → calls render_item() per post
  ├── get_stats( $ids )               → count totals by status
  └── build_response( $content, $query, $page, $stats )
      → apply_filters('ld_dashboard_{id}_content', $content)
      → apply_filters('ld_dashboard_{id}_content_data', $response)

The response array contains:

array(
    'content'     => string,  // Rendered HTML
    'next'        => bool,    // Has next page
    'prev'        => bool,    // Has previous page
    'first'       => bool,    // Is not first page
    'last'        => bool,    // Is not last page
    'maxpages'    => int,
    'currentpage' => int,
    'totalitems'  => int,
    'stats'       => array(   // Counts by post status
        'total'     => int,
        'published' => int,
        'draft'     => int,
        'pending'   => int,
    ),
)

Access Control Helpers

The base class provides access control methods you can call inside render_item():

// Get full access status for a post
$access = $this->get_access_status( $post );
// Returns:
// array(
//     'can_edit'   => bool,
//     'can_delete' => bool,
//     'badge'      => 'your-content' | 'shared' | 'view-only',
//     'badge_text' => string,
// )

// Individual checks
$can_edit   = $this->user_can_edit( $post );
$can_delete = $this->user_can_delete( $post );

Badge values:

  • your-content (green) — User is the post author.
  • shared (blue) — User can edit via course co-instructor relationship.
  • view-only (gray) — User can see but not edit.

Edit permission logic:

  1. Administrator → always true.
  2. Group leader with admin capability → always true.
  3. Post author → always true.
  4. Co-instructor on the associated course → true.

Built-in Helper Methods

MethodDescription
build<em>action</em>links( $post, $nonce, $access )Returns HTML for view/edit/delete action links.
get<em>elementor</em>edit_link( $post )Returns an Elementor edit link if the post type supports it.
render<em>empty</em>state( $search_term )Returns empty-state HTML with a “Clear Filters” button.
get<em>status</em>label( $status )Returns a translated status label for publish, draft, pending.
get<em>associated</em>course<em>id( $post</em>id )Retrieves the course ID associated with a post via meta or learndash<em>get</em>course_id().
get<em>nonce</em>action()Returns {id}-nonce as the nonce action.
get<em>edit</em>action()Returns edit-{id}.
get<em>delete</em>action()Returns delete-{id}.
get<em>tab</em>param()Returns my-{id}s.
get<em>post</em>param()Returns ld-{id}.

LDDashboardTab_Registry

Located at includes/tabs/class-ld-dashboard-tab-registry.php.

The singleton registry manages tab instances and dispatches AJAX requests.

Registering Custom Tabs

// Via filter (preferred — runs before any tab is instantiated)
add_filter( 'ld_dashboard_registered_tabs', function( $tabs ) {
    $tabs['gradebook'] = 'My_Gradebook_Tab';
    return $tabs;
} );

// Via registry method (must run before AJAX dispatch)
add_action( 'init', function() {
    LD_Dashboard_Tab_Registry::instance()->register( 'gradebook', 'My_Gradebook_Tab' );
} );

AJAX Dispatch

The registry handles the ld<em>dashboard</em>get<em>instructor</em>tab<em>content and ld</em>dashboard<em>tab</em>content_filter AJAX actions. Your tab is automatically included in AJAX dispatch when registered.

// The registry dispatches to your tab via:
$response = LD_Dashboard_Tab_Registry::instance()->render( 'gradebook', $params );

AJAX params passed to render():

ParamTypeDescription
course_idintFilter by course
lesson_idintFilter by lesson
topic_idintFilter by topic
quiz_idintFilter by quiz
pageintPagination page
searchstringSearch term
grade_typestringGrade type filter

Complete Example: Gradebook Tab

<?php
/**
 * Gradebook Tab for LD Dashboard.
 *
 * @since 1.0.0
 */
class My_Gradebook_Tab extends LD_Dashboard_Tab_Base {

    /**
     * Constructor.
     *
     * @since 1.0.0
     */
    public function __construct() {
        parent::__construct();
        $this->post_type = 'sfwd-quiz';
        $this->tab_name  = 'gradebook';
    }

    /**
     * Get the unique tab ID.
     *
     * @since 1.0.0
     * @return string
     */
    public function get_id(): string {
        return 'gradebook';
    }

    /**
     * Get the LearnDash custom label key.
     *
     * @since 1.0.0
     * @return string
     */
    protected function get_learndash_label_key(): string {
        return 'quiz';
    }

    /**
     * Get post IDs accessible to the current user.
     *
     * @since 1.0.0
     * @param array $params Request parameters.
     * @return array Post IDs the user can access.
     */
    protected function get_accessible_ids( array $params ): array {
        $user_id = $this->current_user_id;

        // Admins see all quizzes — return empty to skip post__in.
        if ( learndash_is_admin_user( $user_id ) || LD_Dashboard_Helper::group_leader_has_admin_cap() ) {
            return array();
        }

        // Instructors see quizzes attached to their courses.
        $course_ids = LD_Dashboard_Course_Helper::get_instructor_courses( $user_id );

        if ( empty( $course_ids ) ) {
            return array( 0 ); // Force empty result.
        }

        $quiz_ids = array();
        foreach ( $course_ids as $course_id ) {
            $quizzes = learndash_get_course_quiz_list( $course_id );
            foreach ( $quizzes as $quiz ) {
                $quiz_ids[] = $quiz['post']->ID;
            }
        }

        return array_unique( $quiz_ids ) ?: array( 0 );
    }

    /**
     * Apply tab-specific filter args.
     *
     * @since 1.0.0
     * @param array $args   Base WP_Query args.
     * @param array $params Request parameters.
     * @return array Modified args.
     */
    protected function get_filter_args( array $args, array $params ): array {
        // Filter by course.
        if ( ! empty( $params['course_id'] ) ) {
            $course_quizzes = learndash_get_course_quiz_list( absint( $params['course_id'] ) );
            $course_quiz_ids = wp_list_pluck(
                array_column( $course_quizzes, 'post' ),
                'ID'
            );

            // Intersect with already-restricted IDs.
            if ( ! empty( $args['post__in'] ) && ! empty( $course_quiz_ids ) ) {
                $args['post__in'] = array_intersect( $args['post__in'], $course_quiz_ids );
            } elseif ( ! empty( $course_quiz_ids ) ) {
                $args['post__in'] = $course_quiz_ids;
            }
        }

        // Filter by status.
        if ( ! empty( $params['grade_type'] ) && 'all' !== $params['grade_type'] ) {
            $args['post_status'] = sanitize_key( $params['grade_type'] );
        }

        return $args;
    }

    /**
     * Render a single quiz item.
     *
     * @since 1.0.0
     * @param WP_Post $post   Quiz post object.
     * @param array   $params Request parameters.
     * @return string HTML content.
     */
    protected function render_item( WP_Post $post, array $params ): string {
        $access   = $this->get_access_status( $post );
        $nonce    = wp_create_nonce( $this->get_nonce_action() );
        $attempts = learndash_get_user_quiz_attempts_by_quiz( $this->current_user_id, $post->ID );

        ob_start();
        ?>
        <div class="ld-dashboard-list-item ld-gradebook-item"
             data-post-id="<?php echo esc_attr( $post->ID ); ?>">

            <div class="ld-dashboard-item-badge ld-badge--<?php echo esc_attr( $access['badge'] ); ?>">
                <?php echo esc_html( $access['badge_text'] ); ?>
            </div>

            <h4 class="ld-dashboard-item-title">
                <?php echo esc_html( $post->post_title ); ?>
            </h4>

            <div class="ld-dashboard-item-meta">
                <span class="ld-gradebook-attempts">
                    <?php
                    printf(
                        /* translators: %d: number of attempts */
                        esc_html( _n( '%d attempt', '%d attempts', count( $attempts ), 'my-plugin' ) ),
                        count( $attempts )
                    );
                    ?>
                </span>
                <span class="ld-dashboard-item-status ld-status--<?php echo esc_attr( $post->post_status ); ?>">
                    <?php echo esc_html( $this->get_status_label( $post->post_status ) ); ?>
                </span>
            </div>

            <div class="ld-dashboard-item-actions">
                <?php echo wp_kses_post( $this->build_action_links( $post, $nonce, $access ) ); ?>
            </div>
        </div>
        <?php
        return ob_get_clean();
    }
}

// Register the tab.
add_filter( 'ld_dashboard_registered_tabs', function( $tabs ) {
    $tabs['gradebook'] = 'My_Gradebook_Tab';
    return $tabs;
} );

Using the Tab via Registry

// Render programmatically.
$response = LD_Dashboard_Tab_Registry::render_tab( 'gradebook', array(
    'course_id' => 123,
    'page'      => 1,
    'search'    => '',
) );

// $response['content'] contains the HTML.
// $response['totalitems'] contains the total count.

Extending Built-in Tabs

You can replace a built-in tab by re-registering it with your own class:

add_filter( 'ld_dashboard_registered_tabs', function( $tabs ) {
    // Replace the quiz tab with a custom implementation.
    $tabs['quiz'] = 'My_Enhanced_Quiz_Tab';
    return $tabs;
} );

My<em>Enhanced</em>Quiz<em>Tab can extend the original LD</em>Dashboard<em>Tab</em>Quiz to inherit its logic:

class My_Enhanced_Quiz_Tab extends LD_Dashboard_Tab_Quiz {

    protected function render_item( WP_Post $post, array $params ): string {
        // Add custom fields before the default render.
        $extra_html = '<div class="my-extra-data">...</div>';
        return $extra_html . parent::render_item( $post, $params );
    }
}

Built-in Tab IDs

IDClassPost Type
lessonLD<em>Dashboard</em>Tab_Lessonsfwd-lessons
topicLD<em>Dashboard</em>Tab_Topicsfwd-topic
quizLD<em>Dashboard</em>Tab_Quizsfwd-quiz
questionLD<em>Dashboard</em>Tab_Questionsfwd-question
assignmentLD<em>Dashboard</em>Tab_Assignmentsfwd-assignment
announcementsLD<em>Dashboard</em>Tab_Announcementsfwd-announcements
certificateLD<em>Dashboard</em>Tab_Certificatesfwd-certificates
Last updated: March 4, 2026