diff --git a/README.md b/README.md index 2388f58..ba8cefb 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,15 @@ npm run dev # Watch mode with auto-reload | `sheets_unmerge_cells` | Unmerge previously merged cells in a range | `spreadsheetId`, `range` | | `sheets_add_conditional_formatting` | Add a conditional formatting rule (gradient or boolean) to a range | `spreadsheetId`, `range`, `rule` | +### Native Tables + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `sheets_add_table` | Create a native Google Sheets table with typed columns and optional dropdown values | `spreadsheetId`, `sheetName`, `range`, `name`, `columns` | +| `sheets_update_table` | Update an existing native Google Sheets table by table ID using an explicit field mask | `spreadsheetId`, `tableId`, `fields`, `name`, `range`, `columns` | +| `sheets_delete_table` | Delete a native Google Sheets table by table ID | `spreadsheetId`, `tableId` | +| `sheets_get_tables` | Read native tables for a spreadsheet or a specific sheet | `spreadsheetId`, `sheetName` | + ### Charts | Tool | Description | Key Parameters | diff --git a/src/index.ts b/src/index.ts index 8057e08..5ce4b77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,6 +60,10 @@ const toolHandlers = new Map Promise>([ ['sheets_merge_cells', tools.mergeCellsHandler], ['sheets_unmerge_cells', tools.unmergeCellsHandler], ['sheets_add_conditional_formatting', tools.addConditionalFormattingHandler], + ['sheets_add_table', tools.addTableHandler], + ['sheets_update_table', tools.updateTableHandler], + ['sheets_delete_table', tools.deleteTableHandler], + ['sheets_get_tables', tools.getTablesHandler], // Batch operations ['sheets_batch_delete_sheets', tools.handleBatchDeleteSheets], ['sheets_batch_format_cells', tools.handleBatchFormatCells], @@ -110,6 +114,10 @@ const allTools = [ tools.mergeCellsTool, tools.unmergeCellsTool, tools.addConditionalFormattingTool, + tools.addTableTool, + tools.updateTableTool, + tools.deleteTableTool, + tools.getTablesTool, // Batch operations tools.batchDeleteSheetsTool, tools.batchFormatCellsTool, diff --git a/src/tools/add-table.ts b/src/tools/add-table.ts new file mode 100644 index 0000000..40c075f --- /dev/null +++ b/src/tools/add-table.ts @@ -0,0 +1,132 @@ +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { sheets_v4 } from 'googleapis'; +import { getAuthenticatedClient } from '../utils/google-auth.js'; +import { handleError } from '../utils/error-handler.js'; +import { formatToolResponse } from '../utils/formatters.js'; +import { ToolResponse } from '../types/tools.js'; +import { getSheetId, parseRange, findSheetOrThrow } from '../utils/range-helpers.js'; +import { + TABLE_COLUMN_TYPES, + buildTableColumnProperties, + ensureNoOverlappingTable, + formatTableForResponse, + resolveTableRangeInput, + validateColumnCount, + validateTableGridRange, +} from '../utils/table-helpers.js'; + +const columnSchema = z.object({ + name: z.string().min(1), + columnType: z.enum(TABLE_COLUMN_TYPES), + dropdownValues: z.array(z.string()).optional(), +}); + +const addTableInputSchema = z.object({ + spreadsheetId: z.string().min(1), + sheetName: z.string().min(1), + range: z.string().min(1), + name: z.string().min(1), + columns: z.array(columnSchema).min(1), +}); + +export const addTableTool: Tool = { + name: 'sheets_add_table', + description: + 'Create a native Google Sheets table with typed columns and optional dropdown values', + inputSchema: { + type: 'object', + properties: { + spreadsheetId: { + type: 'string', + description: 'The ID of the spreadsheet (found in the URL after /d/)', + }, + sheetName: { + type: 'string', + description: 'Name of the sheet (tab) where the table will be created', + }, + range: { + type: 'string', + description: 'A1 notation range for the table, e.g. "A1:D20" or "Sheet1!A1:D20"', + }, + name: { + type: 'string', + description: 'Unique table name within the spreadsheet', + }, + columns: { + type: 'array', + description: 'Column definitions for the table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + columnType: { + type: 'string', + enum: [...TABLE_COLUMN_TYPES], + description: 'Google Sheets table column type', + }, + dropdownValues: { + type: 'array', + items: { type: 'string' }, + description: 'Allowed values for DROPDOWN columns', + }, + }, + required: ['name', 'columnType'], + }, + }, + }, + required: ['spreadsheetId', 'sheetName', 'range', 'name', 'columns'], + }, +}; + +export async function addTableHandler(input: any): Promise { + try { + const validatedInput = addTableInputSchema.parse(input); + const sheets = await getAuthenticatedClient(); + + const { sheetName, range } = resolveTableRangeInput( + validatedInput.sheetName, + validatedInput.range + ); + const sheetId = await getSheetId(sheets, validatedInput.spreadsheetId, sheetName); + const gridRange = parseRange(range, sheetId); + + validateTableGridRange(gridRange, `${sheetName}!${range}`); + validateColumnCount(gridRange, validatedInput.columns, `${sheetName}!${range}`); + + const metadataResponse = await sheets.spreadsheets.get({ + spreadsheetId: validatedInput.spreadsheetId, + fields: 'sheets.properties.title,sheets.properties.sheetId,sheets.tables', + }); + const sheetData = findSheetOrThrow(metadataResponse.data.sheets ?? [], sheetName); + ensureNoOverlappingTable(sheetData.tables ?? [], gridRange, sheetName); + + const table: sheets_v4.Schema$Table = { + name: validatedInput.name, + range: gridRange, + columnProperties: buildTableColumnProperties(validatedInput.columns), + }; + + const response = await sheets.spreadsheets.batchUpdate({ + spreadsheetId: validatedInput.spreadsheetId, + requestBody: { + requests: [ + { + addTable: { + table, + }, + }, + ], + }, + }); + + const addedTable = response.data.replies?.[0]?.addTable?.table ?? table; + + return formatToolResponse(`Successfully created table "${validatedInput.name}"`, { + spreadsheetId: response.data.spreadsheetId, + table: formatTableForResponse(addedTable, sheetName), + }); + } catch (error) { + return handleError(error); + } +} diff --git a/src/tools/delete-table.ts b/src/tools/delete-table.ts new file mode 100644 index 0000000..69cb757 --- /dev/null +++ b/src/tools/delete-table.ts @@ -0,0 +1,70 @@ +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { sheets_v4 } from 'googleapis'; +import { getAuthenticatedClient } from '../utils/google-auth.js'; +import { handleError } from '../utils/error-handler.js'; +import { formatToolResponse } from '../utils/formatters.js'; +import { ToolResponse } from '../types/tools.js'; + +const deleteTableInputSchema = z.object({ + spreadsheetId: z.string().min(1), + tableId: z.string().min(1), +}); + +export const deleteTableTool: Tool = { + name: 'sheets_delete_table', + description: 'Delete a native Google Sheets table by tableId', + inputSchema: { + type: 'object', + properties: { + spreadsheetId: { + type: 'string', + description: 'The ID of the spreadsheet (found in the URL after /d/)', + }, + tableId: { + type: 'string', + description: 'The ID of the table to delete', + }, + }, + required: ['spreadsheetId', 'tableId'], + }, +}; + +export async function deleteTableHandler(input: any): Promise { + try { + const validatedInput = deleteTableInputSchema.parse(input); + const sheets = await getAuthenticatedClient(); + + const metadataResponse = await sheets.spreadsheets.get({ + spreadsheetId: validatedInput.spreadsheetId, + fields: 'sheets.tables.tableId', + }); + const tableExists = ((metadataResponse.data.sheets ?? []) as sheets_v4.Schema$Sheet[]) + .flatMap((sheet) => sheet.tables ?? []) + .some((table) => table.tableId === validatedInput.tableId); + + if (!tableExists) { + throw new Error(`Table "${validatedInput.tableId}" not found`); + } + + const response = await sheets.spreadsheets.batchUpdate({ + spreadsheetId: validatedInput.spreadsheetId, + requestBody: { + requests: [ + { + deleteTable: { + tableId: validatedInput.tableId, + }, + }, + ], + }, + }); + + return formatToolResponse(`Successfully deleted table "${validatedInput.tableId}"`, { + spreadsheetId: response.data.spreadsheetId, + tableId: validatedInput.tableId, + }); + } catch (error) { + return handleError(error); + } +} diff --git a/src/tools/get-tables.ts b/src/tools/get-tables.ts new file mode 100644 index 0000000..32fb1cf --- /dev/null +++ b/src/tools/get-tables.ts @@ -0,0 +1,75 @@ +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { sheets_v4 } from 'googleapis'; +import { getAuthenticatedClient } from '../utils/google-auth.js'; +import { handleError } from '../utils/error-handler.js'; +import { formatSuccessResponse } from '../utils/formatters.js'; +import { ToolResponse } from '../types/tools.js'; +import { findSheetOrThrow } from '../utils/range-helpers.js'; +import { formatTableForResponse } from '../utils/table-helpers.js'; + +const getTablesInputSchema = z.object({ + spreadsheetId: z.string().min(1), + sheetName: z.string().min(1).optional(), +}); + +export const getTablesTool: Tool = { + name: 'sheets_get_tables', + description: 'Read native Google Sheets tables for a spreadsheet or a specific sheet', + inputSchema: { + type: 'object', + properties: { + spreadsheetId: { + type: 'string', + description: 'The ID of the spreadsheet (found in the URL after /d/)', + }, + sheetName: { + type: 'string', + description: 'Optional name of the sheet (tab) to inspect', + }, + }, + required: ['spreadsheetId'], + }, +}; + +export async function getTablesHandler(input: any): Promise { + try { + const validatedInput = getTablesInputSchema.parse(input); + const sheets = await getAuthenticatedClient(); + + const response = await sheets.spreadsheets.get({ + spreadsheetId: validatedInput.spreadsheetId, + fields: 'sheets.properties.title,sheets.properties.sheetId,sheets.tables', + }); + + const spreadsheetSheets = (response.data.sheets ?? []) as sheets_v4.Schema$Sheet[]; + const targetSheets = validatedInput.sheetName + ? [findSheetOrThrow(spreadsheetSheets, validatedInput.sheetName)] + : spreadsheetSheets; + + const tables = targetSheets.flatMap((sheet) => { + const sheetName = sheet.properties?.title ?? undefined; + return (sheet.tables ?? []).map((table) => ({ + sheetName: sheetName ?? null, + sheetId: sheet.properties?.sheetId ?? null, + ...formatTableForResponse(table, sheetName), + })); + }); + + const context = validatedInput.sheetName + ? ` in sheet "${validatedInput.sheetName}"` + : ' in spreadsheet'; + + return formatSuccessResponse( + { + spreadsheetId: validatedInput.spreadsheetId, + sheetName: validatedInput.sheetName, + tableCount: tables.length, + tables, + }, + `Found ${tables.length} table(s)${context}` + ); + } catch (error) { + return handleError(error); + } +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 353bd1f..cbce627 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -16,6 +16,10 @@ export * from './format-cells.js'; export * from './update-borders.js'; export * from './merge-cells.js'; export * from './conditional-formatting.js'; +export * from './add-table.js'; +export * from './update-table.js'; +export * from './delete-table.js'; +export * from './get-tables.js'; export * from './check-access.js'; // Batch operations diff --git a/src/tools/update-table.ts b/src/tools/update-table.ts new file mode 100644 index 0000000..0fea00d --- /dev/null +++ b/src/tools/update-table.ts @@ -0,0 +1,194 @@ +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { sheets_v4 } from 'googleapis'; +import { getAuthenticatedClient } from '../utils/google-auth.js'; +import { handleError } from '../utils/error-handler.js'; +import { formatToolResponse } from '../utils/formatters.js'; +import { ToolResponse } from '../types/tools.js'; +import { + extractSheetName, + findSheetOrThrow, + getSheetId, + parseRange, +} from '../utils/range-helpers.js'; +import { + TABLE_COLUMN_TYPES, + buildTableColumnProperties, + ensureNoOverlappingTable, + formatTableForResponse, + resolveTableRangeInput, + validateColumnCount, + validateTableGridRange, +} from '../utils/table-helpers.js'; + +const columnSchema = z.object({ + name: z.string().min(1), + columnType: z.enum(TABLE_COLUMN_TYPES), + dropdownValues: z.array(z.string()).optional(), +}); + +const updateTableInputSchema = z + .object({ + spreadsheetId: z.string().min(1), + tableId: z.string().min(1), + fields: z.string().min(1), + sheetName: z.string().min(1).optional(), + range: z.string().min(1).optional(), + name: z.string().min(1).optional(), + columns: z.array(columnSchema).min(1).optional(), + }) + .refine((value) => value.name || value.range || value.columns, { + message: 'At least one of name, range, or columns must be provided', + }); + +export const updateTableTool: Tool = { + name: 'sheets_update_table', + description: 'Update an existing native Google Sheets table by tableId', + inputSchema: { + type: 'object', + properties: { + spreadsheetId: { + type: 'string', + description: 'The ID of the spreadsheet (found in the URL after /d/)', + }, + tableId: { + type: 'string', + description: 'The ID of the table to update', + }, + fields: { + type: 'string', + description: + 'Required field mask for the table update, e.g. "name", "range", "columnProperties", or "name,range"', + }, + sheetName: { + type: 'string', + description: 'Name of the target sheet when updating the table range', + }, + range: { + type: 'string', + description: 'Optional new A1 notation range for the table, e.g. "A1:D20"', + }, + name: { + type: 'string', + description: 'Optional new table name', + }, + columns: { + type: 'array', + description: 'Optional replacement column definitions for the table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + columnType: { + type: 'string', + enum: [...TABLE_COLUMN_TYPES], + description: 'Google Sheets table column type', + }, + dropdownValues: { + type: 'array', + items: { type: 'string' }, + description: 'Allowed values for DROPDOWN columns', + }, + }, + required: ['name', 'columnType'], + }, + }, + }, + required: ['spreadsheetId', 'tableId', 'fields'], + }, +}; + +export async function updateTableHandler(input: any): Promise { + try { + const validatedInput = updateTableInputSchema.parse(input); + const sheets = await getAuthenticatedClient(); + + const metadataResponse = await sheets.spreadsheets.get({ + spreadsheetId: validatedInput.spreadsheetId, + fields: 'sheets.properties.title,sheets.properties.sheetId,sheets.tables', + }); + const allSheets = (metadataResponse.data.sheets ?? []) as sheets_v4.Schema$Sheet[]; + const existingTable = allSheets + .flatMap((sheet) => sheet.tables ?? []) + .find((table) => table.tableId === validatedInput.tableId); + + if (!existingTable) { + throw new Error(`Table "${validatedInput.tableId}" not found`); + } + + const table: sheets_v4.Schema$Table = { + tableId: validatedInput.tableId, + }; + + if (validatedInput.name) { + table.name = validatedInput.name; + } + + if (validatedInput.columns) { + table.columnProperties = buildTableColumnProperties(validatedInput.columns); + } + + let responseSheetName: string | undefined; + + if (validatedInput.range) { + const extracted = extractSheetName(validatedInput.range); + const sheetName = validatedInput.sheetName ?? extracted.sheetName; + + if (!sheetName) { + throw new Error('sheetName is required when updating a table range'); + } + + const resolvedRange = resolveTableRangeInput(sheetName, validatedInput.range); + responseSheetName = resolvedRange.sheetName; + + const sheetId = await getSheetId( + sheets, + validatedInput.spreadsheetId, + resolvedRange.sheetName + ); + const gridRange = parseRange(resolvedRange.range, sheetId); + + validateTableGridRange(gridRange, `${resolvedRange.sheetName}!${resolvedRange.range}`); + + if (validatedInput.columns) { + validateColumnCount( + gridRange, + validatedInput.columns, + `${resolvedRange.sheetName}!${resolvedRange.range}` + ); + } + + const sheetData = findSheetOrThrow(allSheets, resolvedRange.sheetName); + ensureNoOverlappingTable( + sheetData.tables ?? [], + gridRange, + resolvedRange.sheetName, + validatedInput.tableId + ); + + table.range = gridRange; + } + + const response = await sheets.spreadsheets.batchUpdate({ + spreadsheetId: validatedInput.spreadsheetId, + requestBody: { + requests: [ + { + updateTable: { + table, + fields: validatedInput.fields, + }, + }, + ], + }, + }); + + return formatToolResponse(`Successfully updated table "${validatedInput.tableId}"`, { + spreadsheetId: response.data.spreadsheetId, + table: formatTableForResponse({ ...existingTable, ...table }, responseSheetName), + fields: validatedInput.fields, + }); + } catch (error) { + return handleError(error); + } +} diff --git a/src/utils/range-helpers.ts b/src/utils/range-helpers.ts index 16e9e79..5b60d00 100644 --- a/src/utils/range-helpers.ts +++ b/src/utils/range-helpers.ts @@ -99,7 +99,7 @@ export async function getSheetId( if (sheetName) { const sheet = sheetsData.find((s) => s.properties?.title === sheetName); - if (!sheet?.properties?.sheetId) { + if (sheet?.properties?.sheetId === undefined || sheet.properties.sheetId === null) { const availableSheets = sheetsData .map((s) => s.properties?.title) .filter((title) => title) diff --git a/src/utils/table-helpers.ts b/src/utils/table-helpers.ts new file mode 100644 index 0000000..7b4538a --- /dev/null +++ b/src/utils/table-helpers.ts @@ -0,0 +1,194 @@ +import { sheets_v4 } from 'googleapis'; +import { extractSheetName, gridRangeToA1 } from './range-helpers.js'; + +export const TABLE_COLUMN_TYPES = [ + 'COLUMN_TYPE_UNSPECIFIED', + 'DOUBLE', + 'CURRENCY', + 'PERCENT', + 'DATE', + 'TIME', + 'DATE_TIME', + 'TEXT', + 'BOOLEAN', + 'DROPDOWN', + 'FILES_CHIP', + 'PEOPLE_CHIP', + 'FINANCE_CHIP', + 'PLACE_CHIP', + 'RATINGS_CHIP', +] as const; + +export type TableColumnType = (typeof TABLE_COLUMN_TYPES)[number]; + +export interface TableColumnInput { + name: string; + columnType: TableColumnType; + dropdownValues?: string[] | undefined; +} + +export function resolveTableRangeInput( + sheetName: string, + range: string +): { sheetName: string; range: string } { + const extracted = extractSheetName(range); + + if (extracted.sheetName && extracted.sheetName !== sheetName) { + throw new Error(`Range sheet "${extracted.sheetName}" does not match sheetName "${sheetName}"`); + } + + return { + sheetName: extracted.sheetName ?? sheetName, + range: extracted.range, + }; +} + +export function buildTableColumnProperties( + columns: TableColumnInput[] +): sheets_v4.Schema$TableColumnProperties[] { + return columns.map((column, index) => { + const columnProperties: sheets_v4.Schema$TableColumnProperties = { + columnIndex: index, + columnName: column.name, + columnType: column.columnType, + }; + + if (column.dropdownValues && column.dropdownValues.length > 0) { + columnProperties.dataValidationRule = { + condition: { + type: 'ONE_OF_LIST', + values: column.dropdownValues.map((value) => ({ userEnteredValue: value })), + }, + }; + } + + return columnProperties; + }); +} + +export function getRangeColumnCount(range: sheets_v4.Schema$GridRange): number | undefined { + if ( + range.startColumnIndex === undefined || + range.startColumnIndex === null || + range.endColumnIndex === undefined || + range.endColumnIndex === null + ) { + return undefined; + } + + return range.endColumnIndex - range.startColumnIndex; +} + +export function validateTableGridRange(range: sheets_v4.Schema$GridRange, rangeText: string): void { + const startRow = range.startRowIndex; + const endRow = range.endRowIndex; + const startColumn = range.startColumnIndex; + const endColumn = range.endColumnIndex; + + if ( + startRow === undefined || + startRow === null || + endRow === undefined || + endRow === null || + startColumn === undefined || + startColumn === null || + endColumn === undefined || + endColumn === null || + startRow < 0 || + startColumn < 0 || + endRow <= startRow || + endColumn <= startColumn + ) { + throw new Error(`Invalid table range: ${rangeText}. Use a rectangular A1 range like "A1:D20".`); + } +} + +export function validateColumnCount( + range: sheets_v4.Schema$GridRange, + columns: TableColumnInput[], + rangeText: string +): void { + const columnCount = getRangeColumnCount(range); + + if (columnCount !== undefined && columnCount !== columns.length) { + throw new Error( + `Column count mismatch: range ${rangeText} spans ${columnCount} column(s), but ${columns.length} column definition(s) were provided` + ); + } +} + +export function rangesOverlap( + first: sheets_v4.Schema$GridRange, + second: sheets_v4.Schema$GridRange +): boolean { + if ( + first.sheetId !== undefined && + first.sheetId !== null && + second.sheetId !== undefined && + second.sheetId !== null && + first.sheetId !== second.sheetId + ) { + return false; + } + + const firstStartRow = first.startRowIndex ?? 0; + const firstEndRow = first.endRowIndex ?? Number.POSITIVE_INFINITY; + const firstStartColumn = first.startColumnIndex ?? 0; + const firstEndColumn = first.endColumnIndex ?? Number.POSITIVE_INFINITY; + + const secondStartRow = second.startRowIndex ?? 0; + const secondEndRow = second.endRowIndex ?? Number.POSITIVE_INFINITY; + const secondStartColumn = second.startColumnIndex ?? 0; + const secondEndColumn = second.endColumnIndex ?? Number.POSITIVE_INFINITY; + + return ( + firstStartRow < secondEndRow && + firstEndRow > secondStartRow && + firstStartColumn < secondEndColumn && + firstEndColumn > secondStartColumn + ); +} + +export function ensureNoOverlappingTable( + tables: sheets_v4.Schema$Table[], + range: sheets_v4.Schema$GridRange, + sheetName: string, + excludeTableId?: string +): void { + const overlappingTable = tables.find( + (table) => table.tableId !== excludeTableId && table.range && rangesOverlap(table.range, range) + ); + + if (!overlappingTable) { + return; + } + + const existingRange = overlappingTable.range ? gridRangeToA1(overlappingTable.range) : 'unknown'; + const requestedRange = gridRangeToA1(range); + const tableLabel = overlappingTable.name || overlappingTable.tableId || 'unnamed table'; + + throw new Error( + `Table range ${sheetName}!${requestedRange} overlaps existing table "${tableLabel}" (${sheetName}!${existingRange}). Choose a non-overlapping range or update/delete the existing table.` + ); +} + +export function formatTableForResponse( + table: sheets_v4.Schema$Table, + sheetName?: string +): { + tableId: string | null; + name: string | null; + range: string | null; + gridRange: sheets_v4.Schema$GridRange | null; + columnProperties: sheets_v4.Schema$TableColumnProperties[]; +} { + const range = table.range ? gridRangeToA1(table.range) : null; + + return { + tableId: table.tableId ?? null, + name: table.name ?? null, + range: range && sheetName ? `${sheetName}!${range}` : range, + gridRange: table.range ?? null, + columnProperties: table.columnProperties ?? [], + }; +} diff --git a/tests/unit/tools/table-tools.test.ts b/tests/unit/tools/table-tools.test.ts new file mode 100644 index 0000000..03accd2 --- /dev/null +++ b/tests/unit/tools/table-tools.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { addTableHandler } from '../../../src/tools/add-table'; +import { updateTableHandler } from '../../../src/tools/update-table'; +import { deleteTableHandler } from '../../../src/tools/delete-table'; +import { getTablesHandler } from '../../../src/tools/get-tables'; +import * as googleAuth from '../../../src/utils/google-auth'; +import * as errorHandler from '../../../src/utils/error-handler'; + +vi.mock('../../../src/utils/google-auth'); +vi.mock('../../../src/utils/error-handler'); + +describe('native table tools', () => { + const mockSheets = { + spreadsheets: { + get: vi.fn(), + batchUpdate: vi.fn(), + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(googleAuth.getAuthenticatedClient).mockResolvedValue(mockSheets as any); + }); + + describe('addTableHandler', () => { + it('should create a table with dropdown validation columns', async () => { + mockSheets.spreadsheets.get + .mockResolvedValueOnce({ + data: { + sheets: [{ properties: { title: 'Sheet1', sheetId: 0 } }], + }, + }) + .mockResolvedValueOnce({ + data: { + sheets: [{ properties: { title: 'Sheet1', sheetId: 0 }, tables: [] }], + }, + }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { + spreadsheetId: 'test-id', + replies: [ + { + addTable: { + table: { + tableId: 'table-1', + name: 'Tasks', + range: { + sheetId: 0, + startRowIndex: 0, + endRowIndex: 10, + startColumnIndex: 0, + endColumnIndex: 2, + }, + columnProperties: [], + }, + }, + }, + ], + }, + }); + + const result = await addTableHandler({ + spreadsheetId: 'test-id', + sheetName: 'Sheet1', + range: 'A1:B10', + name: 'Tasks', + columns: [ + { name: 'Task', columnType: 'TEXT' }, + { name: 'Status', columnType: 'DROPDOWN', dropdownValues: ['Todo', 'Done'] }, + ], + }); + + expect(mockSheets.spreadsheets.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + requestBody: { + requests: [ + { + addTable: { + table: { + name: 'Tasks', + range: { + sheetId: 0, + startRowIndex: 0, + endRowIndex: 10, + startColumnIndex: 0, + endColumnIndex: 2, + }, + columnProperties: [ + { columnIndex: 0, columnName: 'Task', columnType: 'TEXT' }, + { + columnIndex: 1, + columnName: 'Status', + columnType: 'DROPDOWN', + dataValidationRule: { + condition: { + type: 'ONE_OF_LIST', + values: [{ userEnteredValue: 'Todo' }, { userEnteredValue: 'Done' }], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }); + expect(result.content[0].text).toContain('Successfully created table "Tasks"'); + }); + + it('should return a clear error for overlapping tables', async () => { + mockSheets.spreadsheets.get + .mockResolvedValueOnce({ + data: { + sheets: [{ properties: { title: 'Sheet1', sheetId: 0 } }], + }, + }) + .mockResolvedValueOnce({ + data: { + sheets: [ + { + properties: { title: 'Sheet1', sheetId: 0 }, + tables: [ + { + tableId: 'existing', + name: 'Existing', + range: { + sheetId: 0, + startRowIndex: 0, + endRowIndex: 5, + startColumnIndex: 0, + endColumnIndex: 2, + }, + }, + ], + }, + ], + }, + }); + vi.mocked(errorHandler.handleError).mockReturnValue({ + content: [{ type: 'text', text: 'Error: overlaps existing table' }], + } as any); + + const result = await addTableHandler({ + spreadsheetId: 'test-id', + sheetName: 'Sheet1', + range: 'A1:B10', + name: 'Tasks', + columns: [ + { name: 'Task', columnType: 'TEXT' }, + { name: 'Status', columnType: 'TEXT' }, + ], + }); + + expect(errorHandler.handleError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('overlaps existing table') }) + ); + expect(mockSheets.spreadsheets.batchUpdate).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain('overlaps'); + }); + }); + + describe('updateTableHandler', () => { + it('should update a table with the required field mask', async () => { + mockSheets.spreadsheets.get.mockResolvedValue({ + data: { + sheets: [ + { + properties: { title: 'Sheet1', sheetId: 0 }, + tables: [{ tableId: 'table-1', name: 'Old Name' }], + }, + ], + }, + }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { spreadsheetId: 'test-id' }, + }); + + await updateTableHandler({ + spreadsheetId: 'test-id', + tableId: 'table-1', + fields: 'name', + name: 'New Name', + }); + + expect(mockSheets.spreadsheets.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + requestBody: { + requests: [ + { + updateTable: { + table: { + tableId: 'table-1', + name: 'New Name', + }, + fields: 'name', + }, + }, + ], + }, + }); + }); + + it('should fail validation when fields is missing', async () => { + vi.mocked(errorHandler.handleError).mockReturnValue({ + content: [{ type: 'text', text: 'Error: validation' }], + } as any); + + await updateTableHandler({ spreadsheetId: 'test-id', tableId: 'table-1', name: 'New Name' }); + + expect(errorHandler.handleError).toHaveBeenCalled(); + expect(mockSheets.spreadsheets.batchUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('deleteTableHandler', () => { + it('should delete a table by tableId', async () => { + mockSheets.spreadsheets.get.mockResolvedValue({ + data: { sheets: [{ tables: [{ tableId: 'table-1' }] }] }, + }); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ + data: { spreadsheetId: 'test-id' }, + }); + + const result = await deleteTableHandler({ spreadsheetId: 'test-id', tableId: 'table-1' }); + + expect(mockSheets.spreadsheets.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + requestBody: { + requests: [{ deleteTable: { tableId: 'table-1' } }], + }, + }); + expect(result.content[0].text).toContain('Successfully deleted table "table-1"'); + }); + }); + + describe('getTablesHandler', () => { + it('should read tables for a specific sheet', async () => { + mockSheets.spreadsheets.get.mockResolvedValue({ + data: { + sheets: [ + { + properties: { title: 'Sheet1', sheetId: 0 }, + tables: [ + { + tableId: 'table-1', + name: 'Tasks', + range: { + sheetId: 0, + startRowIndex: 0, + endRowIndex: 10, + startColumnIndex: 0, + endColumnIndex: 2, + }, + columnProperties: [{ columnIndex: 0, columnName: 'Task', columnType: 'TEXT' }], + }, + ], + }, + ], + }, + }); + + const result = await getTablesHandler({ spreadsheetId: 'test-id', sheetName: 'Sheet1' }); + + expect(mockSheets.spreadsheets.get).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + fields: 'sheets.properties.title,sheets.properties.sheetId,sheets.tables', + }); + const parsed = JSON.parse(result.content[0].text!.split('\n\n')[1]); + expect(parsed.tableCount).toBe(1); + expect(parsed.tables[0]).toMatchObject({ + sheetName: 'Sheet1', + sheetId: 0, + tableId: 'table-1', + name: 'Tasks', + range: 'Sheet1!A1:B10', + }); + }); + }); +}); diff --git a/tests/unit/utils/range-helpers.test.ts b/tests/unit/utils/range-helpers.test.ts index 36f21e2..a305c57 100644 --- a/tests/unit/utils/range-helpers.test.ts +++ b/tests/unit/utils/range-helpers.test.ts @@ -185,7 +185,6 @@ describe('parseRange', () => { expect(() => parseRange('A1:b2')).toThrow('Invalid range format: A1:b2'); }); - it('should throw error for mixed formats', () => { expect(() => parseRange('A1B2')).toThrow('Invalid range format: A1B2'); expect(() => parseRange('1A')).toThrow('Invalid range format: 1A'); @@ -260,8 +259,9 @@ describe('getSheetId', () => { }, }); - await expect(getSheetId(mockSheets, 'spreadsheet-id', 'NonExistent')) - .rejects.toThrow('Sheet "NonExistent" not found'); + await expect(getSheetId(mockSheets, 'spreadsheet-id', 'NonExistent')).rejects.toThrow( + 'Sheet "NonExistent" not found' + ); }); it('should throw error when no sheets in spreadsheet', async () => { @@ -271,17 +271,15 @@ describe('getSheetId', () => { }, }); - await expect(getSheetId(mockSheets, 'spreadsheet-id')) - .rejects.toThrow('No sheets found in spreadsheet'); + await expect(getSheetId(mockSheets, 'spreadsheet-id')).rejects.toThrow( + 'No sheets found in spreadsheet' + ); }); - it('should handle sheet with ID 0', async () => { (mockSheets.spreadsheets.get as any).mockResolvedValue({ data: { - sheets: [ - { properties: { sheetId: 0, title: 'Sheet1' } }, - ], + sheets: [{ properties: { sheetId: 0, title: 'Sheet1' } }], }, }); @@ -289,13 +287,25 @@ describe('getSheetId', () => { expect(result).toBe(0); }); + it('should return sheet ID 0 when resolving by name', async () => { + (mockSheets.spreadsheets.get as any).mockResolvedValue({ + data: { + sheets: [{ properties: { sheetId: 0, title: 'Sheet1' } }], + }, + }); + + const result = await getSheetId(mockSheets, 'spreadsheet-id', 'Sheet1'); + expect(result).toBe(0); + }); + it('should handle null/undefined sheets array', async () => { (mockSheets.spreadsheets.get as any).mockResolvedValue({ data: {}, }); - await expect(getSheetId(mockSheets, 'spreadsheet-id')) - .rejects.toThrow('No sheets found in spreadsheet'); + await expect(getSheetId(mockSheets, 'spreadsheet-id')).rejects.toThrow( + 'No sheets found in spreadsheet' + ); }); it('should throw when first sheet has undefined sheetId', async () => { @@ -306,8 +316,9 @@ describe('getSheetId', () => { }, }); - await expect(getSheetId(mockSheets, 'spreadsheet-id')) - .rejects.toThrow('No sheets found in spreadsheet'); + await expect(getSheetId(mockSheets, 'spreadsheet-id')).rejects.toThrow( + 'No sheets found in spreadsheet' + ); }); it('should handle case-sensitive sheet names', async () => { @@ -429,9 +440,7 @@ describe('findSheetOrThrow', () => { }); it('should throw when sheets array is empty', () => { - expect(() => findSheetOrThrow([], 'Sheet1')).toThrow( - 'Sheet "Sheet1" not found. Available: ' - ); + expect(() => findSheetOrThrow([], 'Sheet1')).toThrow('Sheet "Sheet1" not found. Available: '); }); it('should be case-sensitive', () => { @@ -487,4 +496,4 @@ describe('gridRangeToA1', () => { // startCol=0, startRow=1, endCol defaults to startCol+1-1=0, endRow defaults to startRow=1 expect(result).toBe('A1:A1'); }); -}); \ No newline at end of file +});