tablo-core 0.10.0

The core toolkit types for Tablo, a server-rendered admin toolkit on Topcoat and Toasty.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
//! `Panel` — resources and pages under one prefix, which an app mounts into its router.

mod actions;
pub(crate) mod area;
mod audit;
mod banner;
mod bar;
mod build;
pub(crate) mod commit_hook;
mod detail;
pub(crate) mod error_page;
mod files;
mod forms;
pub(crate) mod gate;
mod headers;
mod host;
mod impersonation;
mod input;
mod list;
mod page_filters;
pub(crate) mod pages;
mod record_nav;
mod record_page;
mod register;
mod relations;
mod search;
mod shell;
pub(crate) mod state;
#[cfg(test)]
pub(crate) mod test_support;
pub mod url;
mod widgets;
mod write;

use topcoat::{asset::Asset, font::Font, router::LayoutRenderFn};

pub use self::{
    area::{Area, AreaGroup},
    audit::AuditLog,
    banner::Banner,
    build::{PanelHandle, RouterBuilderPanelExt},
    commit_hook::{ChangedField, ChangedRecord, CommitHook, PanelChange},
    gate::{can_list, can_open, can_view_field},
    impersonation::Impersonate,
    list::{WiredTable, list_query, wired_table},
    page_filters::page_filters,
    pages::page_filter_bar,
    shell::{Brand, Environment, Radius},
    widgets::{record_table, stats, widgets},
};
pub(crate) use self::{bar::header_bar, pages::current_page};
use self::{
    register::{PageRegistration, Registration, ResourceRegistration},
    shell::ShellAssets,
};
use crate::{
    DeclarationError, DeclarationErrorKind, Page, PageDef,
    resource::{Resource, ResourceDef},
};

/// `R`'s sidebar entry in the context's panel, with its label, URL and icon; `None` when the
/// panel does not register `R`. A resource hidden from the sidebar still has one, to link to it by.
pub fn navigation_item<R: Resource>(cx: &topcoat::context::Cx) -> Option<crate::NavigationItem> {
    crate::resource::mounted::<R>(cx).map(|resource| resource.navigation.clone())
}

