Building a List Page
This guide builds the index pages of the demo app's Showcase. The Posts page binds a list, a filter and a toolbar together, with bulk actions on the checked rows and a quick post popup. The Groups page edits its rows in a popup, and the Categories page shows a tree that you can reorder by dragging. Every snippet is the demo's own code, so you can run the demo app and try each page as you read.
Rendering a list
A list starts with a YAML file of columns. The posts list has a column for each attribute it shows, several of them related records:
# ===================================
# Post List Column Definitions
# ===================================
#
# Columns marked invisible start hidden and can be shown from the list setup.
columns:
id:
label: ID
sortable: true
invisible: true
width: 70px
featured_image:
label: Image
type: image
sortable: false
title:
label: Title
searchable: true
sortable: true
author:
label: Author
type: linkage
relation: author
select: name
linkUrl: authors/:author_id/edit
sortable: true
category:
label: Category
type: linkage
relation: category
select: name
linkUrl: categories/:category_id/edit
sortable: true
tags:
label: Tags
type: partial
path: ~/resources/views/posts/_column_tags.php
relationWith: tags
sortable: false
tooltip:
title: Up to three tags show here, open the post to see all of them.
icon: ph ph-info
status:
label: Status
type: selectable
sortable: true
width: 140px
published:
label: Live
type: switch
sortable: true
width: 80px
options:
0: Not published
1: Published
published_at:
label: Published
type: timetense
sortable: true
comments_count:
label: Comments
type: number
relation: comments
relationCount: true
sortable: true
width: 110px
views:
label: Views
type: number
sortable: true
invisible: true
excerpt:
label: Excerpt
type: summary
limitWords: 10
searchable: true
invisible: true
created_at:
label: Created
type: timesince
sortable: true
invisible: trueEach key names an attribute, and its definition sets the header, the column type and what the column allows. sortable adds a sorting link to the header, searchable includes the column in a search, and invisible hides the column until someone shows it from the list setup. The columns page covers every column type.
The posts controller's index action returns view('posts.index', $this->prepareListVars()), and the PostList trait builds the list with Lists::make():
/**
* makeListWidget builds the paginated list of posts, newest first, with a setup popup for columns and page size.
*/
protected function makeListWidget(): Lists
{
return Lists::make(
model: new Post,
columns: '~/resources/ui/post/columns.yaml',
recordsPerPage: 10,
perPageOptions: [10, 20, 50],
showCheckboxes: true,
showSetup: true,
recordUrl: 'posts/:id/edit',
defaultSort: ['column' => 'published_at', 'direction' => 'desc'],
noRecordsMessage: 'No posts match the search and filters.',
);
}model is an empty instance of the model to list, and columns the YAML file (a path starting with ~/ resolves from the application root, and an array of definitions works too). The other options set the page size and the sizes offered in the list setup, add checkboxes and the setup popup, link each row to its post, and sort the newest posts first. Every option is listed under Widget configuration, and the list options page shows each one live.
The view prints the widgets. Widgets are Htmlable, so Blade's renders their markup as it is:
@extends('layouts.app')
@section('title', 'Posts')
@section('content')
<h1 class="mb-3">
Posts
</h1>
<p class="fg-3 mb-7">
Archived posts are filtered out by default. Check rows to run a bulk action, press Shift+Q for a quick post, and use the list setup icon beside the search to choose the columns and page size.
<a href="{{ config('reference.docs_url') }}/guides/lists" target="_blank" rel="noopener">
How this page is built
</a>
</p>
{{ $toolbar }}
{{ $filter }}
{{ $list }}
@endsectionSorting, paging, searching and filtering post back to the page's own address, where the list's handlers (list::onSort, list::onPaginate and so on) answer them. The route must accept POST as well as GET, as shown in Routes.
Binding a toolbar and a filter
prepareListVars() builds the three widgets of the page and binds the filter and the toolbar to the list:
/**
* prepareListVars builds the list, filter and toolbar, bound together, for the index view.
*
* @return array{list: Lists, filter: Filter, toolbar: Toolbar}
*/
protected function prepareListVars(): array
{
$list = $this->listWidget = $this->makeListWidget();
$filter = $this->makeFilterWidget();
$filter->bindToListWidget($list);
$toolbar = $this->makeToolbarWidget($list);
// The top bar search submits here as ?q=
if (request()->filled('q')) {
$toolbar->getSearchWidget()->setActiveTerm(request('q'));
}
$toolbar->bindToListWidget($list);
return [
'list' => $list,
'filter' => $filter,
'toolbar' => $toolbar,
];
}$filter->bindToListWidget($list)adds the filter's scopes to the list's query and shares the list's page parameter. When a scope changes, the filter returns the list refreshed from page one in the same response.$toolbar->bindToListWidget($list)links the toolbar's buttons to the list's checkboxes, passes the search box'smodeandscopeto the list, applies the current search term, and refreshes the list when a new term is entered.
The list is built first, since the others bind to it, and kept in the public $listWidget property so the bulk action and popup handlers can refresh it. The demo's top bar has a search box of its own that submits to this page as ?q=, and setActiveTerm() hands that term to the toolbar's search before the binding applies it.
The filter and the toolbar are built in the same trait:
/**
* makeFilterWidget builds the filter scopes for the posts list.
*/
protected function makeFilterWidget(): Filter
{
return Filter::make(
model: new Post,
scopes: '~/resources/ui/post/scopes.yaml',
);
}
/**
* makeToolbarWidget builds the toolbar from a partial, with a search that runs through a model scope and the list setup icon.
*/
protected function makeToolbarWidget(Lists $list): Toolbar
{
return Toolbar::make(
buttons: '~/resources/views/posts/_list_toolbar.php',
search: [
'prompt' => 'Search posts...',
'scope' => 'applySearch',
],
setupHandler: $list->getEventHandler('onLoadSetup'),
);
}Filter::make() takes the model and a YAML file of scopes. Each scope constrains the list's query, and the values persist in the session, so the filter survives a reload. The posts filter has a switch, group scopes for the category, author, tags and status, a checkbox that hides archived posts by default, and a date range:
# ===================================
# Post Filter Scope Definitions
# ===================================
scopes:
published:
label: Published
type: switch
conditions:
- published = false
- published = true
category:
label: Category
type: group
modelClass: App\Models\Category
nameFrom: name
optionsScope: hasPosts
author:
label: Author
type: group
modelClass: App\Models\User
nameFrom: name
optionsScope: hasPosts
tags:
label: Tags
type: group
nameFrom: name
emptyOption: Untagged
status:
label: Status
type: group
optionsMethod: 'App\Models\Post::listStatusOptions'
hide_archived:
label: Hide archived
type: checkbox
default: 1
conditions: status <> 'archived'
published_at:
label: Published
type: daterangeThe scope types and filter options pages cover every scope.
Toolbar::make() takes the buttons, the search box and the list setup icon:
buttonsis a YAML file of button definitions, or a partial ofUibuttons as here. Either renders inside the toolbar, linked to the list.searchadds the search box.promptis its placeholder, andscopenames a model scope that runs the search instead of thesearchablecolumns, hereapplySearch, which also matches the category, tag and author names (see search with a scope).setupHandlershows the icon that opens the list setup.$list->getEventHandler('onLoadSetup')returnslist::onLoadSetup, the list's own handler.
The posts toolbar is a partial, which lets it hold a popup button and a dropdown:
<div data-control="toolbar">
<?= Ui::button(
label: 'New Post',
href: route('posts.create'),
icon: 'ph ph-plus',
primary: true
) ?>
<?= Ui::popupButton(
label: 'Quick Post',
handler: 'onLoadQuickPost',
icon: 'ph ph-lightning',
hotkey: 'shift+q'
) ?>
<?php Ui::dropdownButton(
label: 'Bulk Actions',
icon: 'ph ph-stack',
dataListCheckedTrigger: true
)->slot() ?>
<?= Ui::dropdownItem(
label: 'Publish',
handler: 'onBulkPublish',
icon: 'ph ph-eye',
dataListCheckedRequest: true,
dataRequestFlash: true
) ?>
<?= Ui::dropdownItem(
label: 'Unpublish',
handler: 'onBulkUnpublish',
icon: 'ph ph-eye-slash',
dataListCheckedRequest: true,
dataRequestFlash: true
) ?>
<?= Ui::dropdownDivider() ?>
<?= Ui::dropdownItem(
label: 'Delete',
handler: 'onBulkDelete',
icon: 'ph ph-trash',
dataListCheckedRequest: true,
dataRequestFlash: true,
dataRequestConfirm: 'Delete the checked posts?'
) ?>
<?php Ui::end() ?>
<?= Ui::button(
label: 'Categories',
href: route('categories.index'),
icon: 'ph ph-folders'
) ?>
</div>The Bulk Actions dropdown is disabled until a row is checked (dataListCheckedTrigger), and each item sends the ids of the checked rows with its request (dataListCheckedRequest). The Authors page declares its buttons in YAML instead, where checked: true does both for its Delete button:
# ===================================
# Author Toolbar Button Definitions
# ===================================
buttons:
create:
label: New Author
icon: ph ph-plus
type: primary
url: authors/create
delete:
label: Delete
icon: action.delete
type: danger
handler: onDelete
confirm: Delete the selected authors? Their posts stay, without an author.
checked: trueSee buttons from YAML and buttons from a partial for the full set of button options.
Bulk actions on checked rows
The bulk action handlers read the checked ids, change those posts, and return the refreshed list with a flash message:
/**
* onBulkPublish publishes the checked posts, stamping a publish date on those without one.
*/
public function onBulkPublish(): AjaxResponse
{
$posts = Post::whereIn('id', $this->getCheckedPostIds());
$count = (clone $posts)->update(['published' => true]);
(clone $posts)->whereNull('published_at')->update(['published_at' => now()]);
return ajax()
->flash('success', $count.' '.Str::plural('post', $count).' published.')
->update($this->listWidget->onRefresh());
}
/**
* onBulkUnpublish takes the checked posts offline and keeps their publish dates.
*/
public function onBulkUnpublish(): AjaxResponse
{
$count = Post::whereIn('id', $this->getCheckedPostIds())->update(['published' => false]);
return ajax()
->flash('success', $count.' '.Str::plural('post', $count).' unpublished.')
->update($this->listWidget->onRefresh());
}
/**
* onBulkDelete deletes the checked posts one by one, so their comments, tags and images go with them.
*/
public function onBulkDelete(): AjaxResponse
{
$posts = Post::whereIn('id', $this->getCheckedPostIds())->get();
$posts->each->delete();
return ajax()
->flash('success', $posts->count().' '.Str::plural('post', $posts->count()).' deleted.')
->update($this->listWidget->onRefresh());
}
/**
* getCheckedPostIds returns the ids of the checked rows posted by a bulk action.
*
* @return array<int, int>
*/
protected function getCheckedPostIds(): array
{
return array_map('intval', array_filter((array) input('checked'), 'is_numeric'));
}The checked ids arrive as checked. $this->listWidget->getAllCheckedIds() reads them too, adding rows checked on other pages of the list. $this->listWidget->onRefresh() returns the list's markup keyed by its element id, which ajax()->update() sends to the browser. The property holds the list because Larajax runs the index action before the handler, so prepareListVars() has already built it.
Record URLs
recordUrl: 'posts/:id/edit' makes each row a link to its post. :id, and any other attribute name after a colon, is replaced with the record's value, and the result becomes an address through Ui::url(), which you can change for your application. Clicking anywhere on a row follows the link, except cells that link on their own. The author and category columns are linkage columns with their own linkUrl, so they open the author or the category instead.
recordOnClick runs JavaScript on a row click in place of a link, with the same :id replacement. Its popup value opens the record in a popup, as the Groups page does next. See record URL and record click.
Editing rows in a popup
The Groups page edits a group in a popup when you click its row. The list sets recordOnClick: 'popup':
<?php
namespace App\Controllers\GroupsController;
use App\Models\UserGroup;
use Larajax\Ui\Widgets\Lists;
use Larajax\Ui\Widgets\Toolbar;
/**
* GroupList builds the groups list and its toolbar.
*/
trait GroupList
{
/**
* @var Lists|null listWidget is kept on the instance so the popup save handler can refresh it.
*/
public ?Lists $listWidget = null;
/**
* prepareListVars builds the list and its toolbar, bound together, for the index view.
*
* @return array{list: Lists, toolbar: Toolbar}
*/
protected function prepareListVars(): array
{
$list = $this->listWidget = $this->makeListWidget();
$toolbar = $this->makeToolbarWidget();
$toolbar->bindToListWidget($list);
return [
'list' => $list,
'toolbar' => $toolbar,
];
}
/**
* makeListWidget builds the list of groups with their member counts, where clicking a row edits the group in a popup.
*/
protected function makeListWidget(): Lists
{
return Lists::make(
model: new UserGroup,
columns: '~/resources/ui/user_group/columns.yaml',
recordsPerPage: 10,
recordOnClick: 'popup',
defaultSort: ['column' => 'name', 'direction' => 'asc'],
);
}
/**
* makeToolbarWidget builds the list toolbar with its buttons and search.
*/
protected function makeToolbarWidget(): Toolbar
{
return Toolbar::make(
buttons: '~/resources/views/groups/_list_toolbar.php',
search: ['prompt' => 'Search groups...'],
);
}
}A row click calls the controller's onLoadPopupForm handler, posting the record's id as form_record_id (and form_context with popup@context). The handler returns the popup's markup, and the popup's form posts back to onSavePopupForm:
/**
* onLoadPopupForm renders a group's form in a popup, as the AJAX handler for the list's popup record click.
*/
public function onLoadPopupForm(): string
{
$group = UserGroup::findOrFail(input('form_record_id'));
$this->formWidget = $this->makeFormWidget($group, 'update');
return view('groups._popup_form', [
'group' => $group,
'formWidget' => $this->formWidget,
])->render();
}
/**
* onSavePopupForm saves a group from the popup form and refreshes the list.
*/
public function onSavePopupForm(): AjaxResponse
{
$group = UserGroup::findOrFail(input('form_record_id'));
$this->formWidget = $this->makeFormWidget($group, 'update');
$this->saveForm($group);
return ajax()
->flash('success', 'Group updated.')
->update($this->listWidget->onRefresh());
}Both handlers find the group by the posted id and build the same form. The popup's markup is a form with a dialog header, body and footer around the form widget:
<form data-request="onSavePopupForm" data-request-flash data-popup-load-indicator>
<input type="hidden" name="form_record_id" value="{{ $group->id }}" />
<div class="dialog-header">
<h4 class="dialog-title">
Edit Group
</h4>
<button type="button" class="btn-close" data-dismiss="popup" aria-label="Close"></button>
</div>
<div class="dialog-body">
{{ $formWidget }}
</div>
<div class="dialog-footer">
<button type="submit" class="btn-solid theme-primary">
Save
</button>
<button type="button" class="btn-solid theme-secondary" data-dismiss="popup">
Cancel
</button>
</div>
</form>The hidden form_record_id input sends the id with the save. data-popup-load-indicator shows a loading state while the request runs and closes the popup once it succeeds, and the handler's $this->listWidget->onRefresh() updates the row behind it. The popup shorthand calls the global jax object that the Larajax bundle provides, so an application that imports jax through Vite should also assign it to window.jax. The popups page covers the dialog markup and the other ways to open a popup.
A quick create popup
The Posts toolbar's Quick Post button, or Shift+Q, opens a short form for a new post without leaving the list. Ui::popupButton() in the toolbar partial above names the handler that renders the popup, and the QuickPostForm trait holds it:
<?php
namespace App\Controllers\PostsController;
use App\Models\Post;
use Larajax\Classes\AjaxResponse;
use Larajax\Ui\Widgets\Form;
/**
* QuickPostForm builds the short form shown in the quick post popup and creates a post from it.
*/
trait QuickPostForm
{
/**
* onLoadQuickPost renders the quick post form in a popup, as the AJAX handler for the toolbar popup button.
*/
public function onLoadQuickPost(): string
{
$this->formWidget = $this->makeQuickPostFormWidget(new Post);
return view('posts._quick_post_form', [
'formWidget' => $this->formWidget,
])->render();
}
/**
* onCreateQuickPost creates a post from the popup form and refreshes the list.
*/
public function onCreateQuickPost(): AjaxResponse
{
$post = new Post;
$this->formWidget = $this->makeQuickPostFormWidget($post);
$post->fill($this->formWidget->getSaveData())->save();
return ajax()
->flash('success', 'Post created.')
->update($this->listWidget->onRefresh());
}
/**
* makeQuickPostFormWidget builds the quick post form, with its own alias so its fields stay apart from the main post form.
*/
protected function makeQuickPostFormWidget(Post $post): Form
{
return Form::make(
alias: 'quickPostForm',
context: 'create',
model: $post,
fields: '~/resources/ui/post/fields_quick.yaml',
);
}
}The quick form has its own fields:
# ===================================
# Quick Post Form Field Definitions
# ===================================
fields:
title:
label: Title
span: full
required: true
autoFocus: true
slug:
label: Slug
span: left
required: true
preset:
field: title
type: slug
category:
label: Category
type: relation
span: right
required: true
emptyOption: Choose a category
excerpt:
label: Excerpt
type: textarea
size: small
published:
label: Publish now
type: switch
comment: Publishes the post straight away instead of saving a draft.and its own popup markup, posting to onCreateQuickPost:
<form data-request="onCreateQuickPost" data-request-flash data-popup-load-indicator>
<div class="dialog-header">
<h4 class="dialog-title">
Quick Post
</h4>
<button type="button" class="btn-close" data-dismiss="popup" aria-label="Close"></button>
</div>
<div class="dialog-body">
{{ $formWidget }}
</div>
<div class="dialog-footer">
<button type="submit" class="btn-solid theme-primary">
Create Post
</button>
<button type="button" class="btn-solid theme-secondary" data-dismiss="popup">
Cancel
</button>
</div>
</form>The form is built with the alias quickPostForm, so its inputs and handlers stay apart from those of the main post form, which uses the default alias form. Creating the post returns the refreshed list, so the new post appears behind the closing popup. The post model's rules validate the quick form the same way as the full one, see validation.
A tree of records
The Categories page shows the categories as a nested tree. ListStructure is a list that adds the tree and drag-and-drop reordering, and it takes every list option as well as its own:
<?php
namespace App\Controllers\CategoriesController;
use App\Models\Category;
use Larajax\Ui\Widgets\ListStructure;
use Larajax\Ui\Widgets\Toolbar;
/**
* CategoryList builds the category tree and its toolbar.
*/
trait CategoryList
{
/**
* prepareListVars builds the tree and its toolbar, bound together, for the index view.
*
* @return array{list: ListStructure, toolbar: Toolbar}
*/
protected function prepareListVars(): array
{
$list = $this->makeListWidget();
$toolbar = $this->makeToolbarWidget();
$toolbar->bindToListWidget($list);
return [
'list' => $list,
'toolbar' => $toolbar,
];
}
/**
* makeListWidget builds the nested set tree, where dragging a row nests or reorders it three levels deep at most.
*/
protected function makeListWidget(): ListStructure
{
return ListStructure::make(
model: new Category,
columns: '~/resources/ui/category/columns.yaml',
recordUrl: 'categories/:id/edit',
noRecordsMessage: 'There are no categories yet.',
showTree: true,
treeExpanded: true,
maxDepth: 3,
dragRow: false,
);
}
/**
* makeToolbarWidget builds the toolbar with its buttons and search, where searching shows the matches as a flat list.
*/
protected function makeToolbarWidget(): Toolbar
{
return Toolbar::make(
buttons: '~/resources/ui/category/buttons.yaml',
search: ['prompt' => 'Search categories...'],
);
}
}showTree draws the tree with arrows to open and close each branch, treeExpanded starts with every branch open, and maxDepth stops a category from nesting more than three levels deep. With dragRow: false, a row moves by its handle only, so a click on the row still opens the category. The category is an October Rain model with the NestedTree trait, which stores the tree and the order, and dragging a row moves the category in place. Searching shows the matches as a flat list until the search is cleared, and so does sorting by a column. The list structure page shows a browsing tree and a flat reorderable list as well.
Widget configuration
Lists::make() takes these options as named arguments:
| Option | Description |
|---|---|
| model | an instance of the model to list. |
| columns | the column definitions, as a YAML file path or an array. |
| recordsPerPage | the number of rows on a page. Pagination turns on when it is set. |
| perPageOptions | the page sizes offered in the list setup. Default: [20, 40, 80, 100, 120]. |
| showCheckboxes | adds a checkbox to each row and one in the header. Default: false. |
| showSetup | lets visitors choose the columns and the page size in a setup popup, kept in the session. Default: false. |
| recordUrl | the link for each row, where :id and other :attribute names take the record's values. |
| recordOnClick | JavaScript to run on a row click, with the same replacements. popup (or popup@context) opens the controller's onLoadPopupForm handler in a popup. |
| defaultSort | the initial sort, as a column name or ['column' => 'name', 'direction' => 'desc']. |
| showSorting | adds sorting links to the column headers. Default: true. |
| noRecordsMessage | the message shown when no records match. Default: No records found. |
| showPagination | shows the pagination. Default: auto, on when recordsPerPage is set. |
| showPageNumbers | shows the record count and a link for each page. Turn it off to skip counting the records on a large table. Default: true. |
| customPageName | the query string name of the page number. Default: page. |
| expandLastColumn | lets the last column take the spare width. Default: false. |
| alias | the name the list's AJAX handlers are addressed by, as alias::onHandler. Default: list. Give each list on a page its own. |
ListStructure::make() adds showTree, treeExpanded, showReorder, maxDepth and dragRow, see the list structure page. Filter::make() takes model, scopes, context and alias, and Toolbar::make() takes buttons, search, setupHandler and alias. Options without a named argument pass through make() by name, and Lists::makeWithConfig($config) takes a configuration array put together at runtime.
Extending the list
To constrain the query without subclassing, add a callback with addFilter(). It receives the query before the list reads its records, as in $list->addFilter(fn ($query) => $query->where('published', true)). A bound filter adds its scopes the same way.
Every extension point of the list is a method that fires a local event. Bind to the event after building the list, such as $list->bindEvent('list.injectRowClass', function ($record) { ... }) to add a class to some rows (see rows by record), or subclass Larajax\Ui\Widgets\Lists, override the method, and build your class with make() as before. Where a point may return something, a listener's return value is used the same way. The extension points are:
| Method | Event | Called |
|---|---|---|
| eventExtendColumns() | list.extendColumns | once the columns are defined, to add them with addColumns() or remove them with removeColumn(). |
| eventExtendQueryBefore($query) | list.extendQueryBefore | before the list applies its own constraints to the query. |
| eventExtendSearchQuery($query) | list.extendSearchQuery | inside the search conditions, to search more places. |
| eventExtendSortColumn($query, $column, $direction) | list.extendSortColumn | after the sort is applied. |
| eventExtendQuery($query) | list.extendQuery | after the list's constraints, and may return a replacement query. |
| eventExtendRecords($records) | list.extendRecords | once the records are read, and may return a replacement collection. |
| eventRefresh($result) | list.refresh | after a refresh, with the partial updates. Return it with your own updates added. |
| eventOverrideRecordAction($record, $url, $onClick) | list.overrideRecordAction | for each row, and may return a new URL, or an array with url, onclick or clickable. |
| eventOverrideHeaderValue($column, $value) | list.overrideHeaderValue | for each header, and may return a new label. |
| eventOverrideColumnValueRaw($record, $column, $value) | list.overrideColumnValueRaw | for each cell before its column type formats it, and may return a new value. |
| eventOverrideColumnValue($record, $column, $value) | list.overrideColumnValue | for each cell after formatting, and may return new markup. |
| eventInjectRowClass($record, $value) | list.injectRowClass | for each row, and may return CSS classes for it. |
A list built without columns asks the model for them instead: a defineListColumns($list) method on the model adds each column with $list->defineColumn('name', 'Label'), which returns a definition to chain the column's options on, such as ->sortable(true).
Extending the filter
Model hooks
The filter's model can take part in two ways:
filterScopes($scopes, $context)runs every time the filter renders and after each scope change, with each scope reachable by name on$scopes. Change a scope's properties there, such as$scopes->category->hidden = true, to adapt the filter to the record or the visitor.$contextis the filter'scontextoption.defineFilterScopes($filter)supplies the scopes when the filter is built without ascopesoption. Add each one with$filter->defineScope('name', 'Label'), which returns a definition to chain the scope's options on, such as->displayAs('checkbox')->conditions('is_active = 1'). The scopes then work exactly as if they came from YAML.
Subclassing the filter
For changes that belong to the filter rather than the model, subclass Larajax\Ui\Widgets\Filter, override these methods, and build your class with make() as before:
| Method | Called |
|---|---|
| eventExtendScopesBefore() | before the scope definitions are read. |
| eventExtendScopes() | once the scopes are defined, to add them with addScopes() or remove them with removeScope(). |
| eventOverrideHeaderValue($scope, $value) | for each scope, and may return a new label for its button. |
| eventUpdate($result, $params) | after a scope changes, with the partial updates. The base class adds the bound list here, so call parent::eventUpdate() and add your own updates. |
| eventExtendQuery($query, $scope) | when a scope reads its options, to constrain the option query. |
Next steps
- Building a Form Page: the post form each row links to
- Columns: every column type
- Scope Types: every filter scope