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