/// An admin panel: resources and pages under one prefix, framed by one shell
/// and gated by one [`Auth`](crate::auth::Auth). Its pages carry a `robots` tag that asks search
/// engines to neither index them nor follow their links.
///
/// The app owns the router and mounts the panel into it with
/// [`RouterBuilderPanelExt::panel`]:
///
/// ```rust,no_run
/// # use tablo_core::{NoForm, Panel, Resource, RouterBuilderPanelExt};
/// # use topcoat::router::{Router, RouterBuilderDiscoverExt};
/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
/// # struct UserResource;
/// # impl Resource for UserResource {
/// #     type Model = User;
/// #     type Form = NoForm<User>;
/// # }
/// # fn main() -> topcoat::Result<()> {
/// # let db: toasty::Db = todo!();
/// let router = Router::builder()
///     .discover()
///     .with(db)
///     .panel(Panel::new("admin").resource::<UserResource>())?
///     .build();
/// # let _ = router;
/// # Ok(())
/// # }
/// ```
///
/// Registering is declarative: mounting builds each resource's [`ResourceDef`] and checks every
/// slug, route and declaration before the panel serves anything.
///
/// Besides its resources' and pages' routes, a panel at `/admin` serves:
///
/// - `GET /admin`: the [home page](Self::home), or a redirect to the first registered resource's
///   list when there is none;
/// - `GET`, `POST /admin/login` and `POST /admin/logout`, unless authentication is disabled, and
///   `POST /admin/tenant`, the tenant switcher, when the panel also acts for tenants: it offers a
///   tenant registration or settings page, or mounts a resource over tenant-owned rows;
/// - `/admin/register`, `/admin/profile`, `/admin/forgot-password` and `/admin/reset-password` when
///   [`Auth`](crate::auth::Auth) installs the feature each serves;
/// - `POST /admin/{page}/-/actions/{action}` for a page's header actions, and
///   `/admin/-/actions/{action}` for the home page's;
/// - `GET /admin/-/search?q=`, the [global search](Self::without_global_search)'s results page,
///   when the panel searches a resource;
/// - every other path under `/admin`, with any method, as a 404 behind the auth gate, so a visitor
///   signs in before learning a path serves nothing. The app's own routes under the prefix still
///   take precedence.
pub struct Panel {
    prefix: String,
    shell_assets: Option<ShellAssets>,
    brand: Option<Brand>,
    /// Whether [`Self::dark_by_default`] starts a visitor who has not chosen a theme dark.
    dark_mode: bool,
    /// The deployment [`Self::environment`] names on every page.
    environment: Option<Environment>,
    /// Frames the panel's pages; `None` renders the shipped shell.
    layout: Option<LayoutRenderFn>,
    /// The resources and pages, in registration order.
    registrations: Vec<Box<dyn Registration>>,
    /// The `Content-Security-Policy` of every response under the prefix.
    content_security_policy: headers::Policy,
    /// The sources [`content_security_policy_source`](Self::content_security_policy_source)
    /// adds to the policy, as `(directive, source)`.
    content_security_policy_sources: Vec<(String, String)>,
    /// `Strict-Transport-Security`'s `max-age`, when the panel sends it.
    strict_transport_security: Option<std::time::Duration>,
    /// Mistakes in the panel's own configuration, for mount to report.
    configuration_errors: Vec<DeclarationError>,
    /// Where file field bytes go; `None` refuses to mount a form with a file field.
    uploads: Option<crate::upload::InstalledUploader>,
    /// App-owned filesystem directories served with hardening headers.
    served_dirs: Vec<headers::ServedDir>,
    login_hint: Option<crate::Text>,
    /// The app's entries in the user menu, above Sign out.
    user_menu: Vec<crate::NavigationItem>,
    banners: Vec<banner::BannerFn>,
    auth: crate::auth::AuthConfig,
    /// The paths of the runtime endpoints that answer only this panel's sessions.
    endpoints: Vec<String>,
    /// Whether the topbar shows the bell over the user's stored notifications.
    notifications: bool,
    /// The zone a user without their own reads and enters wall-clock times in.
    time_zone: jiff::tz::TimeZone,
    /// The locales the panel offers, the default first.
    locales: Vec<topcoat::l10n::Locale>,
    /// The app's catalogs, by locale, over Tablo's.
    translations: Vec<(topcoat::l10n::Locale, String)>,
    /// Whether the topbar shows the global search box.
    global_search: bool,
    limits: crate::Limits,
    /// The hooks every write of the panel runs before it commits, in registration order.
    commit_hooks: Vec<commit_hook::CommitHookFn>,
    /// How [`audit_log`](Self::audit_log) adjusts the [`AuditLog`] resource's def, when the panel
    /// keeps one: it registers in the area group that places it, else in the panel.
    audit_log: Option<audit::CustomizeAuditLog>,
}

impl Panel {
    /// Creates a `Panel` mounted at `prefix`, defaulting an empty prefix to `"/admin"`.
    pub fn new(prefix: impl Into<String>) -> Self {
        let prefix = crate::naming::normalize_prefix(&prefix.into());
        let configuration_errors = prefix
            .trim_matches('/')
            .split('/')
            .filter_map(|segment| build::validate_route_segment("panel prefix", segment).err())
            .map(DeclarationError::panel)
            .collect();
        Self {
            prefix,
            shell_assets: None,
            brand: None,
            dark_mode: false,
            environment: None,
            layout: None,
            registrations: Vec::new(),
            content_security_policy: headers::Policy::Default,
            content_security_policy_sources: Vec::new(),
            strict_transport_security: None,
            configuration_errors,
            uploads: None,
            served_dirs: Vec::new(),
            login_hint: None,
            user_menu: Vec::new(),
            banners: Vec::new(),
            auth: crate::auth::Auth::default().into_config(),
            endpoints: Vec::new(),
            notifications: false,
            time_zone: jiff::tz::TimeZone::UTC,
            locales: vec![crate::i18n::ENGLISH.clone()],
            translations: Vec::new(),
            global_search: true,
            limits: crate::Limits::default(),
            commit_hooks: Vec::new(),
            audit_log: None,
        }
    }

    /// Returns the mount prefix, e.g. `"/admin"`.
    pub fn prefix(&self) -> &str {
        &self.prefix
    }

