mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 03:30:10 +00:00
feat(sheets): destination-tab dimensions + headers preview in google_sheets_write editor (#5614)
This commit is contained in:
parent
051b96d661
commit
200a80adb4
5 changed files with 201 additions and 2 deletions
|
|
@ -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;
|
||||
|
|
|
|||
58
skyvern-frontend/src/hooks/useGoogleSheetDimensions.ts
Normal file
58
skyvern-frontend/src/hooks/useGoogleSheetDimensions.ts
Normal file
|
|
@ -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<GetSheetDimensionsResponse>({
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
<div className="space-y-1 rounded-md border border-slate-700 bg-slate-900/40 px-2 py-1.5 text-[0.7rem] text-slate-300">
|
||||
<div>
|
||||
Sheet{" "}
|
||||
<span className="font-medium text-slate-100">
|
||||
"{dimensionsQuery.data.title}"
|
||||
</span>{" "}
|
||||
- {dimensionsQuery.data.column_count} columns (last is{" "}
|
||||
<span className="font-mono">
|
||||
{dimensionsQuery.data.last_column_letter}
|
||||
</span>
|
||||
) x {dimensionsQuery.data.row_count} rows.
|
||||
</div>
|
||||
{dimensionsQuery.data.headers.length > 0 ? (
|
||||
<details className="text-slate-400">
|
||||
<summary className="cursor-pointer">
|
||||
{dimensionsQuery.data.headers.length} header
|
||||
{dimensionsQuery.data.headers.length === 1
|
||||
? ""
|
||||
: "s"}{" "}
|
||||
in row 1
|
||||
</summary>
|
||||
<ul className="mt-1 grid grid-cols-2 gap-x-3 pl-2">
|
||||
{dimensionsQuery.data.headers.map((h) => (
|
||||
<li key={h.letter}>
|
||||
<span className="font-mono text-slate-500">
|
||||
{h.letter}
|
||||
</span>{" "}
|
||||
{h.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
{overflowingMappings.length > 0 ? (
|
||||
<div className="rounded border border-amber-600/40 bg-amber-900/20 px-2 py-1 text-amber-200">
|
||||
Column mapping writes past column{" "}
|
||||
<span className="font-mono">
|
||||
{dimensionsQuery.data.last_column_letter}
|
||||
</span>{" "}
|
||||
(
|
||||
{overflowingMappings
|
||||
.map((m) => `${m.field}->${m.letter}`)
|
||||
.join(", ")}
|
||||
). The sheet will be auto-widened on run.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Range - only for Update Range mode */}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue