Skip to content

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:

  1. The navigation entry, in PHP (CustomPage).
  2. The Vue component, in your frontend.
  3. The endpoints the component consumes, in your application.

The navigation entry ​

php
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.

MethodDescription
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:

ts
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.

js
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.

registerMountablePageregisterPage
Vue instanceYours, separateArrel's
TemplatesAny, with your buildOnly h() or precompiled components
LifecycleYou manage it (mount() and unmount())Arrel manages it

The global window.Arrel API ​

MemberDescription
panelThe identifier of the current panel.
prefixThe 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.
VueVue 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.

ComponentMain props
Badgecolor: gray, success, warning, danger or info.
Buttonvariant: primary, secondary or danger. type, disabled, href, newTab.
DatePickerv-model, displayFormat, min, max, isDisabled(iso), clearable, invalid.
Iconname, size.
Inputv-model, type, placeholder, invalid.
Labelfor.
RichEditorv-model, field, error.
Selectv-model, options, nullable, placeholder, invalid.
Textareav-model, rows, placeholder, invalid.
Togglev-model.

Besides the components, it exports requestJson, ApiError, formatDate and t.

  • requestJson<T>(url, init) does a fetch with the session and the CSRF header that Sanctum expects, and throws an ApiError if the response is not successful. For a 422 response, error.validationErrors has the validation errors per field.
  • formatDate(value, format) formats a date with PHP's date() syntax.
  • t(key) returns an interface text, already translated with Panel::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:

js
// 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:

json
{
    "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:

php
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.