A new topic type
A topic is one step of a lesson. Every topic has a polymorphic topicable: a content model
that holds the type-specific data (a video file, a URL, a quiz). A topic type touches four
places:
| Layer | Where | What you add |
|---|---|---|
| API | your package (or topic-types) |
content model, table, rules, resources, registration |
| Admin | admin/src/components/ProgramForm/ThreeColProgram/ |
enum entry, selector button, form fields, icon |
| SDK | front/sdk/src/topics.ts, types.ts |
the short name to TopicKind mapping |
| Reference frontend | front/web/src/lib/page-docs.ts |
the catalogue document for the player, completion mode |
The worked example is OEmbed: one value column holding a media URL. A type with its own
tables, endpoints and events is topic-type-project (Project).
API: the content model
Section titled “API: the content model”A content model extends Ulams\TopicTypes\Models\TopicContent\AbstractTopicContent. That class
extends the courses package’s AbstractTopicContent (an Eloquent model implementing
TopicContentContract). The contract has two methods:
interface TopicContentContract{ public static function rules(): array; public function topic(): MorphOne;}The base class provides topic() (a morphOne to Topic as topicable), a fillable value
and a default fixAssetPaths(). The topic-types version also dispatches
Ulams\TopicTypes\Events\TopicTypeChanged when the content is created or its value changes.
<?php
namespace Ulams\TopicTypes\Models\TopicContent;
use Illuminate\Database\Eloquent\Factories\HasFactory;
/** * @OA\Schema( * schema="TopicOEmbed", * required={"value"}, * @OA\Property( * property="id", * description="id", * @OA\Schema( * type="integer", * ) * ), * @OA\Property( * property="value", * description="value", * type="string" * ) * ) */class OEmbed extends AbstractTopicContent{ use HasFactory;
public $table = 'topic_oembeds';
/** * Validation rules. * * @return array<string, array<int, string>> */ public static function rules(): array { return [ 'value' => ['required', 'string'], ]; }
protected static function newFactory() { return \Ulams\TopicTypes\Database\Factories\TopicContent\OEmbedFactory::new(); }
public function fixAssetPaths(): array { return []; }
public function getMorphClass() { return self::class; }}Notes:
rules()validates the request fields of this type when a topic is created or updated.TopicRepositoryrunsValidator::make($request->all(), $class::rules()), fills the model with the validated attributes, saves it and attaches the topic. Validation errors come back as aTopicException(CONTENT_VALIDATION).getMorphClass()returns the class name, which is stored intopics.topicable_type. Clients identify the type by that string.- Files: types with uploads (Video, Audio, PDF, Image) implement
TopicFileContentContract. The repository then accepts an upload or a stored path for eachgetFileKeyNames()field and stores it under the course directory. - Table: a migration in your package creates the table (
topic_oembedshere:id,value, timestamps).
API: resources and registration
Section titled “API: resources and registration”Each type has up to three JSON resources, selected by context:
| Key | Used by | For |
|---|---|---|
client |
Ulams\Courses\Http\Resources\TopicResource |
learners (course program) |
admin |
TopicAdminResource |
the admin program editor |
export |
courses-import-export |
course export packages |
Without a registered resource, the model is serialised as-is.
<?php
namespace Ulams\TopicTypes\Http\Resources\TopicType\Client;
use Ulams\TopicTypes\Http\Resources\TopicType\Contacts\TopicTypeResourceContract;use Illuminate\Http\Resources\Json\JsonResource;
class OEmbedResource extends JsonResource implements TopicTypeResourceContract{ public function toArray($request) { return [ 'id' => $this->resource->id, 'value' => $this->resource->value, ]; }}Register the model and the resources in your provider’s boot() through the Topic facade
(Ulams\Courses\Facades\Topic, backed by TopicRepository):
<?php
namespace Ulams\TopicTypeProject;
use Ulams\Courses\UlamsCourseServiceProvider;use Ulams\Courses\Facades\Topic;use Ulams\TopicTypeProject\Providers\AuthServiceProvider;use Ulams\TopicTypeProject\Http\Resources\TopicType\Admin\ProjectResource as AdminProjectResource;use Ulams\TopicTypeProject\Http\Resources\TopicType\Client\ProjectResource as ClientProjectResource;use Ulams\TopicTypeProject\Http\Resources\TopicType\Export\ProjectResource as ExportProjectResource;use Ulams\TopicTypeProject\Models\Project;use Ulams\TopicTypeProject\Repositories\Contracts\ProjectSolutionRepositoryContract;use Ulams\TopicTypeProject\Repositories\ProjectSolutionRepository;use Ulams\TopicTypeProject\Services\ProjectSolutionService;use Ulams\TopicTypeProject\Services\Contracts\ProjectSolutionServiceContract;use Ulams\TopicTypes\UlamsTopicTypesServiceProvider;use Illuminate\Support\ServiceProvider;
/** * SWAGGER_VERSION */class UlamsTopicTypeProjectServiceProvider extends ServiceProvider{ public const SERVICES = [ ProjectSolutionServiceContract::class => ProjectSolutionService::class, ];
public const REPOSITORIES = [ ProjectSolutionRepositoryContract::class => ProjectSolutionRepository::class, ];
public $singletons = self::SERVICES + self::REPOSITORIES;
public function boot(): void { $this->loadRoutesFrom(__DIR__ . '/routes.php'); $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
Topic::registerContentClass(Project::class); Topic::registerResourceClasses(Project::class, [ 'client' => ClientProjectResource::class, 'admin' => AdminProjectResource::class, 'export' => ExportProjectResource::class, ]); }
public function register(): void { $this->app->register(AuthServiceProvider::class); $this->app->register(UlamsTopicTypesServiceProvider::class); $this->app->register(UlamsCourseServiceProvider::class); }}registerContentClass silently ignores a class that does not exist or does not implement
TopicContentContract. GET /api/admin/topics/types lists the registered classes. Creating a
topic of an unregistered topicable_type fails with “Type ‘…’ is not allowed”.
Topics are created and updated by the courses package (POST /api/admin/topics, multipart, with
lesson_id, title, topicable_type and your type’s fields). Do not insert topic rows
yourself: your package only provides the content model.
Progress and completion
Section titled “Progress and completion”Progress is per topic and user in course_progress, with status from
Ulams\Courses\Enum\ProgressStatus: INCOMPLETE = 0, COMPLETE = 1, IN_PROGRESS = 2.
- From the client:
PUT /api/courses/progress/{topic_id}/pingmarks the topic in progress and adds time.PATCH /api/courses/progress/{course_id}with{"progress": [{"topic_id": 12, "status": 1}]}sets the status (ProgressService::update). - From the server: when completion is decided by your package (a tracked player, a grade,
an external tool), call
CourseProgressRepositoryContract::updateInTopic($topic, $user, ProgressStatus::COMPLETE).topic-types/src/Listeners/CompleteScormTopics.phpdoes this for SCORM, and the LTI and LiaScript packages do the same. Check that the user may attend the course first (Gate::forUser($user)->allows('attend', $course)).
On the transition to complete, updateInTopic dispatches Ulams\Courses\Events\TopicFinished
and queues the lesson check (CheckFinishedLessons). ProgressService::update marks the
course finished and dispatches CourseFinished when every topic is done. TopicFinished only
fires when a progress row already exists and is not complete, so create it as IN_PROGRESS
first, as CompleteScormTopics does.
Admin: the topic form
Section titled “Admin: the topic form”The admin program editor is in admin/src/components/ProgramForm/ThreeColProgram/:
- Add the class name to the
TopicTypeenum inadmin/src/services/ulams/enums.ts(for exampleOEmbed = 'Ulams\\TopicTypes\\Models\\TopicContent\\OEmbed'). - Add a button to
List/TopicTypesSelector.tsx. Each type is hidden when the public settingdisableTopicType-<EnumKey>is true (topicTypeToSettingNameinadmin/src/pages/Settings/global.tsxcreates one switch per enum key). - Add the form fields in
TopicForm/media/<type>.tsxand render them inTopicForm/index.tsxfor yourTopicType. The fields are sent with the topic, so their names must match yourrules(). - Add an icon in
getTypeIcon(TopicForm/index.tsx), keyed by the short class name. - Add the label (the enum key) to
admin/src/locales/en-US.tsandpl-PL.ts.
import { Button, Input, Row } from 'antd';import React, { useState } from 'react';import { FormattedMessage } from 'umi';
import { OEmbed } from '@/components/OEmbed';
export const Oembed: React.FC<{ text: string; onChange: (value: string) => void }> = ({ text, onChange,}) => { const [currentValue, setCurrentValue] = useState<string>(text); const [previewValue, setPreviewValue] = useState<string>(text);
return ( <React.Fragment> <Row> <Input value={text} onChange={(e) => { setCurrentValue(e.target.value); onChange(e.target.value); }} /> <Button onClick={() => setPreviewValue(currentValue)}> <FormattedMessage id="preview" /> </Button> </Row> <Row>{previewValue && <OEmbed key={previewValue} url={previewValue} />}</Row> </React.Fragment> );};
export default Oembed;Learner rendering in the reference frontend
Section titled “Learner rendering in the reference frontend”The lesson player is front/web/src/pages/learn/[courseId]/[topicId].astro. It loads the course
program, picks the topic and renders topicDoc(...), a UI catalogue
document, with <Render doc={doc} />. There is no per-type Astro page.
-
SDK:
topicKind()infront/sdk/src/topics.tsmaps the last segment oftopicable_type, lowercased, to aTopicKindthroughKIND_BY_CLASS(oembed: "oembed"). Add your class there and to theTopicKindunion infront/sdk/src/types.ts. Unknown classes become"unknown". -
Document: add a
caseto theswitch (kind)intopicDoc(front/web/src/lib/page-docs.ts). It builds catalogue nodes fromtopic.topicable, which is the output of yourclientresource:case "oembed":children.push({ component: "Embed", props: { url: str(t.value) ?? "", title: topic.title } });if (description) children.push({ component: "Prose", props: { markdown: description, size: "sm" } });break;The
defaultcase renders a warning callout with the class name. If no existing component fits, add one to the catalogue. -
Format: add the kind to
FORMAT_BY_KINDinfront/web/src/lib/view-model.ts(the format icon and label on course pages;oembedis"embed"). -
Completion:
completionMode(topic)inpage-docs.tstells the<ulams-progress>element (front/ui/src/elements/progress.ts) how the topic completes:Mode Behaviour view(default)completes when the end of the content is in view (after 4 seconds) manualonly the “Mark as complete” button mediathe player dispatches ulams:completewhen playback endsh5p,quizthe player dispatches ulams:completeon a completing xAPI statement or a passed attemptInteractive components call
announceComplete(source)fromfront/ui/src/elements/bff.ts. The progress element then sendsPATCH /api/courses/progress/{course_id}with status 1. If your package completes topics on the server, usemanual, so the page does not mark the topic complete on view.
Checklist
Section titled “Checklist”- Content model, migration,
rules(),getMorphClass(), factory. client,adminandexportresources, registered inboot().- Feature tests: create a topic through
POST /api/admin/topicswith your type, read it as a learner, and test completion. Add a tenant isolation test if you add endpoints. - Admin enum, selector, form, icon, labels.
- SDK kind,
topicDoccase, format, completion mode.