How to Create Your First Coob Platform Plugin: A Complete Developer Guide

Coob TeamJanuary 12, 2025

Learn how to build custom educational plugins for the Coob platform. Step-by-step tutorial with real examples, authentication, and deployment instructions.

How to Create Your First Coob Platform Plugin: A Complete Developer Guide

How to Create Your First Coob Platform Plugin: A Complete Developer Guide

Have you ever wanted to create custom interactive components for educational courses? The Coob platform provides a powerful plugin system that allows developers to build specialized educational tools. In this comprehensive guide, we'll walk through creating your first plugin from scratch!

What Are Coob Plugins?

Coob plugins are independent modules that extend the functionality of educational courses. They come in three main types:

  • Presentation Plugins: Display information (text, images, videos, etc.)
  • Training Plugins: Interactive exercises with scoring (quizzes, tasks, etc.)
  • Assignment Plugins: Student work submission for instructor review

All plugins feature:

  • 📱 Responsive design for desktop and mobile
  • 🌍 Multilingual support (i18n capabilities)
  • Interactive UI with modern animations
  • ⚙️ Configurable settings for different use cases
  • Input validation and error handling

Plugin Repository and Examples

Before we start building, let's explore the official plugin repository where you can find examples and contribute your own plugins:

🔗 Official Repository: https://github.com/coob-app/plugins

This repository contains numerous plugin examples, including:

  • singlechoose - Single-answer quiz component
  • smart-quiz - Advanced multiple-choice quiz system
  • text - Rich text display component
  • video - Video player component
  • And many more!

Understanding Plugin Structure

Every Coob plugin follows a standardized structure. Let's examine the anatomy of a plugin by looking at real examples.

Core Files Required

Each plugin directory must contain:

my-plugin/
├── manifest.json          # Plugin metadata and configuration
├── package.json          # NPM package configuration
├── webpack.config.js     # Build configuration
├── src/                  # Source files
│   ├── edit/            # Editor interface
│   │   ├── edit.html
│   │   ├── edit.js
│   │   └── edit.css
│   └── view/            # Student view interface
│       ├── view.html
│       ├── view.js
│       └── view.css
└── dist/                # Built files (generated)
    ├── state.json       # Plugin state definition
    ├── settings.json    # Configuration schema
    ├── handler.lua      # Logic handler (training plugins)
    ├── edit.html        # Compiled editor
    ├── view.html        # Compiled student view
    └── icon.png         # Plugin icon

The Manifest File

The manifest.json file is the heart of every plugin. Here's an example from the smart-quiz plugin:

{
  "status": "active",
  "version": "2.0.0",
  "name": "Smart Quiz",
  "description": "Advanced multiple-choice quiz component with intelligent features",
  "short_description": "Advanced multiple-choice quiz",
  "icon": "./dist/icon.png",
  "settings": {
    "answerRequired": true
  },
  "entry": {
    "state": "./dist/state.json",
    "handler": "./dist/handler.lua",
    "settings": "./dist/settings.json",
    "edit": "./dist/edit.html",
    "view": "./dist/view.html"
  }
}

Let's Build a Simple Poll Plugin Together!

Now, let's create a practical example by building a simple poll plugin that allows students to vote on options and see real-time results.

Step 1: Setting Up the Project Structure

First, create the basic directory structure:

mkdir simple-poll-plugin
cd simple-poll-plugin

Create the essential directories:

mkdir -p src/edit src/view dist

Step 2: Creating the Package Configuration

Let's start with package.json:

{
  "name": "simple-poll",
  "version": "1.0.0",
  "description": "A simple poll plugin for student voting",
  "main": "dist/view.html",
  "keywords": ["poll", "voting", "survey", "education"],
  "author": "Your Name",
  "license": "MIT",
  "scripts": {
    "build": "webpack --mode=production",
    "build:dev": "webpack --mode=development",
    "watch": "webpack --mode=development --watch",
    "publish": "npx coobcli publish",
    "login": "npx coobcli login"
  },
  "devDependencies": {
    "@babel/core": "^7.28.3",
    "@babel/plugin-transform-react-jsx": "^7.27.1",
    "@babel/preset-env": "^7.28.3",
    "@babel/preset-react": "^7.27.1",
    "babel-loader": "^10.0.0",
    "css-loader": "^7.1.2",
    "html-inline-script-webpack-plugin": "^3.2.1",
    "html-webpack-plugin": "^5.6.4",
    "preact": "^10.27.1",
    "style-loader": "^4.0.0",
    "webpack": "^5.101.3",
    "webpack-cli": "^6.0.1"
  },
  "dependencies": {
    "preact-feather": "^4.2.1"
  }
}

Step 3: Webpack Configuration

Create webpack.config.js based on the pattern from existing plugins:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlInlineScriptWebpackPlugin = require('html-inline-script-webpack-plugin');
const webpack = require('webpack');

module.exports = {
  mode: 'production',
  entry: {
    edit: './src/edit/edit.js',
    view: './src/view/view.js'
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', { targets: 'defaults' }],
              ['@babel/preset-react']
            ],
            plugins: [
              ['@babel/plugin-transform-runtime'],
              [
                '@babel/plugin-transform-react-jsx',
                {
                  pragma: 'h',
                  pragmaFrag: 'Fragment'
                }
              ]
            ]
          }
        }
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  },
  plugins: [
    new webpack.DefinePlugin({
      'global': {}
    }),
    new HtmlWebpackPlugin({
      inject: 'body',
      template: path.resolve('src/edit/edit.html'),
      filename: 'edit.html',
      chunks: ['edit']
    }),
    new HtmlWebpackPlugin({
      inject: 'body',
      template: path.resolve('src/view/view.html'),
      filename: 'view.html',
      chunks: ['view']
    }),
    new HtmlInlineScriptWebpackPlugin()
  ],
  resolve: {
    extensions: ['.js', '.jsx']
  }
};

Step 4: Plugin State Definition

Create dist/state.json to define the initial state:

{
  "question": "",
  "options": [],
  "votes": {},
  "totalVotes": 0
}

Step 5: Plugin Settings Schema

Create dist/settings.json for configuration options:

{
  "JSONSchema": {
    "properties": {
      "allowMultipleVotes": {
        "type": "boolean",
        "title": "Allow Multiple Votes",
        "description": "Allow students to change their vote",
        "default": false
      },
      "showResults": {
        "type": "boolean", 
        "title": "Show Live Results",
        "description": "Display voting results in real-time",
        "default": true
      },
      "anonymousVoting": {
        "type": "boolean",
        "title": "Anonymous Voting",
        "description": "Hide voter identities in results",
        "default": true
      }
    },
    "required": []
  },
  "UISchema": {
    "allowMultipleVotes": {
      "ui:help": "When enabled, students can change their vote multiple times"
    },
    "showResults": {
      "ui:help": "When enabled, students see live voting results"
    },
    "anonymousVoting": {
      "ui:help": "When enabled, individual votes are not traced to specific students"
    }
  }
}

Step 6: Creating the Editor Interface

Let's create the editor interface. First, src/edit/edit.html:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Poll Editor</title>
</head>
<body>
    <div id="root"></div>
</body>
</html>

Now the editor JavaScript src/edit/edit.js:

import { h, Component, render } from "preact";
import { Plus, Trash2 } from "preact-feather";
import "./edit.css";

class PollEditor extends Component {
    constructor() {
        super();
        
        this.state = {
            question: '',
            options: ['', ''],
            language: 'en'
        };
    }

