Skip to main content

tablo_core/panel/
area.rs

1//! [`Area`]: a part of a panel under its own path segment, with a sidebar of its own, such as the
2//! settings of an account and of the tenant it acts for.
3
4use topcoat::icon::IconData;
5
6use super::register::{CustomizePage, PageRegistration, Registration, ResourceRegistration};
7use crate::{Page, PageDef, Resource, ResourceDef};
8
9/// A part of a panel under `{prefix}/{slug}` with a sidebar of its own: a settings area, an
10/// account area. Registered with [`Panel::area`](crate::Panel::area).
11///
12/// Outside the area, the panel's sidebar shows the area as one entry, which opens the first of
13/// its entries the user may open. Inside it, the sidebar shows a link back to the panel's home
14/// and the area's groups, each listing the entries the user may open; a group with none is left
15/// out. The topbar's trail starts at the area.
16///
17/// ```rust
18/// # use tablo_core::{Area, NoForm, Panel, Resource};
19/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
20/// # struct Member { #[key] #[auto] id: uuid::Uuid, name: String }
21/// # struct MemberResource;
22/// # impl Resource for MemberResource { type Model = Member; type Form = NoForm<Member>; }
23/// Panel::new("app").area(
24///     Area::new("settings")
25///         .group("Account", |group| group.profile().two_factor())
26///         .tenant_group(|group| group.tenant_profile().resource::<MemberResource>()),
27/// );
28/// ```
29pub struct Area {
30    pub(super) slug: String,
31    pub(super) label: Option<crate::Text>,
32    pub(super) icon: Option<IconData>,
33    pub(super) navigation_order: i32,
34    pub(super) navigation_group: Option<crate::Text>,
35    pub(super) navigation_hidden: bool,
36    pub(super) groups: Vec<(AreaGroupLabel, AreaGroup)>,
37}
38
39impl Area {
40    /// An area at `{prefix}/{slug}`, with no groups yet. `slug` is one URL path segment, taken
41    /// from the namespace the panel's resources and pages share.
42    pub fn new(slug: impl Into<String>) -> Self {
43        Self {
44            slug: slug.into(),
45            label: None,
46            icon: None,
47            navigation_order: 0,
48            navigation_group: None,
49            navigation_hidden: false,
50            groups: Vec::new(),
51        }
52    }
53
54    /// The label of the area's entry in the panel's sidebar and of the first step of its trail.
55    ///
56    /// Defaults to the message `area-{slug}` of the panel's catalogs, else the slug in sentence
57    /// case: `settings` reads `area-settings`, else `Settings`.
58    #[must_use]
59    pub fn label(mut self, label: impl Into<crate::Text>) -> Self {
60        self.label = Some(label.into());
61        self
62    }
63
64    /// The icon of the area's entry in the panel's sidebar.
65    #[must_use]
66    pub fn icon(mut self, icon: IconData) -> Self {
67        self.icon = Some(icon);
68        self
69    }
70
71    /// Where the area's entry sorts among the panel's sidebar entries, lowest first; defaults to
72    /// `0`, as [`PageDef::navigation_order`] does.
73    #[must_use]
74    pub fn navigation_order(mut self, order: i32) -> Self {
75        self.navigation_order = order;
76        self
77    }
78
79    /// The labelled sidebar group the area's entry renders in; defaults to none, above every
80    /// group, as [`PageDef::navigation_group`] does.
81    #[must_use]
82    pub fn navigation_group(mut self, group: impl Into<crate::Text>) -> Self {
83        self.navigation_group = Some(group.into());
84        self
85    }
86
87    /// Keeps the area's entry out of the panel's sidebar. The area stays mounted: the user menu
88    /// and the tenant menu lead into it through the [profile](AreaGroup::profile) and
89    /// [tenant settings](AreaGroup::tenant_profile) pages it holds, or a link of the app's own.
90    #[must_use]
91    pub fn without_navigation(mut self) -> Self {
92        self.navigation_hidden = true;
93        self
94    }
95
96    /// Adds a group headed `label`, after the groups already declared, holding what `build`
97    /// registers in it.
98    #[must_use]
99    pub fn group(
100        mut self,
101        label: impl Into<crate::Text>,
102        build: impl FnOnce(AreaGroup) -> AreaGroup,
103    ) -> Self {
104        self.groups.push((
105            AreaGroupLabel::Text(label.into()),
106            build(AreaGroup::default()),
107        ));
108        self
109    }
110
111    /// Adds a group headed by the name of the tenant the request acts for, after the groups
112    /// already declared, holding what `build` registers in it: the tenant's settings, members and
113    /// billing. A request that acts for no tenant sees none of its entries.
114    #[must_use]
115    pub fn tenant_group(mut self, build: impl FnOnce(AreaGroup) -> AreaGroup) -> Self {
116        self.groups
117            .push((AreaGroupLabel::Tenant, build(AreaGroup::default())));
118        self
119    }
120}
121
122impl std::fmt::Debug for Area {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct("Area")
125            .field("slug", &self.slug)
126            .field("label", &self.label)
127            .field("navigation_order", &self.navigation_order)
128            .field("navigation_group", &self.navigation_group)
129            .field("navigation_hidden", &self.navigation_hidden)
130            .field("groups", &self.groups.len())
131            .finish_non_exhaustive()
132    }
133}
134
135/// What heads a group of an [`Area`].
136pub(super) enum AreaGroupLabel {
137    /// A label of the app's.
138    Text(crate::Text),
139    /// The name of the tenant the request acts for.
140    Tenant,
141}
142
143/// The entries of one group of an [`Area`], in the order they are registered. Each resource and
144/// page mounts at `{prefix}/{area}/{slug}`, its slug taken from the area's own namespace, and
145/// its def's [`navigation_group`](PageDef::navigation_group) gives way to the group.
146#[derive(Default)]
147pub struct AreaGroup {
148    pub(super) entries: Vec<AreaEntry>,
149}
150
151/// One entry of an [`AreaGroup`].
152pub(super) enum AreaEntry {
153    /// A resource or page of the app's.
154    Registration(Box<dyn Registration>),
155    /// A page auth serves, adjusted by its def.
156    Auth(AuthPage, CustomizePage),
157    /// The list of the panel's audit log.
158    AuditLog,
159}
160
161/// A page auth serves that an area can hold.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub(crate) enum AuthPage {
164    /// [`Auth::profile`](crate::Auth::profile)'s page.
165    Profile,
166    /// [`Auth::two_factor`](crate::Auth::two_factor)'s settings page.
167    TwoFactor,
168    /// [`Auth::tenant_profile`](crate::Auth::tenant_profile)'s page.
169    TenantProfile,
170}
171
172impl AreaGroup {
173    /// Registers the [`Resource`] `R` in the group, as [`Panel::resource`](crate::Panel::resource)
174    /// registers it in the panel.
175    #[must_use]
176    pub fn resource<R: Resource>(self) -> Self {
177        self.resource_with::<R>(|def| def)
178    }
179
180    /// Registers the [`Resource`] `R` in the group with `customize` adjusting its def, as
181    /// [`Panel::resource_with`](crate::Panel::resource_with) does.
182    #[must_use]
183    pub fn resource_with<R: Resource>(
184        mut self,
185        customize: impl FnOnce(ResourceDef<R>) -> ResourceDef<R> + Send + 'static,
186    ) -> Self {
187        self.entries.push(AreaEntry::Registration(Box::new(
188            ResourceRegistration::<R>(Box::new(customize)),
189        )));
190        self
191    }
192
193    /// Declares the [`Page`] `P` in the group, as [`Panel::page`](crate::Panel::page) declares it
194    /// in the panel.
195    #[must_use]
196    pub fn page<P: Page>(self) -> Self {
197        self.page_with::<P>(|def| def)
198    }
199
200    /// Declares the [`Page`] `P` in the group with `customize` adjusting its def, as
201    /// [`Panel::page_with`](crate::Panel::page_with) does.
202    #[must_use]
203    pub fn page_with<P: Page>(
204        mut self,
205        customize: impl FnOnce(PageDef) -> PageDef + Send + 'static,
206    ) -> Self {
207        self.entries
208            .push(AreaEntry::Registration(Box::new(PageRegistration::<P> {
209                home: false,
210                customize: Box::new(customize),
211                _page: std::marker::PhantomData,
212            })));
213        self
214    }
215
216    /// Moves the profile page [`Auth::profile`](crate::Auth::profile) installs into the group, at
217    /// `{prefix}/{area}/profile`. The user menu's Profile entry follows it there.
218    #[must_use]
219    pub fn profile(self) -> Self {
220        self.profile_with(|def| def)
221    }
222
223    /// Moves the profile page into the group as [`profile`](Self::profile) does, with `customize`
224    /// adjusting its def: its [`slug`](PageDef::slug), [`label`](PageDef::label),
225    /// [`icon`](PageDef::icon), [`navigation_order`](PageDef::navigation_order) and
226    /// [`header_action`](PageDef::header_action)s, which render in the form's header and serve
227    /// at `{url}/-/actions/{NAME}` as a page's do: deleting the account belongs there.
228    ///
229    /// Auth renders the page's form and decides who opens it, so mounting refuses a def that
230    /// declares widgets, filters or an access check.
231    #[must_use]
232    pub fn profile_with(self, customize: impl FnOnce(PageDef) -> PageDef + Send + 'static) -> Self {
233        self.auth_page(AuthPage::Profile, customize)
234    }
235
236    /// Moves the two-factor settings page [`Auth::two_factor`](crate::Auth::two_factor) installs
237    /// into the group, at `{prefix}/{area}/two-factor`. The user menu's entry follows it there.
238    #[must_use]
239    pub fn two_factor(self) -> Self {
240        self.two_factor_with(|def| def)
241    }
242
243    /// Moves the two-factor settings page into the group as [`two_factor`](Self::two_factor)
244    /// does, with `customize` adjusting its def as [`profile_with`](Self::profile_with) reads it,
245    /// but for header actions, which mounting refuses: the page renders no form header to hold
246    /// them.
247    #[must_use]
248    pub fn two_factor_with(
249        self,
250        customize: impl FnOnce(PageDef) -> PageDef + Send + 'static,
251    ) -> Self {
252        self.auth_page(AuthPage::TwoFactor, customize)
253    }
254
255    /// Moves the tenant settings page [`Auth::tenant_profile`](crate::Auth::tenant_profile)
256    /// installs into the group, at `{prefix}/{area}/tenant`. The tenant menu's entry follows it
257    /// there. The entry shows to a user who may edit the tenant the request acts for.
258    #[must_use]
259    pub fn tenant_profile(self) -> Self {
260        self.tenant_profile_with(|def| def)
261    }
262
263    /// Moves the tenant settings page into the group as [`tenant_profile`](Self::tenant_profile)
264    /// does, with `customize` adjusting its def as [`profile_with`](Self::profile_with) reads it:
265    /// deleting the tenant is a header action of it.
266    #[must_use]
267    pub fn tenant_profile_with(
268        self,
269        customize: impl FnOnce(PageDef) -> PageDef + Send + 'static,
270    ) -> Self {
271        self.auth_page(AuthPage::TenantProfile, customize)
272    }
273
274    /// Moves the list of the audit log [`Panel::audit_log`](crate::Panel::audit_log) keeps into
275    /// the group, at `{prefix}/{area}/audit-log`, with the def `audit_log` gave it. In a
276    /// [`tenant_group`](Area::tenant_group), the list sits beside the tenant's members and billing.
277    ///
278    /// Mounting refuses an area that places the log when the panel keeps none, and a second group
279    /// that places it.
280    #[must_use]
281    pub fn audit_log(mut self) -> Self {
282        self.entries.push(AreaEntry::AuditLog);
283        self
284    }
285
286    fn auth_page(
287        mut self,
288        page: AuthPage,
289        customize: impl FnOnce(PageDef) -> PageDef + Send + 'static,
290    ) -> Self {
291        self.entries
292            .push(AreaEntry::Auth(page, Box::new(customize)));
293        self
294    }
295}
296
297impl std::fmt::Debug for AreaGroup {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        f.debug_struct("AreaGroup")
300            .field("entries", &self.entries.len())
301            .finish()
302    }
303}
304
305/// Where auth serves its settings pages: under `prefix`, but for those `placed` in an area.
306pub(super) fn settings_paths(
307    prefix: &str,
308    placed: &[(AuthPage, String)],
309) -> crate::auth::SettingsPaths {
310    let mut paths = crate::auth::SettingsPaths::under(prefix);
311    for (page, url) in placed {
312        let path = match page {
313            AuthPage::Profile => &mut paths.profile,
314            AuthPage::TwoFactor => &mut paths.two_factor,
315            AuthPage::TenantProfile => &mut paths.tenant_profile,
316        };
317        path.clone_from(url);
318    }
319    paths
320}