From 474a30115ad65875727ececb961b03ad0fcdd426 Mon Sep 17 00:00:00 2001 From: Daniel Winter Date: Fri, 12 Dec 2025 09:38:21 +0100 Subject: [PATCH] add field-id inheritance support --- README.md | 57 ++++-- .../src/app/recipes/[id]/page.tsx | 15 +- .../pages/recipes/[id].tsx | 15 +- .../remix-example/app/routes/recipes.$id.tsx | 15 +- src/core/FieldRegistry.test.ts | 189 +++++++++++++++++- src/core/FieldRegistry.ts | 97 ++++++++- src/core/OverlayManager.test.ts | 83 ++++++++ src/core/OverlayManager.ts | 13 +- 8 files changed, 438 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index dde5195..287b5af 100644 --- a/README.md +++ b/README.md @@ -69,33 +69,54 @@ export function PreviewWrapper({ children }) { ### Mark Your Content -Add `data-hygraph-*` attributes to make content editable: +Add `data-hygraph-*` attributes to make content editable. The SDK supports two patterns: + +#### Pattern 1: Inherited Entry ID (Recommended) + +Child elements with `data-hygraph-field-api-id` automatically inherit `data-hygraph-entry-id` from their nearest ancestor. This keeps your markup clean: ```tsx -
-

- My Article Title +
+

+ {post.title}

-

- Article content here... +

+ {post.content}

``` -Required attribute: -- `data-hygraph-entry-id`: The entry ID from Hygraph. Every element you want to make editable must include this. +#### Pattern 2: Explicit Entry ID + +You can also set `data-hygraph-entry-id` explicitly on each element. This is useful when you have nested content from different entries: + +```tsx +
+

+ {page.title} +

+ + {/* This recipe comes from a different entry */} +
+ {recipe.name} +
+
+``` + +Both patterns work together—explicit entry IDs take precedence over inherited ones. Use whichever fits your code structure. + +#### Attributes Reference -Common optional attributes: -- `data-hygraph-field-api-id`: Identifies which field to open. Without it the edit button opens the entry without focusing a field. -- `data-hygraph-rich-text-format`: Set to `html`, `markdown`, or `text` so the SDK knows which format to update on field sync. -- `data-hygraph-component-chain`: JSON string describing the path to nested components (see below). +| Attribute | Required | Description | +|-----------|----------|-------------| +| `data-hygraph-entry-id` | On element or ancestor | The Hygraph entry ID. Can be set on a parent and inherited by children with `field-api-id`. | +| `data-hygraph-field-api-id` | No | Which field to open. Elements with this attribute inherit entry-id from ancestors if not explicitly set. | +| `data-hygraph-rich-text-format` | No | Set to `html`, `markdown`, or `text` so the SDK knows which format to update on field sync. | +| `data-hygraph-component-chain` | No | JSON string describing the path to nested components (see below). | ```tsx
-
+ {/* Entry ID inheritance: children with field-api-id inherit entry-id from this parent */} +
{/* Hero Image */} {recipe.heroImage && (
@@ -221,40 +222,40 @@ export default async function RecipePage({ params }: { params: Promise<{ id: str )}
-

+

{recipe.title}

{/* Rich Text field with HTML format preference */} -
+
{/* Recipe Meta */}
{recipe.prepTime && ( -
+
⏱️
Prep Time
{recipe.prepTime}min
)} {recipe.cookTime && ( -
+
🔥
Cook Time
{recipe.cookTime}min
)} {recipe.servings && ( -
+
🍽️
Servings
{recipe.servings}
)} {recipe.difficulty && ( -
+
📊
Difficulty
{recipe.difficulty}
diff --git a/examples/nextjs-pages-example/pages/recipes/[id].tsx b/examples/nextjs-pages-example/pages/recipes/[id].tsx index 17f6977..a5c188e 100644 --- a/examples/nextjs-pages-example/pages/recipes/[id].tsx +++ b/examples/nextjs-pages-example/pages/recipes/[id].tsx @@ -203,7 +203,8 @@ export default function RecipePage({ recipe }: PageProps) {
-
+ {/* Entry ID inheritance: children with field-api-id inherit entry-id from this parent */} +
{/* Hero Image */} {recipe.heroImage && (
@@ -220,40 +221,40 @@ export default function RecipePage({ recipe }: PageProps) { )}
-

+

{recipe.title}

{/* Rich Text field with HTML format preference */} -
+
{/* Recipe Meta */}
{recipe.prepTime && ( -
+
⏱️
Prep Time
{recipe.prepTime}min
)} {recipe.cookTime && ( -
+
🔥
Cook Time
{recipe.cookTime}min
)} {recipe.servings && ( -
+
🍽️
Servings
{recipe.servings}
)} {recipe.difficulty && ( -
+
📊
Difficulty
{recipe.difficulty}
diff --git a/examples/remix-example/app/routes/recipes.$id.tsx b/examples/remix-example/app/routes/recipes.$id.tsx index b8052ac..b71359d 100644 --- a/examples/remix-example/app/routes/recipes.$id.tsx +++ b/examples/remix-example/app/routes/recipes.$id.tsx @@ -49,7 +49,8 @@ export default function RecipePage() {
-
+ {/* Entry ID inheritance: children with field-api-id inherit entry-id from this parent */} +
{/* Hero Image */} {recipe.heroImage && (
@@ -64,40 +65,40 @@ export default function RecipePage() { )}
-

+

{recipe.title}

{/* Rich Text field with HTML format preference */} -
+
{/* Recipe Meta */}
{recipe.prepTime && ( -
+
⏱️
Prep Time
{recipe.prepTime}min
)} {recipe.cookTime && ( -
+
🔥
Cook Time
{recipe.cookTime}min
)} {recipe.servings && ( -
+
🍽️
Servings
{recipe.servings}
)} {recipe.difficulty && ( -
+
📊
Difficulty
{recipe.difficulty}
diff --git a/src/core/FieldRegistry.test.ts b/src/core/FieldRegistry.test.ts index be85190..07ceb02 100644 --- a/src/core/FieldRegistry.test.ts +++ b/src/core/FieldRegistry.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { FieldRegistry } from './FieldRegistry'; import { createMockDOM, createPreviewElement, waitFor } from '../test-utils'; @@ -6,6 +6,38 @@ const previewConfig = { endpoint: 'https://example.com/graphql', }; +const previewConfigWithDebug = { + endpoint: 'https://example.com/graphql', + debug: true, +}; + +/** + * Helper to create an element that inherits entry-id from a parent + */ +function createInheritedElement(options: { + parentEntryId: string; + fieldApiId: string; + componentChain?: string; + textContent?: string; +}): { parent: HTMLElement; child: HTMLElement } { + const parent = document.createElement('div'); + parent.setAttribute('data-hygraph-entry-id', options.parentEntryId); + + const child = document.createElement('span'); + child.setAttribute('data-hygraph-field-api-id', options.fieldApiId); + if (options.componentChain) { + child.setAttribute('data-hygraph-component-chain', options.componentChain); + } + if (options.textContent) { + child.textContent = options.textContent; + } + + parent.appendChild(child); + document.body.appendChild(parent); + + return { parent, child }; +} + describe('FieldRegistry', () => { let registry: FieldRegistry; @@ -75,5 +107,160 @@ describe('FieldRegistry', () => { expect(stats.entriesCount).toBe(1); expect(stats.fieldsCount).toBe(2); }); + + describe('entry-id inheritance', () => { + it('inherits entry-id from parent element', () => { + const { child } = createInheritedElement({ + parentEntryId: 'inherited-entry-1', + fieldApiId: 'title', + textContent: 'Inherited title', + }); + + registry.refresh(); + + const results = registry.getElementsForEntryField('inherited-entry-1', 'title'); + expect(results).toHaveLength(1); + expect(results[0].element).toBe(child); + expect(results[0].entryId).toBe('inherited-entry-1'); + expect(results[0].fieldApiId).toBe('title'); + }); + + it('inherits entry-id through multiple ancestor levels', () => { + // Create: grandparent (entry-id) > parent > child (field-api-id) + const grandparent = document.createElement('article'); + grandparent.setAttribute('data-hygraph-entry-id', 'deep-entry'); + + const parent = document.createElement('div'); + const child = document.createElement('h1'); + child.setAttribute('data-hygraph-field-api-id', 'headline'); + + parent.appendChild(child); + grandparent.appendChild(parent); + document.body.appendChild(grandparent); + + registry.refresh(); + + const results = registry.getElementsForEntryField('deep-entry', 'headline'); + expect(results).toHaveLength(1); + expect(results[0].element).toBe(child); + }); + + it('explicit entry-id takes precedence over inherited', () => { + // Parent has entry-id "parent-entry" + // Child has both entry-id "child-entry" and field-api-id + const parent = document.createElement('div'); + parent.setAttribute('data-hygraph-entry-id', 'parent-entry'); + + const child = document.createElement('span'); + child.setAttribute('data-hygraph-entry-id', 'child-entry'); + child.setAttribute('data-hygraph-field-api-id', 'name'); + + parent.appendChild(child); + document.body.appendChild(parent); + + registry.refresh(); + + // Should be registered under child-entry, not parent-entry + const childResults = registry.getElementsForEntryField('child-entry', 'name'); + expect(childResults).toHaveLength(1); + + const parentResults = registry.getElementsForEntryField('parent-entry', 'name'); + expect(parentResults).toHaveLength(0); + }); + + it('handles dynamic child insertion with inheritance', async () => { + // First create parent with entry-id + const parent = document.createElement('div'); + parent.setAttribute('data-hygraph-entry-id', 'dynamic-parent'); + document.body.appendChild(parent); + + // Then dynamically add child with field-api-id + const child = document.createElement('p'); + child.setAttribute('data-hygraph-field-api-id', 'content'); + parent.appendChild(child); + + await waitFor(() => { + const results = registry.getElementsForEntryField('dynamic-parent', 'content'); + expect(results).toHaveLength(1); + expect(results[0].element).toBe(child); + }); + }); + + it('registers multiple fields under same parent entry-id', () => { + const parent = document.createElement('article'); + parent.setAttribute('data-hygraph-entry-id', 'multi-field-entry'); + + const title = document.createElement('h1'); + title.setAttribute('data-hygraph-field-api-id', 'title'); + + const description = document.createElement('p'); + description.setAttribute('data-hygraph-field-api-id', 'description'); + + const author = document.createElement('span'); + author.setAttribute('data-hygraph-field-api-id', 'author'); + + parent.appendChild(title); + parent.appendChild(description); + parent.appendChild(author); + document.body.appendChild(parent); + + registry.refresh(); + + expect(registry.getElementsForEntryField('multi-field-entry', 'title')).toHaveLength(1); + expect(registry.getElementsForEntryField('multi-field-entry', 'description')).toHaveLength(1); + expect(registry.getElementsForEntryField('multi-field-entry', 'author')).toHaveLength(1); + + const stats = registry.getStats(); + // 4 elements: parent (entry-id only) + 3 children (inherited field-api-ids) + expect(stats.totalElements).toBe(4); + expect(stats.entriesCount).toBe(1); + expect(stats.fieldsCount).toBe(3); + }); + + it('preserves component chain on inherited elements', () => { + createInheritedElement({ + parentEntryId: 'component-entry', + fieldApiId: 'heroTitle', + componentChain: 'hero.0.title', + }); + + registry.refresh(); + + const results = registry.getElementsForEntryField('component-entry', 'heroTitle'); + expect(results).toHaveLength(1); + expect(results[0].componentChainRaw).toBe('hero.0.title'); + }); + + it('does not register orphan field elements without ancestor entry-id', () => { + // Create element with field-api-id but no ancestor with entry-id + const orphan = document.createElement('div'); + orphan.setAttribute('data-hygraph-field-api-id', 'orphanField'); + document.body.appendChild(orphan); + + registry.refresh(); + + const results = registry.getElementsForField('orphanField'); + expect(results).toHaveLength(0); + }); + + it('warns in debug mode when field has no ancestor with entry-id', () => { + const debugRegistry = new FieldRegistry(previewConfigWithDebug); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const orphan = document.createElement('div'); + orphan.setAttribute('data-hygraph-field-api-id', 'orphanField'); + document.body.appendChild(orphan); + + debugRegistry.refresh(); + + expect(warnSpy).toHaveBeenCalledWith( + '[FieldRegistry] Element has field-api-id but no ancestor with entry-id:', + orphan + ); + + warnSpy.mockRestore(); + debugRegistry.destroy(); + }); + }); }); diff --git a/src/core/FieldRegistry.ts b/src/core/FieldRegistry.ts index c7f5912..4813e73 100644 --- a/src/core/FieldRegistry.ts +++ b/src/core/FieldRegistry.ts @@ -111,7 +111,16 @@ export class FieldRegistry { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { - this.scanElement(node as HTMLElement); + const element = node as HTMLElement; + this.scanElement(element); + + // Also check if this element has field-api-id without entry-id (inherited) + if ( + element.hasAttribute('data-hygraph-field-api-id') && + !element.hasAttribute('data-hygraph-entry-id') + ) { + this.registerInheritedElement(element); + } } } } @@ -136,8 +145,17 @@ export class FieldRegistry { } private scanExistingElements(): void { - const elements = document.querySelectorAll('[data-hygraph-entry-id]'); - elements.forEach((element) => this.scanElement(element as HTMLElement)); + // Scan elements with explicit entry-id (existing behavior) + const explicitElements = document.querySelectorAll('[data-hygraph-entry-id]'); + explicitElements.forEach((element) => this.scanElement(element as HTMLElement)); + + // Scan elements with field-api-id that inherit entry-id from ancestors + const inheritedElements = document.querySelectorAll( + '[data-hygraph-field-api-id]:not([data-hygraph-entry-id])' + ); + inheritedElements.forEach((element) => + this.registerInheritedElement(element as HTMLElement) + ); } private scanElement(element: HTMLElement): void { @@ -145,9 +163,17 @@ export class FieldRegistry { this.registerElement(element); } - // Also scan children + // Also scan children with explicit entry-id const children = element.querySelectorAll('[data-hygraph-entry-id]'); children.forEach((child) => this.registerElement(child as HTMLElement)); + + // Scan children that inherit entry-id (field-api-id without entry-id) + const inheritedChildren = element.querySelectorAll( + '[data-hygraph-field-api-id]:not([data-hygraph-entry-id])' + ); + inheritedChildren.forEach((child) => + this.registerInheritedElement(child as HTMLElement) + ); } private hasHygraphAttributes(element: HTMLElement): boolean { @@ -195,6 +221,66 @@ export class FieldRegistry { } } + /** + * Register an element that inherits entry-id from an ancestor + */ + private registerInheritedElement(element: HTMLElement): void { + const fieldApiId = element.getAttribute('data-hygraph-field-api-id'); + if (!fieldApiId) return; + + // Find ancestor with entry-id + const ancestor = element.closest('[data-hygraph-entry-id]'); + if (!ancestor) { + if (this.config.debug) { + console.warn( + '[FieldRegistry] Element has field-api-id but no ancestor with entry-id:', + element + ); + } + return; + } + + const entryId = ancestor.getAttribute('data-hygraph-entry-id'); + if (!entryId) return; + + const componentChainRaw = + element.getAttribute('data-hygraph-component-chain') || undefined; + + const registeredElement: RegisteredElement = { + element, + entryId, + fieldApiId, + componentChainRaw, + lastUpdated: Date.now(), + }; + + const key = this.createRegistryKey(entryId, fieldApiId); + + // Initialize array if it doesn't exist + if (!this.registry[key]) { + this.registry[key] = []; + } + + // Check if element is already registered + const existingIndex = this.registry[key].findIndex((reg) => reg.element === element); + if (existingIndex >= 0) { + // Update existing registration + this.registry[key][existingIndex] = registeredElement; + } else { + // Add new registration + this.registry[key].push(registeredElement); + } + + if (this.config.debug) { + console.log(`[FieldRegistry] Registered inherited element:`, { + entryId, + fieldApiId, + element: element.tagName, + inheritedFrom: (ancestor as HTMLElement).tagName, + }); + } + } + private updateElementRegistration(element: HTMLElement): void { // Remove old registrations for this element this.unregisterElement(element); @@ -202,6 +288,9 @@ export class FieldRegistry { // Re-register with new attributes if (this.hasHygraphAttributes(element)) { this.registerElement(element); + } else if (element.hasAttribute('data-hygraph-field-api-id')) { + // Element has field-api-id but no entry-id - try inheritance + this.registerInheritedElement(element); } } diff --git a/src/core/OverlayManager.test.ts b/src/core/OverlayManager.test.ts index d782b5e..f6c6811 100644 --- a/src/core/OverlayManager.test.ts +++ b/src/core/OverlayManager.test.ts @@ -85,5 +85,88 @@ describe('OverlayManager', () => { document.removeEventListener('hygraph-edit-click', listener as EventListener); }); + + describe('entry-id inheritance', () => { + it('shows overlay on element with inherited entry-id', () => { + // Create parent with entry-id, child with only field-api-id + const parent = document.createElement('article'); + parent.setAttribute('data-hygraph-entry-id', 'inherited-overlay-entry'); + + const child = document.createElement('h1'); + child.setAttribute('data-hygraph-field-api-id', 'title'); + child.textContent = 'Inherited field'; + + parent.appendChild(child); + document.body.appendChild(parent); + + const registered: RegisteredElement = { + element: child, + entryId: 'inherited-overlay-entry', + fieldApiId: 'title', + }; + + manager.showOverlay(child, registered); + + expect(overlayElement?.style.display).toBe('block'); + expect(editButton?.style.display).toBe('flex'); + }); + + it('positions overlay on the field element, not the ancestor', () => { + const parent = document.createElement('article'); + parent.setAttribute('data-hygraph-entry-id', 'position-test-entry'); + parent.style.cssText = 'width: 500px; height: 400px; position: relative;'; + + const child = document.createElement('span'); + child.setAttribute('data-hygraph-field-api-id', 'smallField'); + child.style.cssText = 'width: 100px; height: 20px; display: inline-block;'; + child.textContent = 'Small field'; + + parent.appendChild(child); + document.body.appendChild(parent); + + const registered: RegisteredElement = { + element: child, + entryId: 'position-test-entry', + fieldApiId: 'smallField', + }; + + manager.showOverlay(child, registered); + + // Overlay should match child dimensions, not parent + const childRect = child.getBoundingClientRect(); + expect(overlayElement?.style.width).toBe(`${childRect.width}px`); + expect(overlayElement?.style.height).toBe(`${childRect.height}px`); + }); + + it('dispatches edit event with inherited entry-id', () => { + const parent = document.createElement('div'); + parent.setAttribute('data-hygraph-entry-id', 'inherited-click-entry'); + + const child = document.createElement('p'); + child.setAttribute('data-hygraph-field-api-id', 'description'); + + parent.appendChild(child); + document.body.appendChild(parent); + + const registered: RegisteredElement = { + element: child, + entryId: 'inherited-click-entry', + fieldApiId: 'description', + }; + + const listener = vi.fn(); + document.addEventListener('hygraph-edit-click', listener as EventListener); + + manager.showOverlay(child, registered); + editButton?.click(); + + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.detail.entryId).toBe('inherited-click-entry'); + expect(event.detail.fieldApiId).toBe('description'); + + document.removeEventListener('hygraph-edit-click', listener as EventListener); + }); + }); }); diff --git a/src/core/OverlayManager.ts b/src/core/OverlayManager.ts index 75cc0d6..f3c0d6f 100644 --- a/src/core/OverlayManager.ts +++ b/src/core/OverlayManager.ts @@ -181,7 +181,10 @@ export class OverlayManager { return; } - const hygraphElement = target.closest('[data-hygraph-entry-id]') as HTMLElement; + // Find closest element with field-api-id OR entry-id + const hygraphElement = target.closest( + '[data-hygraph-field-api-id], [data-hygraph-entry-id]' + ) as HTMLElement | null; if (hygraphElement && hygraphElement !== this.currentTarget) { // Clear any pending hover timeout @@ -191,9 +194,15 @@ export class OverlayManager { } // Get registered element data - const entryId = hygraphElement.getAttribute('data-hygraph-entry-id'); const fieldApiId = hygraphElement.getAttribute('data-hygraph-field-api-id'); + // Get entry-id: explicit or inherited from ancestor + let entryId = hygraphElement.getAttribute('data-hygraph-entry-id'); + if (!entryId) { + const ancestor = hygraphElement.closest('[data-hygraph-entry-id]') as HTMLElement | null; + entryId = ancestor?.getAttribute('data-hygraph-entry-id') ?? null; + } + if (entryId) { const registeredElement: RegisteredElement = { element: hygraphElement,