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)
| Method | Return | Description |
|---|---|---|
get_id(): string | string | Unique tab ID (e.g., gradebook). |
get<em>learndash</em>label_key(): string | string | LearnDash custom label key (e.g., lesson, quiz). Used by LearnDash<em>Custom</em>Label::get_label(). |
get<em>accessible</em>ids( array $params ): array | array | Return 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 ): array | array | Modify the WP_Query args array with tab-specific filters (status, course, etc.). |
render<em>item( WP</em>Post $post, array $params ): string | string | Render 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:
- Administrator → always true.
- Group leader with admin capability → always true.
- Post author → always true.
- Co-instructor on the associated course → true.
Built-in Helper Methods
| Method | Description |
|---|---|
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():
| 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 |
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
| ID | Class | Post Type |
|---|---|---|
lesson | LD<em>Dashboard</em>Tab_Lesson | sfwd-lessons |
topic | LD<em>Dashboard</em>Tab_Topic | sfwd-topic |
quiz | LD<em>Dashboard</em>Tab_Quiz | sfwd-quiz |
question | LD<em>Dashboard</em>Tab_Question | sfwd-question |
assignment | LD<em>Dashboard</em>Tab_Assignment | sfwd-assignment |
announcements | LD<em>Dashboard</em>Tab_Announcement | sfwd-announcements |
certificate | LD<em>Dashboard</em>Tab_Certificate | sfwd-certificates |
