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.
Prerequisites
Section titled “Prerequisites”- A secured studio (see Secure the studio).
- For custom actions: your own
StudioAuthinstance, so your route can check the same session.
1. Transform values with onSave
Section titled “1. Transform values with onSave”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.
2. Validate with onSave
Section titled “2. Validate with onSave”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.
4. Link to it
Section titled “4. Link to it”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.
Verify it works
Section titled “Verify it works”# Without a session you are sent to the login pagecurl -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.
Next steps
Section titled “Next steps”- ModelAdmin and save hooks reference
- Build larger back-office workflows as gisila controllers; see the framework section.