mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
## Summary Supersede #6182 with a clean implementation of MDX-backed Content Blocks on top of the unchanged `main` Content Block editing model. A connected `.mdx` Asset changes where authored children are loaded and saved; it must not change how Content mode inserts, edits, selects, moves, copies, or deletes instances. The previous implementation remains available in `agent/mdx-content-block-6124` and `agent/mdx-content-block-6124-review`. Reuse its independently tested parsers, converters, schemas, Asset repository, template resolution, publication, MCP, and UI work where those pieces fit the corrected architecture. Do not port its per-operation storage routing, projected editing stores, MDX undo/history, or atomic multi-root machinery. Ref #6124 Ref #6182 Closes #6261 ## Locked behavior - `.md` keeps its existing Markdown behavior; `.mdx` enables MDX. - Standard Markdown structures are stored as Markdown. JSX is used only for structures Markdown cannot represent losslessly. - Generic elements use `<ws.element ws:tag="…">`; template instances use `<ws.element ws:name="…">`. - Invalid, unsafe, or unsupported MDX never prevents editing the file or publishing the project. Builder and publication use the same recovery result: recoverable nodes are skipped with source-located warnings, while an unrecoverable document renders no authored file content in either environment. - Connecting a source never serializes existing Content Block children into the file. **Connect** removes ordinary persisted children, preserves Templates, sets `src`, and uses file content. - Disconnecting resets `src`, discards derived instances, leaves the Content Block empty, and never changes the Asset. - While connected, normal Content Block mutations update in-memory instances synchronously. Asset persistence happens afterward and never blocks rendering. - Genuine external revision conflicts use the standard reload-required flow. Rapid local edits are ordered/coalesced and must never conflict with each other. - Repeated scopes use Content Block ID plus Collection item identity. - Multiple Content Blocks may intentionally share one MDX Asset. They share one revisioned document session and edits propagate to every connected occurrence. - Missing templates show warnings/placeholders only in Builder and are skipped on published sites. ## Reuse strategy - [x] Audit all 62 original implementation commits and 5 later review/fix commits - [x] Reuse the shared Content Engine Markdown/MDX AST, parser, serializer, diagnostics, reference discovery, and tests - [x] Reuse SDK source schemas, instance-name utilities, unique template naming, and source-integrity validation - [x] Reuse template resolution/materialization and authored-tree conversion after removing storage-operation coupling - [x] Reuse revisioned Asset content repository, authorization, limits, and browser/server transport - [x] Reuse publication, document-graph, CLI, MCP, source-control UI, frontmatter UI, and E2E work where contracts remain valid - [x] Reuse tests as behavioral specifications; rewrite tests that assert the rejected projection/routing architecture - [x] Do not port per-operation MDX routing, alternate projected mutation stores, semantic reconciliation after each command, MDX Builder history/undo, atomic multi-file rollback, or canvas loading/saving UI ## Old-branch porting map — do not rewrite Before implementing any checklist batch, inspect the named implementation and tests on `agent/mdx-content-block-6124-review`. Move the implementation and its tests first; change only the boundary that conflicts with the rebuilt architecture. Do not create a second implementation under a new name. When a test encodes the rejected projected-store architecture, retain its behavioral assertion and replace only its setup. ### Already ported - `parseMarkdownAst`, `createMdxDocumentFromMarkdown`, `parseMdxDocument`, `serializeMdxDocument`, `preferMarkdownSyntax`, `discoverMdxBodyAssetReferences`, `discoverMdxAssetReferences`, and `previewMarkdownToMdxConversion` - Asset-reference, byte-stream, document-graph, Markdown, and MDX helpers included in commit `76bc388ba9` - Tests: the corresponding `packages/content-engine/src/**/*.test.ts` files already on this branch ### Port with their existing tests | Area | Functions to move or adapt | Tests to move with them | | --- | --- | --- | | Source contract and integrity | `parseContentBlockSourceProp`, `getContentBlockSource`, `isEqualContentBlockSource`, `formatContentBlockSourceIntegrityIssue`, `getContentBlockSourceIntegrityIssues`, `allocateUniqueContentBlockTemplateName` | `packages/sdk/src/content-block.test.ts`, `packages/sdk/src/schema/content-block.test.ts` | | Existing instance/name utilities | `findChildReferenceIndex`, `findParentInstanceReference`, `getInstanceName` | `packages/sdk/src/instances-utils.test.tsx` | | Template naming in mutations | `assignUniqueBlockTemplateNamesMutable`, `findBlockTemplateNameCollision`, `getSourceBackedBlockTemplateContext`, `getBlockTemplateNameConfirmation`, `getBlockTemplateInsertionIndex` | `packages/project-build/src/runtime/block.test.ts` | | Content-mode property permissions | `getContentModePropEligibility`; preserve its distinction between editable, design-only, incompatible, and unknown props | `packages/project-build/src/runtime/content-mode-permissions.test.ts` | | Template resolution | `resolveMdxTemplates` | `packages/project-build/src/runtime/mdx-template-resolution.test.ts` | | Template materialization | `createMdxScopeIdGenerator`, `materializeMdxTemplates` | `packages/project-build/src/runtime/mdx-materialization.test.ts` | | MDX → instances and instances → MDX | `isMaterializedMdxAuthoredContentRoot`, `preserveMdxAuthoredContentIds`, `materializeMdxAuthoredContent`, `reconcileMdxAuthoredContent`, `omitTransientEmptyParagraphs`, `serializeMdxTemplateInsertion` | `packages/project-build/src/runtime/mdx-authored-content.test.ts`; retain the pure serialization cases from `mdx-storage-adapter.test.ts` | | Revisioned Asset I/O | `createAssetContentRevision`, `readAssetContentBytes`, the package-owned `createHttpAssetContentRepository`, the thin Builder `createBuilderHttpAssetContentRepository`, `createAssetContentBridge`, `initAssetContentBridge`, `getAssetContentBridge`; keep one repository implementation rather than copying the old Builder duplicate | `packages/asset-uploader/src/content-repository.test.ts`, `apps/builder/app/builder/shared/assets/mdx-content-repository.test.ts`, `apps/builder/app/shared/asset-content-bridge.client.test.ts` | | Source lifecycle planning | `createContentBlockBodyRemoval`, `prepareContentBlockConnect`, `prepareContentBlockDisconnect`, `prepareContentBlockSwitch`, `ContentBlockSourceRevisionConflictError` | `packages/project-build/src/runtime/content-block-source-lifecycle.test.ts` | | Per-Asset revision/session behavior | `getContentBlockSessionSource`, `isContentBlockSessionSourceCommitted`; reuse the loading, authorization, revision, queue, conflict, retry, cancellation, and source-replacement internals of `createMdxAssetEditingSession`, but key the rebuilt session by Asset rather than Content Block/render root | Port the applicable cases from `packages/project-build/src/runtime/mdx-asset-session.test.ts`; replace root-projection setup with in-memory snapshot setup | | Template migrations | `validateMdxTemplateMigrationConfirmation`, `planMdxTemplateMigration` | `packages/project-build/src/runtime/mdx-template-migration.test.ts` | | Publication | `createPublishedMdxMaterializationCache`, `materializePublishedMdx`, `getPublishedMdxContentDatabaseMaxBytes`, `getDynamicPublishedMdxSourceBlockIds`, `hasDynamicPublishedMdxSources`, `createPublishedBuildContentCompilationPlan`, `resolvePublishedMdxDependencyClosure`, `resolvePublishedMdxAssetCandidates` | `packages/project-build/src/runtime/published-mdx.test.ts`, `packages/project-build/src/content-database.test.ts` | | Build/import integrity | `assertContentBlockSourceIntegrity` and the Content Block cases in `getBuildIntegrityIssues`/`formatBuildIntegrityIssue` | `packages/project-build/src/build-integrity.test.ts` | | MDX paste/MCP insertion | `insertMdxText` and its input/result schemas; retain parsing, confirmation, template-resolution, and insertion-index logic, then invoke the normal mutation path | `packages/project-build/src/runtime/mdx-paste.test.ts` | | Builder source control | `createContentBlockSourceController`, `createBuilderContentBlockSourceController` and the existing source-control components | `content-block-source-controller.test.ts`, `content-block-source.test.tsx` | | Diagnostics | `formatContentBlockDiagnostic`, `deduplicateContentBlockDiagnostics`, `takeNewContentBlockDiagnostics` | Port only their focused cases from `apps/builder/app/shared/content-block-content.test.ts` | ### Explicitly do not port - `projectContentStorageChanges`, `createContentStorageProjection`, `createContentStorageSelectorProjection`, `executeContentStorageMutation`, `executeContentStorageTextReplacement`, `executeContentStoragePropMutation`, and `executeContentStorageStructuralMutation` - `applyMdxContentStorageChanges` and `prepareMdxContentStorageWrites`; replace them with one post-mutation snapshot serializer built from the reusable pure `reconcileMdxAuthoredContent` converter - `createContentBlockApplicationOperations` and the persistence-plan wrappers that route individual editing commands - Builder `$runtime*` projected stores, materialized mutation functions, alternate selector/tree helpers, pending projected-change state, and MDX-specific mutation sequences from `content-block-content.ts` - Canvas loading/saving/open-file presentation state, MDX history/undo, and atomic rollback machinery ## Implementation checklist ### 1. Preserve the `main` editing architecture - [x] Keep normal synchronous insert, text, Enter, slash menu, property, delete, move, wrap, copy/paste, selection, and focus mutations unchanged - [x] Materialize connected content through the same runtime instance interfaces used by ordinary Content Blocks - [x] Keep connected authored children in memory and exclude them from persisted build data - [x] Route persistence only after a synchronous mutation has updated the canvas - [x] Never wait for Asset loading, conversion, or saving before rendering an interaction - [x] Add no MDX-specific implementation to individual editing commands ### 2. Shared Markdown and MDX conversion - [x] Parse Markdown and the Markdown portion of MDX through one shared AST and plugin configuration - [x] Implement one shared MDX-to-instances converter and one shared instances-to-MDX converter - [x] Serialize headings, paragraphs, links, images, lists, tables, task lists, blockquotes, thematic breaks, code, emphasis, strong, strikethrough, and other supported GFM structures as Markdown - [x] Serialize only non-lossless/custom structures as Webstudio JSX - [x] Preserve nested Markdown in MDX elements, frontmatter, comments, tight/loose list semantics, code whitespace, reference links, and Asset references - [x] Omit transient empty draft paragraphs instead of emitting `<ws.element ws:tag="p" />` - [x] Preserve unsupported source text and authored metadata whenever it cannot be converted safely - [x] Add deterministic round-trip and canonical-output coverage for every supported Markdown structure ### 3. Invalid and unsafe MDX - [x] Preserve file source exactly; never silently repair or rewrite invalid source - [x] Ignore imports, exports, expressions, executable JavaScript, invalid properties, and other unsupported nodes when they can be isolated - [x] Continue rendering valid siblings and report every skipped subtree once with filename, line, and column - [x] Use the same recovery pipeline in Builder and publication so both render the same authored result for the current file revision - [x] When structural syntax prevents recovery, render no authored file content in Builder or publication instead of retaining a stale materialization - [x] Skip an unrecoverable Content Block on published sites and continue the build with a warning - [x] Keep invalid and unsafe files editable and reconnectable - [x] Enforce UTF-8, byte-size, depth, node-count, property-count, and generated-output limits with source-located diagnostics where possible ### 4. File formats and Asset creation - [x] Keep `.md` parsing and raw-HTML behavior unchanged - [x] Support creating, opening, editing, deleting, and recreating `.mdx` files through the normal text-file UI - [x] Open `.mdx` in the same editor experience as `.md` - [x] Always create an independent empty MDX Asset even when identical empty files exist - [x] Allow renaming `.md` to `.mdx` without converting or rewriting its contents; after rename, handle invalid contents through the normal MDX diagnostics and recovery pipeline - [x] Update an already-open MDX editor immediately after canvas persistence - [x] Bound read/write buffering and verify the returned Asset identity and revision ### 5. Source contracts and lifecycle - [x] Support direct Asset bindings and dynamic expression bindings - [x] Keep source mutation controls available only in Design mode; show the connected filename and **Open** action read-only in Content mode - [x] Reject duplicate source props, unsupported prop types, missing Assets, non-MDX Assets, and unauthorized Assets - [x] Analyze selected file diagnostics before confirmation without modifying the block or file; MDX syntax diagnostics do not block connection - [x] For a non-empty block, show **Abort** and **Connect** and explain that ordinary children will be replaced - [x] On connect, remove every non-template child, preserve Templates, set `src`, and materialize file content - [x] Replace disconnect handoff with normal `src` reset; discard derived content and leave the block empty - [x] Remove disconnect confirmation and conversion; resetting a repeated binding leaves every occurrence empty - [x] Switching sources validates/loads the target before replacing the usable binding and leaves both files unchanged - [x] Revalidate Asset revision and project version when confirmation is submitted - [x] Make repeated connect/disconnect operations idempotent - [x] Prevent disposed, stale, or out-of-order loads from replacing the newest source - [x] Keep loading, saving, conflict, empty-file, and open-file actions off canvas ### 6. Asynchronous persistence and conflicts - [x] Snapshot the current in-memory authored root after normal mutations - [x] Serialize/coalesce rapid edits per resolved Asset and always prepare against the latest local snapshot - [x] Allow independent Assets to save concurrently without scope leakage - [x] Use one revisioned document session and one ordered save queue per Asset, even when multiple Content Blocks or render scopes reference it - [x] After a shared Asset changes, rematerialize every connected occurrence without persisting their generated instances to project data - [x] Pin the resolved Asset and exact loaded revision for each write - [x] Recheck authorization immediately before storage - [x] Ensure local queued edits never produce stale-revision conflicts with each other - [x] On a genuine external revision conflict, preserve local content, stop writes, and enter the standard reload-required state - [x] Keep failed non-conflicting writes retryable without discarding local content - [x] Finish already accepted persistence writes after a Content Block unmounts or switches source - [x] Cancel obsolete loads, materializations, and UI updates after unmount, reload, or source switch - [x] Dispose project-scoped roots, queues, parse caches, failure state, generations, and session subscriptions when the Asset session is replaced - [x] Invalidate Asset resources and synchronize open file editors after successful writes - [x] Keep multi-root persistence ordered, serial, and non-atomic; report partial completion without rollback - [x] Do not add Asset-history undo/redo; native text-editor undo remains local to the open editor ### 7. Content mode parity - [x] Empty connected blocks remain selectable and expose the normal Content-mode `+` affordance - [x] First insertion renders immediately - [x] Enter creates and focuses the next paragraph immediately - [x] Slash commands work in newly created empty paragraphs - [x] Deleting one selected instance deletes exactly that instance - [x] Mouse and keyboard moves use the same operation and permissions - [x] Preserve sibling reorder, nested reparenting, and moves between editable Content Blocks where `main` permits them - [x] Preserve content-editable props such as Link `href` - [x] Preserve copy, paste, cut, multi-cut, wrapping, unwrapping, grid insertion, selection, Navigator order, and canvas order - [x] Cover mutation routing centrally, retain the full unconnected Builder suite, and exercise representative connected interactions in real Builder E2E ### 8. Template names and permissions - [x] Resolve only exact user-facing names from the direct flat Templates list - [x] Enforce unique names within each Templates list - [x] Reject duplicate names during rename with inline validation - [x] Allocate unique names during insert, paste, duplicate, move, wrap, and unwrap - [x] Apply valid template properties using the same permissions as Content mode - [x] Preserve and warn about ignored design-only, invalid, or stale properties - [x] Preserve template shell provenance without serializing expanded template internals - [x] Generate deterministic materialized IDs from source identity, render scope, and authored usage path so focus/selection survives saves - [x] Warn before renaming or deleting referenced templates; files remain unchanged - [x] Provide shared AST-based rename/remove migration operations for MCP-assisted multi-file cleanup ### 9. Scope, frontmatter, and diagnostics - [x] Scope materialized content by Content Block ID plus exact Collection-item/render context - [x] Prevent repeated instances and dynamic candidates from leaking generated IDs, template resolution, or diagnostics across scopes while sharing the underlying Asset source and revision where applicable - [x] Remove frontmatter editing from Settings; edit the exact YAML through the MDX Asset file editor - [x] Preserve the exact frontmatter source byte-for-byte during canvas body saves - [x] Route canvas body changes and complete-file editor saves through one revisioned per-Asset coordinator - [x] Show a source warning indicator in Settings and precise source-located diagnostics in the Asset editor - [x] Deduplicate repeated diagnostics without losing distinct locations - [x] Keep unresolved-template placeholders selectable and accessible only in Builder ### 10. Asset references, transfer, and integrity - [x] Discover and rewrite Asset references in frontmatter, Markdown links/images, and MDX properties - [x] Preserve Asset references while editing unrelated content - [x] Include referenced Asset and template revisions in dependency/cache identity - [x] Route referenced Assets through published URL rewriting - [x] Validate Content Block source integrity during project import, fragment/page transfer, copy/paste, build integrity, and pre-publish audit - [x] Allow multiple Content Blocks and dynamic render scopes to reference the same MDX Asset without treating the connection as an integrity error - [x] Preserve Templates when copying a connected Content Block - [x] Preserve direct and dynamic Content Block source bindings when copying or pasting within the same project - [x] When copying only one rendered Collection item, resolve that item’s current MDX Asset and replace the copied Content Block’s dynamic source with a direct binding to that same Asset - [x] When copying an entire Collection, preserve the Collection, its instances, and its dynamic source bindings - [x] Preserve the complete MDX source and frontmatter by reference; copying a connected block must not rewrite the shared file - [x] During cross-project transfer, copy every discoverable distinct bound MDX Asset and rewrite transferred bindings to the destination Assets - [x] If some cross-project dynamic candidates cannot be discovered or copied, continue with successful copies, preserve what can be represented safely, and warn about skipped candidates - [x] Revalidate source revisions and the destination project before applying a cross-project transfer - [x] If cross-project Asset creation or project insertion fails, leave the source project unchanged and clean up any newly reserved destination Asset - [x] Use the same transfer operation through Builder and MCP ### 11. Dynamic sources and publication - [x] Resolve dynamic bindings once per render/save scope - [x] Require finite, discoverable candidate sets for publication and load no unrelated MDX - [x] Warn and skip unbounded, missing, unreadable, malformed, or unsafe sources without failing the complete build - [x] Materialize only rendered Content Blocks; ignore Templates definitions and `src` on non-Content-Block instances - [x] Skip unresolved template subtrees while keeping valid siblings - [x] Include selected template styles, fonts, resources, unresolved names, and referenced Asset revisions in generated dependency output - [x] Keep draft-only MDX Assets out of publication retention ### 12. Builder, MCP, CLI, and shared operations - [x] Use the same converters, source lifecycle, revision checks, persistence service, diagnostics, and template migrations in Builder and MCP - [x] Keep frontmatter editing file-based; retain whole-frontmatter runtime operations only where compatibility requires them - [x] Avoid Builder-only or MCP-only mutation/conversion implementations - [x] Regenerate runtime/API contracts from their source definitions - [x] Update CLI Content Engine reference and generated MCP documentation ### 13. Verification and delivery - [x] Add regression tests that reproduce failures from the previous implementation, including double deletion and blocked rendering - [x] Cover two Content Blocks sharing one Asset: edits from either block serialize once, update both canvases, preserve scope-specific Templates, and cannot overwrite a newer local snapshot - [x] Verify new tests fail against the broken implementation and pass with the corrected behavior - [x] Run SDK, Content Engine, Asset Uploader, Project Build, Builder, React SDK, CLI, Protocol, and HTTP Client affected suites and typechecks - [x] Run real Builder E2E for source lifecycle, live file refresh, empty insertion, Enter/slash focus, Link props, delete, keyboard reorder, switching, disconnect, conflicts, unresolved templates, dynamic sources, and repeated scopes - [x] Regenerate shared project fixture bundles and verify a second generation leaves the tree clean - [x] Run lint, package boundaries, `pnpm oxfmt --check`, and `git diff --check` - [x] Review and update authoritative `docs/` content and navigation - [ ] Run the complete agent evaluation suite — not run because it requires separate explicit approval ### 14. Content-mode parity and disconnect handoff - [x] Allow keyboard reordering in Content mode through the normal move command while keeping the editable Content Block boundary - [x] Let already accepted Asset writes drain independently before resetting the source; disconnect does not load, flush, or convert the file - [x] Remove MDX-to-instance copying from disconnect - [x] Reset `src`, remove the derived tree, and leave the ordinary Content Block empty - [x] Preserve automatic text-editor focus after inserting editable content from the template menu - [x] Remove Builder frontmatter-save UI and report complete-file save failures through the Asset editor - [x] Add regression coverage for keyboard movement, disconnect teardown, source switching, insertion focus, and paragraph deletion focus restoration - [x] Run the complete six-scenario MDX Content Block Builder E2E suite ### 15. Final architecture review fixes - [x] Keep one canonical ordered pending document per Asset so shared Content Block occurrences cannot conflict with one another - [x] Merge ordered local edits from different occurrences while preserving text changes, insertions, deletions, and sibling reordering; keep external revisions on the strict reload-required path - [x] Centralize typed render-scope parsing, repeated-occurrence detection, scoped variable lookup, and dynamic Asset resolution across Canvas, Settings, source lifecycle, and copy/paste - [x] Capture the owning Asset session when a save is accepted so normal unmount/source-switch teardown cannot redirect or lose it - [x] Dispose project-owned roots, queues, caches, generations, failure state, and subscriptions when replacing the project Asset session - [x] Add regression coverage for same-Asset edits from two occurrences, same-node last-write ordering, structural reorder, insertion preservation, scoped source resolution, and project cleanup - [x] Re-run affected full unit suites, typechecks, production build, static checks, and all six MDX Content Block Builder E2E scenarios ### 16. Final persistence and transfer hardening - [x] Drain every accepted per-Asset edit before project-session teardown while retaining the owning authorization context - [x] Drain canvas serialization queues and revisioned Asset writes before pre-publish audit and publication - [x] Block publication on a queued serialization failure or Asset revision conflict - [x] Copy actual Asset bytes during cross-project paste, preserve MDX reference names, rewrite direct and Collection-backed Asset IDs, and clean up newly created Assets when insertion fails - [x] Include every resolved dynamic MDX source when copying an entire Collection and warn while preserving successful copies when individual Assets cannot be transferred - [x] Keep MDX editor save failures visible with retry and route conflicts through the standard reload-required flow - [x] Give keyboard nested reparenting the same editable Content Block boundary used by pointer movement - [x] Add regression coverage and rerun the complete Content Engine and Builder unit suites plus type, lint, formatting, package-boundary, and diff checks ## Current rebuild status - The connected Content Block now owns one complete MDX document: frontmatter feeds designed-shell bindings and the explicit Body outlet owns the authored MDX body. - Builder and published output resolve the same frontmatter document graph, including nested document references and Asset references. - Direct frontmatter text/property edits use the existing ordered per-Asset session; referenced-document values remain file-owned. - Repeated Collection occurrences keep isolated runtime shell/Body identities and occurrence-specific document values. - The local blog article layout has been upgraded to the document/Body model and its bindings validate cleanly. - Full affected package suites and typechecks pass; every new regression was demonstrated fail/pass. - `pnpm lint`, package boundaries, `pnpm oxfmt --check`, generated API/docs checks, fixture builds, and `git diff --check` pass. - Full fixture synchronization is blocked by the development API rejecting this local CLI version before fixture generation; existing fixture bundles build successfully. - Complete agent evaluations remain pending explicit approval. ### 17. Treat Content Block source as a property - [x] Declare Content Block `src` in component metadata as a Design-mode initial property labeled **Source** - [x] Add the short property description: “Create an .mdx file in Assets, then select or bind it here.” - [x] Accept only a direct Asset or an expression resolving to an Asset ID; reject URL/string sources, missing Assets, and non-MDX Assets - [x] Extract and reuse the Image-style Asset picker and binding presentation without duplicating source-control UI - [x] Render Source through the normal properties section and remove the separate source-section placement and special `src` filtering - [x] Route Source changes through Content Block connect, switch, and disconnect lifecycle operations instead of generic prop updates - [x] Keep connect confirmation, Open, diagnostics, and persistence state attached to Source; remove frontmatter and disconnect UI - [x] Add regression coverage for direct Assets, dynamic Asset bindings, invalid resolved values, lifecycle operations, and Design/Content mode visibility - [x] Re-review authoritative `docs/` content for the Source-property UI change and update it if required ### 18. Source property UX - [x] Show one full-width bindable Connect .mdx file button while disconnected - [x] Show equal-width filename and Open buttons while connected - [x] Switch files from the filename button through the source lifecycle - [x] Override PropertyLabel reset and Alt-click with the disconnect lifecycle - [x] Preserve confirmation, pending, failure, repeated-scope, and dynamic-source behavior - [x] Add regression coverage for connect, switch, binding, reset, and disconnect - [x] Update Content Block documentation - [x] Run affected tests, typecheck, lint, formatting, package boundaries, and diff checks ### 19. Reduced file-owned frontmatter follow-up This checklist supersedes the earlier structured-frontmatter and disconnect-copy behavior. Frontmatter remains file-owned and is edited only in the MDX Asset editor. #### A. Remove structured frontmatter UI - [x] Remove the JSON frontmatter editor and unused Builder controller/state - [x] Keep source mutation Design-mode-only; show connected Source read-only in Content mode and allow authorized editors to open the complete file - [x] Retain runtime frontmatter APIs only where compatibility requires them #### B. Preserve source and separate validation - [x] Preserve the exact frontmatter envelope during every canvas body save without parsing/stringifying YAML - [x] Preserve comments, ordering, quoting, whitespace, BOM, newline style, delimiters, and invalid but closed YAML - [x] Keep the existing unsupported-node/property MDX recovery; do not add an ad hoc partial parser - [x] Parse body and frontmatter independently so either valid half remains usable - [x] Keep bounded Asset-query frontmatter extraction unchanged - [x] Treat structurally malformed MDX as having no safe body tree #### C. Share Asset state with the raw editor - [x] Route canvas and complete-file changes through one ordered, non-blocking per-Asset coordinator - [x] Update canvas and clean raw editors immediately from shared in-memory source - [x] Reject stale dirty-editor saves, preserve unsaved text, and require reload; never force-overwrite - [x] Drain accepted writes after source replacement/disconnect and report background failures globally - [x] Dispose sessions only after they have no consumers and no queued work #### D. Connect, replace, and disconnect - [x] Load and diagnose before connect; block only unreadable/unauthorized/non-MDX Assets - [x] Show **Abort / Connect** for non-empty blocks, including diagnostics and the recoverable-content warning - [x] Set `src` and delete persisted children through one runtime mutation - [x] Switch to a loaded source without waiting for the previous Asset's network save - [x] Remove disconnect confirmation and conversion - [x] Reset `src` immediately, discard the derived tree, leave the block empty, and preserve the Asset - [x] Resetting a bound source leaves every repeated occurrence empty #### E. Dynamic and shared sources - [x] Keep one source/revision state per Asset and a separate derived instances tree per occurrence - [x] Resolve Templates independently per Content Block and preserve Builder-only missing-template placeholders - [x] Resolve bound `src` from the selected Collection occurrence - [x] Make **Open** use the occurrence's resolved Asset; update the filename and disable Open when unresolved - [x] Add direct/bound tests including Collection item A opening `a.mdx` and item B opening `b.mdx` #### F. Diagnostics, MCP, and publication - [x] Show an accessible warning icon beside Source with the first diagnostic, line/column, and remaining count - [x] Show precise YAML/MDX diagnostics in the Asset editor without repetitive canvas/toast UI - [x] Reuse parser/diagnostic/revision contracts in Builder, build, publication, and MCP - [x] Keep MCP frontmatter editing file-based and return structured diagnostics for complete-source writes - [x] Emit build warnings; published sites skip invalid/unsupported content without diagnostic UI #### G. Verification and documentation - [x] Add fail/pass regressions for source preservation, independent validation, stale raw saves, lifecycle, dynamic Open, shared Assets, and cleanup - [x] Run affected tests, typechecks, lint, formatting, package boundaries, fixtures, and diff checks - [x] Open Markdown and MDX editors at twice the default dialog size with the right-side preview visible initially - [x] Document canvas body editing, file-owned frontmatter, Content-mode Asset access, and cloning an MDX Asset for a new post - [x] Perform a final correctness, data-loss, DRY, simplification, accessibility, and performance review - [x] Keep this checklist synchronized with implementation status ### 20. Content-mode component properties - [x] Show a connected Content Block Source read-only in Content mode with an active **Open** action - [x] Keep source switching, binding, reset, and disconnect available only in Design mode - [x] Expose HTML Embed code and preserve Code Text's code-editor metadata on authored Text Content - [x] Audit every registered component and expose authored payload while keeping design, structure, and behavior controls in Design mode - [x] Keep media dimensions and Code Text theme Design-only while preserving adapter-owned intrinsic Image metadata during MDX round trips - [x] Add exact component-permission and regression coverage - [x] Update authoritative component documentation - [x] Run affected full suites, typechecks, formatting, lint, package boundaries, and generated contract/documentation checks ### 21. Designed document shell and Body outlet - [x] Add one structural **Content Block Body** outlet and keep legacy Content Blocks without it compatible - [x] Materialize and persist only `document.body` children through the Body outlet while preserving the surrounding designed shell and Templates - [x] Expose the connected document frontmatter through a Content Block `document` parameter for shell text and property bindings - [x] Write direct frontmatter-bound text and properties back through the same ordered per-Asset session without blocking canvas mutations - [x] Resolve document `$ref` dependencies and Asset references consistently in Builder and published output - [x] Keep referenced-document values read-only on canvas and direct editors to the referenced file - [x] Isolate repeated Collection occurrences while retaining occurrence-specific source, frontmatter, shell, and Body content - [x] Render the same designed shell, frontmatter values, and Body substitution in generated/published output - [x] Upgrade the local blog article layout to bind title, excerpt, dates, author data, and images from frontmatter and render MDX through Body - [x] Add fail/pass regressions for nested Body discovery, lifecycle, permissions, repeated values, document references, and generated shell rendering - [x] Update authoritative Content Block documentation - [x] Run affected tests and typechecks, formatting, lint, package boundaries, generated API/docs checks, and fixture builds ### 22. Final nested references and frontmatter editing - [x] Keep direct frontmatter-bound text and property controls editable in Content mode without exposing or replacing their bindings - [x] Keep referenced-document values read-only and preserve legacy instance-property name normalization - [x] Render direct frontmatter text bindings through the plain-text editing path - [x] Resolve nested legacy and structured Asset references from referenced Markdown/MDX frontmatter - [x] Preserve missing Asset references, emit structured `ASSET_NOT_FOUND` diagnostics, and keep them out of the document graph - [x] Add fail/pass regressions for the real Settings control and root/nested missing Asset-reference cases - [x] Fix exact Content Block selection after the Body node appears in Navigator - [x] Run full affected suites/typechecks, all six MDX Content Block E2E scenarios, deterministic fixture builds, generated checks, lint, formatting, package boundaries, and diff checks ### Before merge - [x] Resolve #6261: Nested Asset references in referenced Markdown frontmatter work consistently in Assets queries, previews, publication, and static export ### 23. Explicit writable expression bindings - [x] Add an explicit `readwrite` mode to expression bindings; existing expressions remain read-only by default - [x] Accept `readwrite` only for direct static member paths and centralize their parsing and validation - [x] Require the expression root to provide a writable data source; initially support the connected Content Block document frontmatter - [x] Update Content Block permissions, canvas text editing, and property controls to require a valid writable binding - [x] Persist direct frontmatter edits through the existing ordered per-Asset session - [x] Create new direct frontmatter bindings with `readwrite` mode and migrate existing compatible bindings - [x] Preserve writable mode through schemas, serialization, loading, copy/paste, transfer, and export - [x] Add fail/pass regressions for valid paths, derived expressions, unsupported sources, permissions, persistence, and compatibility - [x] Review authoritative `docs/` content and update it if the persisted binding contract affects user-facing documentation - [x] Reject unsafe frontmatter paths and reject `readwrite` bindings outside a connected Content Block document - [x] Preserve expression binding mode through the public HTTP client and document the MCP authoring contract - [x] Limit frontmatter value write-back to Content mode so Design mode retains normal binding replacement/reset behavior - [x] Keep bindings inside Templates and across nested Content Block ownership boundaries read-only - [x] Upgrade compatible legacy bindings even when the Content Block is disconnected during migration - [x] Run affected tests, typechecks, formatting, lint, package boundaries, fixture checks, and `git diff --check` Verification: full SDK (567), Project Build (2,277), and Builder (2,695) suites pass, along with affected Expression, Migration, Template, HTTP Client, and MCP suites; typechecks, lint, formatting, package boundaries, and diff checks pass. Generated contracts are refreshed. Fixture synchronization remains blocked before generation because the development API rejects this local CLI version as incompatible. The checked-in bundles pass the current Protocol suite and every fixture build with the current bundle version.
719 lines
17 KiB
TypeScript
719 lines
17 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
import {
|
|
blockBodyComponent,
|
|
blockComponent,
|
|
elementComponent,
|
|
} from "@webstudio-is/sdk";
|
|
import { $flatTree, __testing__ } from "./navigator-tree";
|
|
import {
|
|
$allSelectedInstanceSelectors,
|
|
$builderMode,
|
|
$selectedPageId,
|
|
selectInstances,
|
|
} from "~/shared/nano-states";
|
|
import { $instances, $pages } from "~/shared/sync/data-stores";
|
|
import { $externalContentRoots } from "~/shared/external-content-mutations";
|
|
|
|
const {
|
|
commitNavigatorDrop,
|
|
getFocusSelectionSkipCountAfterPointerDown,
|
|
getBuilderDropTarget,
|
|
getNavigatorDragState,
|
|
getNavigatorKeyboardSelectionUpdate,
|
|
getNavigatorSelectionUpdate,
|
|
getNavigatorSiblingSelectionUpdate,
|
|
shouldClearNavigatorMultiSelectionOnEscape,
|
|
shouldSelectOnPointerDown,
|
|
} = __testing__;
|
|
|
|
const createTreeItem = ({
|
|
parentComponent = "Box",
|
|
}: { parentComponent?: string } = {}): Parameters<
|
|
typeof getBuilderDropTarget
|
|
>[0] => {
|
|
$instances.set(
|
|
new Map([
|
|
[
|
|
"body",
|
|
{
|
|
type: "instance",
|
|
id: "body",
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
],
|
|
[
|
|
"parent",
|
|
{
|
|
type: "instance",
|
|
id: "parent",
|
|
component: parentComponent,
|
|
children: [],
|
|
},
|
|
],
|
|
[
|
|
"child",
|
|
{
|
|
type: "instance",
|
|
id: "child",
|
|
component: "Box",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
return {
|
|
selector: ["child", "parent", "body"],
|
|
visibleAncestors: [
|
|
{
|
|
selector: ["body"],
|
|
indexWithinChildren: 0,
|
|
component: "Body",
|
|
},
|
|
{
|
|
selector: ["parent", "body"],
|
|
indexWithinChildren: 2,
|
|
component: parentComponent,
|
|
},
|
|
{
|
|
selector: ["child", "parent", "body"],
|
|
indexWithinChildren: 4,
|
|
component: "Box",
|
|
},
|
|
],
|
|
instance: {
|
|
type: "instance",
|
|
id: "child",
|
|
component: "Box",
|
|
children: [],
|
|
},
|
|
isExpanded: undefined,
|
|
isLastChild: false,
|
|
isHidden: false,
|
|
isReusable: false,
|
|
};
|
|
};
|
|
|
|
test("shows connected Content Block instances in Content mode", () => {
|
|
$pages.set({
|
|
homePageId: "home",
|
|
rootFolderId: "root",
|
|
pages: new Map([
|
|
[
|
|
"home",
|
|
{
|
|
id: "home",
|
|
name: "Home",
|
|
path: "",
|
|
title: "Home",
|
|
meta: {},
|
|
rootInstanceId: "body",
|
|
},
|
|
],
|
|
]),
|
|
folders: new Map([
|
|
["root", { id: "root", name: "Root", slug: "", children: ["home"] }],
|
|
]),
|
|
});
|
|
$instances.set(
|
|
new Map([
|
|
[
|
|
"body",
|
|
{
|
|
type: "instance",
|
|
id: "body",
|
|
component: "Body",
|
|
children: [{ type: "id", value: "block" }],
|
|
},
|
|
],
|
|
[
|
|
"block",
|
|
{
|
|
type: "instance",
|
|
id: "block",
|
|
component: blockComponent,
|
|
children: [{ type: "id", value: "heading" }],
|
|
},
|
|
],
|
|
[
|
|
"heading",
|
|
{
|
|
type: "instance",
|
|
id: "heading",
|
|
component: elementComponent,
|
|
tag: "h1",
|
|
children: [{ type: "text", value: "MDX heading" }],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
$selectedPageId.set("home");
|
|
$builderMode.set("content");
|
|
expect($flatTree.get().map(({ instance }) => instance.id)).toEqual([
|
|
"block",
|
|
"heading",
|
|
]);
|
|
});
|
|
|
|
test("shows the scoped Content Block occurrence in Content mode", () => {
|
|
$pages.set({
|
|
homePageId: "home",
|
|
rootFolderId: "root",
|
|
pages: new Map([
|
|
[
|
|
"home",
|
|
{
|
|
id: "home",
|
|
name: "Home",
|
|
path: "",
|
|
title: "Home",
|
|
meta: {},
|
|
rootInstanceId: "body",
|
|
},
|
|
],
|
|
]),
|
|
folders: new Map([
|
|
["root", { id: "root", name: "Root", slug: "", children: ["home"] }],
|
|
]),
|
|
});
|
|
$instances.set(
|
|
new Map([
|
|
[
|
|
"body",
|
|
{
|
|
type: "instance",
|
|
id: "body",
|
|
component: "Body",
|
|
children: [{ type: "id", value: "block" }],
|
|
},
|
|
],
|
|
[
|
|
"block",
|
|
{
|
|
type: "instance",
|
|
id: "block",
|
|
component: blockComponent,
|
|
children: [{ type: "id", value: "content" }],
|
|
},
|
|
],
|
|
[
|
|
"content",
|
|
{
|
|
type: "instance",
|
|
id: "content",
|
|
component: blockBodyComponent,
|
|
children: [],
|
|
},
|
|
],
|
|
[
|
|
"scoped-block",
|
|
{
|
|
type: "instance",
|
|
id: "scoped-block",
|
|
component: blockComponent,
|
|
children: [{ type: "id", value: "content" }],
|
|
},
|
|
],
|
|
[
|
|
"scoped-content",
|
|
{
|
|
type: "instance",
|
|
id: "scoped-content",
|
|
component: blockBodyComponent,
|
|
children: [{ type: "id", value: "scoped-heading" }],
|
|
},
|
|
],
|
|
[
|
|
"scoped-heading",
|
|
{
|
|
type: "instance",
|
|
id: "scoped-heading",
|
|
component: elementComponent,
|
|
tag: "h1",
|
|
children: [{ type: "text", value: "Scoped MDX heading" }],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
$selectedPageId.set("home");
|
|
$builderMode.set("content");
|
|
$externalContentRoots.set(
|
|
new Map([
|
|
[
|
|
"root",
|
|
{
|
|
sourceBlockInstanceId: "block",
|
|
sourceRenderScope: '["block","body"]',
|
|
blockInstanceId: "scoped-block",
|
|
sourceContentInstanceId: "content",
|
|
contentInstanceId: "scoped-content",
|
|
renderScope: '["scoped-block","body"]',
|
|
instanceIds: new Set(["scoped-heading"]),
|
|
mutationRevision: 0,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect($flatTree.get().map(({ instance }) => instance.id)).toEqual([
|
|
"scoped-block",
|
|
"scoped-content",
|
|
]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
$builderMode.set("design");
|
|
$selectedPageId.set(undefined);
|
|
$pages.set(undefined);
|
|
$instances.set(new Map());
|
|
$externalContentRoots.set(new Map());
|
|
});
|
|
|
|
describe("getNavigatorSelectionUpdate", () => {
|
|
beforeEach(() => {
|
|
selectInstances([]);
|
|
});
|
|
|
|
const flatSelectors = [
|
|
["body"],
|
|
["section", "body"],
|
|
["box", "section", "body"],
|
|
["heading", "section", "body"],
|
|
["footer", "body"],
|
|
];
|
|
|
|
test("selects one instance on normal click and updates anchor", () => {
|
|
expect(
|
|
getNavigatorSelectionUpdate({
|
|
selectedSelectors: [["box", "section", "body"]],
|
|
clickedSelector: ["heading", "section", "body"],
|
|
flatSelectors,
|
|
anchorSelector: ["box", "section", "body"],
|
|
isToggle: false,
|
|
isRange: false,
|
|
})
|
|
).toEqual({
|
|
selectedSelectors: [["heading", "section", "body"]],
|
|
anchorSelector: ["heading", "section", "body"],
|
|
});
|
|
});
|
|
|
|
test("toggles clicked instance and updates anchor", () => {
|
|
expect(
|
|
getNavigatorSelectionUpdate({
|
|
selectedSelectors: [["heading", "section", "body"]],
|
|
clickedSelector: ["box", "section", "body"],
|
|
flatSelectors,
|
|
anchorSelector: ["heading", "section", "body"],
|
|
isToggle: true,
|
|
isRange: false,
|
|
})
|
|
).toEqual({
|
|
selectedSelectors: [
|
|
["box", "section", "body"],
|
|
["heading", "section", "body"],
|
|
],
|
|
anchorSelector: ["box", "section", "body"],
|
|
});
|
|
|
|
expect(
|
|
getNavigatorSelectionUpdate({
|
|
selectedSelectors: [
|
|
["box", "section", "body"],
|
|
["heading", "section", "body"],
|
|
],
|
|
clickedSelector: ["box", "section", "body"],
|
|
flatSelectors,
|
|
anchorSelector: ["heading", "section", "body"],
|
|
isToggle: true,
|
|
isRange: false,
|
|
}).selectedSelectors
|
|
).toEqual([["heading", "section", "body"]]);
|
|
});
|
|
|
|
test("treats range click without anchor as normal click", () => {
|
|
expect(
|
|
getNavigatorSelectionUpdate({
|
|
selectedSelectors: [["box", "section", "body"]],
|
|
clickedSelector: ["heading", "section", "body"],
|
|
flatSelectors,
|
|
anchorSelector: undefined,
|
|
isToggle: false,
|
|
isRange: true,
|
|
})
|
|
).toEqual({
|
|
selectedSelectors: [["heading", "section", "body"]],
|
|
anchorSelector: ["heading", "section", "body"],
|
|
});
|
|
});
|
|
|
|
test("selects visible range and keeps unrelated selected instances", () => {
|
|
expect(
|
|
getNavigatorSelectionUpdate({
|
|
selectedSelectors: [["footer", "body"]],
|
|
clickedSelector: ["heading", "section", "body"],
|
|
flatSelectors,
|
|
anchorSelector: ["section", "body"],
|
|
isToggle: false,
|
|
isRange: true,
|
|
})
|
|
).toEqual({
|
|
selectedSelectors: [
|
|
["section", "body"],
|
|
["box", "section", "body"],
|
|
["heading", "section", "body"],
|
|
["footer", "body"],
|
|
],
|
|
anchorSelector: ["heading", "section", "body"],
|
|
});
|
|
});
|
|
|
|
test("normalizes range selection through the selection store", () => {
|
|
const nextSelection = getNavigatorSelectionUpdate({
|
|
selectedSelectors: [],
|
|
clickedSelector: ["footer", "body"],
|
|
flatSelectors,
|
|
anchorSelector: ["section", "body"],
|
|
isToggle: false,
|
|
isRange: true,
|
|
});
|
|
|
|
selectInstances(nextSelection.selectedSelectors);
|
|
|
|
expect($allSelectedInstanceSelectors.get()).toEqual([
|
|
["section", "body"],
|
|
["footer", "body"],
|
|
]);
|
|
});
|
|
|
|
test("handles multi-select gestures on pointer down before drag can consume click", () => {
|
|
expect(
|
|
shouldSelectOnPointerDown({
|
|
button: 0,
|
|
metaKey: true,
|
|
ctrlKey: false,
|
|
shiftKey: false,
|
|
})
|
|
).toBe(true);
|
|
expect(
|
|
shouldSelectOnPointerDown({
|
|
button: 0,
|
|
metaKey: false,
|
|
ctrlKey: true,
|
|
shiftKey: false,
|
|
})
|
|
).toBe(true);
|
|
expect(
|
|
shouldSelectOnPointerDown({
|
|
button: 0,
|
|
metaKey: false,
|
|
ctrlKey: false,
|
|
shiftKey: true,
|
|
})
|
|
).toBe(true);
|
|
|
|
expect(
|
|
shouldSelectOnPointerDown({
|
|
button: 0,
|
|
metaKey: false,
|
|
ctrlKey: false,
|
|
shiftKey: false,
|
|
})
|
|
).toBe(false);
|
|
expect(
|
|
shouldSelectOnPointerDown({
|
|
button: 2,
|
|
metaKey: true,
|
|
ctrlKey: false,
|
|
shiftKey: false,
|
|
})
|
|
).toBe(false);
|
|
});
|
|
|
|
test("skips focus selection through context menu open and close", () => {
|
|
expect(getFocusSelectionSkipCountAfterPointerDown({ button: 2 })).toBe(2);
|
|
expect(getFocusSelectionSkipCountAfterPointerDown({ button: 0 })).toBe(1);
|
|
});
|
|
|
|
test("consumes escape only when navigator has multi-selection to clear", () => {
|
|
expect(
|
|
shouldClearNavigatorMultiSelectionOnEscape({
|
|
key: "Escape",
|
|
selectedSelectors: [
|
|
["box", "section", "body"],
|
|
["heading", "section", "body"],
|
|
],
|
|
})
|
|
).toBe(true);
|
|
|
|
expect(
|
|
shouldClearNavigatorMultiSelectionOnEscape({
|
|
key: "Escape",
|
|
selectedSelectors: [["box", "section", "body"]],
|
|
})
|
|
).toBe(false);
|
|
|
|
expect(
|
|
shouldClearNavigatorMultiSelectionOnEscape({
|
|
key: "Enter",
|
|
selectedSelectors: [
|
|
["box", "section", "body"],
|
|
["heading", "section", "body"],
|
|
],
|
|
})
|
|
).toBe(false);
|
|
});
|
|
|
|
test("extends selection with keyboard in visible order", () => {
|
|
expect(
|
|
getNavigatorKeyboardSelectionUpdate({
|
|
selectedSelectors: [["box", "section", "body"]],
|
|
focusedSelector: ["box", "section", "body"],
|
|
flatSelectors,
|
|
anchorSelector: undefined,
|
|
direction: "next",
|
|
})
|
|
).toEqual({
|
|
selectedSelectors: [
|
|
["box", "section", "body"],
|
|
["heading", "section", "body"],
|
|
],
|
|
anchorSelector: ["box", "section", "body"],
|
|
});
|
|
|
|
expect(
|
|
getNavigatorKeyboardSelectionUpdate({
|
|
selectedSelectors: [
|
|
["box", "section", "body"],
|
|
["heading", "section", "body"],
|
|
],
|
|
focusedSelector: ["heading", "section", "body"],
|
|
flatSelectors,
|
|
anchorSelector: ["box", "section", "body"],
|
|
direction: "next",
|
|
})
|
|
).toEqual({
|
|
selectedSelectors: [
|
|
["box", "section", "body"],
|
|
["heading", "section", "body"],
|
|
["footer", "body"],
|
|
],
|
|
anchorSelector: ["box", "section", "body"],
|
|
});
|
|
});
|
|
|
|
test("does not change selection when keyboard extension reaches an edge", () => {
|
|
expect(
|
|
getNavigatorKeyboardSelectionUpdate({
|
|
selectedSelectors: [["body"]],
|
|
focusedSelector: ["body"],
|
|
flatSelectors,
|
|
anchorSelector: ["body"],
|
|
direction: "previous",
|
|
})
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test("selects all visible siblings of focused instance", () => {
|
|
expect(
|
|
getNavigatorSiblingSelectionUpdate({
|
|
focusedSelector: ["box", "section", "body"],
|
|
flatSelectors,
|
|
})
|
|
).toEqual({
|
|
selectedSelectors: [
|
|
["box", "section", "body"],
|
|
["heading", "section", "body"],
|
|
],
|
|
anchorSelector: ["box", "section", "body"],
|
|
});
|
|
|
|
expect(
|
|
getNavigatorSiblingSelectionUpdate({
|
|
focusedSelector: ["section", "body"],
|
|
flatSelectors,
|
|
})
|
|
).toEqual({
|
|
selectedSelectors: [
|
|
["section", "body"],
|
|
["footer", "body"],
|
|
],
|
|
anchorSelector: ["section", "body"],
|
|
});
|
|
});
|
|
|
|
test("does not select siblings when focused selector is not selectable", () => {
|
|
expect(
|
|
getNavigatorSiblingSelectionUpdate({
|
|
focusedSelector: ["body"],
|
|
flatSelectors,
|
|
})
|
|
).toBeUndefined();
|
|
|
|
expect(
|
|
getNavigatorSiblingSelectionUpdate({
|
|
focusedSelector: ["missing", "body"],
|
|
flatSelectors,
|
|
})
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("getBuilderDropTarget", () => {
|
|
test("maps before-level tree drop targets to Builder insert positions", () => {
|
|
expect(
|
|
getBuilderDropTarget(createTreeItem(), {
|
|
parentLevel: 1,
|
|
beforeLevel: 2,
|
|
})
|
|
).toEqual({
|
|
itemSelector: ["parent", "body"],
|
|
indexWithinChildren: 4,
|
|
placement: {
|
|
closestChildIndex: 4,
|
|
indexAdjustment: 0,
|
|
childrenOrientation: { type: "vertical", reverse: false },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("maps after-level tree drop targets to Builder insert positions", () => {
|
|
expect(
|
|
getBuilderDropTarget(createTreeItem(), {
|
|
parentLevel: 1,
|
|
afterLevel: 2,
|
|
})
|
|
).toEqual({
|
|
itemSelector: ["parent", "body"],
|
|
indexWithinChildren: 5,
|
|
placement: {
|
|
closestChildIndex: 4,
|
|
indexAdjustment: 1,
|
|
childrenOrientation: { type: "vertical", reverse: false },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("keeps block template slot reserved when dropping into block components", () => {
|
|
expect(
|
|
getBuilderDropTarget(
|
|
createTreeItem({ parentComponent: blockComponent }),
|
|
{
|
|
parentLevel: 1,
|
|
beforeLevel: 0,
|
|
}
|
|
)
|
|
).toEqual({
|
|
itemSelector: ["parent", "body"],
|
|
indexWithinChildren: 1,
|
|
placement: {
|
|
closestChildIndex: 1,
|
|
indexAdjustment: 0,
|
|
childrenOrientation: { type: "vertical", reverse: false },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("ignores incomplete tree drop targets", () => {
|
|
expect(getBuilderDropTarget(createTreeItem(), undefined)).toBeUndefined();
|
|
expect(
|
|
getBuilderDropTarget(createTreeItem(), {
|
|
parentLevel: 10,
|
|
beforeLevel: 2,
|
|
})
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("Navigator drag/drop helpers", () => {
|
|
test("builds drag state for valid drop targets", () => {
|
|
const item = createTreeItem();
|
|
const draggingItem = createTreeItem();
|
|
|
|
expect(
|
|
getNavigatorDragState({
|
|
item,
|
|
draggingItem,
|
|
dropTarget: {
|
|
parentLevel: 1,
|
|
afterLevel: 2,
|
|
},
|
|
canDropTarget: () => true,
|
|
})
|
|
).toEqual({
|
|
isDragging: true,
|
|
dragPayload: {
|
|
origin: "panel",
|
|
type: "reparent",
|
|
dragInstanceSelector: ["child", "parent", "body"],
|
|
},
|
|
dropTarget: {
|
|
itemSelector: ["parent", "body"],
|
|
indexWithinChildren: 5,
|
|
placement: {
|
|
closestChildIndex: 4,
|
|
indexAdjustment: 1,
|
|
childrenOrientation: { type: "vertical", reverse: false },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
getNavigatorDragState({
|
|
item,
|
|
draggingItem,
|
|
dropTarget: {
|
|
parentLevel: 1,
|
|
afterLevel: 2,
|
|
},
|
|
canDropTarget: () => false,
|
|
})
|
|
).toEqual({
|
|
isDragging: false,
|
|
dropTarget: undefined,
|
|
});
|
|
});
|
|
|
|
test("commits Navigator drops through reparent mutation input", () => {
|
|
const calls: unknown[] = [];
|
|
const reparent = ((selector: unknown, input: unknown) => {
|
|
calls.push({ selector, input });
|
|
}) as Parameters<typeof commitNavigatorDrop>[0]["reparent"];
|
|
|
|
expect(
|
|
commitNavigatorDrop({
|
|
item: createTreeItem(),
|
|
dropTarget: {
|
|
itemSelector: ["parent", "body"],
|
|
indexWithinChildren: 3,
|
|
placement: {
|
|
closestChildIndex: 2,
|
|
indexAdjustment: 1,
|
|
childrenOrientation: { type: "vertical", reverse: false },
|
|
},
|
|
},
|
|
reparent,
|
|
})
|
|
).toBe(true);
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
selector: ["child", "parent", "body"],
|
|
input: {
|
|
parentSelector: ["parent", "body"],
|
|
position: 3,
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(
|
|
commitNavigatorDrop({
|
|
item: createTreeItem(),
|
|
dropTarget: undefined,
|
|
reparent,
|
|
})
|
|
).toBe(false);
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
});
|