max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+346 insertions,
-1 deletion
| @@ -270,6 +270,8 @@ | |||
| 270 | 270 | // Sync | |
| 271 | 271 | $crate::commands::sync_get_tiers, | |
| 272 | 272 | $crate::commands::sync_status, | |
| 273 | + | $crate::commands::sync_held_changes, | |
| 274 | + | $crate::commands::sync_retry_held_change, | |
| 273 | 275 | $crate::commands::sync_start_auth, | |
| 274 | 276 | $crate::commands::sync_complete_auth, | |
| 275 | 277 | $crate::commands::sync_disconnect, |
| @@ -2070,6 +2070,56 @@ | |||
| 2070 | 2070 | .sync-stat-value { | |
| 2071 | 2071 | font-size: var(--font-size-md, 0.9rem); | |
| 2072 | 2072 | } | |
| 2073 | + | /* Held changes: data from another device that could not be written here. Spans | |
| 2074 | + | the grid rather than sitting as a third column, because it is a condition to | |
| 2075 | + | act on, not a statistic to read alongside the others. Only rendered when the | |
| 2076 | + | count is non-zero. */ | |
| 2077 | + | .sync-stat-warn { | |
| 2078 | + | grid-column: 1 / -1; | |
| 2079 | + | border: var(--border-width) solid var(--warning); | |
| 2080 | + | } | |
| 2081 | + | .sync-stat-button { | |
| 2082 | + | font: inherit; | |
| 2083 | + | font-size: var(--font-size-md, 0.9rem); | |
| 2084 | + | color: var(--content); | |
| 2085 | + | background: none; | |
| 2086 | + | border: none; | |
| 2087 | + | padding: 0; | |
| 2088 | + | cursor: pointer; | |
| 2089 | + | text-decoration: underline; | |
| 2090 | + | } | |
| 2091 | + | .held-change-intro { | |
| 2092 | + | margin-bottom: var(--gap-section); | |
| 2093 | + | } | |
| 2094 | + | .held-change-list { | |
| 2095 | + | display: flex; | |
| 2096 | + | flex-direction: column; | |
| 2097 | + | gap: var(--gap-group); | |
| 2098 | + | } | |
| 2099 | + | .held-change { | |
| 2100 | + | display: flex; | |
| 2101 | + | align-items: flex-start; | |
| 2102 | + | justify-content: space-between; | |
| 2103 | + | gap: var(--gap-group); | |
| 2104 | + | padding: var(--gap-group); | |
| 2105 | + | background: var(--surface-overlay); | |
| 2106 | + | border-radius: var(--radius-md); | |
| 2107 | + | } | |
| 2108 | + | .held-change-body { | |
| 2109 | + | min-width: 0; | |
| 2110 | + | } | |
| 2111 | + | .held-change-name { | |
| 2112 | + | overflow-wrap: anywhere; | |
| 2113 | + | } | |
| 2114 | + | .held-change-cause, | |
| 2115 | + | .held-change-meta { | |
| 2116 | + | font-size: var(--font-size-sm); | |
| 2117 | + | margin-top: var(--gap-bound); | |
| 2118 | + | overflow-wrap: anywhere; | |
| 2119 | + | } | |
| 2120 | + | .held-change-retry { | |
| 2121 | + | flex-shrink: 0; | |
| 2122 | + | } | |
| 2073 | 2123 | .sync-encryption-error { | |
| 2074 | 2124 | color: var(--danger); | |
| 2075 | 2125 | font-size: var(--font-size-sm); |
| @@ -360,6 +360,8 @@ | |||
| 360 | 360 | subscriptionStatus: () => invoke('sync_subscription_status'), | |
| 361 | 361 | subscribe: (interval) => invoke('sync_subscribe', { interval }), | |
| 362 | 362 | accountInfo: () => invoke('sync_account_info'), | |
| 363 | + | heldChanges: () => invoke('sync_held_changes'), // Remote changes this device could not apply | |
| 364 | + | retryHeldChange: (scope, table, rowId) => invoke('sync_retry_held_change', { scope, table, rowId }), | |
| 363 | 365 | }, | |
| 364 | 366 | ||
| 365 | 367 | // Preferences: small JSON file in the app config dir for settings read before the DB is up |
| @@ -6,6 +6,7 @@ | |||
| 6 | 6 | (function() { | |
| 7 | 7 | 'use strict'; | |
| 8 | 8 | const esc = GoingsOn.utils.escapeHtml; | |
| 9 | + | const escAttr = GoingsOn.utils.escapeAttrValue; | |
| 9 | 10 | ||
| 10 | 11 | // Cloud Sync | |
| 11 | 12 | ||
| @@ -97,6 +98,7 @@ | |||
| 97 | 98 | <div class="sync-stat-label">Pending Changes</div> | |
| 98 | 99 | <div class="sync-stat-value">${status.pendingChanges}</div> | |
| 99 | 100 | </div> | |
| 101 | + | ${renderHeldStat(status.heldChanges)} | |
| 100 | 102 | </div> | |
| 101 | 103 | ||
| 102 | 104 | <div class="sync-section-actions"> | |
| @@ -422,6 +424,13 @@ | |||
| 422 | 424 | dot.classList.add('warn'); | |
| 423 | 425 | labelText = 'Set up encryption'; | |
| 424 | 426 | titleText = 'Cloud Sync: encryption setup needed'; | |
| 427 | + | } else if (status.heldChanges) { | |
| 428 | + | // Held changes are the one sync condition a user cannot see any | |
| 429 | + | // other way: data from another device is missing here and the app | |
| 430 | + | // would otherwise look perfectly synced. | |
| 431 | + | dot.classList.add('warn'); | |
| 432 | + | labelText = `${status.heldChanges} not applied`; | |
| 433 | + | titleText = `Cloud Sync: ${status.heldChanges} change${status.heldChanges === 1 ? '' : 's'} could not be applied`; | |
| 425 | 434 | } else { | |
| 426 | 435 | dot.classList.add('connected'); | |
| 427 | 436 | const ago = _formatSyncAgo(status.lastSyncAt); | |
| @@ -438,6 +447,99 @@ | |||
| 438 | 447 | } | |
| 439 | 448 | } | |
| 440 | 449 | ||
| 450 | + | // Held changes | |
| 451 | + | ||
| 452 | + | /** | |
| 453 | + | * The "Not Applied" stat tile. Rendered only when something is held: a | |
| 454 | + | * permanent zero would be noise, and every count here is a real gap between | |
| 455 | + | * what another device has and what this one does. | |
| 456 | + | * @param {number} held - Held change count across all scopes | |
| 457 | + | * @returns {string} Tile markup, or an empty string when nothing is held | |
| 458 | + | */ | |
| 459 | + | function renderHeldStat(held) { | |
| 460 | + | if (!held) return ''; | |
| 461 | + | return ` | |
| 462 | + | <div class="sync-stat sync-stat-warn"> | |
| 463 | + | <div class="sync-stat-label">Not Applied</div> | |
| 464 | + | <button class="sync-stat-value sync-stat-button" data-act="settings.showHeldChanges" | |
| 465 | + | title="Show the changes this device could not apply">${held}</button> | |
| 466 | + | </div> | |
| 467 | + | `; | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | /** | |
| 471 | + | * List the changes this device is holding, with a Retry for each. | |
| 472 | + | * | |
| 473 | + | * These are changes another device made that could not be written here: a | |
| 474 | + | * task whose project has not arrived yet, a row from a newer version of the | |
| 475 | + | * app. The engine keeps them and retries the retryable ones on every sync, | |
| 476 | + | * so this list is what is left after it has tried. | |
| 477 | + | */ | |
| 478 | + | async function showHeldChanges() { | |
| 479 | + | let held; | |
| 480 | + | try { | |
| 481 | + | held = await GoingsOn.api.sync.heldChanges(); | |
| 482 | + | } catch (err) { | |
| 483 | + | GoingsOn.ui.showToast(`Could not load held changes: ${GoingsOn.utils.getErrorMessage(err)}`, 'error'); | |
| 484 | + | return; | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | GoingsOn.ui.openModal('Changes Not Applied', renderHeldList(held), { large: true }); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | function renderHeldList(held) { | |
| 491 | + | if (!held.length) { | |
| 492 | + | return `<p class="text-secondary">Every change from your other devices has been applied.</p>`; | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | const rows = held.map(h => { | |
| 496 | + | const name = h.title ? `${esc(h.label)}: ${esc(h.title)}` : esc(h.label); | |
| 497 | + | const waiting = h.retryable | |
| 498 | + | ? `Retrying automatically. ${h.attempts} attempt${h.attempts === 1 ? '' : 's'} so far.` | |
| 499 | + | : 'No longer retried automatically.'; | |
| 500 | + | return ` | |
| 501 | + | <div class="held-change"> | |
| 502 | + | <div class="held-change-body"> | |
| 503 | + | <div class="held-change-name">${name}</div> | |
| 504 | + | <div class="held-change-cause text-secondary">${esc(h.cause)}</div> | |
| 505 | + | <div class="held-change-meta text-secondary">${esc(waiting)}</div> | |
| 506 | + | </div> | |
| 507 | + | <button class="btn btn-secondary held-change-retry" data-act="settings.retryHeldChange" | |
| 508 | + | data-a1="${escAttr(h.scope)}" data-a2="${escAttr(h.table)}" data-a3="${escAttr(h.rowId)}" | |
| 509 | + | data-args='["@a1", "@a2", "@a3"]'>Retry</button> | |
| 510 | + | </div> | |
| 511 | + | `; | |
| 512 | + | }).join(''); | |
| 513 | + | ||
| 514 | + | return ` | |
| 515 | + | <p class="text-secondary held-change-intro"> | |
| 516 | + | These changes came from another device and could not be written here yet. | |
| 517 | + | They are kept, not lost, and retried on each sync. | |
| 518 | + | </p> | |
| 519 | + | <div class="held-change-list">${rows}</div> | |
| 520 | + | `; | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | /** | |
| 524 | + | * Queue one held change for another attempt on the next sync. | |
| 525 | + | */ | |
| 526 | + | async function retryHeldChange(scope, table, rowId) { | |
| 527 | + | try { | |
| 528 | + | await GoingsOn.api.sync.retryHeldChange(scope, table, rowId); | |
| 529 | + | } catch (err) { | |
| 530 | + | GoingsOn.ui.showToast(`Could not retry: ${GoingsOn.utils.getErrorMessage(err)}`, 'error'); | |
| 531 | + | return; | |
| 532 | + | } | |
| 533 | + | GoingsOn.ui.showToast('Queued for the next sync.'); | |
| 534 | + | // Re-read rather than patching the row: a retry can change what is left, | |
| 535 | + | // and the engine is the only thing that knows. | |
| 536 | + | try { | |
| 537 | + | GoingsOn.ui.openModal('Changes Not Applied', renderHeldList(await GoingsOn.api.sync.heldChanges()), { large: true }); | |
| 538 | + | } catch (_) { | |
| 539 | + | GoingsOn.ui.closeModal(); | |
| 540 | + | } | |
| 541 | + | } | |
| 542 | + | ||
| 441 | 543 | /** | |
| 442 | 544 | * Subscribe to cloud sync with the given interval. | |
| 443 | 545 | * Opens the Stripe checkout page in the user's browser. | |
| @@ -495,6 +597,8 @@ | |||
| 495 | 597 | updateSyncSettings, | |
| 496 | 598 | disconnectSync, | |
| 497 | 599 | refreshSyncIndicator, | |
| 600 | + | showHeldChanges, | |
| 601 | + | retryHeldChange, | |
| 498 | 602 | subscribeSyncAnnual: () => subscribeSync('annual'), | |
| 499 | 603 | subscribeSyncMonthly: () => subscribeSync('monthly'), | |
| 500 | 604 | }); |
| @@ -15,7 +15,8 @@ | |||
| 15 | 15 | use crate::oauth::credentials::CredentialStore; | |
| 16 | 16 | use crate::oauth::provider::{generate_code_challenge, generate_code_verifier, generate_state}; | |
| 17 | 17 | use crate::state::AppState; | |
| 18 | - | use crate::syncstore::sync_state; | |
| 18 | + | use crate::syncstore::{manifest, sync_state}; | |
| 19 | + | use synckit_client::store::HoldState; | |
| 19 | 20 | ||
| 20 | 21 | // Types | |
| 21 | 22 | ||
| @@ -41,6 +42,33 @@ | |||
| 41 | 42 | pub sync_interval_minutes: u32, | |
| 42 | 43 | pub last_sync_at: Option<String>, | |
| 43 | 44 | pub pending_changes: i64, | |
| 45 | + | /// Remote changes this device could not apply and is holding, across every | |
| 46 | + | /// scope. Non-zero means some of another device's data is not here yet. | |
| 47 | + | pub held_changes: i64, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// One held change, for the drill-in list. | |
| 51 | + | #[derive(Debug, Serialize)] | |
| 52 | + | #[serde(rename_all = "camelCase")] | |
| 53 | + | pub struct HeldChangeResponse { | |
| 54 | + | /// Scope the change belongs to: empty for personal, otherwise a group id. | |
| 55 | + | pub scope: String, | |
| 56 | + | /// Engine table name, needed to address the row on retry. | |
| 57 | + | pub table: String, | |
| 58 | + | /// Wire row id, needed to address the row on retry. | |
| 59 | + | pub row_id: String, | |
| 60 | + | /// Display name for the kind of thing this is ("Task", "Contact"). | |
| 61 | + | pub label: String, | |
| 62 | + | /// A title pulled from the payload, when the row has one worth showing. | |
| 63 | + | pub title: Option<String>, | |
| 64 | + | /// Why it could not be applied. | |
| 65 | + | pub cause: String, | |
| 66 | + | /// Whether a retry is still expected to help. | |
| 67 | + | pub retryable: bool, | |
| 68 | + | /// Retries already spent. | |
| 69 | + | pub attempts: i64, | |
| 70 | + | /// When it was first held (RFC 3339). | |
| 71 | + | pub first_seen: String, | |
| 44 | 72 | } | |
| 45 | 73 | ||
| 46 | 74 | /// Response for sync_start_auth command. | |
| @@ -150,6 +178,18 @@ | |||
| 150 | 178 | let states = states_result.unwrap_or_default(); | |
| 151 | 179 | let pending_changes = pending_changes.unwrap_or(0); | |
| 152 | 180 | ||
| 181 | + | // Held changes come from the engine, which owns the hold's semantics (what | |
| 182 | + | // counts as retryable, when an entry is spent). A read failure degrades to | |
| 183 | + | // zero rather than failing the whole status call; the drill-in reports the | |
| 184 | + | // real error if the user opens it. | |
| 185 | + | let held_changes = match state.sync_store.as_ref() { | |
| 186 | + | Some(store) => store | |
| 187 | + | .held_counts() | |
| 188 | + | .await | |
| 189 | + | .map_or(0, |c| i64::try_from(c.total()).unwrap_or(i64::MAX)), | |
| 190 | + | None => 0, | |
| 191 | + | }; | |
| 192 | + | ||
| 153 | 193 | let device_id = states.get("device_id").filter(|s| !s.is_empty()).cloned(); | |
| 154 | 194 | let auto_sync_enabled = states.get("auto_sync_enabled").is_none_or(|v| v == "1"); | |
| 155 | 195 | let sync_interval_minutes = states | |
| @@ -172,9 +212,70 @@ | |||
| 172 | 212 | sync_interval_minutes, | |
| 173 | 213 | last_sync_at, | |
| 174 | 214 | pending_changes, | |
| 215 | + | held_changes, | |
| 175 | 216 | }) | |
| 176 | 217 | } | |
| 177 | 218 | ||
| 219 | + | /// Lists the remote changes this device is holding because it could not apply | |
| 220 | + | /// them, newest first, across the personal scope and every group. | |
| 221 | + | /// | |
| 222 | + | /// A held change is not lost (the engine keeps the entry and retries the | |
| 223 | + | /// retryable ones on every pull), but it is also not in the database yet, so | |
| 224 | + | /// this is the one place a user can see what is missing and why. | |
| 225 | + | #[tauri::command] | |
| 226 | + | #[instrument(skip_all)] | |
| 227 | + | pub async fn sync_held_changes( | |
| 228 | + | state: State<'_, Arc<AppState>>, | |
| 229 | + | ) -> Result<Vec<HeldChangeResponse>, ApiError> { | |
| 230 | + | let Some(store) = state.sync_store.as_ref() else { | |
| 231 | + | return Ok(Vec::new()); | |
| 232 | + | }; | |
| 233 | + | let held = store | |
| 234 | + | .held_entries() | |
| 235 | + | .await | |
| 236 | + | .map_api_err("Failed to list held changes", ApiError::internal)?; | |
| 237 | + | ||
| 238 | + | Ok(held | |
| 239 | + | .into_iter() | |
| 240 | + | .map(|h| { | |
| 241 | + | let (label, _) = manifest::describe_table(&h.table); | |
| 242 | + | HeldChangeResponse { | |
| 243 | + | title: manifest::payload_title(&h.table, h.payload.as_ref()), | |
| 244 | + | scope: h.scope, | |
| 245 | + | table: h.table, | |
| 246 | + | row_id: h.row_id, | |
| 247 | + | label: label.to_string(), | |
| 248 | + | cause: h.cause, | |
| 249 | + | retryable: matches!(h.state, HoldState::Deferred), | |
| 250 | + | attempts: h.attempts, | |
| 251 | + | first_seen: h.first_seen, | |
| 252 | + | } | |
| 253 | + | }) | |
| 254 | + | .collect()) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// Puts one held change back in the retry queue with a fresh attempt budget. | |
| 258 | + | /// | |
| 259 | + | /// The per-row Retry: for an entry the engine has stopped retrying on its own, | |
| 260 | + | /// after a user has plausibly fixed the cause (upgraded another device, restored | |
| 261 | + | /// the parent record). Returns whether such an entry was there to requeue. | |
| 262 | + | #[tauri::command] | |
| 263 | + | #[instrument(skip_all)] | |
| 264 | + | pub async fn sync_retry_held_change( | |
| 265 | + | state: State<'_, Arc<AppState>>, | |
| 266 | + | scope: String, | |
| 267 | + | table: String, | |
| 268 | + | row_id: String, | |
| 269 | + | ) -> Result<bool, ApiError> { | |
| 270 | + | let Some(store) = state.sync_store.as_ref() else { | |
| 271 | + | return Ok(false); | |
| 272 | + | }; | |
| 273 | + | store | |
| 274 | + | .retry_held(&scope, &table, &row_id) | |
| 275 | + | .await | |
| 276 | + | .map_api_err("Failed to retry the held change", ApiError::internal) | |
| 277 | + | } | |
| 278 | + | ||
| 178 | 279 | /// Starts the SyncKit OAuth2 PKCE flow. | |
| 179 | 280 | #[tauri::command] | |
| 180 | 281 | #[instrument(skip_all)] |
| @@ -358,6 +358,58 @@ | |||
| 358 | 358 | .conflict_strategy(ConflictStrategy::HybridLogicalClock) | |
| 359 | 359 | } | |
| 360 | 360 | ||
| 361 | + | /// How to describe one of the manifest's tables to a person: a display name, and | |
| 362 | + | /// the payload fields worth trying as a title, best first. | |
| 363 | + | /// | |
| 364 | + | /// The dead-letter hold identifies a stuck change by table and wire row id, which | |
| 365 | + | /// tells a user nothing about which of their items is affected. This turns | |
| 366 | + | /// `("tasks", {"title": "Call the bank", ...})` into "Task: Call the bank". A | |
| 367 | + | /// table with nothing worth showing (a join row, a timer) returns no fields and | |
| 368 | + | /// is listed by its type alone. | |
| 369 | + | pub(crate) fn describe_table(table: &str) -> (&'static str, &'static [&'static str]) { | |
| 370 | + | match table { | |
| 371 | + | "projects" => ("Project", &["name"]), | |
| 372 | + | "contacts" => ("Contact", &["display_name", "nickname", "company"]), | |
| 373 | + | "email_accounts" => ("Email account", &["email", "account_name"]), | |
| 374 | + | "sync_accounts" => ("Calendar account", &["account_name", "email"]), | |
| 375 | + | "milestones" => ("Milestone", &["name"]), | |
| 376 | + | "tasks" => ("Task", &["title"]), | |
| 377 | + | "time_sessions" => ("Time session", &[]), | |
| 378 | + | "attachments" => ("Attachment", &["filename"]), | |
| 379 | + | "events" => ("Event", &["title"]), | |
| 380 | + | "annotations" => ("Note", &["note"]), | |
| 381 | + | "subtasks" => ("Subtask", &["text"]), | |
| 382 | + | "task_status_tokens" => ("Task status", &["reference"]), | |
| 383 | + | "contact_emails" => ("Contact email", &["address"]), | |
| 384 | + | "contact_phones" => ("Contact phone", &["number"]), | |
| 385 | + | "contact_social_handles" => ("Contact handle", &["handle", "platform"]), | |
| 386 | + | "contact_custom_fields" => ("Contact field", &["label"]), | |
| 387 | + | "daily_notes" => ("Daily note", &["note_date"]), | |
| 388 | + | "saved_views" => ("Saved view", &["name"]), | |
| 389 | + | "weekly_reviews" => ("Weekly review", &["week_start_date"]), | |
| 390 | + | "monthly_goals" => ("Monthly goal", &["text"]), | |
| 391 | + | "monthly_reflections" => ("Monthly reflection", &["month"]), | |
| 392 | + | "user_config" => ("Setting", &["key"]), | |
| 393 | + | // A table this build does not know. Legitimate at runtime: the change came | |
| 394 | + | // from a newer client, which is exactly the unknown-table case the hold | |
| 395 | + | // defers until this client is upgraded. A test pins that every table the | |
| 396 | + | // manifest DOES declare is described. | |
| 397 | + | _ => ("Change", &[]), | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | /// Pull a human-readable title out of a held change's payload, per | |
| 402 | + | /// [`describe_table`]. `None` when the payload is absent (a delete) or carries | |
| 403 | + | /// nothing worth showing. | |
| 404 | + | pub(crate) fn payload_title(table: &str, payload: Option<&serde_json::Value>) -> Option<String> { | |
| 405 | + | let payload = payload?; | |
| 406 | + | let (_, fields) = describe_table(table); | |
| 407 | + | fields.iter().find_map(|f| { | |
| 408 | + | let text = payload.get(*f)?.as_str()?.trim(); | |
| 409 | + | (!text.is_empty()).then(|| text.to_string()) | |
| 410 | + | }) | |
| 411 | + | } | |
| 412 | + | ||
| 361 | 413 | #[cfg(test)] | |
| 362 | 414 | mod tests { | |
| 363 | 415 | use super::goingson_schema; | |
| @@ -420,6 +472,40 @@ | |||
| 420 | 472 | } | |
| 421 | 473 | } | |
| 422 | 474 | ||
| 475 | + | #[test] | |
| 476 | + | fn every_manifest_table_has_a_description() { | |
| 477 | + | for name in EXPECTED_TABLES { | |
| 478 | + | let (label, _) = super::describe_table(name); | |
| 479 | + | assert_ne!( | |
| 480 | + | label, "Change", | |
| 481 | + | "{name} has no display name; a held change would be listed as a bare 'Change'" | |
| 482 | + | ); | |
| 483 | + | } | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | #[test] | |
| 487 | + | fn a_title_is_pulled_from_the_payload_and_falls_back_cleanly() { | |
| 488 | + | use serde_json::json; | |
| 489 | + | let task = json!({"id": "t1", "title": "Call the bank"}); | |
| 490 | + | assert_eq!( | |
| 491 | + | super::payload_title("tasks", Some(&task)).as_deref(), | |
| 492 | + | Some("Call the bank") | |
| 493 | + | ); | |
| 494 | + | // Blank and absent both mean "nothing worth showing", not an empty label. | |
| 495 | + | assert_eq!( | |
| 496 | + | super::payload_title("tasks", Some(&json!({"title": " "}))), | |
| 497 | + | None | |
| 498 | + | ); | |
| 499 | + | assert_eq!(super::payload_title("time_sessions", Some(&task)), None); | |
| 500 | + | assert_eq!(super::payload_title("tasks", None), None); | |
| 501 | + | // The first field that has something wins. | |
| 502 | + | let contact = json!({"display_name": null, "nickname": "Bo"}); | |
| 503 | + | assert_eq!( | |
| 504 | + | super::payload_title("contacts", Some(&contact)).as_deref(), | |
| 505 | + | Some("Bo") | |
| 506 | + | ); | |
| 507 | + | } | |
| 508 | + | ||
| 423 | 509 | #[test] | |
| 424 | 510 | fn manifest_table_set_and_order_is_frozen() { | |
| 425 | 511 | let schema = goingson_schema(); |