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;