From 200a80adb4aa053d0a4034f3cbd4cb3bbb029299 Mon Sep 17 00:00:00 2001 From: Aaron Perez Date: Wed, 22 Apr 2026 22:38:17 -0500 Subject: [PATCH] feat(sheets): destination-tab dimensions + headers preview in google_sheets_write editor (#5614) --- skyvern-frontend/src/api/types.ts | 9 ++ .../src/hooks/useGoogleSheetDimensions.ts | 58 +++++++++++++ .../GoogleSheetsWriteNode.tsx | 84 ++++++++++++++++++- .../src/util/googleSheetsUrl.test.ts | 38 ++++++++- skyvern-frontend/src/util/googleSheetsUrl.ts | 14 ++++ 5 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 skyvern-frontend/src/hooks/useGoogleSheetDimensions.ts diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index 78c2ee14d..fe9190e61 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -328,6 +328,15 @@ export interface GetSheetHeadersResponse { headers: SheetHeader[]; } +export interface GetSheetDimensionsResponse { + sheet_id: number; + title: string; + column_count: number; + row_count: number; + last_column_letter: string; + headers: SheetHeader[]; +} + export interface CreateGoogleSpreadsheetResponse { spreadsheet: GoogleSpreadsheetSummary; first_sheet_name: string | null; diff --git a/skyvern-frontend/src/hooks/useGoogleSheetDimensions.ts b/skyvern-frontend/src/hooks/useGoogleSheetDimensions.ts new file mode 100644 index 000000000..36bc08102 --- /dev/null +++ b/skyvern-frontend/src/hooks/useGoogleSheetDimensions.ts @@ -0,0 +1,58 @@ +import { useQuery } from "@tanstack/react-query"; +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import type { GetSheetDimensionsResponse } from "@/api/types"; +import { + extractSpreadsheetIdFromUrl, + isTemplateExpression, +} from "@/util/googleSheetsUrl"; + +type Options = { + credentialId: string; + spreadsheetUrlOrId: string; + sheetName: string; +}; + +export function useGoogleSheetDimensions({ + credentialId, + spreadsheetUrlOrId, + sheetName, +}: Options) { + const credentialGetter = useCredentialGetter(); + const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrlOrId); + const urlIsTemplated = isTemplateExpression(spreadsheetUrlOrId); + const nameIsTemplated = isTemplateExpression(sheetName); + const credentialIsTemplated = isTemplateExpression(credentialId); + const enabled = + Boolean(credentialId) && + Boolean(spreadsheetId) && + Boolean(sheetName) && + !urlIsTemplated && + !nameIsTemplated && + !credentialIsTemplated; + + return useQuery({ + queryKey: [ + "googleSheets", + "dimensions", + credentialId, + spreadsheetId, + sheetName, + ], + queryFn: async () => { + const client = await getClient(credentialGetter); + const response = await client.get( + `/google/sheets/spreadsheets/${spreadsheetId}/dimensions`, + { + params: { + credential_id: credentialId, + sheet_title: sheetName, + }, + }, + ); + return response.data as GetSheetDimensionsResponse; + }, + enabled, + staleTime: 30_000, + }); +} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/GoogleSheetsWriteNode/GoogleSheetsWriteNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/GoogleSheetsWriteNode/GoogleSheetsWriteNode.tsx index efaf895cf..03fc6b9d9 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/GoogleSheetsWriteNode/GoogleSheetsWriteNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/GoogleSheetsWriteNode/GoogleSheetsWriteNode.tsx @@ -28,15 +28,18 @@ import { GoogleOAuthCredentialSelector } from "@/routes/workflows/components/Goo import { SpreadsheetCombobox } from "@/routes/workflows/components/SpreadsheetCombobox"; import { SheetTabCombobox } from "@/routes/workflows/components/SheetTabCombobox"; import { + columnLettersToIndex, extractSpreadsheetIdFromUrl, isTemplateExpression, } from "@/util/googleSheetsUrl"; import { ColumnMappingEditor } from "@/routes/workflows/components/ColumnMappingEditor"; +import { parseColumnMapping } from "@/util/columnMappingSerialization"; import { useGoogleSheetHeaders } from "@/hooks/useGoogleSheetHeaders"; +import { useGoogleSheetDimensions } from "@/hooks/useGoogleSheetDimensions"; import { useGoogleOAuthCredentials } from "@/hooks/useGoogleOAuthCredentials"; import { useGoogleSpreadsheet } from "@/hooks/useGoogleSpreadsheet"; import { isReconnectRequired } from "@/util/googleSheetsErrors"; -import { useState } from "react"; +import { useMemo, useState } from "react"; function GoogleSheetsWriteNode({ id, @@ -67,7 +70,37 @@ function GoogleSheetsWriteNode({ spreadsheetUrlOrId: data.spreadsheetUrl, sheetName: data.sheetName, }); + const dimensionsQuery = useGoogleSheetDimensions({ + credentialId: data.credentialId, + spreadsheetUrlOrId: data.spreadsheetUrl, + sheetName: data.sheetName, + }); const needsReconnect = isReconnectRequired(headersQuery.error); + + // Highlight column_mapping targets that fall past the destination tab's last + // column so authors see the resize ahead of run rather than discovering it + // as an HTTP 400 in the run log. The backend auto-extends on run, so this is + // informational, not an error. Reuses parseColumnMapping so the parsing + // rules stay in lockstep with ColumnMappingEditor. + const overflowingMappings = useMemo< + { field: string; letter: string }[] + >(() => { + if (!dimensionsQuery.data || !data.columnMapping) return []; + const lastIndex = columnLettersToIndex( + dimensionsQuery.data.last_column_letter, + ); + // <= 0 covers both an unparseable last_column_letter and a sheet with 0 + // columns (degenerate but theoretically possible) - either way, no warning. + if (lastIndex <= 0) return []; + const out: { field: string; letter: string }[] = []; + for (const { key, letter } of parseColumnMapping(data.columnMapping)) { + const targetIndex = columnLettersToIndex(letter); + if (targetIndex > 0 && targetIndex > lastIndex) { + out.push({ field: key, letter: letter.toUpperCase() }); + } + } + return out; + }, [dimensionsQuery.data, data.columnMapping]); const rerender = useRerender({ prefix: "google-sheets-write" }); const [spreadsheetDisplayName, setSpreadsheetDisplayName] = useState< string | null @@ -318,6 +351,55 @@ function GoogleSheetsWriteNode({ onChange={(value) => update({ sheetName: value })} onSelect={(tabName) => update({ sheetName: tabName })} /> + {dimensionsQuery.data ? ( +
+
+ Sheet{" "} + + "{dimensionsQuery.data.title}" + {" "} + - {dimensionsQuery.data.column_count} columns (last is{" "} + + {dimensionsQuery.data.last_column_letter} + + ) x {dimensionsQuery.data.row_count} rows. +
+ {dimensionsQuery.data.headers.length > 0 ? ( +
+ + {dimensionsQuery.data.headers.length} header + {dimensionsQuery.data.headers.length === 1 + ? "" + : "s"}{" "} + in row 1 + +
    + {dimensionsQuery.data.headers.map((h) => ( +
  • + + {h.letter} + {" "} + {h.name} +
  • + ))} +
+
+ ) : null} + {overflowingMappings.length > 0 ? ( +
+ Column mapping writes past column{" "} + + {dimensionsQuery.data.last_column_letter} + {" "} + ( + {overflowingMappings + .map((m) => `${m.field}->${m.letter}`) + .join(", ")} + ). The sheet will be auto-widened on run. +
+ ) : null} +
+ ) : null} {/* Range - only for Update Range mode */} diff --git a/skyvern-frontend/src/util/googleSheetsUrl.test.ts b/skyvern-frontend/src/util/googleSheetsUrl.test.ts index fd19778a3..7a4617aa7 100644 --- a/skyvern-frontend/src/util/googleSheetsUrl.test.ts +++ b/skyvern-frontend/src/util/googleSheetsUrl.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest"; import { - extractSpreadsheetIdFromUrl, buildSpreadsheetUrl, + columnLettersToIndex, + extractSpreadsheetIdFromUrl, isTemplateExpression, } from "./googleSheetsUrl"; @@ -85,3 +86,38 @@ describe("isTemplateExpression", () => { expect(isTemplateExpression("")).toBe(false); }); }); + +describe("columnLettersToIndex", () => { + it("maps A through Z", () => { + expect(columnLettersToIndex("A")).toBe(1); + expect(columnLettersToIndex("Z")).toBe(26); + }); + + it("maps double-letter columns past Z", () => { + expect(columnLettersToIndex("AA")).toBe(27); + expect(columnLettersToIndex("AZ")).toBe(52); + expect(columnLettersToIndex("ZZ")).toBe(702); + }); + + it("maps triple-letter columns up to ZZZ", () => { + expect(columnLettersToIndex("AAA")).toBe(703); + expect(columnLettersToIndex("ZZZ")).toBe(18278); + }); + + it("is case-insensitive", () => { + expect(columnLettersToIndex("aa")).toBe(27); + }); + + it("returns 0 for non-alpha input", () => { + expect(columnLettersToIndex("")).toBe(0); + expect(columnLettersToIndex("A1")).toBe(0); + expect(columnLettersToIndex("1")).toBe(0); + }); + + it("returns 0 for header-name strings past 3 letters", () => { + // Sheets columns top out at ZZZ, so longer all-caps tokens are literal + // header names (e.g. "TOTAL"), not column references. + expect(columnLettersToIndex("TOTAL")).toBe(0); + expect(columnLettersToIndex("AAAA")).toBe(0); + }); +}); diff --git a/skyvern-frontend/src/util/googleSheetsUrl.ts b/skyvern-frontend/src/util/googleSheetsUrl.ts index af2ff41b9..41951f467 100644 --- a/skyvern-frontend/src/util/googleSheetsUrl.ts +++ b/skyvern-frontend/src/util/googleSheetsUrl.ts @@ -26,3 +26,17 @@ export function buildSpreadsheetUrl(spreadsheetId: string): string { export function isTemplateExpression(input: string): boolean { return input.includes("{{") || input.includes("{%"); } + +// "A" -> 1, "Z" -> 26, "AA" -> 27. Returns 0 for anything that is not a real +// Sheets column reference: Google's per-tab cap is ZZZ (3 chars), so longer +// all-caps tokens like "TOTAL" must be treated as literals - otherwise an +// unmatched header name triggers a false-positive overflow warning. +export function columnLettersToIndex(letters: string): number { + const upper = letters.toUpperCase(); + if (!/^[A-Z]{1,3}$/.test(upper)) return 0; + let index = 0; + for (const ch of upper) { + index = index * 26 + (ch.charCodeAt(0) - "A".charCodeAt(0) + 1); + } + return index; +}