Skip to content
GitHub

Resources

A Resource is a declarative Python class that models an admin entity. It is the central configuration point for everything the admin UI knows about a domain model — which fields appear in forms and tables, which actions are available, how records relate to each other, which navigation cluster the entity belongs to, and what permissions gate each operation.

from lexigram.admin.resources.base import Resource
class MyResource(Resource):
model = None # Your domain model class
name = "my_items" # URL-friendly identifier
label = "My Items" # Human-readable display name (auto-derived from class if omitted)
icon = "box" # Lucide icon identifier
cluster = "content" # Navigation grouping key
AttributeTypeDefaultPurpose
modeltype[DomainModel] | NoneNoneDomain model class
namestr | NoneNoneURL-friendly resource identifier
clusterstr | NoneNoneNavigation group key
groupstr | NoneNoneDeprecated alias for cluster
iconstr"box"Lucide icon name
labelstr | NoneNoneHuman-readable display name
visible_in_sidebarboolTrueShow in navigation sidebar
fieldslist[SchemaField][]Declarative field definitions
columnslist[Column][]Legacy column definitions
actionslist[Action][]Row-level actions
action_layoutstr"horizontal"Layout of action buttons
bulk_actionslist[BulkAction][]Bulk selection actions
filterslist[Filter][]Legacy filter definitions
page_sizeint20Items per page in list view
default_sortstr | NoneNoneDefault sort field
form_classtype[FormBase] | NoneNoneCustom form class
form_display_modestr"modal"Form display: "page", "modal", or "slider"
relationslist[type[RelationManager]][]Inline relation managers
search_fieldslist[str][]Fields searched by global search
search_title_fieldstr"name"Field used as display title in search results

The simplest possible resource needs a name, a cluster, and at least one fields entry:

from lexigram.admin.resources.base import Resource
from lexigram.admin.schema import TextField, EmailField, DateField
class UserResource(Resource):
model = User
name = "users"
cluster = "users"
fields = [
TextField(name="name", label="Name", required=True, sortable=True, searchable=True),
EmailField(name="email", label="Email", required=True),
DateField(name="created_at", label="Created", sortable=True),
]

That’s it. From these declarations the framework auto-derives:

  • Columns for the list table (via SchemaField.render_column())
  • Filters for the filter bar (via SchemaField.render_filter() — fields that return None are excluded)
  • Form fields for create and edit pages (via SchemaField.render_form())
  • Search if you set search_fields

This auto-derivation only kicks in when you use fields and do not explicitly set columns, filters, or form_class. If you set both fields and columns, the explicit columns win for the table rendering.


SchemaField is the abstract base for all field types. It is a frozen dataclass that carries everything needed to render in every context — form, table column, and filter widget.

