vue2-excel-grid 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -820,13 +820,16 @@
820
820
  :visible="localSyncModal.visible"
821
821
  :grid-options="_selfComponentDef"
822
822
  :columns="_localSyncPreviewColumns"
823
- :snapshot="localSyncModal.snapshot"
823
+ :versions="localSyncModal.versions"
824
+ :selected-id="localSyncModal.selectedId"
825
+ :selected-version="localSyncModal.selectedVersion"
824
826
  :locale="locale"
825
827
  :theme="internalTheme"
826
828
  @close="closeLocalSyncModal"
829
+ @select="_selectLocalSyncModalVersion"
827
830
  @save="_onLocalSyncModalSave"
828
- @restore="restoreLocalSnapshot"
829
- @clear="clearLocalSnapshot"
831
+ @restore="restoreLocalVersion"
832
+ @clear="_onLocalSyncModalDelete"
830
833
  />
831
834
 
832
835
  <!-- Lock/unlock state feedback — brief centered icon flash -->
@@ -944,7 +947,8 @@ import EgLocalSyncModal from './EgLocalSyncModal.vue'
944
947
  import { parseDateValue, formatDate } from '../utils/dateUtils.js'
945
948
  import { formatNumber as _formatNumber } from '../utils/numberFormat.js'
946
949
  import { Mask, MaskInput } from '../utils/mask.js'
947
- import { saveSnapshot, loadSnapshot, deleteSnapshot } from '../utils/idbStore.js'
950
+ import { listVersions, getVersion, deleteVersion, saveOrTouchVersion } from '../utils/idbStore.js'
951
+ import { hashRowsAsync } from '../utils/dataHash.js'
948
952
 
