Skip to main content

tablo_core/
panel.rs

1//! `Panel` — resources and pages under one prefix, which an app mounts into its router.
2
3mod actions;
4mod bar;
5mod build;
6mod detail;
7pub(crate) mod error_page;
8mod forms;
9pub(crate) mod gate;
10mod headers;
11mod host;
12mod input;
13mod list;
14pub(crate) mod pages;
15mod register;
16mod relations;
17mod search;
18mod shell;
19pub(crate) mod state;
20#[cfg(test)]
21pub(crate) mod test_support;
22pub mod url;
23mod write;
24
25use std::path::PathBuf;
26
27use topcoat::{asset::Asset, font::Font, router::LayoutRenderFn};
28
29pub(crate) use self::bar::header_bar;
30use self::{
31    build::is_directory_pattern,
32    register::{PageRegistration, Registration, ResourceRegistration},
33    shell::ShellAssets,
34};
35pub use self::{
36    build::{PanelHandle, RouterBuilderPanelExt},
37    forms::MAX_IMPORT_ROWS,
38    gate::{can_list, can_open},
39    list::WiredTable,
40    shell::Brand,
41};
42use crate::{
43    DeclarationError, DeclarationErrorKind, Page, PageDef,
44    resource::{Resource, ResourceDef},
45};
46
47/// Returns the panel's list table for `R`, with the request's row actions wired on, for a page
48/// that owns its table; render it over the page's own query with
49/// [`WiredTable::render`](WiredTable::render).
50///
51/// # Errors
52///
53/// A declaration error when the context's panel does not mount `R`.
54pub fn wired_table<R: Resource>(
55    cx: &topcoat::context::Cx,
56) -> topcoat::Result<WiredTable<R::Model>> {
57    let resource = crate::resource::require_mounted::<R>(cx)?;
58    Ok(self::list::wire_table_actions(cx, &resource))
59}
60
61/// `R`'s sidebar entry in the context's panel, with its label, URL and icon; `None` when the
62/// panel does not register `R`.
63pub fn navigation_item<R: Resource>(cx: &topcoat::context::Cx) -> Option<crate::NavigationItem> {
64    crate::resource::mounted::<R>(cx).map(|resource| resource.navigation.clone())
65}
66
67/// An admin panel: resources and pages under one prefix, framed by one shell
68/// and gated by one [`Auth`](crate::auth::Auth).
69///
70/// The app owns the router and mounts the panel into it with
71/// [`RouterBuilderPanelExt::panel`]:
72///
73/// ```rust,no_run
74/// # use tablo_core::{NoForm, Panel, Resource, RouterBuilderPanelExt};
75/// # use topcoat::router::{Router, RouterBuilderDiscoverExt};
76/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
77/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
78/// # struct UserResource;
79/// # impl Resource for UserResource {
80/// #     type Model = User;
81/// #     type Form = NoForm<User>;
82/// # }
83/// # fn main() -> topcoat::Result<()> {
84/// # let db: toasty::Db = todo!();
85/// let router = Router::builder()
86///     .discover()
87///     .app_context(db)
88///     .panel(Panel::new("admin").resource::<UserResource>())?
89///     .build();
90/// # let _ = router;
91/// # Ok(())
92/// # }
93/// ```
94///
95/// Registering is declarative: mounting builds each resource's [`ResourceDef`] and checks every
96/// slug, route and declaration before the panel serves anything.
97///
98/// Besides its resources' and pages' routes, a panel at `/admin` serves:
99///
100/// - `GET /admin`: the [home page](Self::home), or a redirect to the first registered resource's
101///   list when there is none;
102/// - `GET`, `POST /admin/login`, `POST /admin/logout` and `POST /admin/tenant`, the tenant
103///   switcher, unless authentication is disabled;
104/// - `/admin/register`, `/admin/profile`, `/admin/forgot-password` and `/admin/reset-password` when
105///   [`Auth`](crate::auth::Auth) installs the feature each serves;
106/// - `POST /admin/{page}/-/actions/{action}` for a page's header actions, and
107///   `/admin/-/actions/{action}` for the home page's;
108/// - `GET /admin/-/search?q=`, the [global search](Self::without_global_search)'s results page,
109///   when the panel searches a resource;
110/// - every other path under `/admin`, with any method, as a 404 behind the auth gate, so a visitor
111///   signs in before learning a path serves nothing. The app's own routes under the prefix still
112///   take precedence.
113pub struct Panel {
114    prefix: String,
115    shell_assets: Option<ShellAssets>,
116    brand: Option<Brand>,
117    dark_mode: Option<bool>,
118    /// Frames the panel's pages; `None` renders the shipped shell.
119    layout: Option<LayoutRenderFn>,
120    /// The resources and pages, in registration order.
121    registrations: Vec<Box<dyn Registration>>,
122    /// The `Content-Security-Policy` of every response under the prefix.
123    content_security_policy: headers::Policy,
124    /// `Strict-Transport-Security`'s `max-age`, when the panel sends it.
125    strict_transport_security: Option<std::time::Duration>,
126    /// Mistakes in the panel's own configuration, for mount to report.
127    configuration_errors: Vec<DeclarationError>,
128    /// Where file field bytes go; `None` refuses to mount a form with a file field.
129    uploads: Option<crate::upload::InstalledUploader>,
130    /// App-owned filesystem directories served with hardening headers.
131    served_dirs: Vec<headers::ServedDir>,
132    login_hint: Option<crate::Text>,
133    /// The app's entries in the user menu, above Sign out.
134    user_menu: Vec<crate::NavigationItem>,
135    auth: crate::auth::AuthConfig,
136    /// The paths of the runtime endpoints that answer only this panel's sessions.
137    endpoints: Vec<String>,
138    /// Whether the topbar shows the bell over the user's stored notifications.
139    notifications: bool,
140    /// The zone a user without their own reads and enters wall-clock times in.
141    time_zone: jiff::tz::TimeZone,
142    /// The locales the panel offers, the default first.
143    locales: Vec<topcoat::l10n::Locale>,
144    /// The app's catalogs, by locale, over Tablo's.
145    translations: Vec<(topcoat::l10n::Locale, String)>,
146    /// Whether the topbar shows the global search box.
147    global_search: bool,
148}
149
150impl Panel {
151    /// Creates a `Panel` mounted at `prefix`, defaulting an empty prefix to `"/admin"`.
152    pub fn new(prefix: impl Into<String>) -> Self {
153        let prefix = crate::naming::normalize_prefix(&prefix.into());
154        let configuration_errors = prefix
155            .trim_matches('/')
156            .split('/')
157            .filter_map(|segment| build::validate_route_segment("panel prefix", segment).err())
158            .map(DeclarationError::panel)
159            .collect();
160        Self {
161            prefix,
162            shell_assets: None,
163            brand: None,
164            dark_mode: None,
165            layout: None,
166            registrations: Vec::new(),
167            content_security_policy: headers::Policy::Default,
168            strict_transport_security: None,
169            configuration_errors,
170            uploads: None,
171            served_dirs: Vec::new(),
172            login_hint: None,
173            user_menu: Vec::new(),
174            auth: crate::auth::Auth::default().into_config(),
175            endpoints: Vec::new(),
176            notifications: false,
177            time_zone: jiff::tz::TimeZone::UTC,
178            locales: vec![crate::i18n::ENGLISH.clone()],
179            translations: Vec::new(),
180            global_search: true,
181        }
182    }
183
184    /// Returns the mount prefix, e.g. `"/admin"`.
185    pub fn prefix(&self) -> &str {
186        &self.prefix
187    }
188
189    /// Installs the [`Uploader`](crate::Uploader) this panel's file fields store through; without
190    /// one a panel whose resource form declares a file field refuses to mount.
191    pub fn uploads(mut self, uploader: impl crate::Uploader) -> Self {
192        self.uploads = Some(crate::upload::InstalledUploader::new(uploader));
193        self
194    }
195
196    /// Stores file fields' uploads in `dir` and serves them at `url_prefix` (`"/uploads"`), through
197    /// a [`DirUploader`](crate::upload::DirUploader).
198    ///
199    /// ```no_run
200    /// # use tablo_core::Panel;
201    /// Panel::new("admin").uploads_dir("/uploads", "var/uploads");
202    /// ```
203    ///
204    /// The directory answers only at a signed URL that expires, so a file's bare URL opens
205    /// nothing and a leaked link stops working within two hours. The panel signs a file field's
206    /// link, a [`FileColumn`](crate::FileColumn)'s and an [`ImageColumn`](crate::ImageColumn)'s
207    /// thumbnail when it renders them, on a page whose tenant and policy let the user see the
208    /// record; a page of the app's own signs a stored path with [`url::file`](crate::url::file).
209    /// The key derives from the router's cookie `Key`, else from one drawn per process, so
210    /// without a shared `Key` a link opens only on the instance that rendered it. Any other
211    /// request answers 404. The files carry [`serve_dir`](Self::serve_dir)'s hardening headers.
212    ///
213    /// A file a record form stored for a submission that never saved is deleted after 24 hours:
214    /// [`DirUploader::expire_pending`](crate::upload::DirUploader::expire_pending).
215    pub fn uploads_dir(self, url_prefix: &str, dir: impl Into<PathBuf>) -> Self {
216        self.uploads_dir_with(url_prefix, dir, |uploader| uploader)
217    }
218
219    /// [`uploads_dir`](Self::uploads_dir), with the [`DirUploader`](crate::upload::DirUploader)
220    /// wrapped in an app's own [`Uploader`](crate::Uploader), such as one that checks the bytes
221    /// before it stores them: the files still answer only at signed URLs, and pending ones still
222    /// expire. The wrapper forwards [`keep`](crate::Uploader::keep), and
223    /// [`delete`](crate::Uploader::delete) to delete what a write lets go.
224    pub fn uploads_dir_with<U: crate::Uploader>(
225        self,
226        url_prefix: &str,
227        dir: impl Into<PathBuf>,
228        wrap: impl FnOnce(crate::upload::DirUploader) -> U,
229    ) -> Self {
230        let url_prefix = url_prefix.trim_end_matches('/');
231        let dir = dir.into();
232        self.serve(format!("{url_prefix}/{{*file}}"), dir.clone(), true)
233            .uploads(wrap(
234                crate::upload::DirUploader::new(url_prefix, dir)
235                    .expire_pending(std::time::Duration::from_secs(24 * 3600)),
236            ))
237    }
238
239    /// Serves the directory `dir` at route pattern `path`, which must end in a catch-all.
240    ///
241    /// The files share the panel's origin, so every file response carries hardening headers:
242    ///
243    /// - `X-Content-Type-Options: nosniff`;
244    /// - a sandboxing `Content-Security-Policy`, which is fixed;
245    /// - `Content-Disposition: attachment`, unless the file is a common image, audio or video type
246    ///   or plain text, so an uploaded HTML or SVG file downloads instead of running script.
247    ///
248    /// Serve active documents from another origin. The directory is **public**: the auth gate does
249    /// not cover it, unless `path` sits under the panel prefix, which the gate covers. Serve
250    /// uploads with [`uploads_dir`](Self::uploads_dir) instead, whose URLs expire.
251    pub fn serve_dir(self, path: impl Into<String>, dir: impl Into<PathBuf>) -> Self {
252        self.serve(path.into(), dir.into(), false)
253    }
254
255    /// Serves `dir` at `path`, at signed URLs only when `signed`.
256    fn serve(mut self, path: String, dir: PathBuf, signed: bool) -> Self {
257        if !is_directory_pattern(&path) {
258            self.configuration_errors.push(DeclarationError::panel(
259                DeclarationErrorKind::ServeDirWithoutCatchAll { path: path.clone() },
260            ));
261        }
262        self.served_dirs
263            .push(headers::ServedDir { path, dir, signed });
264        self
265    }
266
267    /// Registers the stylesheet and font the default shell links, resolved through the router's
268    /// asset bundle.
269    pub fn shell_assets(mut self, stylesheet: Asset, font: Font) -> Self {
270        self.shell_assets = Some(ShellAssets { stylesheet, font });
271        self
272    }
273
274    /// Registers the [`Resource`] `R` at `{prefix}/{slug}` with its routes and sidebar entry, as
275    /// [`Resource::declare`] declares it.
276    ///
277    /// A resource with the slug `users`, on a panel at `/admin`, serves:
278    ///
279    /// | Method | Path | Serves |
280    /// | --- | --- | --- |
281    /// | `GET` | `/admin/users` | the list |
282    /// | `GET` | `/admin/users/{id}` | the detail page; 404 when the resource's view is empty |
283    /// | `POST` | `/admin/users/{id}/delete` | delete one record |
284    /// | `POST` | `/admin/users/-/bulk-delete` | delete the selected records |
285    /// | `GET` | `/admin/users/-/export` | the list as CSV |
286    /// | `GET`, `POST` | `/admin/users/-/create` | the create form ¹ |
287    /// | `GET`, `POST` | `/admin/users/{id}/edit` | the edit form ¹ |
288    /// | `GET` | `/admin/users/-/options` | a searchable or dependent choice's options ¹ |
289    /// | `POST` | `/admin/users/{id}/-/actions/{action}` | a record action on one record ² |
290    /// | `POST` | `/admin/users/-/actions/{action}` | a header action, or a bulk action ³ |
291    /// | `GET` | `/admin/users/-/actions/{action}/options` | an action input's options ² ³ |
292    /// | `POST` | `/admin/users/{id}/-/relations/{relation}/-/actions/{action}` | attach, or detach the selection ⁴ |
293    /// | `POST` | `/admin/users/{id}/-/relations/{relation}/{related}/-/actions/{action}` | detach one record ⁴ |
294    /// | `GET` | `/admin/users/{id}/-/relations/{relation}/-/actions/{action}/options` | the attach dialog's options ⁴ |
295    ///
296    /// ¹ Only for a resource whose `Form` is a record form, not [`NoForm`](crate::NoForm).
297    /// ² Only when an action is placed on a row, the detail page or the edit page.
298    /// ³ Only when the resource has a header action, or an action placed on the bulk bar.
299    /// ⁴ Only when the resource has a [many-to-many relation](crate::Relation::belongs_to_many).
300    ///
301    /// `{id}` is the record's primary key. The `-` segment keeps the list's own routes apart from
302    /// a record's: a record whose key is `create` still has its detail page at
303    /// `/admin/users/create`. Each route checks the resource's policy.
304    pub fn resource<R: Resource>(self) -> Self {
305        self.resource_with::<R>(|def| def)
306    }
307
308    /// Registers the [`Resource`] `R` as [`resource`](Self::resource) does, with `customize`
309    /// adjusting its def for this panel only:
310    ///
311    /// ```rust
312    /// # use tablo_core::{NoForm, Panel, ReadOnly, Resource};
313    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
314    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
315    /// # struct PostResource;
316    /// # impl Resource for PostResource {
317    /// #     type Model = Post;
318    /// #     type Form = NoForm<Post>;
319    /// # }
320    /// Panel::new("portal").resource_with::<PostResource>(|def| def.policy(ReadOnly));
321    /// ```
322    pub fn resource_with<R: Resource>(
323        mut self,
324        customize: impl FnOnce(ResourceDef<R>) -> ResourceDef<R> + Send + 'static,
325    ) -> Self {
326        self.registrations
327            .push(Box::new(ResourceRegistration::<R>(Box::new(customize))));
328        self
329    }
330
331    /// Declares a [`Page`] at `{prefix}/{slug}` with its sidebar entry, as [`Page::declare`]
332    /// declares it; pages share the resources' slug namespace.
333    pub fn page<P: Page>(self) -> Self {
334        self.page_with::<P>(|def| def)
335    }
336
337    /// Declares the [`Page`] `P` as [`page`](Self::page) does, with `customize` adjusting its def
338    /// for this panel only:
339    ///
340    /// ```rust
341    /// # use tablo_core::{Page, Panel};
342    /// # use topcoat::{Result, context::Cx, view::View};
343    /// # struct ReportsPage;
344    /// # impl Page for ReportsPage {
345    /// #     async fn render(_cx: &Cx) -> Result<impl View> { Ok(()) }
346    /// # }
347    /// # fn is_manager(_cx: &Cx) -> bool { true }
348    /// Panel::new("portal").page_with::<ReportsPage>(|def| def.access(is_manager));
349    /// ```
350    pub fn page_with<P: Page>(
351        mut self,
352        customize: impl FnOnce(PageDef) -> PageDef + Send + 'static,
353    ) -> Self {
354        self.registrations.push(Box::new(PageRegistration::<P> {
355            home: false,
356            customize: Box::new(customize),
357            _page: std::marker::PhantomData,
358        }));
359        self
360    }
361
362    /// Declares the panel's home page at the panel prefix, replacing the redirect to the first
363    /// resource's list. The page mounts with its def as [`Page::declare`] returns it.
364    pub fn home<P: Page>(mut self) -> Self {
365        self.registrations.push(Box::new(PageRegistration::<P> {
366            home: true,
367            customize: Box::new(|def| def),
368            _page: std::marker::PhantomData,
369        }));
370        self
371    }
372
373    /// Frames the panel's pages with `render` instead of the shipped shell; `render` usually wraps
374    /// [`Panel::layout_shell`].
375    ///
376    /// The panel registers its layout at its prefix itself, with or without this call: a
377    /// `#[layout]` of the app's own there would nest a second document inside the first.
378    pub fn layout(mut self, render: LayoutRenderFn) -> Self {
379        self.layout = Some(render);
380        self
381    }
382
383    /// Sets branding for the shell (sidebar header and the topbar below `md`) and for the sign-in
384    /// and sign-up pages, above their card.
385    pub fn brand(mut self, brand: Brand) -> Self {
386        self.brand = Some(brand);
387        self
388    }
389
390    /// Sets the theme a visitor who has not chosen one sees, dark when `true`.
391    pub fn dark_mode(mut self, enabled: bool) -> Self {
392        self.dark_mode = Some(enabled);
393        self
394    }
395
396    /// Configures this panel's authentication, defaulting to password auth; sessions belong to the
397    /// panel that signed them in.
398    pub fn auth<U: crate::PanelUser>(mut self, auth: crate::auth::Auth<U>) -> Self {
399        self.auth = auth.into_config();
400        self
401    }
402
403    /// Declares a shard or procedure the app serves on this panel's pages: a request to it
404    /// answers as a request under the prefix does.
405    ///
406    /// Topcoat serves shards and procedures under its runtime path, outside every panel's
407    /// prefix. A request to an endpoint no panel declares runs as the panel whose session sent
408    /// it, whichever panel that is; a panel without sign-in has no session, so its request runs as
409    /// no panel, and [`scoped_query`](crate::scoped_query) and the [`url`](crate::url) helpers
410    /// find no resource. A declared one runs as this panel: a session another panel
411    /// signed in is no session here, so it answers 401 when the panel requires sign-in, and
412    /// [`auth::guard`](crate::auth::guard), [`auth::user`](crate::auth::user), the tenant and
413    /// [`can_list`] answer for this panel. Several panels may declare the same
414    /// endpoint; each of their sessions reaches it.
415    ///
416    /// The router still registers the endpoint, through discovery or `.route(..)`. A route
417    /// outside Topcoat's runtime path refuses to mount: the panel's prefix or the app's own
418    /// layers guard it.
419    ///
420    /// ```rust
421    /// # use tablo_core::{Panel, auth};
422    /// # use topcoat::{Result, context::Cx, runtime::shard, view::{View, view}};
423    /// #[shard]
424    /// async fn revenue(cx: &Cx) -> Result<impl View> {
425    ///     auth::guard(cx)?;
426    ///     Ok(view! { "…" })
427    /// }
428    ///
429    /// Panel::new("admin").endpoint(revenue);
430    /// ```
431    pub fn endpoint(mut self, endpoint: impl topcoat::router::Route) -> Self {
432        let path = endpoint.path().as_str().to_owned();
433        if !state::under_prefix(crate::topcoat_compat::RUNTIME_PREFIX, &path) {
434            self.configuration_errors.push(DeclarationError::panel(
435                DeclarationErrorKind::EndpointOutsideRuntime { path: path.clone() },
436            ));
437        }
438        self.endpoints.push(path);
439        self
440    }
441
442    /// Adds `item` to the user menu in the sidebar's footer, above Sign out: a profile or a
443    /// settings page. The item names its URL with
444    /// [`NavigationItem::at`](crate::NavigationItem::at); its badge shows in the sidebar only.
445    pub fn user_menu_item(mut self, item: crate::NavigationItem) -> Self {
446        self.user_menu.push(item);
447        self
448    }
449
450    /// Shows a bell in the topbar over the signed-in user's
451    /// [stored notifications](crate::Notifications): an unread count, and a sheet
452    /// that lists them newest first, to mark read or unread and remove. Once the page loads, the
453    /// bell keeps updating over Topcoat's runtime connection, and a notification sent while the
454    /// page is open also shows as a toast.
455    ///
456    /// The bell lists what was sent on this panel for the tenant the user acts for, and what was
457    /// sent for no tenant; its procedures mark and remove only those, so an id it does not list
458    /// changes nothing. An open bell checks the session again before it shows anything new, and
459    /// [`auth::revoke_sessions_for_user`](crate::auth::revoke_sessions_for_user) stops the user's
460    /// open bells at once. A send wakes the bells open on the process that sent it: behind a load
461    /// balancer, a bell on another process shows the notification on its next page load.
462    ///
463    /// The panel needs auth and the [`StoredNotification`] model, which
464    /// [`models!`](crate::models!) registers. Nothing deletes stored notifications on its own.
465    ///
466    /// ```rust
467    /// # use tablo_core::Panel;
468    /// Panel::new("admin").notifications();
469    /// ```
470    ///
471    /// [`StoredNotification`]: crate::notification::StoredNotification
472    pub fn notifications(mut self) -> Self {
473        self.notifications = true;
474        self
475    }
476
477    /// Sets the time zone a user whose [`PanelUser::time_zone`](crate::PanelUser::time_zone) names
478    /// none reads and enters wall-clock times in, and that an unsigned request uses: UTC by
479    /// default. See [`time_zone`](crate::time_zone).
480    ///
481    /// ```rust
482    /// # use tablo_core::Panel;
483    /// let zone = jiff::tz::TimeZone::get("Europe/Paris").expect("the zone database names it");
484    /// Panel::new("admin").time_zone(zone);
485    /// ```
486    pub fn time_zone(mut self, zone: jiff::tz::TimeZone) -> Self {
487        self.time_zone = zone;
488        self
489    }
490
491    /// Sets the locales the panel offers, the default first: `en` by default. Each request reads
492    /// one, which [`topcoat::l10n::locale()`] returns: the signed-in user's
493    /// [`PanelUser::locale`](crate::PanelUser::locale), else the browser's best match, else the
494    /// default. A locale matches the closest offered one of its language and script, as `fr-CA`
495    /// reads `fr` and `zh-HK` reads `zh-Hant`.
496    ///
497    /// Tablo translates its own words into `en` and `fr`; a locale it does not translate needs the
498    /// app's catalog, from [`translations`](Self::translations). The pseudo-locale `en-XA` spells
499    /// every catalog's English with accented letters, so a word that reads plain on its page is one
500    /// no catalog spells.
501    ///
502    /// ```rust
503    /// # use tablo_core::Panel;
504    /// use topcoat::l10n::locale;
505    ///
506    /// Panel::new("admin").locales([locale!("fr"), locale!("en")]);
507    /// ```
508    ///
509    /// The panel refuses to mount an empty list, a repeated locale, and a locale no catalog
510    /// translates.
511    pub fn locales(mut self, locales: impl IntoIterator<Item = topcoat::l10n::Locale>) -> Self {
512        self.locales = locales.into_iter().collect();
513        self
514    }
515
516    /// Adds the app's Fluent catalog for `locale`: the messages its [`Text`](crate::Text)s name,
517    /// and any of Tablo's it spells otherwise.
518    ///
519    /// A message the request's locale leaves out reads from the default locale's catalogs, then
520    /// from English; an English locale reads English alone. A catalog for a language, such as `fr`,
521    /// serves its regions too: `fr-CA` reads it under its own.
522    ///
523    /// ```rust,ignore
524    /// Panel::new("admin")
525    ///     .locales([locale!("fr"), locale!("en")])
526    ///     .translations(locale!("fr"), include_str!("../locales/fr.ftl"))
527    ///     .translations(locale!("en"), include_str!("../locales/en.ftl"));
528    /// ```
529    ///
530    /// The panel refuses to mount a catalog that is not valid Fluent, and one for a locale it does
531    /// not offer, `en` aside.
532    pub fn translations(
533        mut self,
534        locale: topcoat::l10n::Locale,
535        catalog: impl Into<String>,
536    ) -> Self {
537        self.translations.push((locale, catalog.into()));
538        self
539    }
540
541    /// Leaves out the global search the panel otherwise shows: the box in its topbar and its
542    /// results page.
543    ///
544    /// By default the topbar holds a search box over every resource the panel mounts, unless a
545    /// resource opts out with
546    /// [`ResourceDef::without_global_search`](crate::ResourceDef::without_global_search). As the
547    /// user types, it lists up to 5 results of each resource, headed by the resource's plural
548    /// label in the sidebar's order, and links the page at `{prefix}/-/search?q=`, which lists up
549    /// to 25 of each and which Enter opens, with or without JavaScript. The term matches a record
550    /// when each of its first 8 distinct words does;
551    /// [`ResourceDef::global_search`](crate::ResourceDef::global_search) says what a word matches,
552    /// which records show and what each opens.
553    ///
554    /// A resource is not searched when it has no searchable column and no title column, or when no
555    /// result could open a page: its records have no URL, or it has neither a detail page nor a
556    /// form. The box shows to a request that may list one of the searched resources, and a panel
557    /// that searches none serves no results page.
558    ///
559    /// The box's results are a shard the panel declares as its [endpoint](Self::endpoint), so they
560    /// answer for a panel without sign-in too. Topcoat serves it at one path for every panel, and
561    /// runs it as the panel that signed the session in, else as the first such panel without
562    /// sign-in. On any other panel, such as the second of two without sign-in, the box lists no
563    /// results as the user types and links the results page alone.
564    ///
565    /// ```rust
566    /// # use tablo_core::Panel;
567    /// Panel::new("admin").without_global_search();
568    /// ```
569    pub fn without_global_search(mut self) -> Self {
570        self.global_search = false;
571        self
572    }
573
574    /// Renders a line under the sign-in card for demo credentials or deployment hints.
575    pub fn login_hint(mut self, hint: impl Into<crate::Text>) -> Self {
576        self.login_hint = Some(hint.into());
577        self
578    }
579}
580
581/// What the panel serves at its prefix.
582enum Root {
583    /// A redirect to the first declared resource's list.
584    Redirect(String),
585    /// The [`Panel::home`] page.
586    Home,
587}
588
589#[cfg(test)]
590mod tests;