    /// Registers the stylesheet and font the default shell links, resolved through the router's
    /// asset bundle.
    pub fn shell_assets(mut self, stylesheet: Asset, font: Font) -> Self {
        self.shell_assets = Some(ShellAssets { stylesheet, font });
        self
    }

    /// Registers the [`Resource`] `R` at `{prefix}/{slug}` with its routes and sidebar entry, as
    /// [`Resource::declare`] declares it.
    ///
    /// A resource with the slug `users`, on a panel at `/admin`, serves:
    ///
    /// | Method | Path | Serves |
    /// | --- | --- | --- |
    /// | `GET` | `/admin/users` | the list |
    /// | `GET` | `/admin/users/{id}` | the detail page; 404 when the resource's view is empty |
    /// | `POST` | `/admin/users/{id}/delete` | delete one record |
    /// | `POST` | `/admin/users/-/bulk-delete` | delete the selected records |
    /// | `GET` | `/admin/users/-/export` | the list as CSV |
    /// | `GET`, `POST` | `/admin/users/-/create` | the create form ¹ |
    /// | `GET`, `POST` | `/admin/users/{id}/edit` | the edit form ¹ |
    /// | `GET` | `/admin/users/-/options` | a searchable or dependent choice's options ¹ |
    /// | `POST` | `/admin/users/{id}/-/actions/{action}` | a record action on one record ² |
    /// | `POST` | `/admin/users/-/actions/{action}` | a header action, or a bulk action ³ |
    /// | `GET` | `/admin/users/-/actions/{action}/options` | an action input's options ² ³ |
    /// | `POST` | `/admin/users/{id}/-/relations/{relation}/-/actions/{action}` | attach, or detach the selection ⁴ |
    /// | `POST` | `/admin/users/{id}/-/relations/{relation}/{related}/-/actions/{action}` | detach one record ⁴ |
    /// | `GET` | `/admin/users/{id}/-/relations/{relation}/-/actions/{action}/options` | the attach dialog's options ⁴ |
    ///
    /// ¹ Only for a resource whose `Form` is a record form, not [`NoForm`](crate::NoForm).
    /// ² Only when an action is placed on a row, the detail page or the edit page.
    /// ³ Only when the resource has a header action, or an action placed on the bulk bar.
    /// ⁴ Only when the resource has a [many-to-many relation](crate::Relation::belongs_to_many).
    ///
    /// `{id}` is the record's primary key. The `-` segment keeps the list's own routes apart from
    /// a record's: a record whose key is `create` still has its detail page at
    /// `/admin/users/create`. Each route checks the resource's policy.
    pub fn resource<R: Resource>(self) -> Self {
        self.resource_with::<R>(|def| def)
    }

    /// Registers the [`Resource`] `R` as [`resource`](Self::resource) does, with `customize`
    /// adjusting its def for this panel only:
    ///
    /// ```rust
    /// # use tablo_core::{NoForm, Panel, ReadOnly, Resource};
    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
    /// # struct PostResource;
    /// # impl Resource for PostResource {
    /// #     type Model = Post;
    /// #     type Form = NoForm<Post>;
    /// # }
    /// Panel::new("portal").resource_with::<PostResource>(|def| def.policy(ReadOnly));
    /// ```
    pub fn resource_with<R: Resource>(
        mut self,
        customize: impl FnOnce(ResourceDef<R>) -> ResourceDef<R> + Send + 'static,
    ) -> Self {
        self.registrations
            .push(Box::new(ResourceRegistration::<R>(Box::new(customize))));
        self
    }

    /// Declares a [`Page`] at `{prefix}/{slug}` with its sidebar entry, as [`Page::declare`]
    /// declares it; pages share the resources' slug namespace.
    pub fn page<P: Page>(self) -> Self {
        self.page_with::<P>(|def| def)
    }

