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