    componentDidMount() {
        $_bx.onReady(() => {
            // Restore state from saved data
            this.restoreState();
            
            // Get user language for localization
            const userLanguage = $_bx.language() || "en";
            this.setState({ language: userLanguage });
            
            // Set up before_save_state event
            $_bx.event().on("before_save_state", (v) => {
                const { question, options } = this.state;
                
                // Filter out empty options
                const validOptions = options.filter(opt => opt.trim() !== '');
                
                // Validate before saving
                if (!question.trim()) {
                    $_bx.showErrorMessage("Question is required");
                    return;
                }
                
                if (validOptions.length < 2) {
                    $_bx.showErrorMessage("At least 2 options are required");
                    return;
                }
                
                // Save to global state
                v.state.question = question.trim();
                v.state.options = validOptions;
                v.state.votes = {};
                v.state.totalVotes = 0;
                v.state.submitMeta = {
                    isDisabled: true // Disable default buttons, we use custom ones
                };
            });
        });
    }

    /**
     * Restore state from saved data
     */
    restoreState = () => {
        try {
            // Get saved state from $_bx
            const savedQuestion = $_bx.get("question");
            const savedOptions = $_bx.get("options");
            
            if (savedQuestion) {
                this.setState({ question: savedQuestion });
            }
            
            if (savedOptions && Array.isArray(savedOptions) && savedOptions.length > 0) {
                this.setState({ options: savedOptions });
            }
        } catch (error) {
            console.error('Error restoring state:', error);
        }
    };

    handleQuestionChange = (e) => {
        this.setState({ question: e.target.value });
    };

    handleOptionChange = (index, e) => {
        const options = [...this.state.options];
        options[index] = e.target.value;
        this.setState({ options });
    };

    addOption = () => {
        if (this.state.options.length < 6) {
            this.setState({ 
                options: [...this.state.options, ''] 
            });
        }
    };

    removeOption = (index) => {
        if (this.state.options.length > 2) {
            const options = this.state.options.filter((_, i) => i !== index);
            this.setState({ options });
        }
    };

    render() {
        const { question, options, language } = this.state;
        
        const texts = {
            en: {
                pollQuestion: "Poll Question",
                questionPlaceholder: "Enter your poll question...",
                options: "Answer Options",
                optionPlaceholder: "Option",
                addOption: "Add Option",
                minOptions: "Minimum 2 options required",
                maxOptions: "Maximum 6 options allowed"
            },
            ru: {
                pollQuestion: "Вопрос опроса",
                questionPlaceholder: "Введите вопрос опроса...",
                options: "Варианты ответов",
                optionPlaceholder: "Вариант",
                addOption: "Добавить вариант",
                minOptions: "Требуется минимум 2 варианта",
                maxOptions: "Максимум 6 вариантов"
            }
        };

        const t = texts[language] || texts.en;

        return (
            h("div", { className: "poll-editor" },
                h("div", { className: "form-group" },
                    h("label", null, t.pollQuestion),
                    h("input", {
                        type: "text",
                        value: question,
                        onChange: this.handleQuestionChange,
                        placeholder: t.questionPlaceholder,
                        className: "question-input"
                    })
                ),

                h("div", { className: "form-group" },
                    h("label", null, t.options),
                    options.map((option, index) =>
                        h("div", { key: index, className: "option-row" },
                            h("input", {
                                type: "text",
                                value: option,
                                onChange: (e) => this.handleOptionChange(index, e),
                                placeholder: `${t.optionPlaceholder} ${index + 1}`,
                                className: "option-input"
                            }),
                            options.length > 2 && h("button", {
                                type: "button",
                                onClick: () => this.removeOption(index),
                                className: "remove-btn"
                            }, h(Trash2, { size: 16 }))
                        )
                    ),

                    options.length < 6 && h("button", {
                        type: "button",
                        onClick: this.addOption,
                        className: "add-btn"
                    }, h(Plus, { size: 16 }), " ", t.addOption)
                )
            )
        );
    }
}

$_bx.onReady(() => {
    render(h(PollEditor), document.getElementById('root'));
});

Step 7: Editor Styles

