Skip to content

feat: local-first editor#615

Draft
NagariaHussain wants to merge 15 commits into
frappe:developfrom
NagariaHussain:feat/local-first-editor-step-1
Draft

feat: local-first editor#615
NagariaHussain wants to merge 15 commits into
frappe:developfrom
NagariaHussain:feat/local-first-editor-step-1

Conversation

@NagariaHussain
Copy link
Copy Markdown
Collaborator

Frontend draft workspace store that makes sidebar edits, reorder, and content saves feel local-first while still persisting through the existing CR RPCs. Spec: specs/local_first_editor_migration_step_1.md.

🤖 Generated with Claude Code

Introduce draftWorkspace Pinia store that owns optimistic tree, page draft
state, and a per-mutation queue backed by the existing CR RPCs. Sidebar
create/rename/delete/reorder, content save, and metadata edits all flow
through the store with immediate local feedback; submit/merge are gated
on pending/failed mutations. See specs/local_first_editor_migration_step_1.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NagariaHussain NagariaHussain changed the title feat: local-first editor migration (step 1) feat: local-first editor Apr 29, 2026
NagariaHussain and others added 14 commits April 30, 2026 16:48
Move "View changes" and "Archive" into a three-dot dropdown so the header
only surfaces the primary actions (Merge, Submit for Review). Soften the
Draft state banner from blue to gray.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the spinning icon and reuse the frappe-ui Badge for the sync
state pill. Less visual noise next to the header actions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add three Playwright specs against the local-first draft workspace:
delayed create_cr_page, failed update_cr_page, and delayed reorder.
A new mock helper wraps page.route() to inject latency and 500s on
specific Frappe methods, and SpaceDetails exposes the draft store on
window for deterministic optimistic-action testing.

Fix a real bug surfaced while writing these: the sidebar's root_node
fallback to space.doc.root_group passes a Frappe document name where
the store expects a doc_key, silently no-oping the optimistic insert
when the dialog opens before hydration completes. Drop the wrong
fallback and let createNode default to rootKey when empty.

Cleanup: remove leftover console.log calls from ordering and
public-pages specs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .or() locator tripped Playwright strict-mode when both the toolbar
"New Page" button and the empty-state "Create First Page" button are
visible simultaneously, which is the normal state on an empty space
since the local-first migration. Add .first() so either match passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a single whitelisted apply_cr_operations endpoint that takes an
ordered list of operations (create_node, update_node, update_content,
delete_node, move_node, reorder_children), applies them atomically in
the request transaction, resolves client temp_keys to server doc_keys,
and returns the canonical affected items plus a new operation_version.

Extract the existing per-action CR mutation logic into shared internal
helpers (_create_cr_item, _update_cr_item, _delete_cr_item,
_move_cr_item, _reorder_cr_children, _serialize_cr_item,
_compute_cr_route) so both the legacy create_cr_page / update_cr_page /
move_cr_page / reorder_cr_children / delete_cr_page RPCs and the new
batch endpoint share the same source of truth, including
descendant-cascade delete and ancestor-slug route computation.

Add operation_version (Int, default 0) to Wiki Change Request and bump
it via _bump_operation_version on every mutating path — batch and
legacy alike — so the version is a true monotonic counter for any
write to the CR head revision. The batch endpoint rejects stale
base_version clients with a structured version_conflict response, and
get_cr_tree returns operation_version so the frontend can hydrate
base_version on load.

See specs/local_first_editor_migration_step_2.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Behind a useBatchOperations flag (default on), the draft workspace
store now flushes create/update/save/delete/reorder through a single
applyBatchOps adapter against apply_cr_operations instead of the
per-action CR RPCs. Reorder packs every queued move and its parent's
full sibling order into one atomic batch so a drag is one user intent,
one round trip. The legacy per-RPC paths remain in place under the
flag for rollout safety.

Two correctness guards:

  - Batches serialize behind a per-CR sync tail keyed by CR name, with
    the latest known operation_version captured at enqueue time. This
    prevents same-client concurrent batches racing on base_version
    (false version_conflict) and prevents a mid-flight workspace switch
    from rerouting a queued batch to the wrong CR.

  - operationVersion hydrates from get_cr_tree on load and from every
    successful batch response. version_conflict responses surface on
    sync.conflict so future UI can offer a "Reload latest" recovery.

The three local-first e2e specs now intercept apply_cr_operations
(delay + fail) instead of the individual CR RPCs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Reload latest no longer clears the dirty signal; Submit stays gated
  until typed content is saved or reverted, instead of silently
  shipping a CR that lacks what the editor still shows.
- All CR-mutating endpoints (apply_cr_operations + 5 legacy RPCs)
  load the CR through a shared `_lock_and_load_cr` so the
  `operation_version` monotonic-counter contract survives concurrent
  batch+legacy writers during rollout, not just batch+batch.
- WikiEditor emits dirty-change(false) on the typed-then-undone edge
  so Submit/merge unblock without a redundant save.
- Banner distinguishes "Unsaved changes" (gray) from "Saving…"
  (orange) — the latter is reserved for in-flight RPCs.
- Consolidate the parallel `dirtyEditors` Set onto `page.dirty` via
  `markPageDirty` / new `markPageClean`; `markPageDirty` lazy-stubs
  for WikiDocumentPanel's flow where pagesByKey isn't preloaded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refreshing the tab during the 10s autosave window used to lose
whatever the user had typed. Now WikiEditor emits a `local-content`
event on a 500ms debounce (and flushes on the dirty→clean edge and
on unmount). The draft workspace store writes each emission to an
idb-keyval store dedicated to wiki drafts so we don't collide with
frappe-ui's own IDB usage, and clears the entry once `_doSaveContent`
confirms the content reached the server.

On `hydrate`, the store reads every draft persisted for the current
CR and reseeds `pagesByKey` with `dirty=true`. The banner surfaces
this as "Unsaved changes" and Submit/merge stay gated until the
restored content is saved or reverted. tmp_* and orphan keys are
ignored — Step 3's scope is editor content only, not lost creates
or cross-session pending queues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The durable sync banner is the single source of truth for save
state in the draft editing flow; per-action success toasts are
redundant on top of it. This was the last lingering success toast
in DraftContributionPanel / useTreeDialogs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After clicking through the New Page dialog the user had to click
into the canvas before typing. Now WikiEditor accepts an autoFocus
prop that forwards to TipTap's `autofocus: 'end'` option, and the
draft panel passes true while the route is still on a tmp_* key
(i.e. the create hasn't synced yet). For everywhere else the
editor stays unobtrusive — clicking in still works as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 1,404-line draftWorkspace.js mixed tree modelling, editor buffers,
IndexedDB hydration, transport, operation queueing, temp-key promotion,
conflict handling, and UI-derived sync state. Splits the store into a
thin coordinator that composes seven cohesive sub-modules under
draftWorkspace/: treeModel, pageBuffers, operationQueue, tempKeyResolver,
syncTransport, moveScheduler, saveSerializer. The temp-key rewrite now
fans out through resolver.onResolve() listeners instead of the resolver
reaching into four different maps directly. Public store surface is
preserved exactly; full e2e suite (65/65) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant