Among other use cases, Panel areas are a great way to show and edit data that is not bound to Kirby pages, real or virtual, for example a product list from a JSON file or database.
This recipe is based on the products area demo Bastian presented during the 3.6 product launch, but we will extend it a bit.
You can install the Pluginkit as a basis or create the file structure we need manually, that’s up to you. Also, it doesn’t matter if you use the Plainkit or the Starterkit as a starting point.
You can either download the demo with the products example or ideally code along as we go through the steps in this recipe.
Let’s start by creating a new
products folder inside the plugins folder. Inside the
products folder, we first create a
package.json file with the contents copied from the Pluginkit example mentioned above.
This will take care of compiling our source files into an
index.js file in the root of our
products plugin folder.
The most important stuff for our shiny new area happens in the PHP part of the plugin. Inside the
products folder, we create the obligatory
index.php with the Kirby plugin wrapper. And inside this wrapper, we define the new area:
For the moment, let's comment
searches, we will get to that later.
This code snippet looks very clean, because the separate parts are moved into their own files, which we will go through step by step.
Before we continue, let's create the missing files and folders for the basic plugin structure as outlined in the
index.php file, which looks like this:
products.json file contains the product data for this example, you can copy it into the plugins folder from the demo files. It has the following basic structure (leaving out the fields that are not used in our example):
If we opened the Panel at this point, we wouldn't see much apart from the menu entry. So let's add the PHP part for the view first.
A view is a route with a
pattern that when called executes the given
action, that has to return an array. This array controls the Vue component to be loaded, the props for the component and (optional) other settings for the view.
The Vue component we will create in a second, we call
k-products-view, and as props we return the product list from the JSON file as array via the
products function we defined at the top of our
The props we return to the component can of course be anything, so the product data from the JSON file can easily be replaced with data from a database or an API (see example in the My first Panel area recipe).
We could for example pass the user role to the component and show different stuff based on role, or a config option for the layout etc.. The possibilities here are rather endless.
Let's move on to the JS side of stuff. In our
index.js we register the
The view component itself lives in the imported
Products.vue file and is responsible for actually displaying the data we passed to it as props in the view's route.
The product data itself we display in a table.
Maybe worth mentioning is the
price method which converts the price into a nicely formatted currency.
The last table column contains the options dropdown for the edit and delete buttons).
At this point, it's time to build the
index.js file. To this purpose we open a terminal,
cd into the
products folder and run
Now open the Panel and navigate to the new Panel area. It will now look like this:
Since we haven't implemented the dropdowns yet, they will throw an error if you click on the buttons. But we already have a nice working list view of our products!
The options dropdown is again a route that returns an array of options with text, icon and the dialog to open when clicked. Here the options are
What is still missing is the dialogs. Dialogs are a part of Panel area extensions, and each dialog has a route pattern at which the dialog is called.
Each dialog has a
load callback and a
submit callback. You can read more about those callbacks in the docs linked above.
delete dialog reuses Kirby's
k-remove-dialog component, asks for confirmation, and on submit removes the product with the given
id from the array and writes the remaining new array to file.
The update dialog uses Kirby's
k-form-dialog component, in which we define the fields the user can edit. Each field is an array with the same props you would usually define in the blueprint for the given field type. Here we use two text fields for
type, a textarea field for the
description and a number field for the
value we set the current product.
Once the user submits the dialog, we overwrite the existing array item with the new data and write the new data to file.
At this point, uncomment the commented lines in your
And with these dialogs in place, we can now update items or delete them from the list.
This is where the demo from the video ends. It’s all still a bit rough around the edges. Let’s extend it some more!
There are a few questionable choices in the demo that are only there for practical reasons to shorten the demo for the video.
We still have the
products() helper function in our
index.php and some rough ways to read, update and delete from the
products.json file. To clean this up, we will create a new
Product model class in PHP, which will be responsible for basic CRUD tasks (create, update, delete).
Such a model class will help us to write and organize all the code that it needs to work with products and validate user input.
If you are not familiar with object oriented programming in PHP yet, you should follow Sonja's excellent introduction.
The example class is pretty long, but fully documented. You can either read through the code and comments or copy and paste it into your version and keep scrolling.
We somehow need to include our new
Product class in our plugin. We can do this with a simple
require, by using Composer or with Kirby’s
If you need to load just a couple of classes, Kirby’s
load() method is perfectly fine. For more complex plugins you might want to checkout autoloading with Composer instead.
load() method should be placed right at the top of your
index.php. All classes that should be autoloaded are added with their full name followed by the absolute path to the PHP file.
load() instead of
require? When you require the class file in your
index.php it will be loaded as soon as the plugin is registered. By using the
load() method, the class is only autoloaded when needed.
Our shiny new class can now be used in our existing view and dialogs.
The products view uses the new
Product::list() method to get an array of all products.
The update dialog gets a couple cool updates.
Product::find() will not just load the right entry, it will also throw an exception if it cannot be found. This will automatically be handled by the Panel and the user gets a proper warning.
The same happens when the
Product::update() method in the submit handler detects a validation issue or has any other issues. This increases the UX of the dialog quite a bit.
We’ve also converted the type field to a select box and used the
Product::types() method to create the options. This is great because we can now update our available types in one place, which will update the field and the backend validation at the same time.
The delete dialog uses the new
Product::delete() method. If you want to customize the text for each product, you could also reuse the
Product::find() method in the load handler to load the product and add the title to the text for example.
With the cleaned up PHP code we can now extend our little plugin some more …
Wouldn't it be great to be able to add new items, too? I think so, so let's do that. Luckily, we don't have to add a lot of code to achieve that.
Let's first add an
add button to our view in the
k-header component. The
k-header component has two slots:
right. The left slot is normally used to create action buttons, while the right slot is used for our navigation arrows or for the filter dropdown in the users view.
The view should now look like this:
When the user clicks on this button, a new dialog, which we have yet to define, will open.
We register this new dialog in
index.php next to the existing
and then create an
create.php file in the
/dialogs folder with the following code:
This looks pretty similar to the
update dialog with a few differences:
- Because we don't update an existing product, we don't pass an id in the route pattern
- We don't pass an existing product as value.
- In the
submitaction, we use
Product::create()instead of update
- We use the
submitButtonproperty to change the text of the submit button. The
t()method is used to get the correct translation for
create. This is already available in our translations.
I've intentionally left out the fields definition in this example. We need the same fields as in the update dialog. Instead of repeating ourselves, we can put the fields into their own file instead and use them in both places.
Create a new
fields.php file and move the fields definition there.
You can see that it is exactly the same as before in our update dialog. The two dialogs (create and update) can now both load those fields.
And that's it! We can now also add products to the list. Try it out!
Panel areas also allow you to add your own search type to the Panel - next to pages, files and users. With this step, we add a custom search that allows us to search through our list of products:
query callback, we loop through the list items and return each item that contains the given query string in the product title. The parameters you can return for each item you find in the linked docs.
At this point, when we use the search, it works fine and returns the expected result set as we type.
However, when we click on the desired item, we are just redirected to the current view. Also, we currently have to select the
product search from the menu first.
Let's first fix the second issue by setting the products search as our default search for this view. We do that in the
action callback of our product view route:
Now, we don't have to select the right search component anymore when we use the search function. If you want to set the search as default for every view of the area, you can set the
search option in the area definition.
For the second issue, we will create a single product view we can redirect to when the user clicks on an item.
Let's first pass the id to the
link prop in the
Next, we need a second route for our area, which we register next to the first one in the
We then create the the new
product.php file in the
As route pattern we use a placeholder for the product
id. In the
action return array, we define a new
k-product-view component, and in the
props array, we return the single product with the given id as data for the view.
In this view, we also modify the breadcrumb and add a new entry for our current product. You will see the result of it in a second. Adding breadcrumb items is simple. They need a label and a link.
As before, we register the component in our
And finally create the view for our single product, in which we basically use almost the same code as in the product list, with the difference that we don't need a loop this time, because we are only dealing with a single product:
When we go to the single view now, the result should look like this:
Depending on your real world data, you might want to choose a completely different layout for the single view, but that's up to you and you can start experimenting with your newly aquired knowledge from here on.
It's time to clean up again. The price method is used by both views – products and product. We shouldn't declare it twice. This is easy to fix though.
We'll create a new
price.js file in a new folder
Now we can import that helper in the script part of both components:
Still here? How about some sortable columns?
Our table in the products view could really use some sortable columns. This is an example where our new Fiber architecture really shines again and it's much easier to implement than you might think.
Let’s go back to our
views/products.php first to implement the backend logic.
That’s already it. The view is now able to receive two query parameters:
dir via the
get() method. You can already give it a try by adding the query string to your browser's URL bar manually.
The second argument in the
get() method is the default value that should be used if the key in the query string is empty or not set.
We use Kirby’s
A::sort() to sort our products array by the column defined by
$sort and the sorting direction defined by
We also pass both variables as props. You’ll see in a minute why our view component needs to know about those.
Products.vue component needs a few adjustments to make those table headers clickable and to update the query parameters accordingly.
Let's start by adding buttons to our
<th> elements in the table.
On click, a new
sortBy method is called and the column name is passed.
sortBy method uses our built-in
$reload method to reload the current view. You can use this in your plugin code to create a simple reload, which will update all the data of the current view, but you can also pass query parameters which then can be used on the backend to change data.
In this example, we pass the column name with the sort query parameter. Our
get() method in the
products.php will fetch the column name and the products accordingly. Give it a try.
So far the sorting direction is not changed yet. You will only get sorted results in ascending order.
Oh, I almost forgot: we need to adjust our styling a bit to make the buttons look nicer. Add the following to your style block:
Whenever we click on a sorting button, it would be really nice if the sorting direction could change.
Here’s why we needed to send the
dir props from the backend. Let’s have another look at the
By defining the new
dir props, we can work with them in our
sortBy method. Those props will update whenever the view is loaded or reloaded.
sortBy method will now compare which column is currently resorted. If you click on the same column header twice, the sort direction will now change.
To send the sort direction to the server,
dir is added to the query object and our toggles should work.
So far, the user cannot really see which column is sorted and in which direction. Let’s wrap it up with a nice little sorting arrow next to the column header.
Vue components can have computed properties, which are perfect to create our little arrow:
The computed property will be recalculated whenever the props of the component change (on load or reload in this case). This means that our little arrow will always update according to the sorting direction coming from the server.
Now we only need to add it to our template.
The wrapping span with the if clause makes sure that the arrow only shows up in the currently sorted column.
If you add a class name to that span, you can also use it to apply some extra styling.
Here's our final table with the little sorting arrow: