Skip to content

Commit 2897246

Browse files
authored
feat(document-api): support deleting entire block nodes not only text (#2181)
* feat(document-api): node deletion * fix(document-api): include blocks.delete INVALID_INPUT in contract and CLI mapping * test(doc-api-stories): add block deletion test * fix(document-api): validate nodeId in blocks.delete and add blocks to CLI help * fix(document-api): remove image from deletable block types * chore: fix tests in ci
1 parent 6cfbeca commit 2897246

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1761
-9
lines changed

apps/cli/scripts/export-sdk-contract.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const INTENT_NAMES = {
5858
'doc.insert': 'insert_content',
5959
'doc.replace': 'replace_content',
6060
'doc.delete': 'delete_content',
61+
'doc.blocks.delete': 'delete_block',
6162
'doc.format.apply': 'format_apply',
6263
'doc.format.fontSize': 'format_font_size',
6364
'doc.format.fontFamily': 'format_font_family',

apps/cli/src/__tests__/conformance/scenarios.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,23 @@ export const SUCCESS_SCENARIOS = {
291291
],
292292
};
293293
},
294+
'doc.blocks.delete': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
295+
const stateDir = await harness.createStateDir('doc-blocks-delete-success');
296+
const docPath = await harness.copyFixtureDoc('doc-blocks-delete');
297+
const block = await harness.firstBlockMatch(docPath, stateDir);
298+
return {
299+
stateDir,
300+
args: [
301+
'blocks',
302+
'delete',
303+
docPath,
304+
'--target-json',
305+
JSON.stringify({ kind: 'block', nodeType: block.nodeType, nodeId: block.nodeId }),
306+
'--out',
307+
harness.createOutputPath('doc-blocks-delete-output'),
308+
],
309+
};
310+
},
294311
'doc.lists.list': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
295312
const stateDir = await harness.createStateDir('doc-lists-list-success');
296313
const docPath = await harness.copyListFixtureDoc('doc-lists-list');
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { CLI_COMMAND_SPECS, CLI_HELP } from '../cli/commands';
3+
4+
describe('CLI help regression coverage', () => {
5+
test('includes blocks.delete in help output', () => {
6+
const blocksDeleteCommand = CLI_COMMAND_SPECS.find(
7+
(spec) => !spec.alias && spec.operationId === 'doc.blocks.delete',
8+
);
9+
10+
expect(blocksDeleteCommand).toBeDefined();
11+
expect(CLI_HELP).toContain('blocks:');
12+
expect(CLI_HELP).toContain(blocksDeleteCommand!.key);
13+
});
14+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { mapInvokeError } from '../../lib/error-mapping';
3+
4+
describe('mapInvokeError', () => {
5+
test('maps blocks.delete INVALID_INPUT errors to INVALID_ARGUMENT', () => {
6+
const error = Object.assign(new Error('blocks.delete requires a target.'), {
7+
code: 'INVALID_INPUT',
8+
details: { field: 'target' },
9+
});
10+
11+
const mapped = mapInvokeError('blocks.delete', error);
12+
expect(mapped.code).toBe('INVALID_ARGUMENT');
13+
expect(mapped.message).toBe('blocks.delete requires a target.');
14+
expect(mapped.details).toEqual({ operationId: 'blocks.delete', details: { field: 'target' } });
15+
});
16+
});

apps/cli/src/cli/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ function buildHelpText(): string {
162162
'mutation',
163163
'format',
164164
'create',
165+
'blocks',
165166
'lists',
166167
'comments',
167168
'trackChanges',

apps/cli/src/cli/operation-hints.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const SUCCESS_VERB: Record<CliExposedOperationId, string> = {
3535
insert: 'inserted text',
3636
replace: 'replaced text',
3737
delete: 'deleted text',
38+
'blocks.delete': 'deleted block',
3839
'format.apply': 'applied style',
3940
'format.fontSize': 'set font size',
4041
'format.fontFamily': 'set font family',
@@ -96,6 +97,7 @@ export const OUTPUT_FORMAT: Record<CliExposedOperationId, OutputFormat> = {
9697
insert: 'mutationReceipt',
9798
replace: 'mutationReceipt',
9899
delete: 'mutationReceipt',
100+
'blocks.delete': 'plain',
99101
'format.apply': 'mutationReceipt',
100102
'format.fontSize': 'mutationReceipt',
101103
'format.fontFamily': 'mutationReceipt',
@@ -145,6 +147,7 @@ export const RESPONSE_ENVELOPE_KEY: Record<CliExposedOperationId, string | null>
145147
insert: null,
146148
replace: null,
147149
delete: null,
150+
'blocks.delete': 'result',
148151
'format.apply': null,
149152
'format.fontSize': null,
150153
'format.fontFamily': null,
@@ -204,7 +207,15 @@ export const RESPONSE_VALIDATION_KEY: Partial<Record<CliExposedOperationId, stri
204207
* Operation family — determines which error-mapping rules apply.
205208
* Explicit Record for compile-time completeness (no string-prefix heuristics).
206209
*/
207-
export type OperationFamily = 'trackChanges' | 'comments' | 'lists' | 'textMutation' | 'create' | 'query' | 'general';
210+
export type OperationFamily =
211+
| 'trackChanges'
212+
| 'comments'
213+
| 'lists'
214+
| 'textMutation'
215+
| 'create'
216+
| 'blocks'
217+
| 'query'
218+
| 'general';
208219

209220
export const OPERATION_FAMILY: Record<CliExposedOperationId, OperationFamily> = {
210221
find: 'query',
@@ -215,6 +226,7 @@ export const OPERATION_FAMILY: Record<CliExposedOperationId, OperationFamily> =
215226
insert: 'textMutation',
216227
replace: 'textMutation',
217228
delete: 'textMutation',
229+
'blocks.delete': 'blocks',
218230
'format.apply': 'textMutation',
219231
'format.fontSize': 'textMutation',
220232
'format.fontFamily': 'textMutation',

apps/cli/src/cli/operation-params.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,10 @@ const EXTRA_CLI_PARAMS: Partial<Record<string, CliOperationParamSpec[]>> = {
375375
...LIST_TARGET_FLAT_PARAMS,
376376
],
377377
'doc.lists.exit': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, ...LIST_TARGET_FLAT_PARAMS],
378+
'doc.blocks.delete': [
379+
{ name: 'nodeType', kind: 'flag', flag: 'node-type', type: 'string' },
380+
{ name: 'nodeId', kind: 'flag', flag: 'node-id', type: 'string' },
381+
],
378382
'doc.create.paragraph': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }],
379383
'doc.create.heading': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }],
380384
};

