|
1 |
+ |
//! Settings, described rather than built.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! <!-- wiki: quasi-overview -->
|
|
4 |
+ |
//!
|
|
5 |
+ |
//! Sixth screen ported. The shipped screen is `frontend/js/settings.js` and its
|
|
6 |
+ |
//! two delegate modules exactly as before; see [the module above](super) for why
|
|
7 |
+ |
//! both exist at once.
|
|
8 |
+ |
//!
|
|
9 |
+ |
//! The first port whose subject is not the user's data. Every other described
|
|
10 |
+ |
//! screen reads tasks, projects, contacts or a week; this one reads *the app's
|
|
11 |
+ |
//! own settings*, and that turned out to be the interesting part — three of its
|
|
12 |
+ |
//! eight sections are not describable from a route handler at all, for a reason
|
|
13 |
+ |
//! that is structural and worth stating plainly.
|
|
14 |
+ |
//!
|
|
15 |
+ |
//! # The three sections that are here
|
|
16 |
+ |
//!
|
|
17 |
+ |
//! - **Appearance** — the theme.
|
|
18 |
+ |
//! - **Notifications** — the event indicator lead time.
|
|
19 |
+ |
//! - **Planning & Review** — work hours and the two nudge switches.
|
|
20 |
+ |
//!
|
|
21 |
+ |
//! What those six controls have in common is that they are `user_config` keys:
|
|
22 |
+ |
//! rows in a table this app owns, declared as a closed set in
|
|
23 |
+ |
//! [`crate::config_key::CONFIG`]. So the whole of this screen's writing is one
|
|
24 |
+ |
//! route, `POST /settings/config/{key}`, refused by the same `ensure_known` the
|
|
25 |
+ |
//! Tauri command is refused by. A screen that is a key/value editor should read
|
|
26 |
+ |
//! as one.
|
|
27 |
+ |
//!
|
|
28 |
+ |
//! # The five that are not, and why
|
|
29 |
+ |
//!
|
|
30 |
+ |
//! Email, Sync, Sharing, Import & Export and About are left out rather than
|
|
31 |
+ |
//! half-described, and it is one cause rather than five. **A route handler is
|
|
32 |
+ |
//! `fn(&AppState, Params)`, and those sections are about the host rather than
|
|
33 |
+ |
//! about the app.** About is nothing else: the version comes from
|
|
34 |
+ |
//! `window.__TAURI__.app.getVersion()`, the platform from `navigator`, and
|
|
35 |
+ |
//! whether to offer the app-lock switch at all from asking the OS whether
|
|
36 |
+ |
//! biometry is enrolled. Import & Export is native file dialogs. Sync and
|
|
37 |
+ |
//! Sharing reach a network client through commands that take an `AppHandle`.
|
|
38 |
+ |
//!
|
|
39 |
+ |
//! Half of that is the app's own doing and is fixed here: the theme list also
|
|
40 |
+ |
//! needed an `AppHandle`, because the search path is built from the resource and
|
|
41 |
+ |
//! config directories, and Appearance would have been a fourth casualty.
|
|
42 |
+ |
//! [`AppState::theme_dirs`](crate::state::AppState::theme_dirs) now holds that
|
|
43 |
+ |
//! path, resolved once at startup by the only thing that can resolve it. That is
|
|
44 |
+ |
//! the general answer and it is not quasi's to give: `S` is the app's own state,
|
|
45 |
+ |
//! and a host fact a described screen needs is a host fact the app has to put
|
|
46 |
+ |
//! in `S` while it still has a handle to ask.
|
|
47 |
+ |
//!
|
|
48 |
+ |
//! The other half does not yield to it. A version string would, and so would a
|
|
49 |
+ |
//! platform name. Whether biometry is enrolled is asked at the moment it is
|
|
50 |
+ |
//! asked, and a native save dialog returns a value from an interaction — neither
|
|
51 |
+ |
//! is a fact that can be resolved at startup and held. That is filed.
|
|
52 |
+ |
//!
|
|
53 |
+ |
//! # The shape
|
|
54 |
+ |
//!
|
|
55 |
+ |
//! - `GET /settings` — Appearance, which is where the JS opens.
|
|
56 |
+ |
//! - `GET /settings/{section}` — one section.
|
|
57 |
+ |
//! - `POST /settings/config/{key}` — write one config key, under `value`.
|
|
58 |
+ |
//!
|
|
59 |
+ |
//! The section is an address rather than `settings.js:currentSection`, per
|
|
60 |
+ |
//! decision 2. Unlike the projects filters and the weekly review's week it needs
|
|
61 |
+ |
//! no carrying: a write answers with the section it happened in, and the
|
|
62 |
+ |
//! handler knows which that is from the key.
|
|
63 |
+ |
|
|
64 |
+ |
// Handlers take their params by value because `quasi_router::Handler` is a
|
|
65 |
+ |
// plain `fn(&S, Params)` pointer, so the signature is the router's and not a
|
|
66 |
+ |
// choice made here. Same allow, for the same reason, as quasi-axum's tests.
|
|
67 |
+ |
#![allow(clippy::needless_pass_by_value)]
|
|
68 |
+ |
|
|
69 |
+ |
use std::collections::HashMap;
|
|
70 |
+ |
|
|
71 |
+ |
use quasi_router::screen::{Choice, Field, Row};
|
|
72 |
+ |
use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
|
|
73 |
+ |
|
|
74 |
+ |
use crate::commands::{all_config, write_config};
|
|
75 |
+ |
use crate::state::AppState;
|
|
76 |
+ |
|
|
77 |
+ |
#[cfg(test)]
|
|
78 |
+ |
mod tests;
|
|
79 |
+ |
|
|
80 |
+ |
/// A section of the screen: its address, its heading, and the keys it writes.
|
|
81 |
+ |
struct Section {
|
|
82 |
+ |
/// The last path segment, and what the sidebar sends.
|
|
83 |
+ |
slug: &'static str,
|
|
84 |
+ |
/// What the section is called.
|
|
85 |
+ |
title: &'static str,
|
|
86 |
+ |
}
|
|
87 |
+ |
|
|
88 |
+ |
/// The sections that are described, in the sidebar's order.
|
|
89 |
+ |
///
|
|
90 |
+ |
/// The JS sidebar offers eight and this offers three. The five missing ones are
|
|
91 |
+ |
/// absent rather than disabled, for the reason the task overview left Edit out:
|
|
92 |
+ |
/// a control that is drawn and does nothing is worse than a control that is not
|
|
93 |
+ |
/// drawn, and the module header says which and why.
|
|
94 |
+ |
const SECTIONS: [Section; 3] = [
|
|
95 |
+ |
Section {
|
|
96 |
+ |
slug: "appearance",
|
|
97 |
+ |
title: "Appearance",
|
|
98 |
+ |
},
|
|
99 |
+ |
Section {
|
|
100 |
+ |
slug: "notifications",
|
|
101 |
+ |
title: "Notifications",
|
|
102 |
+ |
},
|
|
103 |
+ |
Section {
|
|
104 |
+ |
slug: "planning",
|
|
105 |
+ |
title: "Planning & Review",
|
|
106 |
+ |
},
|
|
107 |
+ |
];
|
|
108 |
+ |
|
|
109 |
+ |
/// What the app falls back to when a key has never been written.
|
|
110 |
+ |
///
|
|
111 |
+ |
/// Stated once here rather than at each read. The JS states each of these
|
|
112 |
+ |
/// inline at its own call site — `|| 'system'`, `|| '15'`, `|| '9'` — which is
|
|
113 |
+ |
/// how `event_lead_minutes` came to have its default written in three places.
|
|
114 |
+ |
fn default_for(key: &str) -> &'static str {
|
|
115 |
+ |
match key {
|
|
116 |
+ |
"theme" => "system",
|
|
117 |
+ |
"event_lead_minutes" => "15",
|
|
118 |
+ |
"work_start_hour" => "9",
|
|
119 |
+ |
"work_end_hour" => "17",
|
|
120 |
+ |
// Both nudges are on unless someone turned them off, which is what the
|
|
121 |
+ |
// JS's `!== 'disabled'` says the long way round.
|
|
122 |
+ |
_ => "enabled",
|
|
123 |
+ |
}
|
|
124 |
+ |
}
|
|
125 |
+ |
|
|
126 |
+ |
/// The value a key currently holds.
|
|
127 |
+ |
fn value_of<'a>(config: &'a HashMap<String, String>, key: &str) -> &'a str {
|
|
128 |
+ |
config.get(key).map_or_else(
|
|
129 |
+ |
|| {
|
|
130 |
+ |
// Borrowed from a `'static`, which outlives `'a`.
|
|
131 |
+ |
default_for(key)
|
|
132 |
+ |
},
|
|
133 |
+ |
String::as_str,
|
|
134 |
+ |
)
|
|
135 |
+ |
}
|
|
136 |
+ |
|
|
137 |
+ |
/// The route that writes one config key.
|
|
138 |
+ |
fn writes(key: &str) -> Action {
|
|
139 |
+ |
Action::post(format!("/settings/config/{key}"))
|
|
140 |
+ |
}
|
|
141 |
+ |
|
|
142 |
+ |
/// A control that stands on its own and writes as soon as it changes.
|
|
143 |
+ |
///
|
|
144 |
+ |
/// [`Node::Field`] and [`Field::changes`] together, which is what finding
|
|
145 |
+ |
/// `14612ed8` was closed for and what this screen is the first consumer of. The
|
|
146 |
+ |
/// JS says the same thing with `data-change` on a bare `<select>` with no form
|
|
147 |
+ |
/// around it; wrapping these in a [`Node::Form`] would describe a submit button
|
|
148 |
+ |
/// that does not exist.
|
|
149 |
+ |
fn setting(field: Field) -> Node {
|
|
150 |
+ |
let key = field.name.clone();
|
|
151 |
+ |
Node::field(field.changes(writes(&key)))
|
|
152 |
+ |
}
|
|
153 |
+ |
|
|
154 |
+ |
/// A select over a fixed set of values, holding the one in force.
|
|
155 |
+ |
fn choice_field(
|
|
156 |
+ |
config: &HashMap<String, String>,
|
|
157 |
+ |
key: &'static str,
|
|
158 |
+ |
label: &str,
|
|
159 |
+ |
options: Vec<Choice>,
|
|
160 |
+ |
) -> Field {
|
|
161 |
+ |
Field::select(key, label, options).value(value_of(config, key))
|
|
162 |
+ |
}
|
|
163 |
+ |
|
|
164 |
+ |
/// The themes on offer.
|
|
165 |
+ |
///
|
|
166 |
+ |
/// # The first finding
|
|
167 |
+ |
///
|
|
168 |
+ |
/// **A set of choices cannot be grouped.** `renderAppearance` puts the themes in
|
|
169 |
+ |
/// four `<optgroup>`s — System, Light Themes, Dark Themes, High Contrast — and
|
|
170 |
+ |
/// [`Choice`] is a value and a label. A flat list of twenty themes with no
|
|
171 |
+ |
/// grouping is a worse control than the one it replaces, so the variant goes
|
|
172 |
+ |
/// into the label, which keeps the fact and loses the structure.
|
|
173 |
+ |
///
|
|
174 |
+ |
/// Small, and filed rather than worked around further: the honest fix is a
|
|
175 |
+ |
/// group on the choice, and it is the same shape in every app that has ever
|
|
176 |
+ |
/// filled a select from more than one source.
|
|
177 |
+ |
///
|
|
178 |
+ |
/// The list itself is read through [`AppState::theme_dirs`], which is the whole
|
|
179 |
+ |
/// reason this section exists at all — see the module header.
|
|
180 |
+ |
fn theme_choices(state: &AppState) -> Vec<Choice> {
|
|
181 |
+ |
let mut choices = vec![Choice::new("system", "Follow System")];
|
|
182 |
+ |
for theme in makeover::list_themes_from_dirs(&state.theme_dirs) {
|
|
183 |
+ |
let variant = match theme.variant.as_str() {
|
|
184 |
+ |
"high-contrast" => "High Contrast",
|
|
185 |
+ |
"light" => "Light",
|
|
186 |
+ |
// makeover's variant is a string and dark is what everything else
|
|
187 |
+ |
// is, which is the same fallback `getThemesByType` makes.
|
|
188 |
+ |
_ => "Dark",
|
|
189 |
+ |
};
|
|
190 |
+ |
choices.push(Choice::new(
|
|
191 |
+ |
&theme.id,
|
|
192 |
+ |
format!("{} ({variant})", theme.name),
|
|
193 |
+ |
));
|
|
194 |
+ |
}
|
|
195 |
+ |
choices
|
|
196 |
+ |
}
|
|
197 |
+ |
|
|
198 |
+ |
/// Appearance.
|
|
199 |
+ |
///
|
|
200 |
+ |
/// # The second finding
|
|
201 |
+ |
///
|
|
202 |
+ |
/// **Import and Export are absent because a file dialog is not an address.**
|
|
203 |
+ |
/// `themes.importTheme` opens a native open-dialog and `themes.exportTheme` a
|
|
204 |
+ |
/// native save-dialog, and both then call a command with the path the user
|
|
205 |
+ |
/// picked. `FieldKind::File` covers picking a file to *submit*, which is the
|
|
206 |
+ |
/// import half and would work here if the write route existed; the export half
|
|
207 |
+ |
/// is a control that asks the host where to put something and then acts, and
|
|
208 |
+ |
/// nothing in the vocabulary names that.
|
|
209 |
+ |
///
|
|
210 |
+ |
/// Left out rather than dangled, to the standard the contacts port set. Filed
|
|
211 |
+ |
/// on quasicoherent alongside the About section's host facts, because it is the
|
|
212 |
+ |
/// same finding wearing different clothes: the description can say what to do
|
|
213 |
+ |
/// and cannot reach what the host knows.
|
|
214 |
+ |
fn appearance(state: &AppState, config: &HashMap<String, String>) -> Vec<Node> {
|
|
215 |
+ |
vec![
|
|
216 |
+ |
Node::section("Appearance"),
|
|
217 |
+ |
setting(
|
|
218 |
+ |
choice_field(config, "theme", "Theme", theme_choices(state))
|
|
219 |
+ |
.hint("Choose a color theme for the interface."),
|
|
220 |
+ |
),
|
|
221 |
+ |
]
|
|
222 |
+ |
}
|
|
223 |
+ |
|
|
224 |
+ |
/// Notifications.
|
|
225 |
+ |
fn notifications(config: &HashMap<String, String>) -> Vec<Node> {
|
|
226 |
+ |
let options = [5, 10, 15, 30, 60]
|
|
227 |
+ |
.into_iter()
|
|
228 |
+ |
.map(|minutes| {
|
|
229 |
+ |
let label = if minutes == 60 {
|
|
230 |
+ |
"1 hour".to_owned()
|
|
231 |
+ |
} else {
|
|
232 |
+ |
format!("{minutes} minutes")
|
|
233 |
+ |
};
|
|
234 |
+ |
// The default is marked in the label because the control cannot
|
|
235 |
+ |
// otherwise say which value it would hold if nobody had chosen. The
|
|
236 |
+ |
// JS marks the same one the same way.
|
|
237 |
+ |
let label = if minutes == 15 {
|
|
238 |
+ |
format!("{label} (default)")
|
|
239 |
+ |
} else {
|
|
240 |
+ |
label
|
|
241 |
+ |
};
|
|
242 |
+ |
Choice::new(minutes.to_string(), label)
|
|
243 |
+ |
})
|
|
244 |
+ |
.collect();
|
|
245 |
+ |
|
|
246 |
+ |
vec![
|
|
247 |
+ |
Node::section("Notifications"),
|
|
248 |
+ |
setting(
|
|
249 |
+ |
choice_field(
|
|
250 |
+ |
config,
|
|
251 |
+ |
"event_lead_minutes",
|
|
252 |
+ |
"Event indicator lead time",
|
|
253 |
+ |
options,
|
|
254 |
+ |
)
|
|
255 |
+ |
.hint("How far in advance the Events tab dot turns yellow."),
|
|
256 |
+ |
),
|
|
257 |
+ |
]
|
|
258 |
+ |
}
|
|
259 |
+ |
|
|
260 |
+ |
/// Every hour of the day, as the clock writes it.
|
|
261 |
+ |
///
|
|
262 |
+ |
/// `buildHourOptions`, which builds the same 24 labels. Twelve-hour with AM and
|
|
263 |
+ |
/// PM because that is what the shipped screen shows; a description that emitted
|
|
264 |
+ |
/// `09:00` would be answering a question about the user's locale that nothing
|
|
265 |
+ |
/// here asked.
|
|
266 |
+ |
fn hour_choices() -> Vec<Choice> {
|
|
267 |
+ |
(0..24)
|
|
268 |
+ |
.map(|hour| {
|
|
269 |
+ |
let label = match hour {
|
|
270 |
+ |
0 => "12:00 AM".to_owned(),
|
|
271 |
+ |
1..=11 => format!("{hour}:00 AM"),
|
|
272 |
+ |
12 => "12:00 PM".to_owned(),
|
|
273 |
+ |
_ => format!("{}:00 PM", hour - 12),
|
|
274 |
+ |
};
|
|
275 |
+ |
Choice::new(hour.to_string(), label)
|
|
276 |
+ |
})
|
|
277 |
+ |
.collect()
|
|
278 |
+ |
}
|
|
279 |
+ |
|
|
280 |
+ |
/// Whether a switch of this kind is on.
|
|
281 |
+ |
fn on_off() -> Vec<Choice> {
|
|
282 |
+ |
vec![
|
|
283 |
+ |
Choice::new("enabled", "Enabled (default)"),
|
|
284 |
+ |
Choice::new("disabled", "Disabled"),
|
|
285 |
+ |
]
|
|
286 |
+ |
}
|
|
287 |
+ |
|
|
288 |
+ |
/// Planning and review.
|
|
289 |
+ |
///
|
|
290 |
+ |
/// # The third finding
|
|
291 |
+ |
///
|
|
292 |
+ |
/// **Two controls answering one question are two controls.** Work hours is a
|
|
293 |
+ |
/// start and an end, drawn by the JS as one labelled row reading "9:00 AM to
|
|
294 |
+ |
/// 5:00 PM" with the word "to" between two selects. The description has one
|
|
295 |
+ |
/// label per field, so it says "Work day starts" and "Work day ends" — every
|
|
296 |
+ |
/// fact kept, and the pairing gone.
|
|
297 |
+ |
///
|
|
298 |
+ |
/// Filed as a finding and expected to be refused, which is why it is written
|
|
299 |
+ |
/// down rather than argued for. A group of fields inside a form is furniture,
|
|
300 |
+ |
/// and the 2026-08-08 row ruling refused the same door on the same grounds. The
|
|
301 |
+ |
/// labels here are the honest answer, not a workaround for a missing member.
|
|
302 |
+ |
fn planning(config: &HashMap<String, String>) -> Vec<Node> {
|
|
303 |
+ |
vec![
|
|
304 |
+ |
Node::section("Planning & Review"),
|
|
305 |
+ |
setting(
|
|
306 |
+ |
choice_field(config, "work_start_hour", "Work day starts", hour_choices())
|
|
307 |
+ |
.hint("Controls when plan and review nudge dots appear."),
|
|
308 |
+ |
),
|
|
309 |
+ |
setting(choice_field(
|
|
310 |
+ |
config,
|
|
311 |
+ |
"work_end_hour",
|
|
312 |
+ |
"Work day ends",
|
|
313 |
+ |
hour_choices(),
|
|
314 |
+ |
)),
|
|
315 |
+ |
setting(choice_field(config, "plan_nudges", "Plan nudges", on_off())),
|
|
316 |
+ |
setting(choice_field(
|
|
317 |
+ |
config,
|
|
318 |
+ |
"review_nudges",
|
|
319 |
+ |
"Review nudges",
|
|
320 |
+ |
on_off(),
|
|
321 |
+ |
)),
|
|
322 |
+ |
]
|
|
323 |
+ |
}
|
|
324 |
+ |
|
|
325 |
+ |
/// The section under this slug, or 404.
|
|
326 |
+ |
fn section_of(slug: &str) -> Result<&'static Section, RouteError> {
|
|
327 |
+ |
SECTIONS
|
|
328 |
+ |
.iter()
|
|
329 |
+ |
.find(|section| section.slug == slug)
|
|
330 |
+ |
.ok_or_else(|| RouteError::not_found("no such settings section"))
|
|
331 |
+ |
}
|
|
332 |
+ |
|
|
333 |
+ |
/// Which section a config key belongs to, so a write can answer with it.
|
|
334 |
+ |
///
|
|
335 |
+ |
/// A match on the key rather than a param the control carries: the section a
|
|
336 |
+ |
/// setting sits in is a fact about the setting, and threading it through every
|
|
337 |
+ |
/// action would be the screen telling the handler something the handler already
|
|
338 |
+ |
/// knows. An unknown key never reaches here — [`write_config`] refuses it first.
|
|
339 |
+ |
fn section_for_key(key: &str) -> &'static str {
|
|
340 |
+ |
match key {
|
|
341 |
+ |
"event_lead_minutes" => "notifications",
|
|
342 |
+ |
"work_start_hour" | "work_end_hour" | "plan_nudges" | "review_nudges" => "planning",
|
|
343 |
+ |
_ => "appearance",
|
|
344 |
+ |
}
|
|
345 |
+ |
}
|
|
346 |
+ |
|
|
347 |
+ |
/// The whole screen, showing one section.
|
|
348 |
+ |
fn screen(state: &AppState, slug: &str) -> Result<Screen, RouteError> {
|
|
349 |
+ |
let section = section_of(slug)?;
|
|
350 |
+ |
let config = all_config(state).map_err(|error| RouteError::internal(error.to_string()))?;
|
|
351 |
+ |
|
|
352 |
+ |
// The first port to use `Region::Sidebar` for what it is named for. The
|
|
353 |
+ |
// weekly review took `SidebarContent` for a band and one pane; here the
|
|
354 |
+ |
// sidebar is the section nav, which is what `settings.js` draws as
|
|
355 |
+ |
// `.settings-nav-item` buttons and tracks with an `active` class.
|
|
356 |
+ |
let nav = Slot::new("settings-nav", RegionKind::Sidebar).with(Node::list(SECTIONS.iter().map(
|
|
357 |
+ |
|item| {
|
|
358 |
+ |
let mut row =
|
|
359 |
+ |
Row::new(item.title).activate(Action::get(format!("/settings/{}", item.slug)));
|
|
360 |
+ |
// `current` and not `selected`: this is the app's own pointer at
|
|
361 |
+ |
// what the pane is showing, which is the distinction the 2026-08-08
|
|
362 |
+ |
// rename drew, and the first place in the port where the sidebar
|
|
363 |
+ |
// half of it is what is wanted. Set on the field because the two
|
|
364 |
+ |
// have no paired constructor the way `toggling` pairs the other
|
|
365 |
+ |
// two, and inventing one for a plain bool would be noise.
|
|
366 |
+ |
row.current = item.slug == section.slug;
|
|
367 |
+ |
row
|
|
368 |
+ |
},
|
|
369 |
+ |
)));
|
|
370 |
+ |
|
|
371 |
+ |
let mut pane = Slot::new("settings-content", RegionKind::Pane);
|
|
372 |
+ |
pane = pane.extend(match section.slug {
|
|
373 |
+ |
"notifications" => notifications(&config),
|
|
374 |
+ |
"planning" => planning(&config),
|
|
375 |
+ |
_ => appearance(state, &config),
|
|
376 |
+ |
});
|
|
377 |
+ |
|
|
378 |
+ |
Ok(Screen::sidebar_content("Settings").with(nav).with(pane))
|
|
379 |
+ |
}
|
|
380 |
+ |
|
|
381 |
+ |
/// Appearance, which is where the JS opens.
|
|
382 |
+ |
fn index(state: &AppState, _params: quasi_router::Params) -> Result<Response, RouteError> {
|
|
383 |
+ |
Ok(screen(state, "appearance")?.into())
|
|
384 |
+ |
}
|
|
385 |
+ |
|
|
386 |
+ |
/// One section.
|
|
387 |
+ |
fn section(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
|
|
388 |
+ |
let slug = params
|
|
389 |
+ |
.get("section")
|
|
390 |
+ |
.ok_or_else(|| RouteError::not_found("no section"))?;
|
|
391 |
+ |
Ok(screen(state, slug)?.into())
|
|
392 |
+ |
}
|
|
393 |
+ |
|
|
394 |
+ |
/// Write one config key.
|
|
395 |
+ |
///
|
|
396 |
+ |
/// One route for every control on the screen, because every control on the
|
|
397 |
+ |
/// screen writes one `user_config` row. The closed set is [`write_config`]'s to
|
|
398 |
+ |
/// enforce, so a key the spec does not declare is refused here exactly as it is
|
|
399 |
+ |
/// refused through the command, and the screen does not carry a second list of
|
|
400 |
+ |
/// what it is willing to name.
|
|
401 |
+ |
///
|
|
402 |
+ |
/// A refusal is an [`Internal`](quasi_router::Class::Internal) rather than the
|
|
403 |
+ |
/// field carrying its own error, and rather than the 400 that is not there to
|
|
404 |
+ |
/// reach for. `RouteError` has four classes and none of them is "the caller
|
|
405 |
+ |
/// asked for something malformed", which is a real gap in general and the right
|
|
406 |
+ |
/// answer here: every value this route can receive was put on the control by
|
|
407 |
+ |
/// this screen, so an undeclared key or a missing value is a bug in the
|
|
408 |
+ |
/// description and not in what the user chose. `Field::error` is for the other
|
|
409 |
+ |
/// case, which this screen does not have — nothing here is typed.
|
|
410 |
+ |
fn set(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
|
|
411 |
+ |
let key = params
|
|
412 |
+ |
.get("key")
|
|
413 |
+ |
.ok_or_else(|| RouteError::not_found("no config key"))?
|
|
414 |
+ |
.to_owned();
|
|
415 |
+ |
let value = params
|
|
416 |
+ |
.get(Node::SELECTED)
|
|
417 |
+ |
.ok_or_else(|| RouteError::internal("the control sent no value"))?;
|
|
418 |
+ |
|
|
419 |
+ |
write_config(state, &key, value).map_err(|error| RouteError::internal(error.to_string()))?;
|
|
420 |
+ |
|
|
421 |
+ |
// Answer with the section the setting lives in, re-read, for the reason
|
|
422 |
+ |
// every other port re-reads: the write is the database's to confirm.
|
|
423 |
+ |
Ok(screen(state, section_for_key(&key))?.into())
|
|
424 |
+ |
}
|
|
425 |
+ |
|
|
426 |
+ |
/// The settings screen's routes.
|
|
427 |
+ |
#[must_use]
|
|
428 |
+ |
pub fn routes(router: Router<AppState>) -> Router<AppState> {
|
|
429 |
+ |
router
|
|
430 |
+ |
.get("/settings", index)
|
|
431 |
+ |
.get("/settings/:section", section)
|
|
432 |
+ |
.post("/settings/config/:key", set)
|
|
433 |
+ |
}
|