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