Skip to content

Save hooks and custom actions

The studio has one built-in list action, bulk delete, and one extension point in the save path, onSave hooks. This guide shows how to use hooks for per-field logic, and how to add a custom action (here: “deactivate user”) as your own Shelf route that reuses the studio’s login and CSRF protection.

  • A secured studio (see Secure the studio).
  • For custom actions: your own StudioAuth instance, so your route can check the same session.

A StudioSaveHook receives the parsed value and returns what to store:

import 'package:gisila_studio/gisila_studio.dart';
studio.register<User>(
UserTable.metadata,
onSave: {
// Hash with the same function the app uses for sign-in.
'password': (v) => hashPassword(v as String),
// Normalise.
'email': (v) => (v as String).trim().toLowerCase(),
},
);

Hooks run on add and edit, for each hooked column that is being written. A password left blank on edit is not written, so its hook does not run.

Throw a FormatException to reject a value. The form is shown again with status 422 and the message under the field:

studio.register<Product>(
ProductTable.metadata,
onSave: {
'sku': (v) {
final sku = (v as String).trim().toUpperCase();
if (!RegExp(r'^[A-Z0-9-]{4,20}$').hasMatch(sku)) {
throw const FormatException('Use 4-20 letters, digits or dashes.');
}
return sku;
},
// Hooks may be async, e.g. to check a remote service.
'vatNumber': (v) async => v == null ? null : await vat.normalise(v as String),
},
);

Hooks see one column at a time. For rules that involve several columns or other tables, use a database constraint (the studio shows the database error on the form) or a dedicated controller.

3. Add a custom action under the studio’s session

Section titled “3. Add a custom action under the studio’s session”

Create the auth object yourself so your own handler can call it, and mount your handler under the studio prefix (so the browser sends the studio cookie) before the studio mount:

import 'dart:convert';
import 'dart:io';
import 'package:gisila_orm/gisila.dart';
import 'package:gisila_studio/gisila_studio.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
Router buildAdmin(Database db) {
final env = Platform.environment;
final auth = JwtStudioAuth(
username: env['STUDIO_USERNAME']!,
password: env['STUDIO_PASSWORD']!,
secret: env['STUDIO_SECRET']!,
);
final studio = GisilaStudio(db: db, auth: auth, title: 'Shop Admin')
..register<User>(UserTable.metadata);
final tools = Router()
..get('/deactivate/<id>', (Request req, String id) {
final token = auth.currentToken(req.headers);
if (token == null || !auth.isAuthenticated(req.headers)) {
return Response.found('/admin/login/');
}
final csrf = studioCsrfToken(token);
final safeId = htmlEscape.convert(id);
return Response.ok(
'<form method="post" action="/admin/tools/deactivate/$safeId">'
'<input type="hidden" name="_csrf" value="$csrf">'
'<button>Deactivate user $safeId</button></form>',
headers: {'content-type': 'text/html; charset=utf-8'},
);
})
..post('/deactivate/<id>', (Request req, String id) async {
final token = auth.currentToken(req.headers);
if (token == null || !auth.isAuthenticated(req.headers)) {
return Response.forbidden('Sign in first.');
}
final sent = Uri.splitQueryString(await req.readAsString())['_csrf'];
if (sent == null || !constantTimeEquals(sent, studioCsrfToken(token))) {
return Response.forbidden('Bad CSRF token.');
}
await db.context().execute(
'UPDATE "user" SET is_active = false WHERE id = \$1',
parameters: [int.parse(id)],
);
return Response.found('/admin/user/${Uri.encodeComponent(id)}/change/');
});
// Registered first, so /admin/tools/... is matched before the studio mount.
return Router()
..mount('/admin/tools', tools.call)
..mount('/admin', studio.handler(prefix: '/admin'));
}

The studio cookie’s Path is the studio prefix (/admin), so routes outside it never receive the cookie. Check the session and the CSRF token on every state-changing request, as above; the studio’s own CSRF gate does not cover routes you mount yourself.

The studio has no hook to add buttons to its pages. Link to your action from wherever is useful, for example a bookmark, or a column in your own reporting page: /admin/tools/deactivate/42.

/admin/login/
# Without a session you are sent to the login page
curl -si localhost:8080/admin/tools/deactivate/42 | grep -i '^location'

Signed in, open /admin/tools/deactivate/42, click the button, and you land on the user’s edit page with is_active unchecked. Saving a product with an invalid SKU shows your hook’s message under the field.