@dataclass(frozen=True, kw_only=True)
class SchemaField(ABC, Generic[T]):
name: str # Field name, matches the model attribute
label: str | None = None # Display label (auto-derived from name if None)
help_text: str | None = None # Tooltip or help text shown below the field
placeholder: str | None = None # Placeholder text inside the form input
nullable: bool = True # Whether None is a valid value
readonly: bool = False # Whether the field is read-only in forms
required: bool = False # Whether the field requires a value
sortable: bool = True # Whether the list table column is sortable
searchable: bool = False # Whether the field is included in searches
filterable: bool = True # Whether a filter widget is rendered for this field
visible_in_form: bool = True # Show in create/edit forms
visible_in_list: bool = True # Show in the list table
visible_in_view: bool = True # Show in the read-only detail view
validators: list[FieldValidator] = field(default_factory=list)
default: T | None = None # Default value when creating a new record
FieldFormColumnNotes
TextField<input type="text">Plain textCommon single-line text
EmailField<input type="email">mailto: linkEmail addresses
PasswordField<input type="password">••••••Masked in columns
URLField<input type="url">Clickable linkOpens in new tab
TextAreaField<textarea>Plain textMulti-line text
MarkdownFieldRich markdown editorRendered markdownSupports preview
RichTextFieldWYSIWYG editorRendered HTMLFull rich text
TextField(name="title", required=True, sortable=True, searchable=True)
EmailField(name="email", required=True, label="Email Address")
URLField(name="website", placeholder="https://example.com")
PasswordField(name="password", visible_in_list=False)
TextAreaField(name="bio", rows=5, help_text="Tell us about yourself")
FieldFormColumnNotes
IntegerField<input type="number">Integer textWhole numbers only
FloatField<input type="number" step="any">Decimal textFloating point
NumberField<input type="number">Number textGeneric numeric
CurrencyField<input type="number">Formatted currencyTakes currency kwarg (e.g. "USD")
IntegerField(name="age", min_value=0, max_value=150)
FloatField(name="price", required=True, min_value=0)
CurrencyField(name="revenue", currency="USD")
FieldFormColumnNotes
BooleanFieldCheckboxCheck/cross iconStandard boolean
ToggleFieldToggle switchCheck/cross iconAlways renders a switch
BooleanField(name="is_active", label="Active")
ToggleField(name="notifications", label="Push Notifications")
FieldFormColumnNotes
SelectField<select> dropdownLabel textOptions as list[tuple[str, str]] or dict[str, str]
EnumField<select> dropdownLabel textAuto-derives options from enum_cls
MultiSelectFieldMulti-selectComma-separated tagsMultiple values
RadioFieldRadio button groupLabel textSingle selection
SelectField(
name="status",
options={
"draft": "Draft",
"published": "Published",
"archived": "Archived",
},
default="draft",
sortable=True,
)
EnumField(name="role", enum_cls=UserRole)
MultiSelectField(
name="tags",
options=[("python", "Python"), ("js", "JavaScript"), ("rust", "Rust")],
)
FieldFormColumnNotes
DateFieldDate pickerFormatted dateDate only
DateTimeFieldDateTime pickerFormatted datetimeDate + time
TimeFieldTime pickerFormatted timeTime only
DateField(name="published_at", nullable=True, sortable=True)
DateTimeField(name="created_at", label="Created")
TimeField(name="opens_at", help_text="Store opening time")
FieldFormColumnNotes
JsonFieldJSON editorFormatted JSONStructured data
ColorFieldColor pickerColor swatchHex color values
TagsFieldTag inputTag badgesComma/keyword tags
KeyValueFieldKey-value pairsTable displayArbitrary metadata
FileFieldFile uploadFile linkUploaded files
ImageFieldImage uploadThumbnailUploaded images
AvatarFieldImage uploadCircular avatarsize kwarg (default 40)
RatingFieldStar ratingStar iconsNumeric rating
HiddenFieldHidden inputHiddenSet programmatically
FieldPurpose
BelongsToFieldBelongs-to relationship (FK lookup)
HasManyFieldHas-many relationship
MorphFieldPolymorphic relationship
RelationFieldGeneric relation field

Validators are FieldValidator protocol instances. You attach them to any SchemaField via the validators list:

from lexigram.admin.schema import (
EmailValidator,
LengthValidator,
RangeValidator,
PatternValidator,
RequiredValidator,
URLValidator,
)
TextField(
name="username",
validators=[
RequiredValidator(),
LengthValidator(min_length=3, max_length=50),
PatternValidator(r"^[a-zA-Z0-9_]+$"),
],
)
NumberField(name="age", validators=[RangeValidator(min_value=0, max_value=150)])
EmailField(name="email", validators=[EmailValidator()])
URLField(name="site", validators=[URLValidator()])

Each validator returns Ok(value) on success or Err(FieldError) on failure. Validators run in order — the first failure short-circuits.

When Resource.fields is set and columns/filters/form_class are not explicitly set on the class, the following derivation happens automatically in __init_subclass__:

# Auto-derived (behind the scenes):
cls.columns = list(cls.fields) # Each field knows how to render_column()
cls.filters = [f for f in cls.fields if getattr(f, "filterable", False)]

If you do set any of columns, filters, or form_class alongside fields, the framework emits a DeprecationWarning and your explicit definitions take precedence.


Actions are the primary way users interact with records. The framework provides three action categories — row, bulk, and header — with a full lifecycle for visibility, authorization, confirmation, and execution.

Import and attach ready-to-use actions:

from lexigram.admin.actions.standard import EditAction, ViewAction, DeleteAction, CreateAction, DeleteBulkAction
class PostResource(Resource):
actions = [EditAction(), ViewAction(), DeleteAction()]
bulk_actions = [DeleteBulkAction()]
# Header actions are defined separately via config or legacy attribute
Standard ActionCategoryIconColorConfirmation
EditActionRowpencilPRIMARYNo
ViewActionRoweyeGRAYNo
DeleteActionRowtrashDANGERYes
CreateActionHeaderplusPRIMARYNo
DeleteBulkActionBulktrashDANGERYes
Action[R, Outcome] (abstract base)
├── RowAction[Any, Any] # operates on a single record
├── BulkAction[list[Any], Any] # operates on multiple selected records
└── HeaderAction[None, Any] # no record context, rendered in header

Every action subclass can override these hooks, all of which are optional:

class MyAction(RowAction):
# 1. VISIBILITY — hide the action for certain records
def visible_for(self, record: Any, user: Any | None = None) -> bool:
return record.status != "archived"
# 2. AUTHORIZATION — reject unauthorized users
def authorize(self, record: Any, user: Any | None = None) -> Result[None, PermissionDenied]:
if user and user.is_admin:
return Ok(None)
return Err(PermissionDenied("Admins only"))
# 3. CONFIRMATION — show a dialog before executing
def confirm(self) -> ConfirmationConfig | None:
return ConfirmationConfig(
title="Archive Post",
message="This will hide the post from the public site.",
style=ActionColor.WARNING,
)
# 4. PARAMETER COLLECTION — optional form before execution
def form(self) -> Any | None:
return None
# 5. EXECUTION — the actual work
async def execute(self, record: Any, ctx: ActionContext) -> Result[Any, Any]:
...
from lexigram.admin.actions.base import RowAction
from lexigram.admin.actions.types import ActionColor, ActionContext
from lexigram.result import Ok, Result
class PublishAction(RowAction):
"""Publish a draft post."""
def __init__(self):
super().__init__(
name="publish",
label="Publish",
icon="send",
color=ActionColor.SUCCESS,
)
def visible_for(self, record: Any, user: Any | None = None) -> bool:
return record.status == "draft"
def confirm(self) -> ConfirmationConfig | None:
return ConfirmationConfig(
title="Publish Post",
message="This will make the post visible to everyone.",
style=ActionColor.WARNING,
)
async def execute(self, record: Any, ctx: ActionContext) -> Result[Any, Any]:
await publish_service.publish(record.id)
return Ok({"message": f"Published #{record.id}", "published": True})
class ArchiveBulkAction(BulkAction):
"""Archive multiple posts at once."""
def __init__(self):
super().__init__(
name="archive",
label="Archive Selected",
icon="archive",
color=ActionColor.WARNING,
)
async def execute(self, records: list[Any], ctx: ActionContext) -> Result[Any, Any]:
count = len(records)
async with bulk_archive_service.batch() as batch:
for record in records:
await batch.archive(record.id)
return Ok({"message": f"Archived {count} posts", "archived_count": count})
class ExportAllAction(HeaderAction):
"""Export all records to CSV."""
def __init__(self):
super().__init__(
name="export",
label="Export All",
icon="download",
color=ActionColor.INFO,
)
async def execute(self, record: None, ctx: ActionContext) -> Result[Any, Any]:
csv_data = await export_service.export_all()
return Ok({"message": "Export complete", "csv": csv_data})