Create src/edit/edit.css:

.poll-editor {
    padding: 20px;
    max-width: 600px;
    margin: 0 auto;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.form-group {
    margin-bottom: 24px;
}

label {
    display: block;
    margin-bottom: 8px;
    font-weight: 600;
    color: #374151;
}

.question-input,
.option-input {
    width: 100%;
    padding: 12px;
    border: 2px solid #e5e7eb;
    border-radius: 8px;
    font-size: 16px;
    transition: border-color 0.2s;
}

.question-input:focus,
.option-input:focus {
    outline: none;
    border-color: #3b82f6;
}

.option-row {
    display: flex;
    gap: 8px;
    margin-bottom: 8px;
    align-items: center;
}

.option-input {
    flex: 1;
}

.remove-btn {
    background: #ef4444;
    color: white;
    border: none;
    padding: 8px;
    border-radius: 6px;
    cursor: pointer;
    display: flex;
    align-items: center;
    transition: background-color 0.2s;
}

.remove-btn:hover {
    background: #dc2626;
}

.add-btn {
    background: #10b981;
    color: white;
    border: none;
    padding: 10px 16px;
    border-radius: 6px;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 4px;
    font-size: 14px;
    transition: background-color 0.2s;
}

.add-btn:hover {
    background: #059669;
}

@media (max-width: 480px) {
    .poll-editor {
        padding: 16px;
    }
    
    .option-row {
        flex-direction: column;
        align-items: stretch;
    }
    
    .remove-btn {
        align-self: flex-end;
        width: fit-content;
    }
}

Step 8: Student View Interface

Create the student view HTML src/view/view.html:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Poll</title>
</head>
<body>
    <div id="root"></div>
</body>
</html>

And the view JavaScript src/view/view.js:

import { h, Component, render } from "preact";
import { BarChart3, Users } from "preact-feather";
import "./view.css";

class PollView extends Component {
    constructor() {
        super();
        
        this.state = {
            question: '',
            options: [],
            votes: {},
            totalVotes: 0,
            userVote: null,
            showResults: false,
            language: 'en',
            loading: false
        };

        // Bind methods
        this.handleVote = this.handleVote.bind(this);
        this.saveUserState = this.saveUserState.bind(this);
        this.restoreState = this.restoreState.bind(this);
    }

    componentDidMount() {
        $_bx.onReady(() => {
            // Restore state from saved data
            this.restoreState();
            
            // Get user language
            const language = $_bx.language() || 'en';
            this.setState({ language });
            
            // Get plugin settings
            const settings = $_bx.getSettings() || {};
            this.setState({
                showResults: settings.showResults !== false
            });

            // Set up before_submit event for training plugins
            $_bx.event().on("before_submit", (v) => {
                // Save final state before submission
                v.state.question = this.state.question;
                v.state.options = this.state.options;
                v.state.votes = this.state.votes;
                v.state.totalVotes = this.state.totalVotes;
                v.state.userVote = this.state.userVote;
                v.state.completed = true;
                v.state.submittedAt = new Date().toISOString();
                v.state.submitMeta = {
                    isDisabled: true
                };
            });
        });
    }

    /**
     * Restore state from saved data
     */
    restoreState = () => {
        try {
            // Get saved state from $_bx
            const savedQuestion = $_bx.get("question");
            const savedOptions = $_bx.get("options");
            const savedVotes = $_bx.get("votes");
            const savedTotalVotes = $_bx.get("totalVotes");
            const savedState = $_bx.getState();

            console.log('Restoring poll state:', {
                savedQuestion,
                savedOptions,
                savedVotes,
                savedTotalVotes
            });

            if (savedQuestion) {
                this.setState({ question: savedQuestion });
            }
            
            if (savedOptions && Array.isArray(savedOptions)) {
                this.setState({ options: savedOptions });
            }
            
            if (savedVotes) {
                this.setState({ votes: savedVotes });
            }
            
            if (typeof savedTotalVotes === 'number') {
                this.setState({ totalVotes: savedTotalVotes });
            }

            // Restore user vote from user state
            if (savedState && savedState.userVote !== undefined) {
                this.setState({ userVote: savedState.userVote });
            }
        } catch (error) {
            console.error('Error restoring poll state:', error);
        }
    };

    handleVote = (optionIndex) => {
        const { votes, totalVotes, userVote } = this.state;
        
        // Check if user already voted and multiple votes not allowed
        if (userVote !== null && !this.isMultipleVotesAllowed()) {
            return;
        }

        const newVotes = { ...votes };
        let newTotal = totalVotes;

        // Remove previous vote if exists
        if (userVote !== null) {
            newVotes[userVote] = Math.max(0, (newVotes[userVote] || 0) - 1);
            newTotal--;
        }

        // Add new vote
        newVotes[optionIndex] = (newVotes[optionIndex] || 0) + 1;
        newTotal++;

        this.setState({
            votes: newVotes,
            totalVotes: newTotal,
            userVote: optionIndex
        });

        // Save user state
        this.saveUserState(optionIndex);
    };

    /**
     * Save current user state
     */
    saveUserState = (userVote) => {
        try {
            const userState = {
                userVote: userVote,
                votedAt: new Date().toISOString(),
                sessionId: $_bx.getComponentID(),
                timestamp: Date.now()
            };

            $_bx.saveUserState(userState).then(() => {
                console.log('Poll user state saved successfully');
            }).catch((error) => {
                console.error('Failed to save poll user state:', error);
            });
        } catch (error) {
            console.error('Error preparing poll user state:', error);
        }
    };

    isMultipleVotesAllowed = () => {
        const settings = $_bx.getSettings() || {};
        return settings.allowMultipleVotes === true;
    };

    getVotePercentage = (optionIndex) => {
        const { votes, totalVotes } = this.state;
        if (totalVotes === 0) return 0;
        return Math.round(((votes[optionIndex] || 0) / totalVotes) * 100);
    };

    render() {
        const { question, options, votes, totalVotes, userVote, showResults, language } = this.state;
        
        const texts = {
            en: {
                vote: "Vote",
                votes: "votes",
                totalVotes: "Total votes",
                yourVote: "Your vote",
                changeVote: "Change vote",
                pollNotConfigured: "Poll not configured"
            },
            ru: {
                vote: "Голосовать",
                votes: "голосов",
                totalVotes: "Всего голосов",
                yourVote: "Ваш голос",
                changeVote: "Изменить голос",
                pollNotConfigured: "Опрос не настроен"
            }
        };

        const t = texts[language] || texts.en;

        if (!question || options.length === 0) {
            return h("div", { className: "poll-empty" }, t.pollNotConfigured);
        }

        return (
            h("div", { className: "poll-view" },
                h("h3", { className: "poll-question" }, question),
                
                h("div", { className: "poll-options" },
                    options.map((option, index) => {
                        const voteCount = votes[index] || 0;
                        const percentage = this.getVotePercentage(index);
                        const isUserVote = userVote === index;
                        const canVote = userVote === null || this.isMultipleVotesAllowed();

                        return h("div", { 
                            key: index, 
                            className: `poll-option ${isUserVote ? 'user-voted' : ''} ${canVote ? 'clickable' : 'disabled'}`
                        },
                            h("button", {
                                className: "option-button",
                                onClick: () => canVote && this.handleVote(index),
                                disabled: !canVote
                            },
                                h("span", { className: "option-text" }, option),
                                isUserVote && h("span", { className: "vote-indicator" }, "✓")
                            ),

                            showResults && totalVotes > 0 && h("div", { className: "vote-results" },
                                h("div", { className: "vote-bar" },
                                    h("div", { 
                                        className: "vote-fill",
                                        style: { width: `${percentage}%` }
                                    })
                                ),
                                h("span", { className: "vote-stats" }, 
                                    `${voteCount} ${t.votes} (${percentage}%)`
                                )
                            )
                        );
                    })
                ),

                showResults && totalVotes > 0 && h("div", { className: "poll-summary" },
                    h(Users, { size: 16 }),
                    h("span", null, `${t.totalVotes}: ${totalVotes}`)
                )
            )
        );
    }
}

$_bx.onReady(() => {
    render(h(PollView), document.getElementById('root'));
});

Step 9: View Styles

Create src/view/view.css:

.poll-view {
    padding: 20px;
    max-width: 600px;
    margin: 0 auto;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.poll-question {
    margin-bottom: 20px;
    color: #1f2937;
    font-size: 20px;
    font-weight: 600;
}

.poll-options {
    margin-bottom: 20px;
}

.poll-option {
    margin-bottom: 12px;
    border: 2px solid #e5e7eb;
    border-radius: 8px;
    overflow: hidden;
    transition: border-color 0.2s;
}

.poll-option.clickable:hover {
    border-color: #3b82f6;
}

.poll-option.user-voted {
    border-color: #10b981;
    background-color: #f0fdf4;
}

.option-button {
    width: 100%;
    padding: 16px;
    background: none;
    border: none;
    text-align: left;
    cursor: pointer;
    font-size: 16px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    transition: background-color 0.2s;
}

.option-button:hover:not(:disabled) {
    background-color: #f9fafb;
}

.option-button:disabled {
    cursor: not-allowed;
    opacity: 0.6;
}

.option-text {
    flex: 1;
}

.vote-indicator {
    color: #10b981;
    font-weight: bold;
    font-size: 18px;
}

.vote-results {
    padding: 8px 16px;
    background-color: #f8fafc;
    border-top: 1px solid #e2e8f0;
}

.vote-bar {
    height: 8px;
    background-color: #e2e8f0;
    border-radius: 4px;
    margin-bottom: 4px;
    overflow: hidden;
}

.vote-fill {
    height: 100%;
    background-color: #3b82f6;
    transition: width 0.3s ease;
}

.vote-stats {
    font-size: 12px;
    color: #64748b;
}

.poll-summary {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 12px;
    background-color: #f1f5f9;
    border-radius: 6px;
    color: #475569;
    font-size: 14px;
}

.poll-empty {
    text-align: center;
    padding: 40px;
    color: #9ca3af;
    font-style: italic;
}

@media (max-width: 480px) {
    .poll-view {
        padding: 16px;
    }
    
    .poll-question {
        font-size: 18px;
    }
    
    .option-button {
        padding: 14px;
        font-size: 15px;
    }
}

Step 10: Creating the Manifest

Finally, create the manifest.json:

{
  "status": "active", 
  "version": "1.0.0",
  "name": "Simple Poll",
  "description": "A simple poll component that allows students to vote on options and see real-time results. Perfect for gathering feedback, conducting surveys, and engaging students in interactive voting activities.",
  "short_description": "Interactive voting poll component",
  "icon": "./dist/icon.png",
  "settings": {
    "answerRequired": false
  },
  "entry": {
    "state": "./dist/state.json",
    "settings": "./dist/settings.json", 
    "edit": "./dist/edit.html",
    "view": "./dist/view.html"
  }
}

Building and Testing Your Plugin

Now let's build our plugin:

# Install dependencies
npm install

# Build the plugin
npm run build

This will generate the compiled files in the dist/ directory.

Authentication and Publishing

Step 1: Install Coob CLI

The Coob CLI tool is essential for plugin development and publishing:

npm install -g coobcli

Step 2: Authenticate with Your Coob Account

Before you can publish plugins, you must authenticate with your coob.app account:

npx coobcli login

This command will prompt you to enter your Coob platform credentials. Make sure you have a valid account on coob.app before proceeding.

Step 3: Publishing Your Plugin

Once authenticated, publishing is straightforward:

# Make sure you're in the plugin directory
cd simple-poll-plugin

# Publish the plugin
npx coobcli publish

The CLI will:

  • Validate your plugin structure
  • Check the manifest.json file
  • Upload your plugin to the Coob platform
  • Make it available for use in courses

Step 4: Using Your Plugin

After successful publication, your plugin will be available in the Coob course editor. Course creators can:

  1. Add your plugin to course steps
  2. Configure it using the settings you defined
  3. Students will interact with it through the view interface

JavaScript API Reference

Coob plugins have access to the global $_bx object that provides platform integration. Here are the methods based on real smart-quiz plugin implementation:

Core Methods

// Execute callback when platform is ready
$_bx.onReady(callback)

// Get current component state data by key
$_bx.get(key)

// Get all component state
$_bx.getState()

// Get plugin settings configured in settings.json
$_bx.getSettings()

// Get current language (en/ru/etc)
$_bx.language()

// Get current component ID
$_bx.getComponentID()

// Save user-specific state (for preserving user progress)
$_bx.saveUserState(userState)

// Submit answer (for training plugins)
$_bx.answerSubmit()

UI Feedback Methods

// Display success message to user
$_bx.showSuccessMessage(message)

// Display error message to user  
$_bx.showErrorMessage(message)

Event System

The event system is the backbone of state management in Coob plugins:

// Hook into state saving - called before component state is saved
$_bx.event().on("before_save_state", (v) => {
    // v.state is the state object that will be saved
    // Modify v.state to set what gets saved to the database
    
    v.state.myData = this.state.myLocalData;
    v.state.lastModified = Date.now();
    
    // Control button states
    v.state.submitMeta = {
        isDisabled: true // Disable default submit buttons
    };
});

// Hook into form submission - called before student submits answer
$_bx.event().on("before_submit", (v) => {
    // Validate user input before submission
    if (!this.isAnswerComplete()) {
        $_bx.showErrorMessage("Please complete all fields");
        return; // Prevents submission
    }
    
    // Save final answer state
    v.state.finalAnswer = this.getFinalAnswer();
    v.state.completed = true;
    v.state.submittedAt = new Date().toISOString();
});

State Management Patterns

Based on the smart-quiz implementation, here's the recommended pattern:

class MyPlugin extends Component {
    componentDidMount() {
        $_bx.onReady(() => {
            // 1. Restore saved state
            this.restoreState();
            
            // 2. Get user language and settings
            const language = $_bx.language() || 'en';
            const settings = $_bx.getSettings() || {};
            
            // 3. Set up state saving
            $_bx.event().on("before_save_state", (v) => {
                // Validate data before saving
                if (!this.validateData()) {
                    return; // Prevent saving if invalid
                }
                
                // Save to global state
                v.state.question = this.state.question;
                v.state.options = this.state.options;
                v.state.submitMeta = { isDisabled: true };
            });
            
            // 4. Set up submission handling (for training plugins)
            $_bx.event().on("before_submit", (v) => {
                v.state.userAnswer = this.state.userAnswer;
                v.state.completed = true;
            });
        });
    }
    
    restoreState = () => {
        // Get saved data
        const savedQuestion = $_bx.get("question");
        const savedOptions = $_bx.get("options");
        const savedState = $_bx.getState();
        
        // Restore component state
        if (savedQuestion) {
            this.setState({ question: savedQuestion });
        }
        if (savedOptions) {
            this.setState({ options: savedOptions });
        }
    };
    
    saveUserState = () => {
        // Save user-specific data (progress, answers, etc.)
        const userState = {
            userAnswer: this.state.userAnswer,
            progress: this.state.progress,
            timestamp: Date.now(),
            sessionId: $_bx.getComponentID()
        };
        
        $_bx.saveUserState(userState).then(() => {
            console.log('User state saved');
        }).catch(error => {
            console.error('Failed to save user state:', error);
        });
    };
}

Training Plugins and Lua Handlers

For training plugins that need to evaluate student responses, you'll need a Lua handler. Here's an example dist/handler.lua:

function main()
    -- Access plugin state and user input
    local component = bx_state.component
    local request = bx_state.request
    local settings = bx_state.component._settings or {}
    
    -- Get user's answer
    local userAnswer = request.answer
    local correctAnswer = component.correctAnswer
    
    -- Evaluate the response
    local isCorrect = false
    local message = "Please try again"
    
    if userAnswer == correctAnswer then
        isCorrect = true
        message = settings.successMessage or "Correct!"
    else
        message = settings.errorMessage or "Incorrect answer"
    end
    
    -- Return result (isCorrect: boolean, message: string)
    return isCorrect, message
end

Best Practices and Tips

1. Responsive Design

Always test your plugin on mobile devices. Use CSS media queries and flexible layouts.

2. Internationalization

Support multiple languages from the start. Store text strings in objects and use the $_bx.language() method.

3. State Management

Keep your state minimal and serializable. Avoid storing complex objects or functions in the component state.

4. Error Handling

Always validate user input and provide meaningful error messages.

5. Performance

Use efficient rendering techniques and avoid unnecessary re-renders.

6. Accessibility

Include proper ARIA labels and keyboard navigation support.

Advanced Features

Custom Settings Validation

You can add custom validation to your settings schema:

{
  "JSONSchema": {
    "properties": {
      "maxVotes": {
        "type": "integer",
        "title": "Maximum Votes",
        "minimum": 1,
        "maximum": 100,
        "default": 1
      }
    }
  }
}

Complex State Management

For more complex plugins, consider using state patterns:

class PluginState {
    constructor(initialData) {
        this.data = initialData;
        this.listeners = [];
    }
    
    update(newData) {
        this.data = { ...this.data, ...newData };
        this.notifyListeners();
        $_bx.save(this.data);
    }
    
    subscribe(listener) {
        this.listeners.push(listener);
    }
    
    notifyListeners() {
        this.listeners.forEach(listener => listener(this.data));
    }
}

Troubleshooting Common Issues

Build Errors

Problem: Webpack build fails with JSX errors
Solution: Check your babel configuration and ensure proper JSX pragma settings

Problem: CSS not loading properly
Solution: Verify style-loader and css-loader are properly configured

Authentication Issues

Problem: "Authentication failed" during login
Solution: Verify your Coob account credentials and check internet connection

Problem: "Permission denied" during publish
Solution: Ensure you're logged in with npx coobcli login

Runtime Errors

Problem: $_bx is not defined
Solution: Wrap your code in $_bx.onReady() callback

Problem: State not persisting
Solution: Use the event system correctly:

$_bx.event().on("before_save_state", (v) => {
    v.state.myData = this.state.myData;
});

Problem: User state not saving
Solution: Use $_bx.saveUserState() for user-specific data:

$_bx.saveUserState({ userAnswer: answer }).then(() => {
    console.log('Saved successfully');
});

Conclusion

You've now learned how to create a complete Coob plugin from scratch! Here's what we covered:

Plugin architecture and file structure
Development setup with Webpack and Babel
Building a real example (poll plugin)
Authentication with Coob CLI
Publishing process and deployment
JavaScript API for platform integration
Best practices and troubleshooting

The Coob plugin ecosystem is constantly growing, and we encourage you to contribute your own plugins to the community repository at https://github.com/coob-app/plugins.

Next Steps

  1. Explore existing plugins in the repository for more complex examples
  2. Join the developer community for support and collaboration
  3. Build more advanced plugins with custom logic and integrations
  4. Share your creations with the educational community

Happy plugin development! 🚀


Have questions or need help? Join our developer community or check out more examples in the official plugin repository.

Ready to Create Your Course?

Try coob.app and launch an interactive course in Telegram today

Start for Free