Skip to content

Creating Custom Tabs

Instructor content tabs use a Template Method pattern introduced in version 7.5.0. The LD_Dashboard_Tab_Base abstract class defines the rendering algorithm; subclasses implement the content-specific steps.


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.

protected $post_type; // WordPress post type for this tab (e.g., 'sfwd-lessons')
protected $tab_name; // Tab identifier string
Method Return Description
get_id(): string string Unique tab ID (e.g., gradebook).
get_learndash_label_key(): string string LearnDash custom label key (e.g., lesson, quiz). Used by LearnDash_Custom_Label::get_label().
get_accessible_ids( array $params ): array array Return an array of post IDs the current user can see. Return empty array for admins (no restriction).
get_filter_args( array $args, array $params ): array array Modify the WP_Query args array with tab-specific filters (status, course, etc.).
render_item( WP_Post $post, array $params ): string string Render a single list item’s HTML. Must return a string.

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,
),
)

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.
  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.

Method Description
build_action_links( $post, $nonce, $access ) Returns HTML for view/edit/delete action links.
get_elementor_edit_link( $post ) Returns an Elementor edit link if the post type supports it.
render_empty_state( $search_term ) Returns empty-state HTML with a “Clear Filters” button.
get_status_label( $status ) Returns a translated status label for publish, draft, pending.
get_associated_course_id( $post_id ) Retrieves the course ID associated with a post via meta or learndash_get_course_id().
get_nonce_action() Returns {id}-nonce as the nonce action.
get_edit_action() Returns edit-{id}.
get_delete_action() Returns delete-{id}.
get_tab_param() Returns my-{id}s.
get_post_param() Returns ld-{id}.

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

The singleton registry manages tab instances and dispatches AJAX requests.

// 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' );
} );

The registry handles the ld_dashboard_get_instructor_tab_content and ld_dashboard_tab_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():

Param Type Description
course_id int Filter by course
lesson_id int Filter by lesson
topic_id int Filter by topic
quiz_id int Filter by quiz
page int Pagination page
search string Search term
grade_type string Grade type filter

<?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;
} );
// 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.

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_Enhanced_Quiz_Tab can extend the original LD_Dashboard_Tab_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 );
}
}

ID Class Post Type
lesson LD_Dashboard_Tab_Lesson sfwd-lessons
topic LD_Dashboard_Tab_Topic sfwd-topic
quiz LD_Dashboard_Tab_Quiz sfwd-quiz
question LD_Dashboard_Tab_Question sfwd-question
assignment LD_Dashboard_Tab_Assignment sfwd-assignment
announcements LD_Dashboard_Tab_Announcement sfwd-announcements
certificate LD_Dashboard_Tab_Certificate sfwd-certificates