max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+326 insertions,
-46 deletions
| @@ -50,8 +50,11 @@ | |||
| 50 | 50 | /src-tauri/frontend/css/layout.css | |
| 51 | 51 | /src-tauri/frontend/css/tables.css | |
| 52 | 52 | ||
| 53 | - | # Generated by src-tauri/build.rs from the column descriptions | |
| 53 | + | # Generated by src-tauri/build.rs from the column descriptions. The JSON is read | |
| 54 | + | # off disk by the node tests, the JS by the running frontend; both are the same | |
| 55 | + | # bytes out of one function. | |
| 54 | 56 | /src-tauri/frontend/tables.columns.json | |
| 57 | + | /src-tauri/frontend/js/tables.generated.js | |
| 55 | 58 | ||
| 56 | 59 | # Secrets and credentials. The baseline every repo under ~/Code carries, kept | |
| 57 | 60 | # identical so no repo is the one that forgot. Suffix-matched rather than |
| @@ -8373,6 +8373,10 @@ | |||
| 8373 | 8373 | "winnow 1.0.4", | |
| 8374 | 8374 | ] | |
| 8375 | 8375 | ||
| 8376 | + | [[patch.unused]] | |
| 8377 | + | name = "ops-status" | |
| 8378 | + | version = "0.1.0" | |
| 8379 | + | ||
| 8376 | 8380 | [[patch.unused]] | |
| 8377 | 8381 | name = "quasi-axum" | |
| 8378 | 8382 | version = "0.11.0" | |
| @@ -8388,7 +8392,3 @@ | |||
| 8388 | 8392 | [[patch.unused]] | |
| 8389 | 8393 | name = "quasi-store" | |
| 8390 | 8394 | version = "0.1.0" | |
| 8391 | - | ||
| 8392 | - | [[patch.unused]] | |
| 8393 | - | name = "ops-status" | |
| 8394 | - | version = "0.1.0" |
| @@ -5,7 +5,7 @@ | |||
| 5 | 5 | use makeover_geometry::SizeClass; | |
| 6 | 6 | use makeover_layout::{Column, Priority, Width}; | |
| 7 | 7 | use makeover_webview::Emit; | |
| 8 | - | use makeover_webview::list::{Sizing, narrowing_css}; | |
| 8 | + | use makeover_webview::list::{Sizing, column_class, narrowing_css}; | |
| 9 | 9 | ||
| 10 | 10 | /// The tasks table, left to right. | |
| 11 | 11 | /// | |
| @@ -181,7 +181,18 @@ | |||
| 181 | 181 | /// said. That is how the events table came to render six cells into five | |
| 182 | 182 | /// tracks. This file is the description in a form the frontend can check itself | |
| 183 | 183 | /// against. | |
| 184 | + | /// | |
| 185 | + | /// # Why the class is carried rather than spelled | |
| 186 | + | /// | |
| 187 | + | /// `classes` is the column's own class as [`column_class`] writes it, and it is | |
| 188 | + | /// here so that no reader has to derive `col-` + name for itself. The derivation | |
| 189 | + | /// is not the identity it looks like: `push_column_name` reduces a name to | |
| 190 | + | /// identifier characters, so a column named `Due date` is `col-Due-date` and a | |
| 191 | + | /// reader that concatenated would spell `col-Due date`, which the HTML parser | |
| 192 | + | /// reads as two classes and which the narrowing selector matches neither of. | |
| 193 | + | /// One writer, every reader. | |
| 184 | 194 | fn table_columns_json() -> String { | |
| 195 | + | let opts = Emit::default(); | |
| 185 | 196 | let mut json = String::from("{\n"); | |
| 186 | 197 | for (i, (table, header, _, columns)) in TABLES.iter().enumerate() { | |
| 187 | 198 | let names = columns | |
| @@ -198,10 +209,15 @@ | |||
| 198 | 209 | .map(|c| format!("\"{}\"", c.name)) | |
| 199 | 210 | .collect::<Vec<_>>() | |
| 200 | 211 | .join(", "); | |
| 212 | + | let classes = columns | |
| 213 | + | .iter() | |
| 214 | + | .map(|c| format!("\"{}\": \"{}\"", c.name, column_class(c, &opts))) | |
| 215 | + | .collect::<Vec<_>>() | |
| 216 | + | .join(", "); | |
| 201 | 217 | let _ = writeln!( | |
| 202 | 218 | json, | |
| 203 | 219 | " \"{table}\": {{ \"header\": \"{header}\", \"columns\": [{names}], \ | |
| 204 | - | \"sortable\": [{sortable}] }}{}", | |
| 220 | + | \"sortable\": [{sortable}], \"classes\": {{{classes}}} }}{}", | |
| 205 | 221 | if i + 1 == TABLES.len() { "" } else { "," } | |
| 206 | 222 | ); | |
| 207 | 223 | } | |
| @@ -209,6 +225,36 @@ | |||
| 209 | 225 | json | |
| 210 | 226 | } | |
| 211 | 227 | ||
| 228 | + | /// The same description again, in a form the running frontend can read. | |
| 229 | + | /// | |
| 230 | + | /// [`table_columns_json`] is read by the node test runner off disk. The webview | |
| 231 | + | /// has no filesystem and fetching a JSON file under Tauri's CSP is a worse | |
| 232 | + | /// bargain than a script tag, so the runtime gets the identical bytes assigned | |
| 233 | + | /// to a global instead. One description, one writer, two readers -- rather than | |
| 234 | + | /// the third copy that lived in the row builders as hand-typed `col-` classes. | |
| 235 | + | /// | |
| 236 | + | /// This is data, not markup. The row builders stay in JS and keep emitting | |
| 237 | + | /// their own cells, which is what `makeover_webview::list`'s own header asks a | |
| 238 | + | /// webview app to do: take `narrowing_css` and `column_class`, and do not put an | |
| 239 | + | /// IPC round trip on a scroll path that runs synchronously at 60Hz. | |
| 240 | + | fn table_columns_js() -> String { | |
| 241 | + | format!( | |
| 242 | + | "// Generated by src-tauri/build.rs from the column descriptions. Do not edit.\n\ | |
| 243 | + | //\n\ | |
| 244 | + | // The runtime half of tables.columns.json, which the node tests read off\n\ | |
| 245 | + | // disk. Both come out of one function, so a row cannot be built against a\n\ | |
| 246 | + | // column list the stylesheet and the tests disagree with.\n\ | |
| 247 | + | (function () {{\n\ | |
| 248 | + | 'use strict';\n\ | |
| 249 | + | const TABLES = {};\n\ | |
| 250 | + | if (window.GoingsOn) {{\n\ | |
| 251 | + | \x20 GoingsOn.tableColumns = TABLES;\n\ | |
| 252 | + | }}\n\ | |
| 253 | + | }})();\n", | |
| 254 | + | table_columns_json().trim_end() | |
| 255 | + | ) | |
| 256 | + | } | |
| 257 | + | ||
| 212 | 258 | /// Generate the tables' column CSS. | |
| 213 | 259 | /// | |
| 214 | 260 | /// Both halves of narrowing come out of one call per breakpoint: the track list | |
| @@ -560,6 +606,8 @@ | |||
| 560 | 606 | fs::write(frontend.join("css/tables.css"), table_css()).expect("write tables.css"); | |
| 561 | 607 | fs::write(frontend.join("tables.columns.json"), table_columns_json()) | |
| 562 | 608 | .expect("write tables.columns.json"); | |
| 609 | + | fs::write(frontend.join("js/tables.generated.js"), table_columns_js()) | |
| 610 | + | .expect("write js/tables.generated.js"); | |
| 563 | 611 | ||
| 564 | 612 | // The generated files above cannot drift from SizeClass. The hand-written | |
| 565 | 613 | // ones can, so they are checked rather than trusted. |
| @@ -709,6 +709,11 @@ | |||
| 709 | 709 | <script src="js/themes.js"></script> | |
| 710 | 710 | <script src="js/query-state.js"></script> | |
| 711 | 711 | ||
| 712 | + | <!-- Generated by build.rs from the column descriptions. Must load before any | |
| 713 | + | row builder: table-cells.js reads the column order and classes off it. --> | |
| 714 | + | <script src="js/tables.generated.js"></script> | |
| 715 | + | <script src="js/table-cells.js"></script> | |
| 716 | + | ||
| 712 | 717 | <!-- Utility Managers --> | |
| 713 | 718 | <script src="js/cache.js"></script> | |
| 714 | 719 | <script src="js/selection-manager.js"></script> |
| @@ -536,19 +536,40 @@ | |||
| 536 | 536 | // so users see "Weekly ยท Mon Wed Fri" rather than a single arbitrary start date. | |
| 537 | 537 | if (isRecurring) { | |
| 538 | 538 | const patternLabel = e.recurrenceDisplay || e.recurrence || 'Recurring'; | |
| 539 | + | const cells = GoingsOn.tableCells.cells('recurring', 'event-cell', [ | |
| 540 | + | { | |
| 541 | + | column: 'pattern', | |
| 542 | + | class: 'event-cell-date', | |
| 543 | + | html: `<span class="event-recurrence-pattern">${esc(patternLabel)}</span>`, | |
| 544 | + | }, | |
| 545 | + | { | |
| 546 | + | column: 'time', | |
| 547 | + | class: 'row-secondary event-cell-time', | |
| 548 | + | html: e.timeFormatted, | |
| 549 | + | }, | |
| 550 | + | { | |
| 551 | + | column: 'title', | |
| 552 | + | class: 'event-cell-title', | |
| 553 | + | html: esc(displayTitle), | |
| 554 | + | }, | |
| 555 | + | { | |
| 556 | + | column: 'location', | |
| 557 | + | class: 'row-secondary event-cell-location', | |
| 558 | + | html: e.location ? esc(e.location) : '-', | |
| 559 | + | }, | |
| 560 | + | { | |
| 561 | + | column: 'actions', | |
| 562 | + | attrs: 'style="text-align: right;" data-act="ui.noop"', | |
| 563 | + | html: `<button class="button--icon row-actions kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">⋮</button>`, | |
| 564 | + | }, | |
| 565 | + | ]); | |
| 539 | 566 | return ` | |
| 540 | 567 | <div class="row event-row-virtual event-recurring" | |
| 541 | 568 | data-id="${escAttr(e.id)}" | |
| 542 | 569 | data-act="events.open" data-a1="${escAttr(e.id)}" | |
| 543 | 570 | data-contextmenu="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" | |
| 544 | 571 | tabindex="0" role="row"> | |
| 545 | - | <div class="event-cell col-pattern event-cell-date"><span class="event-recurrence-pattern">${esc(patternLabel)}</span></div> | |
| 546 | - | <div class="event-cell col-time row-secondary event-cell-time">${e.timeFormatted}</div> | |
| 547 | - | <div class="event-cell col-title event-cell-title">${esc(displayTitle)}</div> | |
| 548 | - | <div class="event-cell col-location row-secondary event-cell-location">${e.location ? esc(e.location) : '-'}</div> | |
| 549 | - | <div class="event-cell col-actions" style="text-align: right;" data-act="ui.noop"> | |
| 550 | - | <button class="button--icon row-actions kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">⋮</button> | |
| 551 | - | </div> | |
| 572 | + | ${cells} | |
| 552 | 573 | </div> | |
| 553 | 574 | `; | |
| 554 | 575 | } | |
| @@ -571,6 +592,43 @@ | |||
| 571 | 592 | dateHeader = `<div class="event-date-group-header">${dayName}, ${startDate.getDate()} ${monthName}</div>`; | |
| 572 | 593 | } | |
| 573 | 594 | ||
| 595 | + | const cells = GoingsOn.tableCells.cells('upcoming', 'event-cell', [ | |
| 596 | + | { | |
| 597 | + | column: 'select', | |
| 598 | + | html: `<input type="checkbox" class="bulk-checkbox event-select-cb" data-id="${escAttr(e.id)}" | |
| 599 | + | data-act="events.toggleEventSelection" data-a1="${escAttr(e.id)}" data-a2="@event" | |
| 600 | + | aria-label="Select event">`, | |
| 601 | + | }, | |
| 602 | + | { | |
| 603 | + | column: 'date', | |
| 604 | + | class: 'event-cell-date', | |
| 605 | + | html: ` | |
| 606 | + | <span class="row-primary event-date-num">${startDate.getDate()} ${monthName}</span> | |
| 607 | + | <span class="event-date-badge event-proximity-${e.proximityClass || 'default'}">${e.proximityLabel || ''}</span> | |
| 608 | + | `, | |
| 609 | + | }, | |
| 610 | + | { | |
| 611 | + | column: 'time', | |
| 612 | + | class: 'row-secondary event-cell-time', | |
| 613 | + | html: e.timeFormatted, | |
| 614 | + | }, | |
| 615 | + | { | |
| 616 | + | column: 'title', | |
| 617 | + | class: 'event-cell-title', | |
| 618 | + | html: esc(displayTitle), | |
| 619 | + | }, | |
| 620 | + | { | |
| 621 | + | column: 'location', | |
| 622 | + | class: 'row-secondary event-cell-location', | |
| 623 | + | html: e.location ? esc(e.location) : '-', | |
| 624 | + | }, | |
| 625 | + | { | |
| 626 | + | column: 'actions', | |
| 627 | + | attrs: 'style="text-align: right;" data-act="ui.noop"', | |
| 628 | + | html: `<button class="button--icon row-actions kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">⋮</button>`, | |
| 629 | + | }, | |
| 630 | + | ]); | |
| 631 | + | ||
| 574 | 632 | return ` | |
| 575 | 633 | ${dateHeader} | |
| 576 | 634 | <div class="row event-row-virtual event-upcoming ${e.isPast || isPast ? 'event-past' : ''}" | |
| @@ -578,21 +636,7 @@ | |||
| 578 | 636 | data-act="events.open" data-a1="${escAttr(e.id)}" | |
| 579 | 637 | data-contextmenu="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" | |
| 580 | 638 | tabindex="0" role="row"> | |
| 581 | - | <div class="event-cell col-select"> | |
| 582 | - | <input type="checkbox" class="bulk-checkbox event-select-cb" data-id="${escAttr(e.id)}" | |
| 583 | - | data-act="events.toggleEventSelection" data-a1="${escAttr(e.id)}" data-a2="@event" | |
| 584 | - | aria-label="Select event"> | |
| 585 | - | </div> | |
| 586 | - | <div class="event-cell col-date event-cell-date"> | |
| 587 | - | <span class="row-primary event-date-num">${startDate.getDate()} ${monthName}</span> | |
| 588 | - | <span class="event-date-badge event-proximity-${e.proximityClass || 'default'}">${e.proximityLabel || ''}</span> | |
| 589 | - | </div> | |
| 590 | - | <div class="event-cell col-time row-secondary event-cell-time">${e.timeFormatted}</div> | |
| 591 | - | <div class="event-cell col-title event-cell-title">${esc(displayTitle)}</div> | |
| 592 | - | <div class="event-cell col-location row-secondary event-cell-location">${e.location ? esc(e.location) : '-'}</div> | |
| 593 | - | <div class="event-cell col-actions" style="text-align: right;" data-act="ui.noop"> | |
| 594 | - | <button class="button--icon row-actions kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">⋮</button> | |
| 595 | - | </div> | |
| 639 | + | ${cells} | |
| 596 | 640 | </div> | |
| 597 | 641 | `; | |
| 598 | 642 | } |
| @@ -181,12 +181,16 @@ | |||
| 181 | 181 | const isSelected = GoingsOn.tasks.selection.isSelected(t.id); | |
| 182 | 182 | const isStarted = t.status === 'Started'; | |
| 183 | 183 | ||
| 184 | - | return ` | |
| 185 | - | <div class="row task-row task-${t.status.toLowerCase()} task-${t.blockedClass || 'ready'} ${t.isSnoozed ? 'task-snoozed' : ''} ${t.isOverdue ? 'task-overdue' : ''} ${isSelected ? 'selected' : ''}" | |
| 186 | - | data-id="${escAttr(t.id)}" | |
| 187 | - | data-contextmenu="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}" | |
| 188 | - | tabindex="0" role="row"> | |
| 189 | - | <div class="task-cell col-description task-description" data-act="taskOverview.open" data-a1="${escAttr(t.id)}"> | |
| 184 | + | // Cells are named, not ordered: the column description in build.rs | |
| 185 | + | // decides what comes where, and the `col-` class each one wears comes | |
| 186 | + | // out of the same description that generated tables.css. Listing them | |
| 187 | + | // in visual order here is a courtesy to the reader and nothing more. | |
| 188 | + | const cells = GoingsOn.tableCells.cells('task', 'task-cell', [ | |
| 189 | + | { | |
| 190 | + | column: 'description', | |
| 191 | + | class: 'task-description', | |
| 192 | + | attrs: `data-act="taskOverview.open" data-a1="${escAttr(t.id)}"`, | |
| 193 | + | html: ` | |
| 190 | 194 | ${isStarted ? '<span class="task-started-icon" title="Started" aria-hidden="true"></span>' : ''} | |
| 191 | 195 | <span class="task-description-text">${esc(displayDesc)}</span> | |
| 192 | 196 | ${renderTokenDot(t)} | |
| @@ -196,28 +200,60 @@ | |||
| 196 | 200 | ${renderTaskBadges(t)} | |
| 197 | 201 | ${t.contactName ? `<span class="contact-badge" title="${escAttrVal(t.contactName)}">${esc(t.contactName)}</span>` : ''} | |
| 198 | 202 | ${t.isSnoozed ? `<span class="snooze-badge" title="Snoozed until ${escAttr(t.snoozedUntilFormatted || '')}" aria-label="Snoozed until ${escAttr(t.snoozedUntilFormatted || '')}">Snoozed</span>` : ''} | |
| 199 | - | </div> | |
| 200 | - | <div class="task-cell col-project row-secondary task-project">${esc(t.projectName) || '-'}${GoingsOn.groups.taskSharedBadge(t.projectId)}</div> | |
| 201 | - | <div class="task-cell col-priority priority-${t.priority.toLowerCase()}" aria-label="Priority ${t.priority}">${t.priority.charAt(0)}</div> | |
| 202 | - | <div class="task-cell col-due row-meta task-due">${t.dueFormatted || '-'}</div> | |
| 203 | - | <div class="task-cell col-recurrence row-secondary task-recurrence">${formatRecurrence(t)}</div> | |
| 204 | - | <div class="task-cell col-progress task-progress"> | |
| 205 | - | ${t.subtaskCount > 0 ? ` | |
| 203 | + | `, | |
| 204 | + | }, | |
| 205 | + | { | |
| 206 | + | column: 'project', | |
| 207 | + | class: 'row-secondary task-project', | |
| 208 | + | html: `${esc(t.projectName) || '-'}${GoingsOn.groups.taskSharedBadge(t.projectId)}`, | |
| 209 | + | }, | |
| 210 | + | { | |
| 211 | + | column: 'priority', | |
| 212 | + | class: `priority-${t.priority.toLowerCase()}`, | |
| 213 | + | attrs: `aria-label="Priority ${t.priority}"`, | |
| 214 | + | html: t.priority.charAt(0), | |
| 215 | + | }, | |
| 216 | + | { | |
| 217 | + | column: 'due', | |
| 218 | + | class: 'row-meta task-due', | |
| 219 | + | html: t.dueFormatted || '-', | |
| 220 | + | }, | |
| 221 | + | { | |
| 222 | + | column: 'recurrence', | |
| 223 | + | class: 'row-secondary task-recurrence', | |
| 224 | + | html: formatRecurrence(t), | |
| 225 | + | }, | |
| 226 | + | { | |
| 227 | + | column: 'progress', | |
| 228 | + | class: 'task-progress', | |
| 229 | + | html: t.subtaskCount > 0 ? ` | |
| 206 | 230 | <div class="progress" title="${t.subtaskCompleted}/${t.subtaskCount} subtasks" | |
| 207 | 231 | role="progressbar" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100" | |
| 208 | 232 | aria-label="${t.subtaskCompleted} of ${t.subtaskCount} subtasks completed"> | |
| 209 | 233 | <div class="progress-fill" data-tone="success" style="width: ${progress}%"></div> | |
| 210 | 234 | </div> | |
| 211 | - | ` : '<span class="row-secondary no-subtasks">-</span>'} | |
| 212 | - | </div> | |
| 213 | - | <div class="task-cell col-actions task-actions-cell"> | |
| 235 | + | ` : '<span class="row-secondary no-subtasks">-</span>', | |
| 236 | + | }, | |
| 237 | + | { | |
| 238 | + | column: 'actions', | |
| 239 | + | class: 'task-actions-cell', | |
| 240 | + | html: ` | |
| 214 | 241 | ${renderRowActions(t, isStarted)} | |
| 215 | 242 | <input type="checkbox" class="bulk-checkbox" data-id="${escAttr(t.id)}" | |
| 216 | 243 | ${isSelected ? 'checked' : ''} | |
| 217 | 244 | data-change="tasks.toggleSelection" data-a1="${escAttr(t.id)}" data-a2="@el" data-a3="@event" | |
| 218 | 245 | aria-label="Select task"> | |
| 219 | 246 | <button class="button--icon row-actions kebab-btn" data-act="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}" title="Actions" aria-label="Task actions">⋮</button> | |
| 220 | - | </div> | |
| 247 | + | `, | |
| 248 | + | }, | |
| 249 | + | ]); | |
| 250 | + | ||
| 251 | + | return ` | |
| 252 | + | <div class="row task-row task-${t.status.toLowerCase()} task-${t.blockedClass || 'ready'} ${t.isSnoozed ? 'task-snoozed' : ''} ${t.isOverdue ? 'task-overdue' : ''} ${isSelected ? 'selected' : ''}" | |
| 253 | + | data-id="${escAttr(t.id)}" | |
| 254 | + | data-contextmenu="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}" | |
| 255 | + | tabindex="0" role="row"> | |
| 256 | + | ${cells} | |
| 221 | 257 | </div> | |
| 222 | 258 | `; | |
| 223 | 259 | } |
| @@ -97,6 +97,13 @@ | |||
| 97 | 97 | require('../virtual-scroller'); // GoingsOn.VirtualScroller | |
| 98 | 98 | require('../emails-threads'); // GoingsOn.emailsThreads (thread-list mutations) | |
| 99 | 99 | ||
| 100 | + | // Both row builders order their cells by the column description rather than by | |
| 101 | + | // hand, so the generated half has to be loaded before either of them. Requiring | |
| 102 | + | // it here is also the load-order assertion: a row built before the description | |
| 103 | + | // arrives renders no cells at all. | |
| 104 | + | require('../tables.generated'); // GoingsOn.tableColumns (written by build.rs) | |
| 105 | + | require('../table-cells'); // GoingsOn.tableCells (cells, in column order) | |
| 106 | + | ||
| 100 | 107 | // tasks-render builds row markup from two collaborators it does not own. Both | |
| 101 | 108 | // are stubbed here so a row render is a pure string call. | |
| 102 | 109 | GoingsOn.tasks = GoingsOn.tasks || {}; | |
| @@ -1084,6 +1091,54 @@ | |||
| 1084 | 1091 | } | |
| 1085 | 1092 | }); | |
| 1086 | 1093 | ||
| 1094 | + | // Test: the cell builder that puts the description in charge of the order | |
| 1095 | + | ||
| 1096 | + | // The row tests above assert the outcome. These assert the mechanism, because | |
| 1097 | + | // the outcome is only as good as what the builder does when a caller and the | |
| 1098 | + | // description disagree -- which is the case the hand-typed `col-` classes used | |
| 1099 | + | // to lose silently. Both behaviours are `cells_html`'s, deliberately: a column | |
| 1100 | + | // with no cell keeps its container so the grid stays aligned, and a cell naming | |
| 1101 | + | // no column is dropped because there is nowhere to put it. | |
| 1102 | + | describe('GoingsOn.tableCells', () => { | |
| 1103 | + | const cells = (list) => GoingsOn.tableCells.cells('recurring', 'event-cell', list); | |
| 1104 | + | ||
| 1105 | + | test('orders cells by the description, not by the caller', () => { | |
| 1106 | + | const html = cells([ | |
| 1107 | + | { column: 'actions', html: 'A' }, | |
| 1108 | + | { column: 'pattern', html: 'P' }, | |
| 1109 | + | { column: 'title', html: 'T' }, | |
| 1110 | + | ]); | |
| 1111 | + | assertDeepEqual( | |
| 1112 | + | [...html.matchAll(/class="[^"]*\bcol-([a-z]+)\b/g)].map(m => m[1]), | |
| 1113 | + | GoingsOn.tableColumns.recurring.columns | |
| 1114 | + | ); | |
| 1115 | + | assert(html.indexOf('>P<') < html.indexOf('>T<'), 'pattern precedes title'); | |
| 1116 | + | }); | |
| 1117 | + | ||
| 1118 | + | test('a column with no cell keeps an empty container', () => { | |
| 1119 | + | const html = cells([{ column: 'title', html: 'T' }]); | |
| 1120 | + | assert(html.includes('<div class="event-cell col-location"></div>'), | |
| 1121 | + | 'the unfilled location column still emits its cell'); | |
| 1122 | + | }); | |
| 1123 | + | ||
| 1124 | + | test('a cell naming no column is dropped', () => { | |
| 1125 | + | const html = cells([ | |
| 1126 | + | { column: 'title', html: 'T' }, | |
| 1127 | + | { column: 'nonesuch', html: 'GONE' }, | |
| 1128 | + | ]); | |
| 1129 | + | assert(!html.includes('GONE'), 'the undescribed cell does not reach the row'); | |
| 1130 | + | assert(!html.includes('col-nonesuch'), 'and neither does its class'); | |
| 1131 | + | }); | |
| 1132 | + | ||
| 1133 | + | test('the column class comes from the description, never from the caller', () => { | |
| 1134 | + | // The caller says which column, never what it is called. That is the | |
| 1135 | + | // whole point: `col-` + name is a derivation the description owns. | |
| 1136 | + | const html = cells([{ column: 'pattern', class: 'event-cell-date', html: 'P' }]); | |
| 1137 | + | assert(html.includes(`class="event-cell ${GoingsOn.tableColumns.recurring.classes.pattern} event-cell-date"`), | |
| 1138 | + | 'base class, described class, then the caller\'s own'); | |
| 1139 | + | }); | |
| 1140 | + | }); | |
| 1141 | + | ||
| 1087 | 1142 | // Report | |
| 1088 | 1143 | ||
| 1089 | 1144 | const success = report(); |
| @@ -1,0 +1,89 @@ | |||
| 1 | + | /** | |
| 2 | + | * GoingsOn - table cells | |
| 3 | + | * | |
| 4 | + | * A row's cells, in the order its column description names, with each cell | |
| 5 | + | * wearing the class the description writes. The counterpart of | |
| 6 | + | * `makeover_webview::list::cells_html`, kept in JS on purpose. | |
| 7 | + | * | |
| 8 | + | * # Why this is not a call into Rust | |
| 9 | + | * | |
| 10 | + | * `cells_html` exists and is the same function, and `list.rs` asks a webview app | |
| 11 | + | * not to reach for it: goingson renders rows through VirtualScroller, whose | |
| 12 | + | * `_render` calls its row builder synchronously while scrolling, at 60Hz+, and | |
| 13 | + | * reaching Rust from there means an IPC round trip and an `await` inside that | |
| 14 | + | * loop during a drag. Batching per page instead of per window does not rescue | |
| 15 | + | * it either -- a task row's markup carries its own selection state, which | |
| 16 | + | * SelectionManager mutates in place without a re-render, so cached markup is | |
| 17 | + | * stale one click later. | |
| 18 | + | * | |
| 19 | + | * So the description crosses the boundary instead of the markup, once, at build | |
| 20 | + | * time. `tables.generated.js` carries the column order and each column's class; | |
| 21 | + | * this builds the cells from them. Nothing is derived here that build.rs did not | |
| 22 | + | * already write, which is the whole point: the row builders used to hand-type | |
| 23 | + | * `col-description` beside a stylesheet that generated it, and a column renamed | |
| 24 | + | * on one side went silently unmatched on the other. | |
| 25 | + | */ | |
| 26 | + | ||
| 27 | + | (function() { | |
| 28 | + | 'use strict'; | |
| 29 | + | ||
| 30 | + | /** | |
| 31 | + | * A table's column description, as build.rs wrote it. | |
| 32 | + | * @param {string} table - Table name ('task', 'upcoming', 'recurring') | |
| 33 | + | * @returns {{columns: string[], classes: Object<string,string>}|null} | |
| 34 | + | */ | |
| 35 | + | function spec(table) { | |
| 36 | + | const spec = GoingsOn.tableColumns?.[table]; | |
| 37 | + | if (!spec) { | |
| 38 | + | // Generated file missing or a table nobody described. Warn rather than | |
| 39 | + | // throw: a row that draws slightly wrong is recoverable inside a scroll | |
| 40 | + | // frame and a row that throws takes the list down with it. | |
| 41 | + | console.warn(`table-cells: no column description for table "${table}"`); | |
| 42 | + | return null; | |
| 43 | + | } | |
| 44 | + | return spec; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /** | |
| 48 | + | * Build a row's cells, ordered by the description rather than by the caller. | |
| 49 | + | * | |
| 50 | + | * Ordered by the columns and not by the cells, so a row cannot silently | |
| 51 | + | * disagree with its table about what comes where. A column with no cell gets an | |
| 52 | + | * empty container, which keeps the grid aligned; a cell naming no column is | |
| 53 | + | * dropped, because there is nowhere to put it. Both behaviours are | |
| 54 | + | * `cells_html`'s, deliberately. | |
| 55 | + | * | |
| 56 | + | * @param {string} table - Table name, keying the generated description | |
| 57 | + | * @param {string} baseClass - The app's own cell class ('task-cell', 'event-cell') | |
| 58 | + | * @param {Array<{column: string, class?: string, attrs?: string, html?: string}>} cells | |
| 59 | + | * @returns {string} HTML string of the row's cells, in column order | |
| 60 | + | */ | |
| 61 | + | function cells(table, baseClass, cells) { | |
| 62 | + | const described = spec(table); | |
| 63 | + | if (!described) return ''; | |
| 64 | + | ||
| 65 | + | for (const cell of cells) { | |
| 66 | + | if (!described.classes[cell.column]) { | |
| 67 | + | console.warn( | |
| 68 | + | `table-cells: cell "${cell.column}" names no column of table "${table}"; dropped` | |
| 69 | + | ); | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | return described.columns.map(name => { | |
| 74 | + | const cell = cells.find(c => c.column === name); | |
| 75 | + | const classes = [baseClass, described.classes[name], cell?.class] | |
| 76 | + | .filter(Boolean) | |
| 77 | + | .join(' '); | |
| 78 | + | const attrs = cell?.attrs ? ` ${cell.attrs}` : ''; | |
| 79 | + | return `<div class="${classes}"${attrs}>${cell?.html ?? ''}</div>`; | |
| 80 | + | }).join(''); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Populate GoingsOn Namespace | |
| 84 | + | ||
| 85 | + | if (window.GoingsOn) { | |
| 86 | + | GoingsOn.tableCells = { cells }; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | })(); |