diff --git a/plugins/google-sheets/src/App.tsx b/plugins/google-sheets/src/App.tsx
index 47a8781ad..a632e4d34 100644
--- a/plugins/google-sheets/src/App.tsx
+++ b/plugins/google-sheets/src/App.tsx
@@ -190,7 +190,9 @@ export function App({ pluginContext }: AppProps) {
const mode = framer.mode
// Not a hook because we don't want to re-run the effect
- const isAllowedToSync = framer.isAllowedTo(...syncMethods)
+ const hasEnumField = context.type === "update" && context.collectionFields.some(field => field.type === "enum")
+ const isAllowedToSync =
+ framer.isAllowedTo(...syncMethods) && (!hasEnumField || framer.isAllowedTo("ManagedCollection.setFields"))
const shouldSyncOnly = mode === "syncManagedCollection" && shouldSyncImmediately(context) && isAllowedToSync
useLayoutEffect(() => {
@@ -222,6 +224,7 @@ export function App({ pluginContext }: AppProps) {
spreadsheetId,
sheetTitle,
fields,
+ configureFields: false,
// Determine if the field type is already configured, otherwise default to "string"
colFieldTypes: headerRow.map(colName => {
const field = fields.find(field => field.name === colName)
diff --git a/plugins/google-sheets/src/pages/MapSheetFields.tsx b/plugins/google-sheets/src/pages/MapSheetFields.tsx
index 04d2ef8ac..9dd8adaae 100644
--- a/plugins/google-sheets/src/pages/MapSheetFields.tsx
+++ b/plugins/google-sheets/src/pages/MapSheetFields.tsx
@@ -29,6 +29,7 @@ const fieldTypeOptions: FieldTypeOption[] = [
{ type: "color", label: "Color" },
{ type: "boolean", label: "Toggle" },
{ type: "number", label: "Number" },
+ { type: "enum", label: "Option" },
{ type: "file", label: "File" },
]
@@ -322,7 +323,7 @@ export function MapSheetFieldsPage({
diff --git a/plugins/google-sheets/src/sheets.ts b/plugins/google-sheets/src/sheets.ts
index cc449ee90..bfa28dbfc 100644
--- a/plugins/google-sheets/src/sheets.ts
+++ b/plugins/google-sheets/src/sheets.ts
@@ -10,7 +10,16 @@ import * as v from "valibot"
import auth from "./auth"
import { logSyncResult } from "./debug.ts"
import { queryClient } from "./main.tsx"
-import { assert, columnToLetter, generateHashId, generateUniqueNames, isDefined, listFormatter, slugify } from "./utils"
+import {
+ assert,
+ columnToLetter,
+ generateHashId,
+ generateUniqueNames,
+ hashStringToEnumCaseId,
+ isDefined,
+ listFormatter,
+ slugify,
+} from "./utils"
const USER_INFO_API_URL = "https://www.googleapis.com/oauth2/v1"
const SHEETS_API_URL = "https://sheets.googleapis.com/v4"
@@ -306,6 +315,11 @@ export interface SyncMutationOptions {
ignoredColumns: string[]
colFieldTypes: VirtualFieldType[]
lastSyncedTime: string | null
+ /**
+ * When false (e.g. sync-only mode), schema is only applied when enum fields need refreshed cases.
+ * When true (default), collection fields are always updated from the mapping.
+ */
+ configureFields?: boolean
}
const BASE_DATE_1900 = new Date(Date.UTC(1899, 11, 30))
@@ -348,6 +362,54 @@ function isValidISODate(dateString: string): boolean {
}
}
+function isEnumCellValueEmpty(cell: CellValue | undefined): boolean {
+ if (!isDefined(cell)) return true
+ if (typeof cell === "string" && cell.trim() === "") return true
+ return false
+}
+
+function buildEnumCasesForColumn(rows: Row[], colIndex: number): { id: string; name: string }[] {
+ const unique = new Set()
+ let hasEmptyCell = false
+
+ for (const row of rows) {
+ const cell = row[colIndex]
+ if (isEnumCellValueEmpty(cell)) {
+ hasEmptyCell = true
+ continue
+ }
+ unique.add(String(cell))
+ }
+
+ const sorted = Array.from(unique).sort((a, b) => a.localeCompare(b))
+ const valueCases = sorted.map(text => ({
+ id: hashStringToEnumCaseId(text),
+ name: text,
+ }))
+
+ if (hasEmptyCell) {
+ return [{ id: "null", name: "None" }, ...valueCases]
+ }
+
+ return valueCases
+}
+
+function enrichFieldsWithEnumCases(
+ fields: SheetCollectionFieldInput[],
+ uniqueHeaderRowNames: string[],
+ rows: Row[]
+): SheetCollectionFieldInput[] {
+ return fields.map(field => {
+ if (field.type !== "enum") return field
+
+ const colIndex = uniqueHeaderRowNames.indexOf(field.id)
+ if (colIndex === -1) return field
+
+ const cases = buildEnumCasesForColumn(rows, colIndex)
+ return { ...field, type: "enum", cases }
+ })
+}
+
function getFieldDataEntryInput(type: VirtualFieldType, cellValue: CellValue): FieldDataEntryInput | null {
switch (type) {
case "number": {
@@ -389,6 +451,10 @@ function getFieldDataEntryInput(type: VirtualFieldType, cellValue: CellValue): F
if (!isDefined(cellValue)) return null
return { type, value: String(cellValue) }
}
+ case "enum": {
+ if (isEnumCellValueEmpty(cellValue)) return null
+ return { type: "enum", value: hashStringToEnumCaseId(String(cellValue)) }
+ }
default:
return null
}
@@ -460,6 +526,12 @@ function processSheetRow({
type: "date",
}
break
+ case "enum":
+ fieldDataEntryInput = {
+ value: "null",
+ type: "enum",
+ }
+ break
}
}
@@ -565,6 +637,7 @@ export async function syncSheet({
ignoredColumns,
slugColumn,
colFieldTypes,
+ configureFields,
}: SyncMutationOptions) {
if (fields.length === 0) {
throw new Error("Expected to have at least one field selected to sync.")
@@ -576,6 +649,13 @@ export async function syncSheet({
const [headerRow, ...rows] = sheet.values
const uniqueHeaderRowNames = generateUniqueNames(headerRow)
+ const enrichedFields = enrichFieldsWithEnumCases(fields, uniqueHeaderRowNames, rows)
+ const needsFieldSchemaUpdate = !configureFields || enrichedFields.some(field => field.type === "enum")
+
+ if (needsFieldSchemaUpdate) {
+ await collection.setFields(enrichedFields.map(mapFieldToFramer))
+ }
+
const headerRowHash = generateHeaderRowHash(headerRow, ignoredColumns)
// Find the longest row length to check if any sheet rows are longer than the header row
@@ -798,13 +878,7 @@ export const useSyncSheetMutation = ({
}) => {
return useMutation({
mutationFn: async (args: SyncMutationOptions) => {
- const collection = await framer.getActiveManagedCollection()
- const fields = args.fields.map(mapFieldToFramer)
- await collection.setFields(fields)
- return await syncSheet({
- ...args,
- fields,
- })
+ return await syncSheet(args)
},
onSuccess,
onError,
diff --git a/plugins/google-sheets/src/utils.ts b/plugins/google-sheets/src/utils.ts
index 6f614f398..2a8a995be 100644
--- a/plugins/google-sheets/src/utils.ts
+++ b/plugins/google-sheets/src/utils.ts
@@ -73,6 +73,29 @@ export function generateHashId(text: string): string {
return unsignedHash.toString(16).padStart(8, "0")
}
+/**
+ * Stable 32-character lowercase hex id derived from text (enum case ids from cell values).
+ */
+export function hashStringToEnumCaseId(text: string): string {
+ let h0 = 5381
+ let h1 = 52711
+ let h2 = 0
+ let h3 = 0
+ for (let i = 0; i < text.length; i++) {
+ const c = text.charCodeAt(i)
+ h0 = (Math.imul(h0, 33) ^ c) >>> 0
+ h1 = (Math.imul(h1, 33) ^ (c + i)) >>> 0
+ h2 = (Math.imul(h2, 33) + c) >>> 0
+ h3 = (h3 + Math.imul(c, i + 1)) >>> 0
+ }
+ return [
+ h0.toString(16).padStart(8, "0"),
+ h1.toString(16).padStart(8, "0"),
+ h2.toString(16).padStart(8, "0"),
+ h3.toString(16).padStart(8, "0"),
+ ].join("")
+}
+
/**
* Generates unique names by appending a suffix if the name is already used.
*/