    /// Declares the [`Page`] `P` as [`page`](Self::page) does, with `customize` adjusting its def
    /// for this panel only:
    ///
    /// ```rust
    /// # use tablo_core::{Page, Panel};
    /// # use topcoat::{Result, context::Cx, view::View};
    /// # struct ReportsPage;
    /// # impl Page for ReportsPage {
    /// #     async fn render(_cx: &Cx) -> Result<impl View> { Ok(()) }
    /// # }
    /// # fn is_manager(_cx: &Cx) -> bool { true }
    /// Panel::new("portal").page_with::<ReportsPage>(|def| def.access(is_manager));
    /// ```
    pub fn page_with<P: Page>(
        mut self,
        customize: impl FnOnce(PageDef) -> PageDef + Send + 'static,
    ) -> Self {
        self.registrations.push(Box::new(PageRegistration::<P> {
            home: false,
            customize: Box::new(customize),
            _page: std::marker::PhantomData,
        }));
        self
    }

    /// Registers `area`, a part of the panel under `{prefix}/{slug}` with a sidebar of its own,
    /// after the resources and pages already registered: see [`Area`].
    pub fn area(mut self, area: Area) -> Self {
        self.registrations.push(Box::new(area));
        self
    }

    /// Declares the panel's home page at the panel prefix, replacing the redirect to the first
    /// resource's list. The page mounts with its def as [`Page::declare`] returns it.
    pub fn home<P: Page>(mut self) -> Self {
        self.registrations.push(Box::new(PageRegistration::<P> {
            home: true,
            customize: Box::new(|def| def),
            _page: std::marker::PhantomData,
        }));
        self
    }

    /// Frames the panel's pages with `render` instead of the shipped shell; `render` usually wraps
    /// [`Panel::layout_shell`].
    ///
    /// The panel registers its layout at its prefix itself, with or without this call: a
    /// `#[layout]` of the app's own there would nest a second document inside the first.
    pub fn layout(mut self, render: LayoutRenderFn) -> Self {
        self.layout = Some(render);
        self
    }

    /// Sets branding for the shell (sidebar header and the topbar below `md`) and for the sign-in
    /// and sign-up pages, above their card.
    pub fn brand(mut self, brand: Brand) -> Self {
        self.configuration_errors.extend(
            brand
                .declaration_errors()
                .into_iter()
                .map(DeclarationError::panel),
        );
        self.brand = Some(brand);
        self
    }

    /// Starts a visitor who has not chosen a theme dark; without the call the panel starts light.
    pub fn dark_by_default(mut self) -> Self {
        self.dark_mode = true;
        self
    }

    /// Configures this panel's authentication, defaulting to password auth; sessions belong to the
    /// panel that signed them in.
    pub fn auth<U: crate::PanelUser>(mut self, auth: crate::auth::Auth<U>) -> Self {
        self.auth = auth.into_config();
        self
    }

    /// Declares a shard or procedure the app serves on this panel's pages: a request to it
    /// answers as a request under the prefix does.
    ///
    /// Topcoat serves shards and procedures under its runtime path, outside every panel's
    /// prefix. A request to an endpoint no panel declares runs as the panel whose session sent
    /// it, whichever panel that is; a panel without sign-in has no session, so its request runs as
    /// no panel, and [`scoped_query`](crate::scoped_query) and the [`url`](crate::url) helpers
    /// find no resource. A declared one runs as this panel: a session another panel
    /// signed in is no session here, so it answers 401 when the panel requires sign-in, and
    /// [`auth::guard`](crate::auth::guard), [`auth::user`](crate::auth::user), the tenant and
    /// [`can_list`] answer for this panel. Several panels may declare the same
    /// endpoint; each of their sessions reaches it.
    ///
    /// The router still registers the endpoint, through discovery or `.route(..)`. A route
    /// outside Topcoat's runtime path refuses to mount: the panel's prefix or the app's own
    /// layers guard it.
    ///
    /// ```rust
    /// # use tablo_core::{Panel, auth};
    /// # use topcoat::{Result, context::Cx, runtime::shard, view::{View, view}};
    /// #[shard]
    /// async fn revenue(cx: &Cx) -> Result<impl View> {
    ///     auth::guard(cx)?;
    ///     Ok(view! { "…" })
    /// }
    ///
    /// Panel::new("admin").endpoint(revenue);
    /// ```
    pub fn endpoint(mut self, endpoint: impl topcoat::router::Route) -> Self {
        let path = endpoint.path().as_str().to_owned();
        if !state::under_prefix(crate::topcoat_compat::RUNTIME_PREFIX, &path) {
            self.configuration_errors.push(DeclarationError::panel(
                DeclarationErrorKind::EndpointOutsideRuntime { path: path.clone() },
            ));
        }
        self.endpoints.push(path);
        self
    }

