diff --git a/packages/server/src/integrations/googlesheets.ts b/packages/server/src/integrations/googlesheets.ts index d8d775c629..4fc47500eb 100644 --- a/packages/server/src/integrations/googlesheets.ts +++ b/packages/server/src/integrations/googlesheets.ts @@ -395,7 +395,12 @@ export class GoogleSheetsIntegration implements DatasourcePlus { sheet, }) case Operation.CREATE_TABLE: - return this.createTable(json?.table?.name) + if (json.table === undefined) { + throw new Error( + "attempted to create a table without specifying the table to create" + ) + } + return this.createTable(json.table) case Operation.UPDATE_TABLE: return this.updateTable(json.table!) case Operation.DELETE_TABLE: @@ -422,13 +427,13 @@ export class GoogleSheetsIntegration implements DatasourcePlus { return rowObject } - private async createTable(name?: string) { - if (!name) { - throw new Error("Must provide name for new sheet.") - } + private async createTable(table: Table) { try { await this.connect() - await this.client.addSheet({ title: name, headerValues: [name] }) + await this.client.addSheet({ + title: table.name, + headerValues: Object.keys(table.schema), + }) } catch (err) { console.error("Error creating new table in google sheets", err) throw err diff --git a/packages/server/src/integrations/tests/googlesheets.spec.ts b/packages/server/src/integrations/tests/googlesheets.spec.ts index 25a3706e10..e31d3e4330 100644 --- a/packages/server/src/integrations/tests/googlesheets.spec.ts +++ b/packages/server/src/integrations/tests/googlesheets.spec.ts @@ -49,12 +49,11 @@ describe("Google Sheets Integration", () => { beforeEach(async () => { nock.cleanAll() mock = GoogleSheetsMock.forDatasource(datasource) - mock.init() }) describe("create", () => { it("creates a new table", async () => { - const table = await config.api.table.save({ + await config.api.table.save({ name: "Test Table", type: "table", sourceId: datasource._id!, @@ -77,11 +76,10 @@ describe("Google Sheets Integration", () => { }, }) - const cell = mock.getCell(table.name, "A1") - if (!cell) { - throw new Error("Cell not found") - } - expect(cell.userEnteredValue.stringValue).toEqual(table.name) + expect(mock.cell("A1")).toEqual("name") + expect(mock.cell("B1")).toEqual("description") + expect(mock.cell("A2")).toEqual(null) + expect(mock.cell("B2")).toEqual(null) }) }) diff --git a/packages/server/src/integrations/tests/utils/googlesheets.ts b/packages/server/src/integrations/tests/utils/googlesheets.ts index 82a274405b..70481e0e0d 100644 --- a/packages/server/src/integrations/tests/utils/googlesheets.ts +++ b/packages/server/src/integrations/tests/utils/googlesheets.ts @@ -3,7 +3,7 @@ import nock from "nock" import { GoogleSheetsConfig } from "../../googlesheets" // https://protobuf.dev/reference/protobuf/google.protobuf/#value -type Value = string | number | boolean +type Value = string | number | boolean | null // https://developers.google.com/sheets/api/reference/rest/v4/Dimension type Dimension = "ROWS" | "COLUMNS" @@ -195,6 +195,9 @@ export class GoogleSheetsMock { spreadsheetId: config.spreadsheetId, sheets: [], } + + this.mockAuth() + this.mockAPI() } private route( @@ -237,6 +240,7 @@ export class GoogleSheetsMock { client_secret: "your-client-secret", }) .persist() + nock("https://oauth2.googleapis.com/") .post("/token", { client_id: "test", @@ -253,9 +257,7 @@ export class GoogleSheetsMock { .persist() } - init() { - this.mockAuth() - + private mockAPI() { this.get(`/v4/spreadsheets/${this.config.spreadsheetId}/`, () => this.handleGetSpreadsheet() ) @@ -280,7 +282,7 @@ export class GoogleSheetsMock { if (!range) { throw new Error("No range provided") } - return this.handleGetValues(decodeURIComponent(range)) + return this.getValueRange(decodeURIComponent(range)) } ) @@ -301,11 +303,17 @@ export class GoogleSheetsMock { params.includeValuesInResponse = false } - const range = url.pathname.split("/").pop() + let range = url.pathname.split("/").pop() if (!range) { throw new Error("No range provided") } + if (range.endsWith(":append")) { + range = range.slice(0, -7) + } + + range = decodeURIComponent(range) + return this.handleValueAppend({ range, params, @@ -315,29 +323,44 @@ export class GoogleSheetsMock { ) } - private handleValueAppend(request: AppendRequest): AppendResponse {} + private handleValueAppend(request: AppendRequest): AppendResponse { + const { range, params, body } = request + const { sheet, bottomRight } = this.parseA1Notation(range) - private handleGetValues(range: string): ValueRange { - const { sheet, topLeft, bottomRight } = this.parseA1Notation(range) - const valueRange: ValueRange = { - range, - majorDimension: "ROWS", - values: [], - } + const newRows = body.values.map(v => this.valuesToRowData(v)) + const toDelete = + params.insertDataOption === "INSERT_ROWS" ? newRows.length : 0 + sheet.data[0].rowData.splice(bottomRight.row + 1, toDelete, ...newRows) + sheet.data[0].rowMetadata.splice(bottomRight.row + 1, toDelete, { + hiddenByUser: false, + hiddenByFilter: false, + pixelSize: 100, + }) - for (let row = topLeft.row; row <= bottomRight.row; row++) { - const values: Value[] = [] - for (let col = topLeft.column; col <= bottomRight.column; col++) { - const cell = this.getCellNumericIndexes(sheet, row, col) - if (!cell) { - throw new Error("Cell not found") - } - values.push(this.unwrapValue(cell.userEnteredValue)) + const updatedRange = this.createA1FromRanges( + sheet, + { + row: bottomRight.row + 1, + column: 0, + }, + { + row: bottomRight.row + newRows.length, + column: 0, } - valueRange.values.push(values) - } + ) - return valueRange + return { + spreadsheetId: this.spreadsheet.spreadsheetId, + tableRange: range, + updates: { + spreadsheetId: this.spreadsheet.spreadsheetId, + updatedRange, + updatedRows: body.values.length, + updatedColumns: body.values[0].length, + updatedCells: body.values.length * body.values[0].length, + updatedData: body, + }, + } } private handleBatchUpdate( @@ -387,29 +410,9 @@ export class GoogleSheetsMock { } private handleValueUpdate(valueRange: ValueRange): UpdateValuesResponse { - if (valueRange.majorDimension !== "ROWS") { - throw new Error("Only row-major updates are supported") - } - - const { sheet, topLeft, bottomRight } = this.parseA1Notation( - valueRange.range - ) - - for (let row = topLeft.row; row <= bottomRight.row; row++) { - for ( - let column = topLeft.column; - column <= bottomRight.column; - column++ - ) { - const cell = this.getCellNumericIndexes(sheet, row, column) - if (!cell) { - continue - } - const value = - valueRange.values[row - topLeft.row][column - topLeft.column] - cell.userEnteredValue = this.createValue(value) - } - } + this.iterateCells(valueRange, (cell, value) => { + cell.userEnteredValue = this.createValue(value) + }) const response: UpdateValuesResponse = { spreadsheetId: this.spreadsheet.spreadsheetId, @@ -422,6 +425,60 @@ export class GoogleSheetsMock { return response } + private iterateCells( + valueRange: ValueRange, + cb: (cell: CellData, value: Value) => void + ) { + if (valueRange.majorDimension !== "ROWS") { + throw new Error("Only row-major updates are supported") + } + + const { sheet, topLeft, bottomRight } = this.parseA1Notation( + valueRange.range + ) + for (let row = topLeft.row; row <= bottomRight.row; row++) { + for (let col = topLeft.column; col <= bottomRight.column; col++) { + const cell = this.getCellNumericIndexes(sheet, row, col) + if (!cell) { + throw new Error("Cell not found") + } + const value = valueRange.values[row - topLeft.row][col - topLeft.column] + cb(cell, value) + } + } + } + + private getValueRange(range: string): ValueRange { + const { sheet, topLeft, bottomRight } = this.parseA1Notation(range) + const valueRange: ValueRange = { + range, + majorDimension: "ROWS", + values: [], + } + + for (let row = topLeft.row; row <= bottomRight.row; row++) { + const values: Value[] = [] + for (let col = topLeft.column; col <= bottomRight.column; col++) { + const cell = this.getCellNumericIndexes(sheet, row, col) + if (!cell) { + throw new Error("Cell not found") + } + values.push(this.unwrapValue(cell.userEnteredValue)) + } + valueRange.values.push(values) + } + + return valueRange + } + + private valuesToRowData(values: Value[]): RowData { + return { + values: values.map(v => { + return { userEnteredValue: this.createValue(v) } + }), + } + } + private unwrapValue(from: ExtendedValue): Value { if (from.stringValue !== undefined) { return from.stringValue @@ -432,12 +489,14 @@ export class GoogleSheetsMock { } else if (from.formulaValue !== undefined) { return from.formulaValue } else { - throw new Error("Unsupported value type") + return null } } private createValue(from: Value): ExtendedValue { - if (typeof from === "string") { + if (from == null) { + return {} + } else if (typeof from === "string") { return { stringValue: from, } @@ -459,15 +518,9 @@ export class GoogleSheetsMock { for (let row = 0; row < numRows; row++) { const cells: CellData[] = [] for (let col = 0; col < numCols; col++) { - cells.push({ - userEnteredValue: { - stringValue: "", - }, - }) + cells.push({ userEnteredValue: this.createValue(null) }) } - rowData.push({ - values: cells, - }) + rowData.push({ values: cells }) } const rowMetadata: DimensionProperties[] = [] for (let row = 0; row < numRows; row++) { @@ -495,16 +548,20 @@ export class GoogleSheetsMock { } } - getCell(sheetName: string, ref: string): CellData | undefined { - const sheet = this.getSheetByName(sheetName) - if (!sheet) { + private cellData(cell: string): CellData | undefined { + const { + sheet, + topLeft: { row, column }, + } = this.parseA1Notation(cell) + return this.getCellNumericIndexes(sheet, row, column) + } + + cell(cell: string): Value | undefined { + const cellData = this.cellData(cell) + if (!cellData) { return undefined } - const { row, column } = this.parseCell(ref) - if (row === "ALL" || column === "ALL") { - throw new Error("Only single cell references are supported") - } - return this.getCellNumericIndexes(sheet, row, column) + return this.unwrapValue(cellData.userEnteredValue) } private getCellNumericIndexes( @@ -525,36 +582,83 @@ export class GoogleSheetsMock { } // https://developers.google.com/sheets/api/guides/concepts#cell + // + // Examples from + // https://code.luasoftware.com/tutorials/google-sheets-api/google-sheets-api-range-parameter-a1-notation + // + // "Sheet1!A1" -> First cell on Row 1 Col 1 + // "Sheet1!A1:C1" -> Col 1-3 (A, B, C) on Row 1 = A1, B1, C1 + // "A1" -> First visible sheet (if sheet name is ommitted) + // "'My Sheet'!A1" -> If sheet name which contain space or start with a bracket. + // "Sheet1" -> All cells in Sheet1. + // "Sheet1!A:A" -> All cells on Col 1. + // "Sheet1!A:B" -> All cells on Col 1 and 2. + // "Sheet1!1:1" -> All cells on Row 1. + // "Sheet1!1:2" -> All cells on Row 1 and 2. + // + // How that translates to our code below, omitting the `sheet` property: + // + // "Sheet1!A1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 0 } } + // "Sheet1!A1:C1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 2 } } + // "A1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 0 } } + // "Sheet1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 100, column: 25 } } + // -> This is because we default to having a 100x26 grid. + // "Sheet1!A:A" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 99, column: 0 } } + // "Sheet1!A:B" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 99, column: 1 } } + // "Sheet1!1:1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 25 } } + // "Sheet1!1:2" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 1, column: 25 } } private parseA1Notation(range: string): { sheet: Sheet topLeft: Range bottomRight: Range } { - let [sheetName, rest] = range.split("!") + let sheet: Sheet + let rest: string + if (!range.includes("!")) { + sheet = this.spreadsheet.sheets[0] + rest = range + } else { + let sheetName = range.split("!")[0] + if (sheetName.startsWith("'") && sheetName.endsWith("'")) { + sheetName = sheetName.slice(1, -1) + } + const foundSheet = this.getSheetByName(sheetName) + if (!foundSheet) { + throw new Error(`Sheet ${sheetName} not found`) + } + sheet = foundSheet + rest = range.split("!")[1] + } + const [topLeft, bottomRight] = rest.split(":") - if (sheetName.startsWith("'") && sheetName.endsWith("'")) { - sheetName = sheetName.slice(1, -1) + const parsedTopLeft = topLeft ? this.parseCell(topLeft) : undefined + let parsedBottomRight = bottomRight + ? this.parseCell(bottomRight) + : undefined + + if (!parsedTopLeft && !parsedBottomRight) { + throw new Error("No range provided") } - const sheet = this.getSheetByName(sheetName) - if (!sheet) { - throw new Error(`Sheet ${sheetName} not found`) + if (!parsedTopLeft) { + throw new Error("No top left cell provided") } - const parsedTopLeft = this.parseCell(topLeft) - const parsedBottomRight = this.parseCell(bottomRight) + if (!parsedBottomRight) { + parsedBottomRight = parsedTopLeft + } - if (parsedTopLeft.row === "ALL") { + if (parsedTopLeft && parsedTopLeft.row === undefined) { parsedTopLeft.row = 0 } - if (parsedBottomRight.row === "ALL") { - parsedBottomRight.row = sheet.properties.gridProperties.rowCount - 1 - } - if (parsedTopLeft.column === "ALL") { + if (parsedTopLeft && parsedTopLeft.column === undefined) { parsedTopLeft.column = 0 } - if (parsedBottomRight.column === "ALL") { + if (parsedBottomRight && parsedBottomRight.row === undefined) { + parsedBottomRight.row = sheet.properties.gridProperties.rowCount - 1 + } + if (parsedBottomRight && parsedBottomRight.column === undefined) { parsedBottomRight.column = sheet.properties.gridProperties.columnCount - 1 } @@ -565,22 +669,31 @@ export class GoogleSheetsMock { } } + private createA1FromRanges(sheet: Sheet, topLeft: Range, bottomRight: Range) { + let title = sheet.properties.title + if (title.includes(" ")) { + title = `'${title}'` + } + const topLeftLetter = this.numberToLetter(topLeft.column) + const bottomRightLetter = this.numberToLetter(bottomRight.column) + const topLeftRow = topLeft.row + 1 + const bottomRightRow = bottomRight.row + 1 + return `${title}!${topLeftLetter}${topLeftRow}:${bottomRightLetter}${bottomRightRow}` + } + /** * Parses a cell reference into a row and column. * @param cell a string of the form A1, B2, etc. * @returns */ - private parseCell(cell: string): { - row: number | "ALL" - column: number | "ALL" - } { + private parseCell(cell: string): Partial { const firstChar = cell.slice(0, 1) if (this.isInteger(firstChar)) { - return { row: parseInt(cell) - 1, column: "ALL" } + return { row: parseInt(cell) - 1 } } const column = this.letterToNumber(firstChar) if (cell.length === 1) { - return { row: "ALL", column } + return { column } } const number = cell.slice(1) return { row: parseInt(number) - 1, column } @@ -594,6 +707,10 @@ export class GoogleSheetsMock { return letter.charCodeAt(0) - 65 } + private numberToLetter(number: number): string { + return String.fromCharCode(number + 65) + } + private getSheetByName(name: string): Sheet | undefined { return this.spreadsheet.sheets.find( sheet => sheet.properties.title === name