apps/cli/src/cli/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export type CliCategory =
119119
| 'mutation'
120120
| 'format'
121121
| 'create'
122+
| 'blocks'
122123
| 'lists'
123124
| 'comments'
124125
| 'trackChanges'

apps/cli/src/lib/error-mapping.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,46 @@ function mapCreateError(operationId: CliExposedOperationId, error: unknown, code
133133
return new CliError('INVALID_ARGUMENT', message, { operationId, details });
134134
}
135135

136-
if (code === 'TRACK_CHANGE_COMMAND_UNAVAILABLE' || code === 'CAPABILITY_UNAVAILABLE') {
136+
if (code === 'TRACK_CHANGE_COMMAND_UNAVAILABLE') {
137137
return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', message, { operationId, details });
138138
}
139139

140+
if (code === 'CAPABILITY_UNAVAILABLE') {
141+
const reason = (details as { reason?: string } | undefined)?.reason;
142+
if (reason === 'tracked_mode_unsupported') {
143+
return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', message, { operationId, details });
144+
}
145+
return new CliError('COMMAND_FAILED', message, { operationId, details });
146+
}
147+
148+
if (code === 'COMMAND_UNAVAILABLE') {
149+
return new CliError('COMMAND_FAILED', message, { operationId, details });
150+
}
151+
152+
if (error instanceof CliError) return error;
153+
return new CliError('COMMAND_FAILED', message, { operationId, details });
154+
}
155+
156+
function mapBlocksError(operationId: CliExposedOperationId, error: unknown, code: string | undefined): CliError {
157+
const message = extractErrorMessage(error);
158+
const details = extractErrorDetails(error);
159+
160+
if (code === 'TARGET_NOT_FOUND') {
161+
return new CliError('TARGET_NOT_FOUND', message, { operationId, details });
162+
}
163+
164+
if (code === 'AMBIGUOUS_TARGET' || code === 'INVALID_TARGET' || code === 'INVALID_INPUT') {
165+
return new CliError('INVALID_ARGUMENT', message, { operationId, details });
166+
}
167+
168+
if (code === 'CAPABILITY_UNAVAILABLE') {
169+
const reason = (details as { reason?: string } | undefined)?.reason;
170+
if (reason === 'tracked_mode_unsupported') {
171+
return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', message, { operationId, details });
172+
}
173+
return new CliError('COMMAND_FAILED', message, { operationId, details });
174+
}
175+
140176
if (code === 'COMMAND_UNAVAILABLE') {
141177
return new CliError('COMMAND_FAILED', message, { operationId, details });
142178
}
@@ -170,6 +206,7 @@ const FAMILY_MAPPERS: Record<
170206
lists: mapListsError,
171207
textMutation: mapTextMutationError,
172208
create: mapCreateError,
209+
blocks: mapBlocksError,
173210
query: mapQueryError,
174211
general: (operationId, error) => {
175212
if (error instanceof CliError) return error;
@@ -272,6 +309,14 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk
272309
return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure });
273310
}
274311

312+
// Blocks family
313+
if (family === 'blocks') {
314+
if (failureCode === 'INVALID_TARGET') {
315+
return new CliError('INVALID_ARGUMENT', failureMessage, { operationId, failure });
316+
}
317+
return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure });
318+
}
319+
275320
// Create family
276321
if (family === 'create') {
277322
if (failureCode === 'TRACK_CHANGE_COMMAND_UNAVAILABLE') {

apps/cli/src/lib/invoke-input.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,20 @@ function normalizeFlatTargetFlags(operationId: CliExposedOperationId, apiInput:
147147
return apiInput;
148148
}
149149

150+
// --- Block delete (nodeType + nodeId → block target) ---
151+
if (operationId === 'blocks.delete') {
152+
const nodeType = apiInput.nodeType;
153+
const nodeId = apiInput.nodeId;
154+
if (typeof nodeType === 'string' && typeof nodeId === 'string') {
155+
const { nodeType: _, nodeId: _n, ...rest } = apiInput;
156+
return {
157+
...rest,
158+
target: { kind: 'block', nodeType, nodeId },
159+
};
160+
}
161+
return apiInput;
162+
}
163+
150164
// --- List operations (nodeId → listItem block target) ---
151165
if (LIST_TARGET_OPERATIONS.has(operationId)) {
152166
const nodeId = apiInput.nodeId;

0 commit comments

Comments
 (0)