import { computed, Default, handler, ifElse, NAME, pattern, UI, Writable, } from "commonfabric"; import { AirtableAuthManager, type ScopeKey, } from "./core/util/airtable-auth-manager.tsx"; import { AirtableClient } from "./core/util/airtable-client.ts"; import type { AirtableAuth } from "./core/airtable-auth.tsx"; // ============================================================================ // TYPES // ============================================================================ /** An Airtable record with its fields */ type AirtableRecordData = { id: string; createdTime: string; fields: Record; }; type BaseInfo = { id: string; name: string }; type TableInfo = { id: string; name: string }; interface Input { selectedBaseId: string | Default<"">; selectedTableId: string | Default<"">; } /** Import records from an Airtable base. #airtableImporter */ export interface Output { records: readonly AirtableRecordData[]; bases: readonly BaseInfo[]; tables: readonly TableInfo[]; selectedBaseId: string; selectedTableId: string; selectedBaseName: string; selectedTableName: string; recordCount: number; } // ============================================================================ // REQUIRED SCOPES // ============================================================================ const REQUIRED_SCOPES: ScopeKey[] = [ "data.records:read", "schema.bases:read", ]; // ============================================================================ // MODULE-SCOPE HANDLERS // ============================================================================ const fetchBases = handler< unknown, { auth: Writable; bases: Writable; loading: Writable; error: Writable; } >(async (_event, { auth, bases, loading, error }) => { loading.set(true); error.set(""); try { const client = AirtableClient(auth); const result = await client.listBases(); bases.set(result.map((b) => ({ id: b.id, name: b.name }))); } catch (e) { error.set(e instanceof Error ? e.message : String(e)); } finally { loading.set(false); } }); const fetchTables = handler< unknown, { auth: Writable; baseId: string; tables: Writable; loading: Writable; error: Writable; } >(async (_event, { auth, baseId, tables, loading, error }) => { if (!baseId) return; loading.set(true); error.set(""); try { const client = AirtableClient(auth); const result = await client.listTables(baseId); tables.set(result.map((t) => ({ id: t.id, name: t.name }))); } catch (e) { error.set(e instanceof Error ? e.message : String(e)); } finally { loading.set(false); } }); const fetchRecords = handler< unknown, { auth: Writable; baseId: string; tableId: string; records: Writable; loading: Writable; error: Writable; } >(async (_event, { auth, baseId, tableId, records, loading, error }) => { if (!baseId || !tableId) return; loading.set(true); error.set(""); try { const client = AirtableClient(auth); const result = await client.listRecords(baseId, tableId, { maxRecords: 500, }); records.set( result.map((r) => ({ id: r.id, createdTime: r.createdTime, fields: r.fields, })), ); } catch (e) { error.set(e instanceof Error ? e.message : String(e)); } finally { loading.set(false); } }); const onSelectBase = handler< unknown, { baseId: string; selectedBaseId: Writable; selectedTableId: Writable; tables: Writable; records: Writable; } >((_event, { baseId, selectedBaseId, selectedTableId, tables, records }) => { selectedBaseId.set(baseId); selectedTableId.set(""); tables.set([]); records.set([]); }); const onSelectTable = handler< unknown, { tableId: string; selectedTableId: Writable; records: Writable; } >((_event, { tableId, selectedTableId, records }) => { selectedTableId.set(tableId); records.set([]); }); // ============================================================================ // PATTERN // ============================================================================ export default pattern( ({ selectedBaseId, selectedTableId }) => { // Auth manager const { auth: authResult, isReady, fullUI: authUI, } = AirtableAuthManager({ requiredScopes: REQUIRED_SCOPES, }); // deno-lint-ignore no-explicit-any const auth = authResult as any; // State const bases = new Writable([]); const tables = new Writable([]); const records = new Writable([]); const loading = new Writable(false); const error = new Writable(""); const hasBases = computed(() => bases.get().length > 0); const hasTables = computed(() => tables.get().length > 0); const hasRecords = computed( () => records.get().length > 0, ); const recordCount = computed( () => records.get().length, ); const selectedBaseName = computed(() => { if (!selectedBaseId) return ""; const base = bases.get().find( (b) => b.id === selectedBaseId, ); return base?.name || ""; }); const selectedTableName = computed(() => { if (!selectedTableId) return ""; const table = tables.get().find( (t) => t.id === selectedTableId, ); return table?.name || ""; }); // Bound handlers — pass reactive inputs directly (no double-cast) const boundFetchBases = fetchBases({ auth, bases, loading, error }); const boundFetchTables = fetchTables({ auth, baseId: selectedBaseId, tables, loading, error, }); const boundFetchRecords = fetchRecords({ auth, baseId: selectedBaseId, tableId: selectedTableId, records, loading, error, }); // NOTE: onSelectBase/onSelectTable are bound per-item in .map() below // (idiomatic CTS: bind the ID into the handler context) // Column headers extracted from records const columnHeaders = computed(() => { const recs = records.get(); if (recs.length === 0) return [] as string[]; const allKeys = new Set(); for (const rec of recs.slice(0, 10)) { for (const key of Object.keys(rec.fields)) { allKeys.add(key); } } return Array.from(allKeys); }); const hasBaseSelected = computed(() => !!selectedBaseId); const hasTableSelected = computed(() => !!selectedTableId); // Data-only computed for base/table lists — JSX rendered inline in UI section const baseList = computed(() => bases.get().map((base) => ({ id: base.id, name: base.name, })) ); const tableList = computed(() => tables.get().map((table) => ({ id: table.id, name: table.name, })) ); // Precompute table rows as plain data (avoid nested JSX .map() in computed) const tableRows = computed(() => { const recs = records.get(); const hdrs = columnHeaders as string[]; return recs.map((rec) => ({ cells: hdrs.map((col) => formatCellValue(rec.fields[col])), })); }); const hasError = computed(() => !!error.get()); return { [NAME]: computed(() => { if (selectedBaseName && selectedTableName) { return `Airtable: ${selectedBaseName} / ${selectedTableName}`; } return "Airtable Importer"; }), [UI]: (

Airtable Importer

{/* Auth section */} {authUI} {/* Main content - only when authenticated */} {ifElse( isReady,
{/* Base selection */}

Select a Base

{ifElse( hasBases,
{baseList.map((base) => ( ))}
,

Click "Load Bases" to see your Airtable bases.

, )}
{/* Table selection */} {ifElse( hasBaseSelected,

Select a Table from {selectedBaseName}

{ifElse( hasTables,
{tableList.map((table) => ( ))}
,

Click "Load Tables" to see tables in this base.

, )}
, null, )} {/* Fetch records */} {ifElse( hasTableSelected,

Records from {selectedTableName}

{ifElse( hasRecords,

{recordCount} records loaded

{columnHeaders.map((col) => ( ))} {tableRows.map( (row) => ( {row.cells.map((cell) => ( ))} ), )}
{col}
{cell}
,

Click "Fetch Records" to load data from this table.

, )}
, null, )} {/* Error display */} {ifElse( hasError,
Error: {error}
, null, )}
, null, )}
), records: computed(() => records.get()), bases: computed(() => bases.get()), tables: computed(() => tables.get()), selectedBaseId, selectedTableId, selectedBaseName, selectedTableName, recordCount, }; }, ); // ============================================================================ // HELPERS // ============================================================================ function formatCellValue(value: unknown): string { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") { return String(value); } if (Array.isArray(value)) { return value.map((v) => formatCellValue(v)).join(", "); } if (typeof value === "object") { // Plain JSON: this fallback feeds user-visible UI cell content, not debug output return JSON.stringify(value); } return String(value); }