Attaching custom actions:

class PostResource(Resource):
actions = [EditAction(), PublishAction(), DeleteAction()]
bulk_actions = [ArchiveBulkAction()]
TypePurpose
ActionColorGRAY, PRIMARY, SUCCESS, WARNING, DANGER, INFO
ActionContextCarries request, user, resource_name
ConfirmationConfigtitle, message, style
ExceptionCodeWhen
ActionErrorLEX_ERR_ADMIN_ACTION_001Base for all action errors
PermissionDeniedLEX_ERR_ADMIN_ACTION_002User lacks permission

All actions return Result[Outcome, ActionError] from execute(). On Ok, the UI may display the message. On Err, the error is surfaced to the user.


Every Resource auto-generates four standard pages. You can customize any of them by subclassing the corresponding page class.

PagePathPurpose
ListPage/{resource_name}Table with search, pagination, filters
CreatePage/{resource_name}/newForm to create a new record
EditPage/{resource_name}/{id}/editForm pre-filled with existing record
ViewPage/{resource_name}/{id}Read-only detail with relation panels
from lexigram.admin.pages.resource_pages import ListPage, ViewPage
from lexigram.admin.pages.base import Page
from lexigram.admin.pages.types import PageResponse
class CustomListPage(ListPage):
title = "Browse Posts"
async def view(self, request: Any) -> PageResponse:
response = await super().view(request)
# Override to inject custom context, stats, etc.
response.context["stats"] = await stats_service.get_overview()
return response
class DraftListPage(Page):
"""A completely custom page — not tied to the default CRUD flow."""
title = "Draft Queue"
def __init__(self):
self.path = "/admin/content/posts/drafts"
async def view(self, request: Any) -> PageResponse:
drafts = await post_service.find_by_status("draft")
return PageResponse(
content=f"<div>Found {len(drafts)} drafts</div>",
title=self.title,
)

The form_display_mode attribute controls how create/edit forms are presented:

ModeBehavior
"modal"Centered modal dialog (default)
"page"Full-page form
"slider"Side panel slider
class UserResource(Resource):
form_display_mode = "slider" # Side panel for quick editing

The Resource class provides hooks that fire before and after each CRUD operation:

class PostResource(Resource):
async def before_create(self, data: dict) -> dict:
data["slug"] = slugify(data.get("title", ""))
return data
async def after_create(self, record: Any) -> None:
await audit_service.log("post_created", record.id)
async def before_update(self, item_id: Any, data: dict) -> dict:
data["updated_at"] = datetime.utcnow()
return data
async def after_update(self, record: Any) -> None:
await cache_service.invalidate(f"post:{record.id}")
async def before_delete(self, item_id: Any) -> None:
if not await post_service.can_delete(item_id):
raise PermissionError("Cannot delete published post")
async def after_delete(self, item_id: Any) -> None:
await cache_service.invalidate(f"post:{item_id}")

Relations let you display and edit related records inline on a resource’s ViewPage. They are powered by RelationManager subclasses.

from lexigram.admin.relations.manager_ext import RelationManager
from lexigram.admin.schema import TextField, DateField
class PostCommentsRelation(RelationManager):
# Required: used as the URL segment and panel heading
relationship_name = "comments"
# Optional: inline editing policy (all True by default)
inline_create = True
inline_edit = True
inline_delete = True
inline_detach = False
# Required: define the table columns for the related records
@classmethod
def table(cls, table_config=None):
return [
TextField(name="author"),
TextField(name="content"),
DateField(name="created_at"),
]
# Required: fetch the related records for the parent
async def get_query(self):
return await comment_service.find_by_post(self.parent_id)
# Optional: permission predicates
def can_create(self, user=None):
return Ok(None) if user else Err(PermissionDeniedError())
def can_edit(self, record, user=None):
return Ok(None) if user else Err(PermissionDeniedError())
def can_delete(self, record, user=None):
return Ok(None) if user else Err(PermissionDeniedError())
# Optional: custom inline forms
def create_form(self) -> str | None:
return '<form hx-post="...">...</form>'
def edit_form(self, record: Any) -> str | None:
return f'<form hx-put="...">{record.author}</form>'
class PostResource(Resource):
relations = [PostCommentsRelation]