    /// Adds `item` to the user menu in the sidebar's footer, above Sign out: a profile or a
    /// settings page. The item names its URL with
    /// [`NavigationItem::at`](crate::NavigationItem::at); its badge shows in the sidebar only.
    pub fn user_menu_item(mut self, item: crate::NavigationItem) -> Self {
        self.user_menu.push(item);
        self
    }

    /// Shows `B` above every page, under the bar that names whom a user acts as; several show in
    /// the order they are added.
    pub fn banner<B: Banner>(mut self) -> Self {
        self.banners.push(banner::erased::<B>);
        self
    }

    /// Shows a bell in the topbar over the signed-in user's
    /// [stored notifications](crate::Notifications): an unread count, and a sheet
    /// that lists them newest first, to mark read or unread and remove. Once the page loads, the
    /// bell keeps updating over Topcoat's runtime connection, and a notification sent while the
    /// page is open also shows as a toast.
    ///
    /// The bell lists what was sent on this panel for the tenant the user acts for, and what was
    /// sent for no tenant; its procedures mark and remove only those, so an id it does not list
    /// changes nothing. An open bell checks the session again before it shows anything new, and
    /// [`auth::revoke_sessions_for_user`](crate::auth::revoke_sessions_for_user) stops the user's
    /// open bells at once. A send wakes the bells open on the process that sent it: behind a load
    /// balancer, a bell on another process shows the notification on its next page load.
    ///
    /// The panel needs auth and the [`StoredNotification`] model, which
    /// [`models!`](crate::models!) registers. Nothing deletes stored notifications on its own.
    ///
    /// ```rust
    /// # use tablo_core::Panel;
    /// Panel::new("admin").notifications();
    /// ```
    ///
    /// [`StoredNotification`]: crate::notification::StoredNotification
    pub fn notifications(mut self) -> Self {
        self.notifications = true;
        self
    }

    /// Sets the time zone a user whose [`PanelUser::time_zone`](crate::PanelUser::time_zone) names
    /// none reads and enters wall-clock times in, and that an unsigned request uses: UTC by
    /// default. See [`time_zone`](crate::time_zone).
    ///
    /// ```rust
    /// # use tablo_core::Panel;
    /// let zone = jiff::tz::TimeZone::get("Europe/Paris").expect("the zone database names it");
    /// Panel::new("admin").time_zone(zone);
    /// ```
    pub fn time_zone(mut self, zone: jiff::tz::TimeZone) -> Self {
        self.time_zone = zone;
        self
    }

    /// Sets the locales the panel offers, the default first: `en` by default. Each request reads
    /// one, which [`topcoat::l10n::locale()`] returns: the one the visitor picked from the shell's
    /// language menu, else the signed-in user's [`PanelUser::locale`](crate::PanelUser::locale),
    /// else the browser's best match, else the default. A locale matches the closest offered one
    /// of its language and script, as `fr-CA` reads `fr` and `zh-HK` reads `zh-Hant`.
    ///
    /// Offering several shows the language menu in the sidebar footer, each locale named by an
    /// attribute of the message `tablo-shell-language` and falling back to its tag; picking one
    /// remembers it in a cookie under the panel's prefix, and on the signed-in user's account
    /// through [`Authenticator::store_locale`](crate::Authenticator::store_locale).
    ///
    /// Tablo translates its own words into `en` and `fr`; a locale it does not translate needs the
    /// app's catalog, from [`translations`](Self::translations). The pseudo-locale `en-XA` spells
    /// every catalog's English with accented letters, so a word that reads plain on its page is one
    /// no catalog spells.
    ///
    /// ```rust
    /// # use tablo_core::Panel;
    /// use topcoat::l10n::locale;
    ///
    /// Panel::new("admin").locales([locale!("fr"), locale!("en")]);
    /// ```
    ///
    /// The panel refuses to mount an empty list, a repeated locale, and a locale no catalog
    /// translates.
    pub fn locales(mut self, locales: impl IntoIterator<Item = topcoat::l10n::Locale>) -> Self {
        self.locales = locales.into_iter().collect();
        self
    }

