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}