Multiple relations are supported — each renders as a separate panel on the ViewPage, lazy-loaded via HTMX:

class PostResource(Resource):
relations = [PostCommentsRelation, PostRevisionsRelation, PostMetaRelation]

The RelationManager extends AbstractRelationManager, which provides:

MethodPurpose
table(cls, table_config)Define columns for related records
get_query()Fetch related records
count()Count related records
get_items(page, per_page)Paginated related records
get_relationship_name()Derived from relationship_name or class name

Clusters group resources and pages together in the admin sidebar. A Resource declares its cluster membership with the cluster class attribute (string key).

class PostResource(Resource):
cluster = "content"
class CategoryResource(Resource):
cluster = "content"
class UserResource(Resource):
cluster = "users"

Clusters are defined as Cluster dataclass instances at registration time:

from lexigram.admin.clusters.base import Cluster
content_cluster = Cluster(
name="content",
label="Content",
icon="file-text",
order=1,
collapsible=True,
collapsed_by_default=False,
)
users_cluster = Cluster(
name="users",
label="Users & Access",
icon="users",
order=2,
)
# At registration time, resources and pages are populated:
content_cluster.resources.append(PostResource)
content_cluster.resources.append(CategoryResource)
users_cluster.resources.append(UserResource)
AttributeTypeDefaultPurpose
namestrUnique cluster key
labelstrDisplay name in sidebar
iconstr | NoneNoneLucide icon identifier
orderint0Sort order in sidebar
collapsibleboolTrueCan be collapsed
collapsed_by_defaultboolFalseStart collapsed
resourceslist[type][]Populated at registration
pageslist[type][]Populated at registration

Every resource that displays data needs a data source — an object that implements the IDataSource protocol. This protocol abstracts away whether records come from a SQL database, an HTTP API, an in-memory store, or a search index.

from lexigram.admin.data.data_source import IDataSource, QueryResult
class MyAPIAdapter:
"""Implements IDataSource protocol — no base class needed."""
async def find_one(self, item_id: Any) -> dict | None:
"""Fetch a single record by ID."""
...
async def find_many(self, query: Any) -> QueryResult[dict]:
"""Fetch multiple records matching a query."""
...
async def count(self, query: Any) -> int:
"""Count records matching a query."""
...
async def create(self, data: dict[str, Any]) -> dict:
"""Create a new record."""
...
async def update(self, item_id: Any, data: dict[str, Any]) -> dict:
"""Update an existing record."""
...
async def delete(self, item_id: Any) -> bool:
"""Delete a record. Return True on success."""
...
async def bulk_create(self, items: list[dict[str, Any]]) -> list[dict]:
"""Create multiple records."""
...
async def bulk_update(self, ids: list[Any], data: dict[str, Any]) -> int:
"""Update multiple records. Return count of updated."""
...
async def bulk_delete(self, ids: list[Any]) -> int:
"""Delete multiple records. Return count of deleted."""
...

For SQL backends, SqlDataSource provides a ready-to-use base class:

from lexigram.admin.data.data_source import SqlDataSource
class PostDataSource(SqlDataSource):
def __init__(self, db: DatabaseProviderProtocol):
super().__init__(db, table_name="posts", id_field="id")

SqlDataSource implements every IDataSource method using parameterized SQL with proper identifier quoting.

resource = PostResource()
resource.set_data_source(PostDataSource(db))

The set_data_source method validates the protocol at runtime:

