Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/content-reference-removal-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/toolkit": patch
---

Allow editors to label block removal by node type while preserving editor-local deletion.
17 changes: 17 additions & 0 deletions packages/toolkit/src/editor/DragHandle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,23 @@ afterEach(() => {
});

describe("DragHandle menu", () => {
it("uses the host removal label while deleting only the selected block", () => {
const getDeleteLabel = vi.fn(() => "Remove reference");
const { editor, handle } = mountEditor(
"<p>Reference</p><p>Target remains</p>",
{ getDeleteLabel },
);
try {
clickHandle(handle);
expect(getDeleteLabel).toHaveBeenCalledWith(editor.state.doc.firstChild);
clickMenuItem("Remove reference");
expect(editor.state.doc.childCount).toBe(1);
expect(childText(editor, 0)).toBe("Target remains");
} finally {
editor.destroy();
}
});

it("opens the block menu on a single click", () => {
const { editor, handle } = mountEditor("<p>First</p><p>Second</p>");

Expand Down
16 changes: 13 additions & 3 deletions packages/toolkit/src/editor/DragHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface DragHandleOptions {
* unchanged.
*/
wrapperSelector: string;
/** Override a block's removal label without changing its editor-local deletion. */
getDeleteLabel?: (node: ProseMirrorNode) => string | undefined;
/**
* Optional source-side payload for a cross-editor block move. The editor doc
* carries ProseMirror node content, but app-owned side-map data (for example a
Expand Down Expand Up @@ -172,6 +174,7 @@ type DragHandleMenuContext = {
type DragHandleRegistration = {
view: EditorView;
wrapperSelector: string;
getDeleteLabel?: DragHandleOptions["getDeleteLabel"];
getDragTransferData?: DragHandleOptions["getDragTransferData"];
receiveDragTransferData?: DragHandleOptions["receiveDragTransferData"];
handleDrop?: DragHandleOptions["handleDrop"];
Expand Down Expand Up @@ -518,6 +521,7 @@ export const DragHandle = Extension.create<DragHandleOptions>({
addProseMirrorPlugins() {
const editor = this.editor;
const wrapperSelector = this.options.wrapperSelector;
const getDeleteLabel = this.options.getDeleteLabel;
const getDragTransferData = this.options.getDragTransferData;
const receiveDragTransferData = this.options.receiveDragTransferData;
const handleDrop = this.options.handleDrop;
Expand Down Expand Up @@ -888,9 +892,14 @@ export const DragHandle = Extension.create<DragHandleOptions>({
DRAG_HANDLE_MENU_ICON_DUPLICATE,
duplicateBlock,
),
createMenuItem("Delete", DRAG_HANDLE_MENU_ICON_DELETE, deleteBlock, {
danger: true,
}),
createMenuItem(
registrationForView(resolved.view)?.getDeleteLabel?.(
resolved.sourceNode,
) ?? "Delete",
DRAG_HANDLE_MENU_ICON_DELETE,
deleteBlock,
{ danger: true },
),
createMenuItem(
"Insert block below",
DRAG_HANDLE_MENU_ICON_INSERT,
Expand Down Expand Up @@ -1368,6 +1377,7 @@ export const DragHandle = Extension.create<DragHandleOptions>({
const registration: DragHandleRegistration = {
view: editorView,
wrapperSelector,
getDeleteLabel,
getDragTransferData,
receiveDragTransferData,
handleDrop,
Expand Down
20 changes: 0 additions & 20 deletions templates/content/actions/_database-row-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,6 @@ export async function renumberDatabaseRows(
const rows = await db
.select({
id: schema.contentDatabaseItems.id,
documentId: schema.contentDatabaseItems.documentId,
})
.from(schema.contentDatabaseItems)
.innerJoin(
Expand All @@ -209,7 +208,6 @@ export async function renumberDatabaseRows(
if (rows.length === 0) return;

const itemIds = rows.map((row) => row.id);
const documentIds = rows.map((row) => row.documentId);
await db
.update(schema.contentDatabaseItems)
.set({
Expand All @@ -226,22 +224,4 @@ export async function renumberDatabaseRows(
inArray(schema.contentDatabaseItems.id, itemIds),
),
);

await db
.update(schema.documents)
.set({
position: positionCaseSql(
schema.documents.id,
schema.documents.position,
documentIds,
),
updatedAt: now,
})
.where(
and(
eq(schema.documents.ownerEmail, database.ownerEmail),
eq(schema.documents.parentId, database.documentId),
inArray(schema.documents.id, documentIds),
),
);
}
6 changes: 3 additions & 3 deletions templates/content/actions/_database-row-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,7 +961,7 @@ async function withMutationLocks<T>(
run: () => Promise<T>,
): Promise<T> {
return withPositionLock(
documentsPositionScope(database.ownerEmail, database.documentId),
documentsPositionScope(database.ownerEmail, null),
() => withPositionLock(databaseItemsPositionScope(database.id), run),
);
}
Expand All @@ -985,7 +985,7 @@ async function createInsideTransaction(
.where(
and(
eq(schema.documents.ownerEmail, context.database.ownerEmail),
eq(schema.documents.parentId, context.database.documentId),
isNull(schema.documents.parentId),
),
);
const [maxItem] = await tx
Expand All @@ -1005,7 +1005,7 @@ async function createInsideTransaction(
spaceId: context.database.spaceId,
ownerEmail: context.database.ownerEmail,
orgId: context.database.orgId,
parentId: context.database.documentId,
parentId: null,
title: args.title?.trim() ?? "",
content: "",
icon: null,
Expand Down
6 changes: 3 additions & 3 deletions templates/content/actions/_database-source-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6664,7 +6664,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
// document or the same database can't read the same MAX (see
// _position-utils.ts).
return withPositionLock(
documentsPositionScope(args.database.ownerEmail, args.database.documentId),
documentsPositionScope(args.database.ownerEmail, null),
() =>
withPositionLock(
databaseItemsPositionScope(args.database.id),
Expand All @@ -6675,7 +6675,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
.where(
and(
eq(schema.documents.ownerEmail, args.database.ownerEmail),
eq(schema.documents.parentId, args.database.documentId),
isNull(schema.documents.parentId),
),
);
const [maxItemPos] = await db
Expand Down Expand Up @@ -6734,7 +6734,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
spaceId: databaseSpaceId,
ownerEmail: args.database.ownerEmail,
orgId: args.database.orgId,
parentId: args.database.documentId,
parentId: null,
title,
content: "",
icon: null,
Expand Down
17 changes: 1 addition & 16 deletions templates/content/actions/_database-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1279,22 +1279,7 @@ export async function isSoftDeletedDatabaseDocument(documentId: string) {
sql`${schema.contentDatabases.deletedAt} IS NOT NULL`,
),
);
if (ownedDatabase) return true;

const [databaseItem] = await db
.select({ id: schema.contentDatabaseItems.id })
.from(schema.contentDatabaseItems)
.innerJoin(
schema.contentDatabases,
eq(schema.contentDatabases.id, schema.contentDatabaseItems.databaseId),
)
.where(
and(
eq(schema.contentDatabaseItems.documentId, documentId),
sql`${schema.contentDatabases.deletedAt} IS NOT NULL`,
),
);
return !!databaseItem;
return !!ownedDatabase;
}

export async function getDatabaseByDocumentId(
Expand Down
23 changes: 1 addition & 22 deletions templates/content/actions/_document-discovery-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,6 @@ export function softDeletedDatabaseDocumentExclusions(documentId: SQLWrapper) {
schema.contentDatabases,
"deleted_database_document_exclusions",
);
const deletedDatabaseItems = alias(
schema.contentDatabaseItems,
"deleted_database_membership_exclusions",
);
const deletedDatabaseDocument = db
.select({ id: deletedDatabases.id })
.from(deletedDatabases)
Expand All @@ -49,24 +45,7 @@ export function softDeletedDatabaseDocumentExclusions(documentId: SQLWrapper) {
isNotNull(deletedDatabases.deletedAt),
),
);
const deletedDatabaseMembership = db
.select({ id: deletedDatabaseItems.id })
.from(deletedDatabaseItems)
.innerJoin(
deletedDatabases,
eq(deletedDatabases.id, deletedDatabaseItems.databaseId),
)
.where(
and(
eq(deletedDatabaseItems.documentId, documentId),
isNotNull(deletedDatabases.deletedAt),
),
);

return [
notExists(deletedDatabaseDocument),
notExists(deletedDatabaseMembership),
] as const;
return [notExists(deletedDatabaseDocument)] as const;
}

export function documentDiscoveryWhere({
Expand Down
42 changes: 42 additions & 0 deletions templates/content/actions/_document-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ActionContractError } from "@agent-native/core";
import { eq, sql } from "drizzle-orm";

import { getDb, schema } from "../server/db/index.js";

type Db = ReturnType<typeof getDb>;

export function documentTrashedError() {
return new ActionContractError("This page is in Trash.", {
errorCode: "DOCUMENT_TRASHED",
statusCode: 409,
});
}

// Acquire database and membership locks first. Individual updates impose the
// same document lock order on PostgreSQL and SQLite, unlike an IN predicate.
export async function lockDocumentsForLifecycle(db: Db, documentIds: string[]) {
const documents = [];
for (const id of [...new Set(documentIds)].sort()) {
const [document] = await db
.update(schema.documents)
.set({ updatedAt: sql`${schema.documents.updatedAt}` })
.where(eq(schema.documents.id, id))
.returning();
if (!document) {
throw new ActionContractError("Document not found.", {
errorCode: "DOCUMENT_NOT_FOUND",
statusCode: 404,
});
}
documents.push(document);
}
return documents;
}

export async function lockLiveDocuments(db: Db, documentIds: string[]) {
const documents = await lockDocumentsForLifecycle(db, documentIds);
if (documents.some((document) => document.trashedAt !== null)) {
throw documentTrashedError();
}
return documents;
}
103 changes: 103 additions & 0 deletions templates/content/actions/_document-mutation-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { ActionContractError } from "@agent-native/core";
import { getRequestUserEmail } from "@agent-native/core/server/request-context";
import { accessFilter } from "@agent-native/core/sharing";
import { and, asc, inArray, isNull, or, sql } from "drizzle-orm";

import { getDb, schema } from "../server/db/index.js";
import { chunks } from "./_batch-utils.js";
import { getContentOrganizationMembership } from "./_content-space-access.js";

// Call after locking documents. Holding existing grants prevents a concurrent
// revoke from racing the authorization check and the subsequent mutation.
export async function assertDocumentMutationAccess(
db: ReturnType<typeof getDb>,
documentIds: string[],
role: "viewer" | "editor" | "admin",
) {
const ids = [...new Set(documentIds)].sort();
for (const batch of chunks(ids, 90)) {
await db
.select({ id: schema.documentShares.id })
.from(schema.documentShares)
.where(inArray(schema.documentShares.resourceId, batch))
.orderBy(asc(schema.documentShares.id))
.for("share");
let authorized = await db
.select({ id: schema.documents.id })
.from(schema.documents)
.where(
and(
inArray(schema.documents.id, batch),
accessFilter(
schema.documents,
schema.documentShares,
undefined,
role,
{ includePublic: role === "viewer" },
),
),
);
const userEmail = getRequestUserEmail();
if (authorized.length !== batch.length && role === "viewer" && userEmail) {
const { orgMembers } = await import("@agent-native/core/org");
const memberships = await db
.select({ orgId: orgMembers.orgId })
.from(orgMembers)
.where(
and(
sql`LOWER(${orgMembers.email}) = ${userEmail.trim().toLowerCase()}`,
isNull(orgMembers.federationRemovalPendingAt),
),
)
.orderBy(asc(orgMembers.orgId))
.for("share");
const contexts = [];
for (const membership of memberships) {
if (
await getContentOrganizationMembership(membership.orgId, userEmail, {
db,
})
) {
contexts.push({ userEmail, orgId: membership.orgId });
}
}
if (contexts.length > 0) {
authorized = await db
.select({ id: schema.documents.id })
.from(schema.documents)
.where(
and(
inArray(schema.documents.id, batch),
or(
accessFilter(
schema.documents,
schema.documentShares,
undefined,
role,
{ includePublic: true },
),
...contexts.map((context) =>
accessFilter(
schema.documents,
schema.documentShares,
context,
role,
{ includePublic: true },
),
),
),
),
);
}
}
if (authorized.length !== batch.length) {
throw new ActionContractError(
"You no longer have permission to change every page in this operation.",
{
errorCode: "DOCUMENT_MUTATION_ACCESS_CHANGED",
statusCode: 403,
},
);
}
}
}
Loading
Loading