    /// Adds the app's Fluent catalog for `locale`: the messages its [`Text`](crate::Text)s name,
    /// and any of Tablo's it spells otherwise.
    ///
    /// A message the request's locale leaves out reads from the default locale's catalogs, then
    /// from English; an English locale reads English alone. A catalog for a language, such as `fr`,
    /// serves its regions too: `fr-CA` reads it under its own.
    ///
    /// ```rust,ignore
    /// Panel::new("admin")
    ///     .locales([locale!("fr"), locale!("en")])
    ///     .translations(locale!("fr"), include_str!("../locales/fr.ftl"))
    ///     .translations(locale!("en"), include_str!("../locales/en.ftl"));
    /// ```
    ///
    /// Tablo's messages name a tenant through the Fluent term `-tenant`, so an app that calls its
    /// tenants teams overrides that one term in each of its catalogs rather than each message.
    /// Each locale's term has the forms its messages read, listed atop Tablo's catalog for it;
    /// the French one adds a `.gender` attribute the messages agree with:
    ///
    /// ```ftl
    /// -tenant = { $form ->
    ///    *[lower] team
    ///     [upper] Team
    ///     [upper-plural] Teams
    ///     [indefinite] a team
    /// }
    /// ```
    ///
    /// The panel refuses to mount a catalog that is not valid Fluent, and one for a locale it does
    /// not offer, `en` aside.
    pub fn translations(
        mut self,
        locale: topcoat::l10n::Locale,
        catalog: impl Into<String>,
    ) -> Self {
        self.translations.push((locale, catalog.into()));
        self
    }

    /// Leaves out the global search the panel otherwise shows: the box in its topbar and its
    /// results page.
    ///
    /// By default the topbar holds a search box over every resource the panel mounts, unless a
    /// resource opts out with
    /// [`ResourceDef::without_global_search`](crate::ResourceDef::without_global_search). As the
    /// user types, it lists up to 5 results of each resource, headed by the resource's plural
    /// label in the sidebar's order, and links the page at `{prefix}/-/search?q=`, which lists up
    /// to 25 of each and which Enter opens, with or without JavaScript. Cmd+K, or Ctrl+K, focuses
    /// the box from anywhere on the page. The term matches a record
    /// when each of its first 8 distinct words does;
    /// [`ResourceDef::global_search`](crate::ResourceDef::global_search) says what a word matches,
    /// which records show and what each opens.
    ///
    /// A resource is not searched when it has no searchable column and no title column, or when no
    /// result could open a page: its records have no URL, or it has neither a detail page nor a
    /// form. The box shows to a request that may list one of the searched resources, and a panel
    /// that searches none serves no results page.
    ///
    /// The box's results are a shard the panel declares as its [endpoint](Self::endpoint), so they
    /// answer for a panel without sign-in too. Topcoat serves it at one path for every panel, and
    /// runs it as the panel that signed the session in, else as the first such panel without
    /// sign-in. On any other panel, such as the second of two without sign-in, the box lists no
    /// results as the user types and links the results page alone.
    ///
    /// ```rust
    /// # use tablo_core::Panel;
    /// Panel::new("admin").without_global_search();
    /// ```
    pub fn without_global_search(mut self) -> Self {
        self.global_search = false;
        self
    }

    /// Renders a line under the sign-in card for demo credentials or deployment hints.
    pub fn login_hint(mut self, hint: impl Into<crate::Text>) -> Self {
        self.login_hint = Some(hint.into());
        self
    }

    /// Sets the bounds the panel holds each request to: rows exported and imported, records one
    /// bulk action runs on, and bytes a request body holds. [`Limits::default`](crate::Limits)
    /// lists the defaults.
    pub fn limits(mut self, limits: crate::Limits) -> Self {
        self.limits = limits;
        self
    }
}

/// What the panel serves at its prefix.
enum Root {
    /// A redirect to the first declared resource's list that the sidebar shows.
    Redirect(String),
    /// The [`Panel::home`] page.
    Home,
}

#[cfg(test)]
mod tests;