Creating Index Pages
Most index pages follow a similar pattern - a search field, possibly some additional filters, a table of results, and pagination.
This article explains how to build up such a page from scratch. You can avoid some of this work by using Nexus Generator.
Basic Results
First, create a basic controller (app/Http/Controllers/ExampleController.php):
class ExampleController extends Controller
{
public function index(Request $request)
{
$examples = Example
::orderBy('name')
->orderBy('id')
->paginate()
->withQueryString();
return Inertia::render('Examples/PageIndex', [
'examples' => data($examples, [
'*.id',
'*.name',
'*.slug',
]),
]);
}
}
This fetches results from the Example model in a fixed order, paginating them with the default number of records per page (50, unless overridden in the model). We then extract just the id and name fields, and also the URL slug (generated by the Nexus Model class).
Note: You should always include id because the table uses it as a key. You should use slug rather than id when generating URLs, because it is more user-friendly.
Then create a page - resources/vue/Examples/PageIndex.vue:
<script>
import CardTableExamples from '@/vue/Examples/CardTableExamples';
import LayoutMain from '@alberon/nexus/vue/LayoutMain';
import Pagination from '@alberon/nexus/vue/Pagination';
export default {
components: {
CardTableExamples,
LayoutMain,
Pagination,
},
props: {
examples: { type: Object, required: true },
},
}
</script>
<template>
<LayoutMain title="Examples">
<div v-if="examples.pages > 1" class="row">
<div class="col mb-3">
<Pagination :paginator="examples" right />
</div>
</div>
<div class="row">
<div class="col mb-3">
<CardTableExamples :examples="examples" />
</div>
</div>
<div v-if="examples.pages > 1" class="row">
<div class="col mb-3">
<Pagination :paginator="examples" />
</div>
</div>
</LayoutMain>
</template>
This displays the standard layout, results table, and pagination links at the top-right and bottom-left (if there is more than one page).
And a table of results - resources/vue/Examples/CardTableExamples.vue:
<script>
import ButtonClearSearch from '@alberon/nexus/vue/ButtonClearSearch';
import ButtonNew from '@alberon/nexus/vue/ButtonNew';
import ButtonView from '@alberon/nexus/vue/ButtonView';
import CardTable from '@alberon/nexus/vue/CardTable';
export default {
components: {
ButtonClearSearch,
ButtonNew,
ButtonView,
CardTable,
},
props: {
examples: { type: Object, required: true },
},
}
</script>
<template>
<CardTable primary :rows="examples.data" title="Examples">
<template #headings>
<th>
Name
</th>
</template>
<template #heading-buttons>
<ButtonNew :href="$route('examples.create')" small text="New" />
</template>
<template #row="{ row: example }">
<td>
<InertiaLink :href="$route('examples.show', example.slug)">{{ example.name }}</InertiaLink>
</td>
</template>
<template #row-buttons="{ row: example }">
<ButtonView
:href="$route('examples.show', example.slug)"
:name="example.name"
small
/>
</template>
<template #empty>
No examples found.
</template>
<template #empty-buttons>
<ButtonNew class="mr-1" :href="$route('examples.create')" text="New Example" />
<ButtonClearSearch />
</template>
</CardTable>
</template>
At this point you should be able to see the data, see/use the pagination links (if there are more than 50 records), and click through to the "show" and "create" pages.
Additional Columns
Adding additional columns is fairly simple. First, add them to the controller:
return Inertia::render('Examples/PageIndex', [
'examples' => data($examples, [
'*.active',
'*.email',
'*.id',
'*.name',
'*.phone',
'*.slug',
]),
]);
See the data() function documentation for details of how this works (it can also fetch related models).
Then add them to the table. There are several "Format" components that may be useful - for example:
If a field that may be empty, use <FormatMaybeEmpty>. This displays a grey "—" (emdash) if it is empty, rather than a blank cell.
<td>
<FormatMaybeEmpty :value="example.email" />
</td>
If you want to use custom HTML to format the field, use this structure:
<td>
<FormatMaybeEmpty>
<!-- Note the 'v-if' here ensures the HTML is only generated if the field is filled in -->
<a v-if="example.email" class="text-inherit" :href="`mailto:${example.email}`">{{ example.email }}</a>
</FormatMaybeEmpty>
</td>
For boolean fields, use <IconBoolean> to display a tick or cross icon. The default tooltip is "Yes" or "No", but that can be customised:
<td>
<IconBoolean v-model="example.active" yes="Active" no="Inactive" />
</td>
For date or datetime fields, use <FormatDateTime>. I recommend using the short modifier in tables:
<td>
<FormatDateTime short date :value="example.created_at" />
</td>
(For fields other than created_at and updated_at, make sure you also configure $casts in the model.)
If the table starts to get too big for mobile devices, you can use the Bootstrap display utilities to hide/show columns at different breakpoints - for example:
<td class="d-none d-md-table-cell">
...
</td>
Edit/Delete Buttons
In addition to the View button we added already, it is a good idea to include Edit and Delete buttons for each row:
<template #row-buttons="{ row: example }">
<ButtonView
:href="$route('examples.show', example.slug)"
:name="example.name"
small
/>
<ButtonEdit
class="ml-1"
:href="$route('examples.edit', example.slug)"
:name="example.name"
small
/>
<ButtonDelete
:action="$route('examples.destroy', example.slug)"
class="ml-1"
:name="example.name"
small
/>
</template>
To avoid cluttering the screen too much, they are only displayed when you hover over the row (except on touch devices where they are always displayed).
Also see: Buttons.
Permissions Checks
If the permissions are broken down into View, Create, Edit and Delete, you will need to add something like this to the controller:
return Inertia::render('Examples/PageIndex', [
'examples' => data($examples, [
// ...
]),
'userCan' => user_can([
Permission::CreateExamples,
Permission::EditExamples,
Permission::DeleteExamples,
]),
]);
Then add the extra prop to both PageIndex.vue and CardTableExamples.vue:
props: {
examples: { type: Object, required: true },
userCan: { type: Object, required: true },
},
And pass it through from PageIndex to CardTableExamples:
<ExamplesTable :examples="examples" :user-can="userCan" />
Note: If you end up with more than 2-3 props, you should reformat it so there is one prop per line. They should always be in alphabetical order.
Then add v-if to the buttons to conditionally hide/show them:
<ButtonEdit
v-if="userCan.EditExamples"
class="ml-1"
:href="$route('examples.edit', example.slug)"
:name="example.name"
small
/>
<ButtonDelete
v-if="userCan.DeleteExamples"
:action="$route('examples.destroy', example.slug)"
class="ml-1"
:name="example.name"
small
/>
Note: This only hides the buttons. You must also check the permissions server-side (in the routes file or the controller).
Advanced Permissions Checks
If the permissions vary per record, a more complex solution is required. First, adapt the controller to call the code that checks the permissions for each record and add it to the model - for example:
return Inertia::render('Examples/PageIndex', [
'examples' => data($examples, [
// ...
'userCan' => fn(Example $example) => user_can([
Permission::CreateExample => $example,
Permission::EditExample => $example,
Permission::DeleteExample => $example,
]),
]),
]);
Then adapt the view code to check each model separately. I also recommend making the buttons disabled instead of hidden so the layout is consistent:
<ButtonEdit
class="ml-1"
:disabled="!example.userCan.EditExample"
:href="$route('examples.edit', example.slug)"
:name="example.name"
small
/>
<ButtonDelete
:action="$route('examples.destroy', example.slug)"
:disabled="!example.userCan.DeleteExample"
class="ml-1"
:name="example.name"
small
/>
Sortable Columns
So far, we have a fixed ORDER BY in the query - but it would be nicer for users if they could change the order by clicking the column headings. To do that, we first need to change the Vue code:
<template #headings>
<th>
<SortableLink name="name">Name</SortableLink>
</th>
<th>
<SortableLink name="email">Email</SortableLink>
</th>
<th>
<SortableLink name="age" desc>Age</SortableLink>
</th>
<th>
<SortableLink name="active" default desc>Active?</SortableLink>
</th>
</template>
Any columns that you want to be sorted descending by default should have the desc prop.
The column that is sorted by default should have the default prop. If the default order is a column not listed, e.g. id, then don't add default to any headings. Not all columns have to be sortable.
Second, we need to adapt the query to indicate that these columns are sortable:
$examples = Example
::sortable([
'active DESC',
'name',
'id',
'age DESC',
'email',
])
->paginate()
->withQueryString();
The default column should be listed first, followed by all columns that are sortable in the order they should be sorted. Columns that should be sorted descending by default should have DESC after their name. See the sortable() scope documentation for more advanced sorting options.
Stacked Columns
Sometimes there is too much information to comfortably display it all in separate columns. One possible solution to this is to display secondary information in small text under the main data:
For example (taken from the IT Ops app):
<td>
{{ example.package }}
<small class="d-block text-secondary">
{{ example.package_version }}
</small>
</td>
The sortable() scope can be adapted to sort by both columns (if that is necessary) as follows:
$examples = Example
::sortable([
'package' => ['package', 'package_version'],
'id',
])
->paginate()
->withQueryString();
Alternatively you could make them both sortable by including two sortable links in the same header:
<th>
<SortableLink name="package">Package</SortableLink>
<SortableLink name="package_version">Version</SortableLink>
</th>
The CSS causes them to be displayed as "Package / Version". The downside is we can't use display: block; so only the text is clickable rather than the whole table cell.
Basic Search
Next, let's add a simple search form. First, add a search form to PageIndex.vue at the top of <LayoutMain> (above the top pagination):
<template>
<LayoutMain title="Examples">
<div class="row">
<div class="col mb-3">
<CardSearchForm primary title="Examples" />
</div>
</div>
...
</LayoutMain>
</template>
We now have the page title ("Examples") displayed on two different cards, but we only want it on the first card, so edit CardTableExamples.vue and remove it:
<CardTable :rows="examples.data">
...
</CardTable>
Now add searchFor() to the query in the controller:
$examples = Example
::searchFor($request->q, [
'name',
'email',
])
->sortable([
'name',
'id',
'email',
])
->paginate()
->withQueryString();
The first parameter is the string to search for - i.e. the ?q= request parameter. The second is an array of fields that are searchable. The optional third parameter is a list of fields that must match a keyword exactly - e.g. in the Support Cases system we use ['id'] so that searching for 3 finds case number 3 but not case number 33.
If you want to search related tables, join with those tables and use the full column names:
$examples = Example
::select('examples.*')
->join('statuses', 'statuses.id', 'examples.status_id')
->searchFor($request->q, [
'examples.name',
'examples.email',
'statuses.name',
])
->sortable([
'name' => 'examples.name',
'id' => 'examples.id',
'email' => 'examples.email',
'status' => 'statuses.name',
])
->with('status')
->paginate()
->withQueryString();
I generally order the query to match a SQL query - i.e. SELECT, JOIN, WHERE (searchFor()) then ORDER BY (sortable()), but the order doesn't technically matter.
Make sure you include the select('examples.*') (or a different list of columns), else it will generate SELECT * by default (i.e. columns from all joined tables) and you may get statuses.name returned instead of examples.name.
Basic Filters
In addition to the search field, you can easily add one (or maybe two) basic filters below it. Basic filters are always displayed but they have no label - so it should be obvious from the labels what they are for. For example, you may have radio buttons with "Active", "Inactive" and "Any status".
[Demo]
There are two ways to do this - either we can put the filters directly into PageIndex.vue, or we can make a custom component. We will start with the first option, and below in Advanced filters we'll look at how to move them to a separate component.
First, we need to create a data object to hold the selected filter:
export default {
// ...
data() {
return {
basicFilters: {
active: this.$page.props.request.active,
},
};
},
}
Here we initialise it to the current value taken straight from the request parameters. (Alternatively we could pass it from the controller via a prop - but this is simpler.)
Next we create the basic filters form inside the search card:
<CardSearchForm :basic-filters="basicFilters" primary title="Examples">
<div class="kv-value">
<FieldWithMultipleInputs label="Status" name="active" role="radiogroup">
<div class="custom-control custom-radio custom-control-inline">
<input class="custom-control-input" id="active-1" name="active" type="radio" value="1" v-model="basicFilters.active" />
<label class="custom-control-label" for="active-1">Active</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input class="custom-control-input" id="active-0" name="active" type="radio" value="0" v-model="basicFilters.active" />
<label class="custom-control-label" for="active-0">Inactive</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input class="custom-control-input" id="active-any" name="active" type="radio" :value="undefined" v-model="basicFilters.active" />
<label class="custom-control-label" for="active-any">Any Status</label>
</div>
</FieldWithMultipleInputs>
</div>
</CardSearchForm>
TODO: We should create a reusable radio buttons component...
(The kv-value div is required because this is rendered inside a kv grid. You will see why later.)
Finally, we need to adapt the query to apply this filter:
$examples = Example
::searchFor($request->q, [
'name',
'email',
])
->when($request->has('active'), fn(Builder $query) => $query
->where('active', (bool)$request->active)
)
->sortable([
'name',
'id',
'email',
])
->paginate()
->withQueryString();
This uses the Laravel when() method to conditionally add a WHERE clause to the query.
Note: We can't use ->when($request->active, ...) because the value 0 is falsy.
Advanced Filters
If we need more then one or two basic filters, we can create an "Advanced filters" form. This is hidden until the user clicks "More Filters...".
You can combine this with basic filters, although you don't have to.
First, let's move the basic filters we added above into a new component, Examples/CardSearchFormExamples.vue:
<script>
import CardSearchForm from '@alberon/nexus/vue/CardSearchForm';
import FieldWithMultipleInputs from '@alberon/nexus/vue/FieldWithMultipleInputs';
export default {
components: {
CardSearchForm,
FieldWithMultipleInputs,
},
data() {
return {
basicFilters: {
active: this.$page.props.request.active,
},
};
},
}
</script>
<template>
<CardSearchForm :basic-filters="basicFilters" primary title="Examples">
<div class="kv-value">
<FieldWithMultipleInputs label="Status" name="active" role="radiogroup">
<div class="custom-control custom-radio custom-control-inline">
<input class="custom-control-input" id="active-1" name="active" type="radio" value="1" v-model="basicFilters.active" />
<label class="custom-control-label" for="active-1">Active</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input class="custom-control-input" id="active-0" name="active" type="radio" value="0" v-model="basicFilters.active" />
<label class="custom-control-label" for="active-0">Inactive</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input class="custom-control-input" id="active-any" name="active" type="radio" :value="undefined" v-model="basicFilters.active" />
<label class="custom-control-label" for="active-any">Any Status</label>
</div>
</FieldWithMultipleInputs>
</div>
</CardSearchForm>
</template>
Then use this in PageIndex.vue instead of <CardSearchForm>:
<div class="row">
<div class="col mb-3">
<CardSearchFormExamples />
</div>
</div>
Next, like the basic filters, we need a data object:
export default {
// ...
data() {
return {
basicFilters: {
active: this.$page.props.request.active,
},
advancedFilters: {
age: this.$page.props.request.age,
},
};
},
}
The two sets of filters are split up so the search card knows whether to expand the Advanced Filters automatically on page load. (If any field from the advanced search form is filled in - non-NULL - it will expand it.)
Now we can add an advanced search form:
<CardSearchForm :basic-filters="basicFilters" primary title="Examples">
...
<template #advanced>
<div class="kv-key-for-input">
<label for="age">
Age Group:
</label>
</div>
<div class="kv-value">
<select class="custom-select" id="age" v-model="advancedFilters.age"
>
<option :value="undefined"></option>
<option value="0">0-9</option>
<option value="10">10-19</option>
...
<option value="90">90-99</option>
<option value="100+">100+</option>
</select>
</div>
</template>
</CardSearchForm>
We also need to adapt the basic filters slightly to use the same layout when the advanced filters are expanded:
<CardSearchForm :basic-filters="basicFilters" primary title="Examples">
<template #default="{ advancedVisible }">
<div v-if="advancedVisible" class="kv-key" aria-hidden="true">
Status:
</div>
<div class="kv-value">
<FieldWithMultipleInputs label="Status" name="active" role="radiogroup">
...
</FieldWithMultipleInputs>
</div>
</template>
<template #advanced>
...
</template>
</CardSearchForm>
Finally, update the controller with the additional field(s):
$examples = Example
::searchFor($request->q, [
'name',
'email',
])
->when($request->has('active'), fn(Builder $query) => $query
->where('active', (bool)$request->active)
)
->when($request->has('age'), function (Builder $query) use ($request) {
if ($request->age === '100+') {
$query->where('age', '>', 100);
} else {
$query->whereBetween('age', [$request->age, $request->age + 9]);
}
})
->sortable([
'name',
'id',
'email',
])
->paginate()
->withQueryString();
Bulk Actions
Tables also support bulk actions. If enabled, you get a checkbox for each row, and when one or more rows are checked a set of bulk actions buttons appears.
First, we need to add a data object to hold the list of checked rows in CardTableExamples.vue:
export default {
// ...
data() {
return {
checkedRowIds: [],
};
},
}
Then we need to pass this to the table and enable bulk actions:
<CardTable :rows="examples.data" v-model="checkedRowIds" with-bulk-actions>
...
</CardTable>
And add the bulk actions buttons:
<CardTable :rows="examples.data" v-model="checkedRowIds" with-bulk-actions>
...
<template #bulk-actions>
<button class="btn btn-primary mr-1" type="button" @click="publishSelectedRecords">
Publish…
</button>
<button class="btn btn-primary" type="button" @click="deleteSelectedRecords">
Delete…
</button>
</template>
</CardTable>
The implementation of publishSelectedRecords() and deleteSelectedRecords() is left up to you. I would suggest displaying a confirmation modal, then posting the list of record IDs (this.checkedRowIds) to the server.
You could also use dropdown menus if you have multiple related actions - e.g. a "Change Status" button with "Published", "Pending Review" and "Draft" links.
If you need to get the list of selected records in full, add a computed such as this:
selectedRecords() {
return this.examples.data.filter(example => this.checkedRowIds.includes(example.id));
},
Section Navigation
You may also want to add a section navigation tabs bar, with links such as "List", "New" and "Export".