def set_data_source(self, data_source: IDataSource) -> None:
if not isinstance(data_source, IDataSource):
raise TypeError(
f"data_source must implement IDataSource, got {type(data_source).__name__}"
)
self._data_source = data_source

The Resource.fetch_list method builds query objects from pagination, search, filter, and sort parameters via QuerySpec:

from lexigram.admin.data.query import QuerySpec
# Constructed behind the scenes in Resource.fetch_list():
query = (
QuerySpec()
.with_page(page)
.with_per_page(limit)
.with_search(term, search_fields)
.with_sort(sort_by, sort_order)
.with_filter("status", "published")
.with_filter("category_id", [1, 2, 3])
)

A complete BlogPostResource that ties together fields, actions, pages, relations, cluster, data source, permissions, search, and lifecycle hooks:

from __future__ import annotations
from typing import Any
from lexigram.admin.actions.base import RowAction
from lexigram.admin.actions.standard import CreateAction, DeleteAction, EditAction
from lexigram.admin.actions.types import ActionColor, ActionContext, ConfirmationConfig
from lexigram.admin.data.data_source import IDataSource
from lexigram.admin.pages.resource_pages import ListPage
from lexigram.admin.relations.manager_ext import RelationManager
from lexigram.admin.resources.base import Resource
from lexigram.admin.schema import (
BooleanField,
DateField,
SelectField,
TextAreaField,
TextField,
)
from lexigram.result import Ok, Result
# -- Custom action --
class PublishAction(RowAction):
def __init__(self):
super().__init__(
name="publish",
label="Publish",
icon="send",
color=ActionColor.SUCCESS,
)
def visible_for(self, record: Any, user: Any | None = None) -> bool:
return getattr(record, "status", None) == "draft"
def confirm(self) -> ConfirmationConfig | None:
return ConfirmationConfig(
title="Publish Post",
message="This will make the post visible on the public site.",
style=ActionColor.WARNING,
)
async def execute(self, record: Any, ctx: ActionContext) -> Result[Any, Any]:
await publish_service.publish(record.id)
return Ok({"message": f"Published #{record.id}"})
# -- Custom page --
class PostListPage(ListPage):
title = "All Posts"
async def view(self, request: Any) -> PageResponse:
response = await super().view(request)
response.context["stats"] = await post_stats()
return response
# -- Relation manager --
class PostCommentsRelation(RelationManager):
relationship_name = "comments"
@classmethod
def table(cls, table_config=None):
from lexigram.admin.schema import DateTimeField, EmailField
return [
TextField(name="author", sortable=True),
EmailField(name="email"),
TextField(name="content"),
DateTimeField(name="created_at", sortable=True),
]
async def get_query(self):
return await comment_service.find_by_post(self.parent_id)
class PostRevisionsRelation(RelationManager):
relationship_name = "revisions"
inline_create = False
inline_edit = False
inline_delete = True
@classmethod
def table(cls, table_config=None):
from lexigram.admin.schema import DateTimeField
return [
TextField(name="title"),
TextField(name="author"),
DateTimeField(name="created_at", sortable=True),
]
async def get_query(self):
return await revision_service.find_by_post(self.parent_id)
# -- Resource --
class BlogPostResource(Resource):
# Identity
model = BlogPost
name = "posts"
label = "Blog Posts"
icon = "file-text"
cluster = "content"
# Fields (canonical declaration)
fields = [
TextField(name="title", required=True, sortable=True, searchable=True),
TextField(name="slug", readonly=True, help_text="Auto-generated from title"),
SelectField(
name="status",
options={
"draft": "Draft",
"published": "Published",
"archived": "Archived",
},
default="draft",
sortable=True,
),
TextAreaField(name="excerpt", rows=3, help_text="Short summary for listings"),
TextAreaField(name="content", rows=20),
BooleanField(name="featured", label="Featured Post"),
DateField(name="published_at", nullable=True, sortable=True),
]
# Search
search_fields = ["title", "excerpt"]
search_title_field = "title"
# Actions
actions = [EditAction(), PublishAction(), DeleteAction()]
bulk_actions = [] # none for now
# Relations
relations = [PostCommentsRelation, PostRevisionsRelation]
# Form display
form_display_mode = "page" # Full-page form for blog posts
# Custom page
list_page_class = PostListPage
# Permissions
def has_view_permission(self, user: Any) -> bool:
return user is not None
def has_add_permission(self, user: Any) -> bool:
return user and user.is_editor
def has_change_permission(self, user: Any) -> bool:
return user and user.is_editor
def has_delete_permission(self, user: Any) -> bool:
return user and user.is_admin
# Lifecycle hooks
async def before_create(self, data: dict) -> dict:
data["slug"] = slugify(data.get("title", ""))
return data
async def after_create(self, record: Any) -> None:
await audit_service.log("post_created", record.id)
async def before_update(self, item_id: Any, data: dict) -> dict:
if "published_at" not in data and data.get("status") == "published":
data["published_at"] = datetime.utcnow()
return data
async def after_delete(self, item_id: Any) -> None:
await cache_service.invalidate(f"post:{item_id}")
# -- Data source attachment (at registration) --
class PostDataSource(IDataSource):
async def find_one(self, item_id): ...
async def find_many(self, query): ...
async def count(self, query): ...
async def create(self, data): ...
async def update(self, item_id, data): ...
async def delete(self, item_id): ...
async def bulk_create(self, items): ...
async def bulk_update(self, ids, data): ...
async def bulk_delete(self, ids): ...
# In your registration module:
# resource = BlogPostResource()
# resource.set_data_source(PostDataSource(db))

