feat(sheets): destination-tab dimensions + headers preview in google_sheets_write editor (#5614)

This commit is contained in:
Aaron Perez 2026-04-22 22:38:17 -05:00 committed by GitHub
parent 051b96d661
commit 200a80adb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 201 additions and 2 deletions

View file

@ -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;

View 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,
});
}

View file

@ -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 */}

View file

@ -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);
});
});

View file

@ -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;
}