949
953
  const SIZE_CONFIG = {
950
954
  small: { row: 22, header: 24, footer: 24, filter: 26 },
@@ -1409,34 +1413,48 @@ const ExcelGridComponent = {
1409
1413
  sort: { type: Object, default: null },
1410
1414
 
1411
1415
  /**
1412
- * Unique key that opts the grid into local autosave: whenever `data`
1413
- * changes, a debounced (~800ms) copy is written to the browser's
1414
- * IndexedDB, keyed by this string — so a page reload/crash/tab close
1415
- * doesn't lose in-progress work. Also adds a toolbar button ('localSync'
1416
- * — see the `toolbar` prop's `list`/`disabled`) that opens a modal
1417
- * showing the last saved copy as a read-only table, with three actions:
1418
- * **Save** (pushes the current `v-model` into the saved copy right now,
1419
- * bypassing the debounce), **Restore** (the other direction — pulls the
1420
- * saved copy back into `v-model`), and **Delete** (clears the saved
1421
- * copy).
1416
+ * Unique key that opts the grid into local version history: when the page
1417
+ * is being left (tab hidden/closed, or this component unmounted — NOT on
1418
+ * every edit), the current `v-model` is content-hashed (via a Web Worker
1419
+ * when available, so the main thread stays responsive right as the page
1420
+ * may be unloading) and saved to the browser's IndexedDB as a **version**,
1421
+ * keyed by this string. If the hash matches an already-saved version for
1422
+ * this key, that version's timestamp is refreshed instead of creating a
1423
+ * duplicate — only genuinely different content produces a new version.
1424
+ * Also adds a toolbar button ('localSync' — see the `toolbar` prop's
1425
+ * `list`/`disabled`) that opens a modal listing saved versions, each with
1426
+ * a read-only preview and **Restore** / **Delete** actions, plus a
1427
+ * **Save** button for an explicit checkpoint.
1422
1428
  *
1423
1429
  * Two different grid instances must use different keys — the same key
1424
- * means "the same saved copy" (last writer wins). Omit this prop
1425
- * entirely to opt out completely: no IndexedDB access happens at all,
1426
- * and the toolbar button never renders.
1430
+ * means "the same version history" (concurrent tabs interleave). Omit
1431
+ * this prop entirely to opt out completely: no IndexedDB access happens
1432
+ * at all, and the toolbar button never renders.
1427
1433
  *
1428
- * See also the ref-callable methods `saveLocalSnapshot()`,
1429
- * `restoreLocalSnapshot()`, `clearLocalSnapshot()`, `getLocalSnapshot()`,
1430
- * `getLocalSnapshotInfo()`, `openLocalSyncModal()`/`closeLocalSyncModal()`,
1431
- * and the
1432
- * `local-sync-save`/`local-sync-restore`/`local-sync-clear`/`local-sync-error`
1434
+ * See also `localSyncMaxVersions`, the ref-callable methods
1435
+ * `listLocalVersions()`, `getLocalVersion(id)`, `restoreLocalVersion(id)`,
1436
+ * `deleteLocalVersion(id)`, `saveLocalVersionNow()`,
1437
+ * `openLocalSyncModal()`/`closeLocalSyncModal()`, and the
1438
+ * `local-sync-save`/`local-sync-restore`/`local-sync-clear`/`local-sync-error`/`local-sync-available`
1433
1439
  * events — [details](/local-sync).
1434
1440
  * @type {String}
1435
1441
  * @default null
1436
1442
  * @example
1437
1443
  * <excel-grid :columns="columns" v-model="data" local-sync-key="invoice-42" toolbar />
1438
1444
  */
1439
- localSyncKey: { type: String, default: null }
1445
+ localSyncKey: { type: String, default: null },
1446
+
1447
+ /**
1448
+ * Maximum number of local versions kept per `localSyncKey`. Once a new
1449
+ * version pushes the count past this, the oldest version(s) (by
1450
+ * `createdAt`) are deleted automatically (FIFO). Has no effect without
1451
+ * `localSyncKey`.
1452
+ * @type {Number}
1453
+ * @default 10
1454
+ * @example
1455
+ * <excel-grid :columns="columns" v-model="data" local-sync-key="invoice-42" :local-sync-max-versions="20" />
1456
+ */
1457
+ localSyncMaxVersions: { type: Number, default: 10 }
1440
1458
  },
1441
1459
 
1442
1460
  data() {
@@ -1506,12 +1524,14 @@ const ExcelGridComponent = {
1506
1524
  importState: { active: false, percent: 0, message: '' },
1507
1525
  exportState: { active: false },
1508
1526
 
1509
- // localSyncKey feature — modal state + pending debounce timer (the
1510
- // timer itself lives outside `data()`'s reactive tracking, see created()).
1511
- localSyncModal: { visible: false, snapshot: null },
1512
- // True when a snapshot already existed at mount and the user hasn't
1513
- // dealt with it yet — drives the toolbar button's "unseen draft" dot.
1514
- // See _checkLocalSnapshotOnMount() for the full lifecycle.
1527
+ // localSyncKey feature — modal state: the version list (metadata only)
1528
+ // plus which one is selected/previewed. `selectedVersion` holds that
1529
+ // version's full { id, data, createdAt, updatedAt } for the nested-grid
1530
+ // preview (see EgLocalSyncModal.vue).
1531
+ localSyncModal: { visible: false, versions: [], selectedId: null, selectedVersion: null },
1532
+ // True when one or more versions already existed at mount and the user
1533
+ // hasn't opened the modal yet — drives the toolbar button's "unseen
1534
+ // local versions" dot. See _loadLocalVersionsOnMount() for the full lifecycle.
1515
1535
  localSyncAvailable: false,
1516
1536
 
1517
1537
  resizing: {
@@ -2270,13 +2290,6 @@ const ExcelGridComponent = {
2270
2290
  this.$nextTick(() => this.updateVirtualScroll())
2271
2291
  },
2272
2292
 
2273
- // Guarded by _lockFlashArmed (set once mounted()) so an initial `locked:
2274
- // true` prop doesn't flash the icon before the grid has even appeared.
2275
- internalLocked(v) {
2276
- if (!this._lockFlashArmed) return
2277
- this._flashLock(v)
2278
- },
2279
-
2280
2293
  autoWidth(v) {
2281
2294
  if (v) this._applyAutoWidth()
2282
2295
  },
@@ -2347,9 +2360,21 @@ const ExcelGridComponent = {
2347
2360
  immediate: true,
2348
2361
  handler(v) { this.internalCollapsedGroups = v ? [...v] : [] }
2349
2362
  },
2363
+ // `immediate: true` guarantees this fires once, synchronously, right at
2364
+ // setup regardless of `v` — that's the one reliable signal that THIS
2365
+ // particular call is the initial sync, not a real host-driven change
2366
+ // (e.g. via `:locked.sync`). Every call after the first is a genuine
2367
+ // change, so only those flash. (Watching internalLocked itself for this
2368
+ // instead — the previous approach — can't tell "initial value happened
2369
+ // to be true" from "the user's first-ever real toggle": both look like
2370
+ // "the first time internalLocked actually changed value".)
2350
2371
  locked: {
2351
2372
  immediate: true,
2352
- handler(v) { this.internalLocked = !!v }
2373
+ handler(v) {
2374
+ this.internalLocked = !!v
2375
+ if (this._lockPropSynced) this._flashLock(this.internalLocked)
2376
+ this._lockPropSynced = true
2377
+ }
2353
2378
  },
2354
2379
  data: {
2355
2380
  immediate: true,
@@ -2419,6 +2444,12 @@ const ExcelGridComponent = {
2419
2444
  this._dragScrollRAF = null
2420
2445
  this._dragCleanupTimer = null
2421
2446
 
2447
+ // localSyncKey — the last `data` reference a version was actually saved
2448
+ // for. Initialized to the current (mount-time) value so an untouched grid
2449
+ // that's merely tab-switched away from doesn't spam a "nothing happened"
2450
+ // version on every hide — only a REAL edit (which replaces this array
2451
+ // reference, see _emitDataChange) makes the next hide event flush.
2452
+ this._lastFlushedDataRef = this.data
2422
2453
  },
2423
2454
 
2424
2455
  mounted() {
@@ -2450,8 +2481,17 @@ const ExcelGridComponent = {
2450
2481
  // in both modes) fresh when the host's layout changes.
2451
2482
  if (this.$el.parentElement) this._scrollBodyResizeObserver.observe(this.$el.parentElement)
2452
2483
  }
2453
- this._lockFlashArmed = true
2454
- this._checkLocalSnapshotOnMount()
2484
+ this._loadLocalVersionsOnMount()
2485
+ // localSyncKey — save on page-hide, not per-edit (see _flushLocalVersionOnHide).
2486
+ // 'visibilitychange'->hidden is the primary trigger: it fires for tab
2487
+ // switches, minimizing, AND reliably before actual tab close/navigation on
2488
+ // both desktop and mobile, while the page is still very likely alive —
2489
+ // giving the async worker-hash + IndexedDB write a real chance to finish.
2490
+ // 'pagehide' is a backstop for the cases visibilitychange misses; it's
2491
+ // idempotent with visibilitychange (same content -> same hash -> no-op
2492
+ // beyond a timestamp touch) so firing both is harmless.
2493
+ document.addEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)
2494
+ window.addEventListener('pagehide', this._onPageHideForLocalSync)
2455
2495
  },
2456
2496
 
2457
2497
  updated() {
@@ -2486,7 +2526,6 @@ const ExcelGridComponent = {
2486
2526
  clearTimeout(this._onInputTimer)
2487
2527
  clearTimeout(this._dragCleanupTimer)
2488
2528
  clearTimeout(this._widthSyncChainResetTimer)
2489
- clearTimeout(this._localSyncTimer)
2490
2529
  if (this._dragScrollRAF) cancelAnimationFrame(this._dragScrollRAF)
2491
2530
  document.removeEventListener('mousemove', this.onGlobalMouseMove)
2492
2531
  document.removeEventListener('mouseup', this.onGlobalMouseUp)
@@ -2494,6 +2533,11 @@ const ExcelGridComponent = {
2494
2533
  document.removeEventListener('mousedown', this._onDocMouseDownForDropdown, true)
2495
2534
  window.removeEventListener('resize', this.syncHeaderViewportWidth)
2496
2535
  window.removeEventListener('scroll', this._onWindowScrollForDropdown, true)
2536
+ document.removeEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)
2537
+ window.removeEventListener('pagehide', this._onPageHideForLocalSync)
2538
+ // Best-effort — the component may be torn down (SPA route change) without
2539
+ // the page itself unloading, which visibilitychange/pagehide won't catch.
2540
+ this._flushLocalVersionOnHide()
2497
2541
  if (this.isFullscreen) {
2498
2542
  document.body.style.overflow = this._prevBodyOverflow || ''
2499
2543
  document.removeEventListener('keydown', this._onFullscreenKeyDown)
@@ -6011,7 +6055,6 @@ const ExcelGridComponent = {
6011
6055
  // All edit paths already use spread ({ ...row }) so frozen rows are safe.
6012
6056
  const data = result.data.map(row => Object.freeze(row))
6013
6057
  this.$emit('input', data)
6014
- this._scheduleLocalAutosave()
6015
6058
  this.$emit('update:columns', columns)
6016
6059
 
6017
6060
  const hasMerges = merges && merges.length > 0
@@ -6127,7 +6170,6 @@ const ExcelGridComponent = {
6127
6170
  }
6128
6171
  }
6129
6172
  this.$emit('input', newData)
6130
- this._scheduleLocalAutosave()
6131
6173
  },
6132
6174
 
6133
6175
  // Emit 'update:merges' and record a merges diff entry.
@@ -6161,7 +6203,6 @@ const ExcelGridComponent = {
6161
6203
  newData.length = entry.before.rowCount
6162
6204
  }
6163
6205
  this.$emit('input', newData)
6164
- this._scheduleLocalAutosave()
6165
6206
  } else if (entry.type === 'merges') {
6166
6207
  this.internalMerges = entry.before.slice()
6167
6208
  this.$emit('update:merges', entry.before.slice())
@@ -6184,7 +6225,6 @@ const ExcelGridComponent = {
6184
6225
  newData[entry.after.indices[i]] = entry.after.rows[i]
6185
6226
  }
6186
6227
  this.$emit('input', newData)
6187
- this._scheduleLocalAutosave()
6188
6228
  } else if (entry.type === 'merges') {
6189
6229
  this.internalMerges = entry.after.slice()
6190
6230
  this.$emit('update:merges', entry.after.slice())
@@ -6218,147 +6258,190 @@ const ExcelGridComponent = {
6218
6258
  },
6219
6259
 
6220
6260
  // =========================================================
6221
- // LOCAL SYNC — autosave `data` to IndexedDB, keyed by localSyncKey
6261
+ // LOCAL SYNC — save `data` as a local IndexedDB *version*, keyed by
6262
+ // localSyncKey, only when the page is being left (not per-edit).
6222
6263
  // =========================================================
6223
6264
 
6224
- // Internal: debounced (800ms) autosave. No-op when localSyncKey isn't
6225
- // set — zero IndexedDB access at all in that case.
6226
- //
6227
- // Deliberately called from the grid's OWN data-change points only
6228
- // (_emitDataChange, undo, redo, import) — NOT from a $watch on `data`.
6229
- // A watcher can't tell "the user edited a cell" from "the host replaced
6230
- // `data` wholesale", and the second one is the common case right after
6231
- // mount: a host that loads rows from an API assigns them ~100-2000ms in,
6232
- // which a watcher would treat as a real change and autosave over the
6233
- // user's previously saved draft — destroying exactly the work this
6234
- // feature exists to protect, before they can open the modal to restore
6235
- // it. Autosave must mean "the user worked", not "the data object
6236
- // changed". restoreLocalSnapshot() likewise does NOT call this: the
6237
- // snapshot it just applied is already what's stored.
6238
- _scheduleLocalAutosave() {
6239
- if (!this.localSyncKey) return
6240
- clearTimeout(this._localSyncTimer)
6241
- this._localSyncTimer = setTimeout(() => {
6242
- this.saveLocalSnapshot()
6243
- }, 800)
6244
- },
6245
-
6246
- // Internal, called once from mounted(): a snapshot that already exists at
6247
- // mount is a draft from an EARLIER session, which the user has no way of
6248
- // knowing about unless they happen to click the toolbar button. Flags it
6249
- // so the button shows a dot, and emits local-sync-available so a host can
6250
- // surface its own prompt ("restore unsaved changes?") instead.
6251
- async _checkLocalSnapshotOnMount() {
6265
+ // Internal, called once from mounted(): populates the toolbar "unseen
6266
+ // local versions" dot and emits local-sync-available if any versions
6267
+ // already exist for this key (e.g. saved in an earlier visit).
6268
+ async _loadLocalVersionsOnMount() {
6252
6269
  if (!this.localSyncKey) return
6253
- const snapshot = await this.getLocalSnapshot()
6254
- if (!snapshot) return
6255
- this.localSyncAvailable = true
6256
- this.$emit('local-sync-available', {
6257
- key: this.localSyncKey,
6258
- rows: snapshot.data.length,
6259
- updatedAt: snapshot.updatedAt
6260
- })
6261
- },
6262
-
6263
- // Public, ref-callable: saves the current `data` to IndexedDB right now,
6264
- // bypassing the debounce (e.g. a host wants to force a checkpoint before
6265
- // navigating away). No-op (resolves to null) when localSyncKey isn't set.
6266
- async saveLocalSnapshot() {
6267
- if (!this.localSyncKey) return null
6268
- clearTimeout(this._localSyncTimer)
6269
6270
  try {
6270
- const { updatedAt } = await saveSnapshot(this.localSyncKey, this.data)
6271
- // What's stored is now exactly what's on screen — there's no longer an
6272
- // older draft to go back to, so the "unseen draft" dot would mislead.
6273
- this.localSyncAvailable = false
6274
- this.$emit('local-sync-save', { key: this.localSyncKey, rows: this.data.length, updatedAt })
6275
- return updatedAt
6271
+ const versions = await listVersions(this.localSyncKey)
6272
+ if (!versions.length) return
6273
+ this.localSyncAvailable = true
6274
+ this.$emit('local-sync-available', {
6275
+ key: this.localSyncKey,
6276
+ versions: versions.length,
6277
+ mostRecentUpdatedAt: versions[0].updatedAt
6278
+ })
6276
6279
  } catch (error) {
6277
- this.$emit('local-sync-error', { action: 'save', error })
6278
- return null
6280
+ this.$emit('local-sync-error', { action: 'load', error })
6279
6281
  }
6280
6282
  },
6281
6283
 
6282
- // Modal's "Save" button (push v-model -> IndexedDB — the other direction
6283
- // from "Restore", which pulls IndexedDB -> v-model). Unlike
6284
- // restoreLocalSnapshot(), this deliberately does NOT close the modal —
6285
- // the freshly-saved snapshot is reflected in place (updated timestamp/
6286
- // row count/preview) so the user sees the save actually happened.
6287
- async _onLocalSyncModalSave() {
6288
- const updatedAt = await this.saveLocalSnapshot()
6289
- if (updatedAt != null) {
6290
- this.localSyncModal.snapshot = { data: this.data, updatedAt }
6291
- }
6284
+ _onVisibilityChangeForLocalSync() {
6285
+ if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide()
6286
+ },
6287
+
6288
+ _onPageHideForLocalSync() {
6289
+ this._flushLocalVersionOnHide()
6292
6290
  },
6293
6291
 
6294
- // Public, ref-callable: loads the IndexedDB snapshot and writes it back
6295
- // into v-model (input/update:data) — the modal's "Restore" button calls
6296
- // this too. No-op if localSyncKey isn't set or nothing is saved yet.
6297
- async restoreLocalSnapshot() {
6292
+ // The core save-on-leave path — replaces a per-edit autosave (see the
6293
+ // localSyncKey prop's JSDoc for why: an autosave tied to "data changed"
6294
+ // can't distinguish a user's edit from a host loading rows from an API,
6295
+ // and would silently overwrite a user's saved work the moment a host
6296
+ // does the latter). Triggered from mounted()'s visibilitychange/pagehide
6297
+ // listeners and beforeDestroy() — see those for the three trigger points.
6298
+ //
6299
+ // Content-hashes the current `data` (via a Web Worker when available —
6300
+ // src/utils/dataHash.js — falling back to the same algorithm
6301
+ // synchronously otherwise, so this always resolves) and either touches
6302
+ // an existing local version with the same hash (just refreshes its
6303
+ // timestamp — "ustiga yoziladi, alohida versiya qilinmaydi") or creates
6304
+ // a brand-new one, evicting the oldest past localSyncMaxVersions (FIFO —
6305
+ // see idbStore.js's saveOrTouchVersion()). Never throws — IndexedDB
6306
+ // failures become local-sync-error.
6307
+ //
6308
+ // `force` skips the "nothing changed since the last flush" short-circuit
6309
+ // below. The passive triggers (visibilitychange/pagehide/beforeDestroy)
6310
+ // fire often on an idle tab and must NOT wake the worker/IndexedDB for
6311
+ // no reason, so they leave it in place. saveLocalVersionNow() — an
6312
+ // explicit, infrequent, host/modal-initiated "checkpoint now" action —
6313
+ // passes force so it never silently does nothing: even unchanged data
6314
+ // still gets its existing version's timestamp refreshed via the same
6315
+ // hash-dedup path, rather than the caller seeing no effect at all.
6316
+ async _flushLocalVersionOnHide(force = false) {
6298
6317
  if (!this.localSyncKey) return
6318
+ if (!force && this.data === this._lastFlushedDataRef) return // nothing changed since the last flush
6319
+ const data = this.data
6299
6320
  try {
6300
- const snapshot = await loadSnapshot(this.localSyncKey)
6301
- if (!snapshot) return
6302
- this.$emit('input', snapshot.data)
6303
- this.$emit('update:data', snapshot.data)
6304
- this.localSyncAvailable = false
6305
- this.$emit('local-sync-restore', { key: this.localSyncKey, rows: snapshot.data.length })
6306
- this.closeLocalSyncModal()
6321
+ const hash = await hashRowsAsync(data)
6322
+ if (this.data !== data) return // data changed again mid-hash — a later flush will catch it
6323
+ const { version, overwritten } = await saveOrTouchVersion(
6324
+ this.localSyncKey, data, hash, this.localSyncMaxVersions
6325
+ )
6326
+ this._lastFlushedDataRef = data
6327
+ this.$emit('local-sync-save', {
6328
+ key: this.localSyncKey,
6329
+ id: version.id,
6330
+ rows: data.length,
6331
+ updatedAt: version.updatedAt,
6332
+ overwritten
6333
+ })
6307
6334
  } catch (error) {
6308
- this.$emit('local-sync-error', { action: 'restore', error })
6335
+ this.$emit('local-sync-error', { action: 'save', error })
6309
6336
  }
6310
6337
  },
6311
6338
 
6312
- // Public, ref-callable: deletes the IndexedDB snapshot — the modal's
6313
- // "Delete" button calls this too (after its own inline confirm step).
6314
- async clearLocalSnapshot() {
6315
- if (!this.localSyncKey) return
6339
+ // Public, ref-callable: runs the save-on-leave flow immediately, for a
6340
+ // host that wants its own explicit "save checkpoint" button instead of
6341
+ // waiting for a page-hide. Always goes through IndexedDB (force — see
6342
+ // _flushLocalVersionOnHide()), but still through the same hash-dedup
6343
+ // logic, so repeated calls with unchanged data don't spam duplicate
6344
+ // versions — they just refresh the existing matching version.
6345
+ async saveLocalVersionNow() {
6346
+ await this._flushLocalVersionOnHide(true)
6347
+ },
6348
+
6349
+ // Public, ref-callable: lightweight metadata for every saved version of
6350
+ // this localSyncKey, newest first — no row data (use getLocalVersion(id)
6351
+ // for that). Powers the built-in modal's list; also usable to build a
6352
+ // custom UI.
6353
+ async listLocalVersions() {
6354
+ if (!this.localSyncKey) return []
6316
6355
  try {
6317
- await deleteSnapshot(this.localSyncKey)
6318
- if (this.localSyncModal.snapshot) this.localSyncModal.snapshot = null
6319
- this.localSyncAvailable = false
6320
- this.$emit('local-sync-clear', { key: this.localSyncKey })
6356
+ const versions = await listVersions(this.localSyncKey)
6357
+ return versions.map(v => ({ id: v.id, createdAt: v.createdAt, updatedAt: v.updatedAt, rowCount: v.data.length }))
6321
6358
  } catch (error) {
6322
- this.$emit('local-sync-error', { action: 'clear', error })
6359
+ this.$emit('local-sync-error', { action: 'load', error })
6360
+ return []
6323
6361
  }
6324
6362
  },
6325
6363
 
6326
- // Public, ref-callable: returns the saved copy's full { data, updatedAt }
6327
- // (or null) WITHOUT applying it to v-model and without opening the
6328
- // built-in modal — the pure "Read" a host needs to build its own custom
6329
- // UI (preview/inspect the saved rows, decide what to do, etc.), as
6330
- // opposed to restoreLocalSnapshot() which reads AND immediately commits
6331
- // the result into v-model as one step.
6332
- async getLocalSnapshot() {
6333
- if (!this.localSyncKey) return null
6364
+ // Public, ref-callable: the full { id, data, createdAt, updatedAt } for
6365
+ // one saved version, WITHOUT applying it to v-model — the pure "Read"
6366
+ // for a custom UI (as opposed to restoreLocalVersion(), which reads AND
6367
+ // commits in one step).
6368
+ async getLocalVersion(id) {
6334
6369
  try {
6335
- return await loadSnapshot(this.localSyncKey)
6370
+ return await getVersion(id)
6336
6371
  } catch (error) {
6337
6372
  this.$emit('local-sync-error', { action: 'load', error })
6338
6373
  return null
6339
6374
  }
6340
6375
  },
6341
6376
 
6342
- // Public, ref-callable: lightweight { updatedAt, rowCount } | null variant
6343
- // of getLocalSnapshot() — for a host that only wants to know whether/when
6344
- // something is saved (e.g. an "unsaved local changes" prompt on mount)
6345
- // without pulling the full row data across.
6346
- async getLocalSnapshotInfo() {
6347
- const snapshot = await this.getLocalSnapshot()
6348
- return snapshot ? { updatedAt: snapshot.updatedAt, rowCount: snapshot.data.length } : null
6377
+ // Public, ref-callable: writes one saved version back into v-model
6378
+ // (input/update:data) — the modal's "Restore" button calls this too.
6379
+ async restoreLocalVersion(id) {
6380
+ try {
6381
+ const version = await this.getLocalVersion(id)
6382
+ if (!version) return
6383
+ this.$emit('input', version.data)
6384
+ this.$emit('update:data', version.data)
6385
+ this._lastFlushedDataRef = version.data // what's now on screen is already what's stored
6386
+ this.localSyncAvailable = false
6387
+ this.$emit('local-sync-restore', { key: this.localSyncKey, id, rows: version.data.length })
6388
+ this.closeLocalSyncModal()
6389
+ } catch (error) {
6390
+ this.$emit('local-sync-error', { action: 'restore', error })
6391
+ }
6392
+ },
6393
+
6394
+ // Public, ref-callable: deletes one saved version — the modal's "Delete"
6395
+ // button calls this too (after its own inline confirm step).
6396
+ async deleteLocalVersion(id) {
6397
+ try {
6398
+ await deleteVersion(id)
6399
+ if (this.localSyncModal.selectedId === id) {
6400
+ this.localSyncModal.selectedId = null
6401
+ this.localSyncModal.selectedVersion = null
6402
+ }
6403
+ this.localSyncModal.versions = this.localSyncModal.versions.filter(v => v.id !== id)
6404
+ this.$emit('local-sync-clear', { key: this.localSyncKey, id })
6405
+ } catch (error) {
6406
+ this.$emit('local-sync-error', { action: 'clear', error })
6407
+ }
6349
6408
  },
6350
6409
 
6351
6410
  // Public, ref-callable: opens the local-sync modal (also reachable via
6352
- // the toolbar button), loading the latest snapshot fresh each time.
6411
+ // the toolbar button), loading the version list fresh each time.
6353
6412
  async openLocalSyncModal() {
6354
6413
  if (!this.localSyncKey) return
6355
6414
  this.localSyncModal.visible = true
6356
- this.localSyncAvailable = false // the draft has now been seen
6357
- try {
6358
- this.localSyncModal.snapshot = await loadSnapshot(this.localSyncKey)
6359
- } catch (error) {
6360
- this.localSyncModal.snapshot = null
6361
- this.$emit('local-sync-error', { action: 'load', error })
6415
+ this.localSyncAvailable = false // the version(s) have now been seen
6416
+ const versions = await this.listLocalVersions()
6417
+ this.localSyncModal.versions = versions
6418
+ await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)
6419
+ },
6420
+
6421
+ // Internal: modal's list-selection handler — loads the selected
6422
+ // version's full data for the nested-grid preview (EgLocalSyncModal.vue).
6423
+ async _selectLocalSyncModalVersion(id) {
6424
+ this.localSyncModal.selectedId = id
6425
+ this.localSyncModal.selectedVersion = id ? await this.getLocalVersion(id) : null
6426
+ },
6427
+
6428
+ // Modal's "Save" button — same as saveLocalVersionNow(), then refreshes
6429
+ // the modal's own list/selection in place so the user sees it happened.
6430
+ async _onLocalSyncModalSave() {
6431
+ await this.saveLocalVersionNow()
6432
+ const versions = await this.listLocalVersions()
6433
+ this.localSyncModal.versions = versions
6434
+ await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)
6435
+ },
6436
+
6437
+ // Modal's "Delete" button (after its own inline confirm) — deletes the
6438
+ // given version, then selects the next-most-recent one if the deleted
6439
+ // one was selected.
6440
+ async _onLocalSyncModalDelete(id) {
6441
+ await this.deleteLocalVersion(id)
6442
+ if (this.localSyncModal.selectedId == null) {
6443
+ const remaining = this.localSyncModal.versions
6444
+ await this._selectLocalSyncModalVersion(remaining.length ? remaining[0].id : null)
6362
6445
  }
6363
6446
  },
6364
6447
 
@@ -6371,6 +6454,7 @@ const ExcelGridComponent = {
6371
6454
  this.internalLocked = !this.internalLocked
6372
6455
  this.$emit('update:locked', this.internalLocked)
6373
6456
  this.$emit('lock-change', { locked: this.internalLocked })
6457
+ this._flashLock(this.internalLocked)
6374
6458
  },
6375
6459
 
6376
6460
  // Pops a big lock/unlock icon in the center of the grid and fades it