Overview
When a user publishes a new document (first publish only), automatically add an Embed block (Card view) linking to the child document at the end of its immediate parent document.
Requirements
Link Type: Embed block with Card view
Parent always exists: If we have <account_id>/informes/foo, we must have <account_id>/informes
Root level: Home document always exists for <account_id>/foo
Nested levels: Only add link to immediate parent
Link position: Absolute end of document (after all content)
First publish only: Don't add link when editing existing documents
Skip conditions:
Link to child already exists in parent
Parent has Query block that includes itself (self-referential)
Duplicates: Skip adding if link already exists
User consent: Add "X" button in publish popover to opt-out per parent
Draft handling: If parent has draft, add to draft instead of publishing
Phase 1: Core Logic - Determine if Auto-Link is Needed
File: frontend/apps/desktop/src/models/documents.ts
Create a new function shouldAutoLinkToParent():
async function shouldAutoLinkToParent(
childId: UnpackedHypermediaId,
parentDocument: HMDocument | null
): Promise<boolean>
Logic:
Check if this is a first publish (editId is null)
Check if child has a parent (path length > 0, or if path is empty, parent is home document)
Check parent document content for:
Existing embed/link to the child → skip
Query block that includes the child (self-referential query where space/path is empty or matches parent) → skip
Return true if none of the skip conditions apply
Phase 2: Check for Existing Parent Draft
File: frontend/apps/desktop/src/app-drafts.ts
Add a new tRPC procedure drafts.findByEdit:
findByEdit: t.procedure
.input(z.object({
editUid: z.string(),
editPath: z.array(z.string()),
}))
.query(({input}) => {
return draftIndex?.find(d =>
d.editUid === input.editUid &&
pathMatches(d.editPath || [], input.editPath)
) || null
})
Phase 3: Add Link to Parent Draft (if draft exists)
File: frontend/apps/desktop/src/models/documents.ts
Create function addLinkToParentDraft():
async function addLinkToParentDraft(
parentDraftId: string,
childId: UnpackedHypermediaId
): Promise<void>
Logic:
Fetch draft via client.drafts.get.query(parentDraftId)
Create new embed block in editor format:
{
id: nanoid(10),
type: 'embed',
props: {
url: packHmId(childId),
view: 'Card',
defaultOpen: 'false'
},
content: [],
children: []
}
Append block to end of draft.content
Write back via client.drafts.write.mutate({...draft, content: updatedContent})
Cache invalidation happens automatically
Add TODO comment:
// TODO: If user discards this draft later, the auto-link will be lost.
// Discuss with team whether we should warn user or handle this differently.
Phase 4: Add Link to Parent Document (if no draft exists)
File: frontend/apps/desktop/src/models/documents.ts
Create function publishLinkToParentDocument():
async function publishLinkToParentDocument(
parentId: UnpackedHypermediaId,
parentDocument: HMDocument,
childId: UnpackedHypermediaId,
signingKeyName: string
): Promise<HMDocument>
Logic:
Find the last root-level block in parent document
Generate new block ID
Create DocumentChange operations in correct order:
MoveBlock to position the new block at the end (after last block)
ReplaceBlock to create the embed block
Call grpcClient.documents.createDocumentChange() with:
signingKeyName
account: parentId.uid
path: parentId.path
baseVersion: parentDocument.version
changes: [moveBlock, replaceBlock]
Document changes order:
const changes = [
{
moveBlock: {
blockId: generatedBlockId,
parent: '', // root level
leftSibling: lastBlockId, // after the last existing block
}
},
{
replaceBlock: {
block: {
id: generatedBlockId,
type: 'Embed',
link: packHmId(childId),
attributes: { view: 'Card' }
}
}
}
]
Phase 5: Integrate into Publish Workflow
File: frontend/apps/desktop/src/components/publish-draft-button.tsx
New state:
const [parentPublishInfo, setParentPublishInfo] = useState<{
parentId: UnpackedHypermediaId
hasDraft: boolean
draftId?: string
willAddLink: boolean
optedOut: boolean
} | null>(null)
On publish flow modification:
Before publishing child, compute parentPublishInfo:
Get parent ID from child's destination path
Check if parent has existing draft
Check if should auto-link (using shouldAutoLinkToParent)
After child publishes successfully:
if (parentPublishInfo?.willAddLink && !parentPublishInfo.optedOut) {
if (parentPublishInfo.hasDraft && parentPublishInfo.draftId) {
await addLinkToParentDraft(parentPublishInfo.draftId, childResultId)
} else {
const parentDoc = await publishLinkToParentDocument(
parentPublishInfo.parentId,
parentDocument,
childResultId,
signingAccountId
)
// Push parent along with child
pushResource(hmId(parentDoc.account, {
path: entityQueryPathToHmIdPath(parentDoc.path),
version: parentDoc.version
}))
}
}
Phase 6: UI Changes - Publish Popover
File: frontend/apps/desktop/src/components/publish-draft-button.tsx
Modify "You are publishing" section (parent first, then child):
{/* You are publishing section */}
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">You are publishing</p>
{/* Parent document first (if auto-linking) */}
{parentPublishInfo?.willAddLink && !parentPublishInfo.optedOut && (
<PublishItem
url={parentUrl}
icon={<Document size={12} />}
label={parentPublishInfo.hasDraft ? "(adding link to draft)" : "(adding link)"}
onRemove={() => setParentPublishInfo(prev =>
prev ? {...prev, optedOut: true} : null
)}
/>
)}
{/* Child document (current) - always shown */}
{documentUrl && (
<PublishItem url={documentUrl} icon={<Document size={12} />} />
)}
</div>
New component PublishItem:
function PublishItem({
url,
icon,
label,
onRemove
}: {
url: string
icon: React.ReactNode
label?: string
onRemove?: () => void
}) {
return (
<div className="flex items-center gap-1 group">
<span className="shrink-0">{icon}</span>
<span className="text-xs truncate" style={{direction: 'rtl', textAlign: 'left'}}>
{url}
</span>
{label && <span className="text-xs text-muted-foreground">{label}</span>}
{onRemove && (
<Button
size="iconSm"
variant="ghost"
className="opacity-0 group-hover:opacity-100 ml-auto"
onClick={onRemove}
>
<X size={12} />
</Button>
)}
</div>
)
}
Phase 7: Push Workflow Updates
File: frontend/apps/desktop/src/components/publish-draft-button.tsx
In the onSuccess callback of usePublishResource:
onSuccess: async (resultDoc, input) => {
if (pushOnPublish.data === 'never') return
const [setPushStatus, pushStatus] = writeableStateStream<PushResourceStatus | null>(null)
const childResultId = hmId(resultDoc.account, {
path: entityQueryPathToHmIdPath(resultDoc.path),
version: resultDoc.version,
})
// Handle parent auto-link
let parentResultDoc: HMDocument | null = null
if (parentPublishInfo?.willAddLink && !parentPublishInfo.optedOut) {
if (parentPublishInfo.hasDraft && parentPublishInfo.draftId) {
// Add to draft - no push needed for parent
await addLinkToParentDraft(parentPublishInfo.draftId, childResultId)
} else {
// Publish to parent - will need to push both
parentResultDoc = await publishLinkToParentDocument(...)
}
}
// Push child
const childPushPromise = pushResource(childResultId, undefined, setPushStatus)
// Push parent if we published changes to it
if (parentResultDoc) {
const parentResultId = hmId(parentResultDoc.account, {
path: entityQueryPathToHmIdPath(parentResultDoc.path),
version: parentResultDoc.version,
})
pushResource(parentResultId) // Fire and forget, or chain with Promise.all
}
toast.promise(childPushPromise, {...})
}
Phase 8: Helper Functions
File: frontend/apps/desktop/src/models/documents.ts
Check for existing link to child:
function documentContainsLinkToChild(
document: HMDocument,
childId: UnpackedHypermediaId
): boolean {
const childUrl = packHmId(childId)
// Recursively search all blocks for embed/link to childUrl
function searchBlocks(nodes: HMBlockNode[]): boolean {
for (const node of nodes) {
if (node.block.type === 'Embed' && node.block.link === childUrl) return true
// Also check inline annotations for links
if (node.block.annotations) {
for (const ann of node.block.annotations) {
if ((ann.type === 'Link' || ann.type === 'Embed') && ann.link === childUrl) return true
}
}
if (node.children && searchBlocks(node.children)) return true
}
return false
}
return searchBlocks(document.content || [])
}
Check for self-referential Query block:
function documentHasSelfQuery(
document: HMDocument,
documentId: UnpackedHypermediaId
): boolean {
function searchBlocks(nodes: HMBlockNode[]): boolean {
for (const node of nodes) {
if (node.block.type === 'Query') {
const query = node.block.attributes?.query
if (query?.includes) {
for (const inc of query.includes) {
// Self-referential if space is empty or matches document
const isSpaceMatch = !inc.space || inc.space === documentId.uid
const isPathMatch = !inc.path || inc.path === hmIdPathToEntityQueryPath(documentId.path)
if (isSpaceMatch && isPathMatch) return true
}
}
}
if (node.children && searchBlocks(node.children)) return true
}
return false
}
return searchBlocks(document.content || [])
}
File Changes Summary
| File | Changes | |------|---------| | frontend/apps/desktop/src/app-drafts.ts | Add drafts.findByEdit procedure | | frontend/apps/desktop/src/models/documents.ts | Add helper functions: shouldAutoLinkToParent, addLinkToParentDraft, publishLinkToParentDocument, documentContainsLinkToChild, documentHasSelfQuery | | frontend/apps/desktop/src/components/publish-draft-button.tsx | Add parent tracking state, modify UI to show parent in "You are publishing" section with opt-out X button, update publish flow to handle parent auto-link |
Edge Cases Handled
✅ Parent has existing draft → add link to draft, don't publish parent
✅ Parent has no draft → publish new version of parent with link
✅ Link already exists → skip adding
✅ Parent has Query block to itself → skip adding
✅ User opts out via X button → skip auto-link
✅ First publish only → edits don't trigger auto-link
✅ Push workflow → push both child and parent (if parent was published)
✅ Draft discard edge case → TODO note added