pub struct MapSpec {
pub key_label: String,
pub value_label: String,
pub key_options: Vec<SelectOption>,
pub value_options: Vec<SelectOption>,
pub allow_new_keys: bool,
pub allow_new_values: bool,
pub chip: ChipContent,
}Expand description
The extra description a map column carries. A bundle renders one chip per
entry as key: value, drops an entry whose value is cleared, and writes a
map that empties as an absent field.
A key option carries a label of its own where the stored key is not what a
reader should see—a code beside the title it stands for, say—so
key_options takes the same { value, label } pairs a select’s options do,
and a bundle shows the label in place of the value it stores.
Both option lists are always written, empty or not, because together with
the two allow_new_ flags they are what the control is made of. This is
unlike a select’s options, which is omitted when empty because a select
carries options_by instead.
Fields§
§key_label: String§value_label: String§key_options: Vec<SelectOption>§value_options: Vec<SelectOption>§allow_new_keys: boolLet a key be typed that key_options does not list.
allow_new_values: boolLet a value be typed that value_options does not list, which makes
those options suggestions rather than the whole choice.
chip: ChipContentWhat a chip shows before the value. Unlike the fields above, which together are what the control is made of, this is a preference about how a cell reads, so the usual case is left out of the JSON.
Implementations§
Source§impl MapSpec
impl MapSpec
Sourcepub fn new(key_label: impl Into<String>, value_label: impl Into<String>) -> Self
pub fn new(key_label: impl Into<String>, value_label: impl Into<String>) -> Self
Examples found in repository?
125 fn schema(&self, ctx: &Context) -> Result<Schema, ApiError> {
126 let genres = Self::genres(ctx)?;
127 let branches = Self::branches(ctx)?;
128
129 let mut names: Vec<&str> = genres.iter().map(|g| g.genre.as_str()).collect();
130 names.sort_unstable();
131 names.dedup();
132
133 let mut by_genre = OptionsBy::new("genre");
134 for genre in &names {
135 let subgenres: Vec<&str> = genres
136 .iter()
137 .filter(|g| g.genre == *genre)
138 .map(|g| g.subgenre.as_str())
139 .collect();
140 by_genre.insert(*genre, subgenres);
141 }
142
143 // Wide enough for the longest subgenre there is, computed here so the
144 // browser does not have to measure anything. It is the count of
145 // characters, nothing more: what the control puts around them is the
146 // bundle's business.
147 let widest = genres.iter().map(|g| g.subgenre.len()).max().unwrap_or(0);
148 let width_ch = u16::try_from(widest.max(8)).unwrap_or(u16::MAX);
149
150 // A key that shows a branch's name and stores its code.
151 let branch_keys: Vec<SelectOption> = branches
152 .iter()
153 .map(|b| SelectOption::labelled(&b.code, &b.name))
154 .collect();
155
156 let mut publishers: Vec<String> = ctx
157 .optional_rows::<Book>(BOOKS_FILE)?
158 .iter()
159 .map(|b| b.publisher.trim().to_string())
160 .filter(|p| !p.is_empty())
161 .collect();
162 publishers.sort_unstable();
163 publishers.dedup();
164
165 Ok(Schema::new([
166 Column::string("title", "Title").width_ch(30),
167 Column::string("author_first", "First").width_ch(12),
168 Column::string("author_last", "Last").width_ch(14),
169 Column::select("genre", "Genre", names)
170 .allow_empty()
171 .cascades_to(["subgenre"]),
172 Column::select_by("subgenre", "Subgenre", by_genre)
173 .allow_empty()
174 .width_ch(width_ch),
175 Column::select(
176 "edition",
177 "Edition",
178 [
179 SelectOption::labelled("1", "First (1)"),
180 SelectOption::labelled("2", "Second (2)"),
181 SelectOption::labelled("3", "Third (3)"),
182 ],
183 )
184 .allow_empty()
185 .numeric_value(),
186 // A year is a whole number; a rating is not, which is what the
187 // absence of int_only means.
188 Column::number("year", "Year").int_only().width_ch(4),
189 Column::number("copies", "Copies").int_only().width_ch(3),
190 Column::number("rating", "Rating").width_ch(3),
191 Column::boolean("lent", "Lent"),
192 Column::string("publisher", "Publisher")
193 .width_ch(20)
194 .datalist("publishers"),
195 Column::string("donor", "Donated by")
196 .width_ch(20)
197 .datalist("reader-names"),
198 Column::spaced_string("call_number", "Call no.").width_ch(14),
199 Column::string("pronunciation", "Say")
200 .width_ch(16)
201 .speak(Speak::new(
202 "http://127.0.0.1:8765/say?text={value}",
203 "table-editor-speech-url",
204 )),
205 Column::map(
206 "shelved",
207 "Shelved",
208 MapSpec::new("Branch", "Copies")
209 .chips_show_key()
210 .key_options(branch_keys)
211 .value_options([
212 SelectOption::labelled("one", "One"),
213 SelectOption::labelled("several", "Several"),
214 SelectOption::labelled("many", "Many"),
215 ]),
216 ),
217 Column::string("due", "Due").width_ch(10),
218 Column::string("link", "Catalogue").width_ch(30),
219 Column::computed("shelf", "Shelf mark", "shelf").width_ch(18),
220 Column::text("notes", "Notes").wide(),
221 ])
222 .sortable()
223 .datalist("publishers", Datalist::fixed(publishers))
224 .datalist(
225 "reader-names",
226 Datalist::from_rows(["author_last", "author_first"], ", "),
227 )
228 .new_row(
229 NewRow::new()
230 .with("title", "")
231 .with("author_first", "")
232 .with("author_last", "")
233 .with("genre", "")
234 .with("subgenre", "")
235 .with("publisher", "")
236 .with("donor", "")
237 .with("call_number", "")
238 .with("pronunciation", "")
239 .with("due", "")
240 .with("link", "")
241 .with("notes", "")
242 .with("copies", 1)
243 .carry_forward(["genre", "subgenre", "publisher"]),
244 ))
245 }
246
247 fn validate(&self, rows: &[Book], ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
248 let genres = Self::genres(ctx)?;
249 let branches = Self::branches(ctx)?;
250 let mut errors = Vec::new();
251
252 for (idx, row) in rows.iter().enumerate() {
253 let line = idx + 1;
254 if row.title.trim().is_empty() {
255 errors.push(ValidationError::field(
256 line,
257 "title",
258 "a book needs a title",
259 ));
260 }
261
262 // A subgenre stands or falls with the genre it was chosen under.
263 if !genres.is_empty() && !row.subgenre.is_empty() {
264 let known = genres
265 .iter()
266 .any(|g| g.genre == row.genre && g.subgenre == row.subgenre);
267 if !known {
268 errors.push(ValidationError::field(
269 line,
270 "subgenre",
271 format!("not a subgenre of {}", row.genre),
272 ));
273 }
274 }
275
276 if !branches.is_empty() {
277 for branch in row.shelved.keys() {
278 if !branches.iter().any(|b| b.code == *branch) {
279 errors.push(ValidationError::field(
280 line,
281 "shelved",
282 format!("no branch has the code {branch}"),
283 ));
284 }
285 }
286 }
287 }
288
289 Ok(errors)
290 }
291
292 fn derive(&self, rows: &[Book], _ctx: &Context) -> Result<Vec<Value>, ApiError> {
293 Ok(rows
294 .iter()
295 .map(|row| json!({ "shelf": Self::shelf_mark(row) }))
296 .collect())
297 }
298
299 fn siblings(&self, ctx: &Context) -> Result<Value, ApiError> {
300 Ok(json!({ "genres": Self::genres(ctx)? }))
301 }
302}
303
304// ── Genres ──────────────────────────────────────────────────────────────────
305
306struct Genres;
307
308impl TableLogic for Genres {
309 type Row = Genre;
310
311 fn name(&self) -> &'static str {
312 "genres"
313 }
314
315 fn file(&self) -> &'static str {
316 GENRES_FILE
317 }
318
319 fn title(&self) -> &'static str {
320 "Genres"
321 }
322
323 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
324 Ok(Schema::new([
325 Column::string("genre", "Genre").width_ch(18),
326 Column::string("subgenre", "Subgenre").width_ch(22),
327 ])
328 .sortable()
329 .new_row(
330 NewRow::new()
331 .with("genre", "")
332 .with("subgenre", "")
333 .carry_forward(["genre"]),
334 ))
335 }
336
337 fn validate(&self, rows: &[Genre], _ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
338 let mut errors = Vec::new();
339 for (idx, row) in rows.iter().enumerate() {
340 if row.genre.trim().is_empty() {
341 errors.push(ValidationError::field(
342 idx + 1,
343 "genre",
344 "a genre is needed",
345 ));
346 }
347 if row.subgenre.trim().is_empty() {
348 errors.push(ValidationError::field(
349 idx + 1,
350 "subgenre",
351 "a subgenre is needed",
352 ));
353 }
354 }
355 Ok(errors)
356 }
357}
358
359// ── Branches ────────────────────────────────────────────────────────────────
360
361struct Branches;
362
363impl TableLogic for Branches {
364 type Row = Branch;
365
366 fn name(&self) -> &'static str {
367 "branches"
368 }
369
370 fn file(&self) -> &'static str {
371 BRANCHES_FILE
372 }
373
374 fn title(&self) -> &'static str {
375 "Branches"
376 }
377
378 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
379 // Row order here is the order the branches are listed in, which is a
380 // choice the table makes, so this table is not sortable and rows are
381 // dragged into place instead.
382 Ok(Schema::new([
383 Column::string("code", "Code").width_ch(3),
384 Column::string("name", "Name").width_ch(22),
385 Column::string("librarian_first", "Librarian").width_ch(8),
386 Column::string("librarian_last", "Surname")
387 .width_ch(8)
388 .datalist("librarian-names"),
389 Column::number("staff", "Staff").int_only().width_ch(2),
390 Column::boolean("open", "Open"),
391 Column::map(
392 "hours",
393 "Hours",
394 MapSpec::new("Day", "Hours")
395 .key_options(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"])
396 .value_options([
397 SelectOption::new("09:00-17:00"),
398 SelectOption::new("12:00-20:00"),
399 ])
400 .allow_new_keys()
401 .allow_new_values(),
402 ),
403 ])
404 .datalist(
405 "librarian-names",
406 Datalist::from_rows(["librarian_last"], " "),
407 )
408 .new_row(
409 NewRow::new()
410 .with("code", "")
411 .with("name", "")
412 .with("librarian_first", "")
413 .with("librarian_last", ""),
414 ))
415 }Sourcepub fn key_options(
self,
keys: impl IntoIterator<Item = impl Into<SelectOption>>,
) -> Self
pub fn key_options( self, keys: impl IntoIterator<Item = impl Into<SelectOption>>, ) -> Self
The keys a bundle offers. A plain string is a key that shows itself; a
SelectOption::labelled key is shown by its label and stored by its
value.
Examples found in repository?
125 fn schema(&self, ctx: &Context) -> Result<Schema, ApiError> {
126 let genres = Self::genres(ctx)?;
127 let branches = Self::branches(ctx)?;
128
129 let mut names: Vec<&str> = genres.iter().map(|g| g.genre.as_str()).collect();
130 names.sort_unstable();
131 names.dedup();
132
133 let mut by_genre = OptionsBy::new("genre");
134 for genre in &names {
135 let subgenres: Vec<&str> = genres
136 .iter()
137 .filter(|g| g.genre == *genre)
138 .map(|g| g.subgenre.as_str())
139 .collect();
140 by_genre.insert(*genre, subgenres);
141 }
142
143 // Wide enough for the longest subgenre there is, computed here so the
144 // browser does not have to measure anything. It is the count of
145 // characters, nothing more: what the control puts around them is the
146 // bundle's business.
147 let widest = genres.iter().map(|g| g.subgenre.len()).max().unwrap_or(0);
148 let width_ch = u16::try_from(widest.max(8)).unwrap_or(u16::MAX);
149
150 // A key that shows a branch's name and stores its code.
151 let branch_keys: Vec<SelectOption> = branches
152 .iter()
153 .map(|b| SelectOption::labelled(&b.code, &b.name))
154 .collect();
155
156 let mut publishers: Vec<String> = ctx
157 .optional_rows::<Book>(BOOKS_FILE)?
158 .iter()
159 .map(|b| b.publisher.trim().to_string())
160 .filter(|p| !p.is_empty())
161 .collect();
162 publishers.sort_unstable();
163 publishers.dedup();
164
165 Ok(Schema::new([
166 Column::string("title", "Title").width_ch(30),
167 Column::string("author_first", "First").width_ch(12),
168 Column::string("author_last", "Last").width_ch(14),
169 Column::select("genre", "Genre", names)
170 .allow_empty()
171 .cascades_to(["subgenre"]),
172 Column::select_by("subgenre", "Subgenre", by_genre)
173 .allow_empty()
174 .width_ch(width_ch),
175 Column::select(
176 "edition",
177 "Edition",
178 [
179 SelectOption::labelled("1", "First (1)"),
180 SelectOption::labelled("2", "Second (2)"),
181 SelectOption::labelled("3", "Third (3)"),
182 ],
183 )
184 .allow_empty()
185 .numeric_value(),
186 // A year is a whole number; a rating is not, which is what the
187 // absence of int_only means.
188 Column::number("year", "Year").int_only().width_ch(4),
189 Column::number("copies", "Copies").int_only().width_ch(3),
190 Column::number("rating", "Rating").width_ch(3),
191 Column::boolean("lent", "Lent"),
192 Column::string("publisher", "Publisher")
193 .width_ch(20)
194 .datalist("publishers"),
195 Column::string("donor", "Donated by")
196 .width_ch(20)
197 .datalist("reader-names"),
198 Column::spaced_string("call_number", "Call no.").width_ch(14),
199 Column::string("pronunciation", "Say")
200 .width_ch(16)
201 .speak(Speak::new(
202 "http://127.0.0.1:8765/say?text={value}",
203 "table-editor-speech-url",
204 )),
205 Column::map(
206 "shelved",
207 "Shelved",
208 MapSpec::new("Branch", "Copies")
209 .chips_show_key()
210 .key_options(branch_keys)
211 .value_options([
212 SelectOption::labelled("one", "One"),
213 SelectOption::labelled("several", "Several"),
214 SelectOption::labelled("many", "Many"),
215 ]),
216 ),
217 Column::string("due", "Due").width_ch(10),
218 Column::string("link", "Catalogue").width_ch(30),
219 Column::computed("shelf", "Shelf mark", "shelf").width_ch(18),
220 Column::text("notes", "Notes").wide(),
221 ])
222 .sortable()
223 .datalist("publishers", Datalist::fixed(publishers))
224 .datalist(
225 "reader-names",
226 Datalist::from_rows(["author_last", "author_first"], ", "),
227 )
228 .new_row(
229 NewRow::new()
230 .with("title", "")
231 .with("author_first", "")
232 .with("author_last", "")
233 .with("genre", "")
234 .with("subgenre", "")
235 .with("publisher", "")
236 .with("donor", "")
237 .with("call_number", "")
238 .with("pronunciation", "")
239 .with("due", "")
240 .with("link", "")
241 .with("notes", "")
242 .with("copies", 1)
243 .carry_forward(["genre", "subgenre", "publisher"]),
244 ))
245 }
246
247 fn validate(&self, rows: &[Book], ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
248 let genres = Self::genres(ctx)?;
249 let branches = Self::branches(ctx)?;
250 let mut errors = Vec::new();
251
252 for (idx, row) in rows.iter().enumerate() {
253 let line = idx + 1;
254 if row.title.trim().is_empty() {
255 errors.push(ValidationError::field(
256 line,
257 "title",
258 "a book needs a title",
259 ));
260 }
261
262 // A subgenre stands or falls with the genre it was chosen under.
263 if !genres.is_empty() && !row.subgenre.is_empty() {
264 let known = genres
265 .iter()
266 .any(|g| g.genre == row.genre && g.subgenre == row.subgenre);
267 if !known {
268 errors.push(ValidationError::field(
269 line,
270 "subgenre",
271 format!("not a subgenre of {}", row.genre),
272 ));
273 }
274 }
275
276 if !branches.is_empty() {
277 for branch in row.shelved.keys() {
278 if !branches.iter().any(|b| b.code == *branch) {
279 errors.push(ValidationError::field(
280 line,
281 "shelved",
282 format!("no branch has the code {branch}"),
283 ));
284 }
285 }
286 }
287 }
288
289 Ok(errors)
290 }
291
292 fn derive(&self, rows: &[Book], _ctx: &Context) -> Result<Vec<Value>, ApiError> {
293 Ok(rows
294 .iter()
295 .map(|row| json!({ "shelf": Self::shelf_mark(row) }))
296 .collect())
297 }
298
299 fn siblings(&self, ctx: &Context) -> Result<Value, ApiError> {
300 Ok(json!({ "genres": Self::genres(ctx)? }))
301 }
302}
303
304// ── Genres ──────────────────────────────────────────────────────────────────
305
306struct Genres;
307
308impl TableLogic for Genres {
309 type Row = Genre;
310
311 fn name(&self) -> &'static str {
312 "genres"
313 }
314
315 fn file(&self) -> &'static str {
316 GENRES_FILE
317 }
318
319 fn title(&self) -> &'static str {
320 "Genres"
321 }
322
323 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
324 Ok(Schema::new([
325 Column::string("genre", "Genre").width_ch(18),
326 Column::string("subgenre", "Subgenre").width_ch(22),
327 ])
328 .sortable()
329 .new_row(
330 NewRow::new()
331 .with("genre", "")
332 .with("subgenre", "")
333 .carry_forward(["genre"]),
334 ))
335 }
336
337 fn validate(&self, rows: &[Genre], _ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
338 let mut errors = Vec::new();
339 for (idx, row) in rows.iter().enumerate() {
340 if row.genre.trim().is_empty() {
341 errors.push(ValidationError::field(
342 idx + 1,
343 "genre",
344 "a genre is needed",
345 ));
346 }
347 if row.subgenre.trim().is_empty() {
348 errors.push(ValidationError::field(
349 idx + 1,
350 "subgenre",
351 "a subgenre is needed",
352 ));
353 }
354 }
355 Ok(errors)
356 }
357}
358
359// ── Branches ────────────────────────────────────────────────────────────────
360
361struct Branches;
362
363impl TableLogic for Branches {
364 type Row = Branch;
365
366 fn name(&self) -> &'static str {
367 "branches"
368 }
369
370 fn file(&self) -> &'static str {
371 BRANCHES_FILE
372 }
373
374 fn title(&self) -> &'static str {
375 "Branches"
376 }
377
378 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
379 // Row order here is the order the branches are listed in, which is a
380 // choice the table makes, so this table is not sortable and rows are
381 // dragged into place instead.
382 Ok(Schema::new([
383 Column::string("code", "Code").width_ch(3),
384 Column::string("name", "Name").width_ch(22),
385 Column::string("librarian_first", "Librarian").width_ch(8),
386 Column::string("librarian_last", "Surname")
387 .width_ch(8)
388 .datalist("librarian-names"),
389 Column::number("staff", "Staff").int_only().width_ch(2),
390 Column::boolean("open", "Open"),
391 Column::map(
392 "hours",
393 "Hours",
394 MapSpec::new("Day", "Hours")
395 .key_options(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"])
396 .value_options([
397 SelectOption::new("09:00-17:00"),
398 SelectOption::new("12:00-20:00"),
399 ])
400 .allow_new_keys()
401 .allow_new_values(),
402 ),
403 ])
404 .datalist(
405 "librarian-names",
406 Datalist::from_rows(["librarian_last"], " "),
407 )
408 .new_row(
409 NewRow::new()
410 .with("code", "")
411 .with("name", "")
412 .with("librarian_first", "")
413 .with("librarian_last", ""),
414 ))
415 }Sourcepub fn value_options(
self,
values: impl IntoIterator<Item = impl Into<SelectOption>>,
) -> Self
pub fn value_options( self, values: impl IntoIterator<Item = impl Into<SelectOption>>, ) -> Self
Examples found in repository?
125 fn schema(&self, ctx: &Context) -> Result<Schema, ApiError> {
126 let genres = Self::genres(ctx)?;
127 let branches = Self::branches(ctx)?;
128
129 let mut names: Vec<&str> = genres.iter().map(|g| g.genre.as_str()).collect();
130 names.sort_unstable();
131 names.dedup();
132
133 let mut by_genre = OptionsBy::new("genre");
134 for genre in &names {
135 let subgenres: Vec<&str> = genres
136 .iter()
137 .filter(|g| g.genre == *genre)
138 .map(|g| g.subgenre.as_str())
139 .collect();
140 by_genre.insert(*genre, subgenres);
141 }
142
143 // Wide enough for the longest subgenre there is, computed here so the
144 // browser does not have to measure anything. It is the count of
145 // characters, nothing more: what the control puts around them is the
146 // bundle's business.
147 let widest = genres.iter().map(|g| g.subgenre.len()).max().unwrap_or(0);
148 let width_ch = u16::try_from(widest.max(8)).unwrap_or(u16::MAX);
149
150 // A key that shows a branch's name and stores its code.
151 let branch_keys: Vec<SelectOption> = branches
152 .iter()
153 .map(|b| SelectOption::labelled(&b.code, &b.name))
154 .collect();
155
156 let mut publishers: Vec<String> = ctx
157 .optional_rows::<Book>(BOOKS_FILE)?
158 .iter()
159 .map(|b| b.publisher.trim().to_string())
160 .filter(|p| !p.is_empty())
161 .collect();
162 publishers.sort_unstable();
163 publishers.dedup();
164
165 Ok(Schema::new([
166 Column::string("title", "Title").width_ch(30),
167 Column::string("author_first", "First").width_ch(12),
168 Column::string("author_last", "Last").width_ch(14),
169 Column::select("genre", "Genre", names)
170 .allow_empty()
171 .cascades_to(["subgenre"]),
172 Column::select_by("subgenre", "Subgenre", by_genre)
173 .allow_empty()
174 .width_ch(width_ch),
175 Column::select(
176 "edition",
177 "Edition",
178 [
179 SelectOption::labelled("1", "First (1)"),
180 SelectOption::labelled("2", "Second (2)"),
181 SelectOption::labelled("3", "Third (3)"),
182 ],
183 )
184 .allow_empty()
185 .numeric_value(),
186 // A year is a whole number; a rating is not, which is what the
187 // absence of int_only means.
188 Column::number("year", "Year").int_only().width_ch(4),
189 Column::number("copies", "Copies").int_only().width_ch(3),
190 Column::number("rating", "Rating").width_ch(3),
191 Column::boolean("lent", "Lent"),
192 Column::string("publisher", "Publisher")
193 .width_ch(20)
194 .datalist("publishers"),
195 Column::string("donor", "Donated by")
196 .width_ch(20)
197 .datalist("reader-names"),
198 Column::spaced_string("call_number", "Call no.").width_ch(14),
199 Column::string("pronunciation", "Say")
200 .width_ch(16)
201 .speak(Speak::new(
202 "http://127.0.0.1:8765/say?text={value}",
203 "table-editor-speech-url",
204 )),
205 Column::map(
206 "shelved",
207 "Shelved",
208 MapSpec::new("Branch", "Copies")
209 .chips_show_key()
210 .key_options(branch_keys)
211 .value_options([
212 SelectOption::labelled("one", "One"),
213 SelectOption::labelled("several", "Several"),
214 SelectOption::labelled("many", "Many"),
215 ]),
216 ),
217 Column::string("due", "Due").width_ch(10),
218 Column::string("link", "Catalogue").width_ch(30),
219 Column::computed("shelf", "Shelf mark", "shelf").width_ch(18),
220 Column::text("notes", "Notes").wide(),
221 ])
222 .sortable()
223 .datalist("publishers", Datalist::fixed(publishers))
224 .datalist(
225 "reader-names",
226 Datalist::from_rows(["author_last", "author_first"], ", "),
227 )
228 .new_row(
229 NewRow::new()
230 .with("title", "")
231 .with("author_first", "")
232 .with("author_last", "")
233 .with("genre", "")
234 .with("subgenre", "")
235 .with("publisher", "")
236 .with("donor", "")
237 .with("call_number", "")
238 .with("pronunciation", "")
239 .with("due", "")
240 .with("link", "")
241 .with("notes", "")
242 .with("copies", 1)
243 .carry_forward(["genre", "subgenre", "publisher"]),
244 ))
245 }
246
247 fn validate(&self, rows: &[Book], ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
248 let genres = Self::genres(ctx)?;
249 let branches = Self::branches(ctx)?;
250 let mut errors = Vec::new();
251
252 for (idx, row) in rows.iter().enumerate() {
253 let line = idx + 1;
254 if row.title.trim().is_empty() {
255 errors.push(ValidationError::field(
256 line,
257 "title",
258 "a book needs a title",
259 ));
260 }
261
262 // A subgenre stands or falls with the genre it was chosen under.
263 if !genres.is_empty() && !row.subgenre.is_empty() {
264 let known = genres
265 .iter()
266 .any(|g| g.genre == row.genre && g.subgenre == row.subgenre);
267 if !known {
268 errors.push(ValidationError::field(
269 line,
270 "subgenre",
271 format!("not a subgenre of {}", row.genre),
272 ));
273 }
274 }
275
276 if !branches.is_empty() {
277 for branch in row.shelved.keys() {
278 if !branches.iter().any(|b| b.code == *branch) {
279 errors.push(ValidationError::field(
280 line,
281 "shelved",
282 format!("no branch has the code {branch}"),
283 ));
284 }
285 }
286 }
287 }
288
289 Ok(errors)
290 }
291
292 fn derive(&self, rows: &[Book], _ctx: &Context) -> Result<Vec<Value>, ApiError> {
293 Ok(rows
294 .iter()
295 .map(|row| json!({ "shelf": Self::shelf_mark(row) }))
296 .collect())
297 }
298
299 fn siblings(&self, ctx: &Context) -> Result<Value, ApiError> {
300 Ok(json!({ "genres": Self::genres(ctx)? }))
301 }
302}
303
304// ── Genres ──────────────────────────────────────────────────────────────────
305
306struct Genres;
307
308impl TableLogic for Genres {
309 type Row = Genre;
310
311 fn name(&self) -> &'static str {
312 "genres"
313 }
314
315 fn file(&self) -> &'static str {
316 GENRES_FILE
317 }
318
319 fn title(&self) -> &'static str {
320 "Genres"
321 }
322
323 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
324 Ok(Schema::new([
325 Column::string("genre", "Genre").width_ch(18),
326 Column::string("subgenre", "Subgenre").width_ch(22),
327 ])
328 .sortable()
329 .new_row(
330 NewRow::new()
331 .with("genre", "")
332 .with("subgenre", "")
333 .carry_forward(["genre"]),
334 ))
335 }
336
337 fn validate(&self, rows: &[Genre], _ctx: &Context) -> Result<Vec<ValidationError>, ApiError> {
338 let mut errors = Vec::new();
339 for (idx, row) in rows.iter().enumerate() {
340 if row.genre.trim().is_empty() {
341 errors.push(ValidationError::field(
342 idx + 1,
343 "genre",
344 "a genre is needed",
345 ));
346 }
347 if row.subgenre.trim().is_empty() {
348 errors.push(ValidationError::field(
349 idx + 1,
350 "subgenre",
351 "a subgenre is needed",
352 ));
353 }
354 }
355 Ok(errors)
356 }
357}
358
359// ── Branches ────────────────────────────────────────────────────────────────
360
361struct Branches;
362
363impl TableLogic for Branches {
364 type Row = Branch;
365
366 fn name(&self) -> &'static str {
367 "branches"
368 }
369
370 fn file(&self) -> &'static str {
371 BRANCHES_FILE
372 }
373
374 fn title(&self) -> &'static str {
375 "Branches"
376 }
377
378 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
379 // Row order here is the order the branches are listed in, which is a
380 // choice the table makes, so this table is not sortable and rows are
381 // dragged into place instead.
382 Ok(Schema::new([
383 Column::string("code", "Code").width_ch(3),
384 Column::string("name", "Name").width_ch(22),
385 Column::string("librarian_first", "Librarian").width_ch(8),
386 Column::string("librarian_last", "Surname")
387 .width_ch(8)
388 .datalist("librarian-names"),
389 Column::number("staff", "Staff").int_only().width_ch(2),
390 Column::boolean("open", "Open"),
391 Column::map(
392 "hours",
393 "Hours",
394 MapSpec::new("Day", "Hours")
395 .key_options(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"])
396 .value_options([
397 SelectOption::new("09:00-17:00"),
398 SelectOption::new("12:00-20:00"),
399 ])
400 .allow_new_keys()
401 .allow_new_values(),
402 ),
403 ])
404 .datalist(
405 "librarian-names",
406 Datalist::from_rows(["librarian_last"], " "),
407 )
408 .new_row(
409 NewRow::new()
410 .with("code", "")
411 .with("name", "")
412 .with("librarian_first", "")
413 .with("librarian_last", ""),
414 ))
415 }Sourcepub fn allow_new_keys(self) -> Self
pub fn allow_new_keys(self) -> Self
Examples found in repository?
378 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
379 // Row order here is the order the branches are listed in, which is a
380 // choice the table makes, so this table is not sortable and rows are
381 // dragged into place instead.
382 Ok(Schema::new([
383 Column::string("code", "Code").width_ch(3),
384 Column::string("name", "Name").width_ch(22),
385 Column::string("librarian_first", "Librarian").width_ch(8),
386 Column::string("librarian_last", "Surname")
387 .width_ch(8)
388 .datalist("librarian-names"),
389 Column::number("staff", "Staff").int_only().width_ch(2),
390 Column::boolean("open", "Open"),
391 Column::map(
392 "hours",
393 "Hours",
394 MapSpec::new("Day", "Hours")
395 .key_options(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"])
396 .value_options([
397 SelectOption::new("09:00-17:00"),
398 SelectOption::new("12:00-20:00"),
399 ])
400 .allow_new_keys()
401 .allow_new_values(),
402 ),
403 ])
404 .datalist(
405 "librarian-names",
406 Datalist::from_rows(["librarian_last"], " "),
407 )
408 .new_row(
409 NewRow::new()
410 .with("code", "")
411 .with("name", "")
412 .with("librarian_first", "")
413 .with("librarian_last", ""),
414 ))
415 }Sourcepub fn allow_new_values(self) -> Self
pub fn allow_new_values(self) -> Self
Examples found in repository?
378 fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
379 // Row order here is the order the branches are listed in, which is a
380 // choice the table makes, so this table is not sortable and rows are
381 // dragged into place instead.
382 Ok(Schema::new([
383 Column::string("code", "Code").width_ch(3),
384 Column::string("name", "Name").width_ch(22),
385 Column::string("librarian_first", "Librarian").width_ch(8),
386 Column::string("librarian_last", "Surname")
387 .width_ch(8)
388 .datalist("librarian-names"),
389 Column::number("staff", "Staff").int_only().width_ch(2),
390 Column::boolean("open", "Open"),
391 Column::map(
392 "hours",
393 "Hours",
394 MapSpec::new("Day", "Hours")
395 .key_options(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"])
396 .value_options([
397 SelectOption::new("09:00-17:00"),
398 SelectOption::new("12:00-20:00"),
399 ])
400 .allow_new_keys()
401 .allow_new_values(),
402 ),
403 ])
404 .datalist(
405 "librarian-names",
406 Datalist::from_rows(["librarian_last"], " "),
407 )
408 .new_row(
409 NewRow::new()
410 .with("code", "")
411 .with("name", "")
412 .with("librarian_first", "")
413 .with("librarian_last", ""),
414 ))
415 }Sourcepub fn chips_show_key(self) -> Self
pub fn chips_show_key(self) -> Self
Show the stored key on a chip rather than its label, which keeps a cell of several entries short where the labels are long. The panel that edits the entries shows both either way.
Examples found in repository?
125 fn schema(&self, ctx: &Context) -> Result<Schema, ApiError> {
126 let genres = Self::genres(ctx)?;
127 let branches = Self::branches(ctx)?;
128
129 let mut names: Vec<&str> = genres.iter().map(|g| g.genre.as_str()).collect();
130 names.sort_unstable();
131 names.dedup();
132
133 let mut by_genre = OptionsBy::new("genre");
134 for genre in &names {
135 let subgenres: Vec<&str> = genres
136 .iter()
137 .filter(|g| g.genre == *genre)
138 .map(|g| g.subgenre.as_str())
139 .collect();
140 by_genre.insert(*genre, subgenres);
141 }
142
143 // Wide enough for the longest subgenre there is, computed here so the
144 // browser does not have to measure anything. It is the count of
145 // characters, nothing more: what the control puts around them is the
146 // bundle's business.
147 let widest = genres.iter().map(|g| g.subgenre.len()).max().unwrap_or(0);
148 let width_ch = u16::try_from(widest.max(8)).unwrap_or(u16::MAX);
149
150 // A key that shows a branch's name and stores its code.
151 let branch_keys: Vec<SelectOption> = branches
152 .iter()
153 .map(|b| SelectOption::labelled(&b.code, &b.name))
154 .collect();
155
156 let mut publishers: Vec<String> = ctx
157 .optional_rows::<Book>(BOOKS_FILE)?
158 .iter()
159 .map(|b| b.publisher.trim().to_string())
160 .filter(|p| !p.is_empty())
161 .collect();
162 publishers.sort_unstable();
163 publishers.dedup();
164
165 Ok(Schema::new([
166 Column::string("title", "Title").width_ch(30),
167 Column::string("author_first", "First").width_ch(12),
168 Column::string("author_last", "Last").width_ch(14),
169 Column::select("genre", "Genre", names)
170 .allow_empty()
171 .cascades_to(["subgenre"]),
172 Column::select_by("subgenre", "Subgenre", by_genre)
173 .allow_empty()
174 .width_ch(width_ch),
175 Column::select(
176 "edition",
177 "Edition",
178 [
179 SelectOption::labelled("1", "First (1)"),
180 SelectOption::labelled("2", "Second (2)"),
181 SelectOption::labelled("3", "Third (3)"),
182 ],
183 )
184 .allow_empty()
185 .numeric_value(),
186 // A year is a whole number; a rating is not, which is what the
187 // absence of int_only means.
188 Column::number("year", "Year").int_only().width_ch(4),
189 Column::number("copies", "Copies").int_only().width_ch(3),
190 Column::number("rating", "Rating").width_ch(3),
191 Column::boolean("lent", "Lent"),
192 Column::string("publisher", "Publisher")
193 .width_ch(20)
194 .datalist("publishers"),
195 Column::string("donor", "Donated by")
196 .width_ch(20)
197 .datalist("reader-names"),
198 Column::spaced_string("call_number", "Call no.").width_ch(14),
199 Column::string("pronunciation", "Say")
200 .width_ch(16)
201 .speak(Speak::new(
202 "http://127.0.0.1:8765/say?text={value}",
203 "table-editor-speech-url",
204 )),
205 Column::map(
206 "shelved",
207 "Shelved",
208 MapSpec::new("Branch", "Copies")
209 .chips_show_key()
210 .key_options(branch_keys)
211 .value_options([
212 SelectOption::labelled("one", "One"),
213 SelectOption::labelled("several", "Several"),
214 SelectOption::labelled("many", "Many"),
215 ]),
216 ),
217 Column::string("due", "Due").width_ch(10),
218 Column::string("link", "Catalogue").width_ch(30),
219 Column::computed("shelf", "Shelf mark", "shelf").width_ch(18),
220 Column::text("notes", "Notes").wide(),
221 ])
222 .sortable()
223 .datalist("publishers", Datalist::fixed(publishers))
224 .datalist(
225 "reader-names",
226 Datalist::from_rows(["author_last", "author_first"], ", "),
227 )
228 .new_row(
229 NewRow::new()
230 .with("title", "")
231 .with("author_first", "")
232 .with("author_last", "")
233 .with("genre", "")
234 .with("subgenre", "")
235 .with("publisher", "")
236 .with("donor", "")
237 .with("call_number", "")
238 .with("pronunciation", "")
239 .with("due", "")
240 .with("link", "")
241 .with("notes", "")
242 .with("copies", 1)
243 .carry_forward(["genre", "subgenre", "publisher"]),
244 ))
245 }