Custom pages
When a screen does not fit in a Resource or in a Page (a calendar, a multi-step wizard, a visual editor...), CustomPage registers it in the panel's navigation and its content is a Vue component that you write. Arrel gives you the component kit of its own interface so that the screen looks like the rest of the panel.
A custom page has three pieces:
- The navigation entry, in PHP (
CustomPage). - The Vue component, in your frontend.
- The endpoints the component consumes, in your application.
The navigation entry
Panel::make('admin')
->customPages([
CustomPage::make('pass-list', 'Pass list')
->icon('clipboard-document-check')
->group('Attendance')
->sort(10)
->visible(fn (): bool => auth()->user()->can('attendance.create'))
->badge(fn (): int => Absence::pending()->count()),
])CustomPage::make($slug, $label) reserves the URL /{prefix}/{panel}/custom/{slug} and the menu entry.
| Method | Description |
|---|---|
icon(string $icon) | Navigation icon. See the list. |
group(string $group) | Navigation group. Pages and Resources that share a group name are shown together. |
sort(int $sort) | Position in the navigation, from lowest to highest. |
visible(Closure $callback) | If it returns false, the entry does not appear. It is evaluated on every request, per user. |
badge(Closure $count) | A count shown on the link. It is only shown if it is greater than zero. |
visible() only hides the link. The endpoints that feed the page must do their own authorization (see Your own endpoints).
The component
A page is registered from JavaScript, with a script that you load in the panel with Panel::scripts(). There are two ways to do it. The recommended one is registerMountablePage.
registerMountablePage: your own Vue application
Arrel gives you an empty container and you mount your own Vue application in it, with your build, your dependencies and your version of Vue:
import '@arrel/ui.css'
import { createApp, type App } from 'vue'
import PassListPage from './PassListPage.vue'
const mounted = new WeakMap<HTMLElement, App>()
window.Arrel.registerMountablePage('pass-list', {
mount(container, props) {
const app = createApp(PassListPage, props)
app.mount(container)
mounted.set(container, app)
},
unmount(container) {
mounted.get(container)?.unmount()
mounted.delete(container)
},
})props contains panel (the panel identifier) and slug (the page's). Arrel never reconciles the vnodes of your component, so there is no risk of having two mixed Vue instances.
registerPage: components inside Arrel's tree
registerPage mounts the component inside Arrel's tree, with its Vue instance. This is the runtime only build, without a template compiler: you need h() or precompiled components.
const { h } = window.Arrel.Vue
window.Arrel.registerPage('about', {
render() {
return h('div', [h('h1', 'About')])
},
})window.Arrel.Vue exposes h, ref, reactive, computed, watch, onMounted and onBeforeUnmount.
registerMountablePage | registerPage | |
|---|---|---|
| Vue instance | Yours, separate | Arrel's |
| Templates | Any, with your build | Only h() or precompiled components |
| Lifecycle | You manage it (mount() and unmount()) | Arrel manages it |
The global window.Arrel API
| Member | Description |
|---|---|
panel | The identifier of the current panel. |
prefix | The prefix configured in config('arrel.prefix'). |
url(path = '') | Builds a URL inside the panel: Arrel.url('posts'). |
navigate(path) | Navigates inside the SPA without reloading the page. It returns a promise. |
refreshNavigation() | Reloads the navigation, for example to refresh a badge() after an action. |
registerPage(slug, component) | Registers an Arrel Vue component. |
registerMountablePage(slug, page) | Registers a mountable page. |
Vue | Vue functions for registerPage. |
The component kit (@arrel/ui)
The package publishes the component kit of its own interface, so that your pages have the same buttons, fields and states. Nothing is injected into the page: you bring it into your build.
| Component | Main props |
|---|---|
Badge | color: gray, success, warning, danger or info. |
Button | variant: primary, secondary or danger. type, disabled, href, newTab. |
DatePicker | v-model, displayFormat, min, max, isDisabled(iso), clearable, invalid. |
Icon | name, size. |
Input | v-model, type, placeholder, invalid. |
Label | for. |
RichEditor | v-model, field, error. |
Select | v-model, options, nullable, placeholder, invalid. |
Textarea | v-model, rows, placeholder, invalid. |
Toggle | v-model. |
Besides the components, it exports requestJson, ApiError, formatDate and t.
requestJson<T>(url, init)does afetchwith the session and the CSRF header that Sanctum expects, and throws anApiErrorif the response is not successful. For a 422 response,error.validationErrorshas the validation errors per field.formatDate(value, format)formats a date with PHP'sdate()syntax.t(key)returns an interface text, already translated withPanel::translations().
Connecting it to your build
The kit is distributed compiled in dist/ui inside the package, with Vue as an external dependency (it is your build's Vue). Define an alias in Vite:
// vite.config.js
import path from 'node:path'
const arrel = path.resolve(__dirname, 'vendor/arrel/arrel')
export default defineConfig({
resolve: {
alias: {
'@arrel/ui.css': `${arrel}/dist/ui/arrel-ui.css`,
'@arrel/ui': `${arrel}/dist/ui/arrel-ui.js`,
},
},
})And, for TypeScript, the path to the types:
{
"compilerOptions": {
"paths": {
"@arrel/ui": ["./vendor/arrel/arrel/dist/ui/types/ui.d.ts"]
}
}
}Import @arrel/ui.css once at the entry of your pages, so that the components carry Arrel's styles.
Your own endpoints
The data of a custom page lives in your application, not in Arrel. For them to inherit authentication, the second factor and tenant identification, mount them with the same middleware stack as Arrel:
Route::middleware([
...config('arrel.middleware'),
ForceJsonResponse::class,
EnsureFrontendRequestsAreStateful::class,
'web',
'auth:sanctum',
EnsureEmailIsVerified::class,
EnsureTwoFactorIsSetUp::class,
...config('arrel.authenticated_middleware'),
])->group(function (): void {
Route::get('/arrel-app/pass-list', PassListController::class);
});Inside, authorize every request with your Policies or with Gate. The fact that visible() hides the entry does not stop someone from calling the endpoint by hand.
Once an action on the page changes a count, call window.Arrel.refreshNavigation() so that the badge() refreshes.