When a plugin contributor declares resources via get_resources(), they are automatically namespaced with the contributor’s package_source:

from lexigram.contracts.admin import BaseAdminContributor
class MyPluginContributor(BaseAdminContributor):
name = "my_plugin"
display_name = "My Plugin"
package_source = "my_plugin"
def get_resources(self):
from my_plugin.resources import WidgetResource
return [WidgetResource]

If WidgetResource.name = "widgets", admin registers it as "my_plugin.widgets" and the route becomes /admin/my_plugin/widgets/. This prevents naming collisions when multiple plugins define a resource called "widgets".

Resources from plugins can be discovered at runtime via the container:

# In any registered service:
from lexigram.admin.contributors.resource_collector import ResourceCollector
collector = await container.resolve(ResourceCollector)
all_resources = collector.collect() # includes plugin resources

See the Extension Developer Guide for a complete walkthrough of writing a plugin.

If you are familiar with the old forms/fields/ + ui/columns/ + ui/filters/ system (the “Triplet API”), see the dedicated migration guide at:

docs/MIGRATION_FROM_TRIPLET.md

Before (Triplet)After (SchemaField)
Three separate classes per field typeOne SchemaField subclass
Resource.columns + Resource.filters + form_classResource.fields (auto-derives all three)
Manual sync between form/column/filterSingle source of truth
Import from forms.fields, ui.columns, ui.filtersImport from lexigram.admin.schema
# BEFORE (old style)
class UserResource(Resource):
columns = [
TextColumn("name").sortable().searchable(),
TextColumn("email").sortable().searchable(),
DateColumn("created_at").datetime().sortable(),
BadgeColumn("role", colors={"admin": "purple", "guest": "gray"}),
]
filters = [SelectFilter("role", options=["admin", "guest"])]
# AFTER (new style)
class UserResource(Resource):
fields = [
TextField(name="name", sortable=True, searchable=True),
TextField(name="email", sortable=True, searchable=True),
SelectField(
name="role",
options={"admin": "Admin", "guest": "Guest"},
sortable=True,
),
DateField(name="created_at", label="Created", sortable=True),
]

During migration you can define both fields and columns — but the framework will emit a DeprecationWarning. Plan to remove the explicit columns and filters once the SchemaField equivalents are verified.