From 8cf92b674c2d829b2dea398192db2a1be9fd8aa1 Mon Sep 17 00:00:00 2001 From: Kaloyan Videlov Date: Wed, 25 Mar 2026 11:11:04 +0100 Subject: [PATCH 1/4] Add enum field support to Google Sheets sync. Derive enum cases from sheet values during mapping and sync, and tighten the field-mapping page types so enum configuration stays type-safe. Made-with: Cursor --- .../src/pages/MapSheetFields.tsx | 171 ++++++++++++------ plugins/google-sheets/src/sheets.ts | 58 ++++++ 2 files changed, 170 insertions(+), 59 deletions(-) diff --git a/plugins/google-sheets/src/pages/MapSheetFields.tsx b/plugins/google-sheets/src/pages/MapSheetFields.tsx index 04d2ef8ac..5a7114c8f 100644 --- a/plugins/google-sheets/src/pages/MapSheetFields.tsx +++ b/plugins/google-sheets/src/pages/MapSheetFields.tsx @@ -3,25 +3,41 @@ import { framer, useIsAllowedTo } from "framer-plugin" import { Fragment, useMemo, useState } from "react" import { CheckboxTextfield } from "../components/CheckboxTextField" import { IconChevron } from "../components/Icons" -import type { - CellValue, - HeaderRow, - PluginContext, - Row, - SheetCollectionFieldInput, - SyncMutationOptions, - VirtualFieldType, -} from "../sheets" -import { generateUniqueNames, isDefined, syncMethods } from "../utils" +import type { CellValue, HeaderRow, PluginContext, PluginContextUpdate, Row, SyncMutationOptions } from "../sheets" +import { generateHashId, generateUniqueNames, isDefined, syncMethods } from "../utils" + +type FieldType = + | "string" + | "number" + | "boolean" + | "formattedText" + | "enum" + | "date" + | "dateTime" + | "link" + | "image" + | "color" + | "file" + | "collectionReference" + | "multiCollectionReference" + | "array" interface FieldTypeOption { - type: VirtualFieldType + type: FieldType label: string } +interface EditableField { + id: string + name: string + type: FieldType + cases?: { id: string; name: string }[] +} + const fieldTypeOptions: FieldTypeOption[] = [ { type: "string", label: "Plain Text" }, { type: "formattedText", label: "Formatted Text" }, + { type: "enum", label: "Option" }, { type: "date", label: "Date" }, { type: "dateTime", label: "Date & Time" }, { type: "link", label: "Link" }, @@ -32,12 +48,17 @@ const fieldTypeOptions: FieldTypeOption[] = [ { type: "file", label: "File" }, ] -const getInitialSlugColumn = (context: PluginContext, slugFields: SheetCollectionFieldInput[]): string => { - if (context.type === "update" && context.slugColumn) { +function isUpdateContext(context: PluginContext): context is PluginContextUpdate { + return context.type === "update" +} + +const getInitialSlugColumn = (context: PluginContext, slugFields: EditableField[]): string => { + if (isUpdateContext(context) && context.slugColumn) { return context.slugColumn } - return slugFields[0]?.id ?? "" + const firstSlugField = slugFields[0] + return firstSlugField?.id ?? "" } const getLastSyncedTime = (context: PluginContext, slugColumn: string): string | null => { @@ -54,7 +75,7 @@ const getLastSyncedTime = (context: PluginContext, slugColumn: string): string | return context.lastSyncedTime } -const inferFieldType = (cellValue: CellValue): VirtualFieldType => { +const inferFieldType = (cellValue: CellValue): FieldType => { if (typeof cellValue === "boolean") return "boolean" if (typeof cellValue === "number") return "number" @@ -102,9 +123,9 @@ const inferFieldType = (cellValue: CellValue): VirtualFieldType => { return "string" } -const getFieldType = (context: PluginContext, columnId: string, cellValue?: CellValue): VirtualFieldType => { +const getFieldType = (context: PluginContext, columnId: string, cellValue?: CellValue): FieldType => { // Determine if the field type is already configured - if ("collectionFields" in context) { + if (isUpdateContext(context)) { const field = context.collectionFields.find(field => field.id === columnId) return field?.type ?? "string" } @@ -118,24 +139,27 @@ const createFieldConfig = ( uniqueColumnNames: string[], context: PluginContext, row?: Row -): SheetCollectionFieldInput[] => { - return headerRow - .map((_, columnIndex) => { - const sanitizedName = uniqueColumnNames[columnIndex] - if (!sanitizedName) return null - - return { - id: sanitizedName, - name: sanitizedName, - type: getFieldType(context, sanitizedName, row?.[columnIndex]), - } as SheetCollectionFieldInput +): EditableField[] => { + const fields: EditableField[] = [] + + for (const [columnIndex] of headerRow.entries()) { + const sanitizedName = uniqueColumnNames[columnIndex] + if (!sanitizedName) continue + const initialType: FieldType = getFieldType(context, sanitizedName, row?.[columnIndex]) + + fields.push({ + id: sanitizedName, + name: sanitizedName, + type: initialType, }) - .filter(isDefined) + } + + return fields } const getFieldNameOverrides = (context: PluginContext): Record => { const result: Record = {} - if (context.type !== "update") return result + if (!isUpdateContext(context)) return result for (const field of context.collectionFields) { result[field.id] = field.name @@ -144,7 +168,7 @@ const getFieldNameOverrides = (context: PluginContext): Record = return result } -const getPossibleSlugFields = (fieldConfig: SheetCollectionFieldInput[]): SheetCollectionFieldInput[] => { +const getPossibleSlugFields = (fieldConfig: EditableField[]): EditableField[] => { return fieldConfig.filter(field => field.type === "string") } @@ -168,11 +192,11 @@ export function MapSheetFieldsPage({ rows, }: Props) { const uniqueColumnNames = useMemo(() => generateUniqueNames(headerRow), [headerRow]) - const [fieldConfig, setFieldConfig] = useState(() => + const [fieldConfig, setFieldConfig] = useState(() => createFieldConfig(headerRow, uniqueColumnNames, pluginContext, rows[0]) ) - const [disabledColumns, setDisabledColumns] = useState( - () => new Set(pluginContext.type === "update" ? pluginContext.ignoredColumns : []) + const [disabledColumns, setDisabledColumns] = useState>( + () => new Set(isUpdateContext(pluginContext) ? pluginContext.ignoredColumns : []) ) const slugFields = useMemo(() => getPossibleSlugFields(fieldConfig), [fieldConfig]) const [slugColumn, setSlugColumn] = useState(() => getInitialSlugColumn(pluginContext, slugFields)) @@ -199,18 +223,16 @@ export function MapSheetFieldsPage({ })) } - const handleFieldTypeChange = (id: string, type: VirtualFieldType) => { - setFieldConfig(current => - current.map(field => { - if (field.id === id) { - return { - ...field, - type, - } as SheetCollectionFieldInput - } - return field - }) - ) + const handleFieldTypeChange = (id: string, type: FieldType) => { + setFieldConfig(current => { + const nextFields: EditableField[] = [] + + for (const field of current) { + nextFields.push(field.id === id ? { ...field, type } : field) + } + + return nextFields + }) } const handleSubmit = async (e: React.FormEvent) => { @@ -218,28 +240,59 @@ export function MapSheetFieldsPage({ if (isPending) return - const allFields = fieldConfig - .filter(field => !disabledColumns.has(field.id)) - .map(field => { - const maybeOverride = fieldNameOverrides[field.id] - if (maybeOverride) { - field.name = maybeOverride + const allFields: EditableField[] = [] + for (const field of fieldConfig) { + if (disabledColumns.has(field.id)) continue + + const result: EditableField = { ...field } + const maybeOverride = fieldNameOverrides[result.id] + if (maybeOverride) { + result.name = maybeOverride + } + + if (result.type === "enum") { + const colIndex = uniqueColumnNames.indexOf(result.id) + if (colIndex !== -1) { + const uniqueValues: string[] = [] + const seenValues = new Set() + for (const row of rows) { + const rawValue = row[colIndex] + if (!isDefined(rawValue)) continue + + const normalizedValue = String(rawValue).trim() + if (!normalizedValue || seenValues.has(normalizedValue)) continue + + seenValues.add(normalizedValue) + uniqueValues.push(normalizedValue) + } + result.cases = uniqueValues.map(value => ({ + id: generateHashId(value), + name: value, + })) } + } + + allFields.push(result) + } - return field - }) + const colFieldTypes: FieldType[] = [] + for (const field of fieldConfig) { + colFieldTypes.push(field.type) + } await framer.setCloseWarning("Synchronization in progress. Closing will cancel the sync.") - onSubmit({ - fields: allFields, + const submitOptions: SyncMutationOptions = { + fields: allFields as SyncMutationOptions["fields"], spreadsheetId, sheetTitle, - colFieldTypes: fieldConfig.map(field => field.type), + colFieldTypes: colFieldTypes as SyncMutationOptions["colFieldTypes"], ignoredColumns: Array.from(disabledColumns), slugColumn, lastSyncedTime: getLastSyncedTime(pluginContext, slugColumn), - }) + } + + onSubmit(submitOptions) } const isAllowedToManage = useIsAllowedTo("ManagedCollection.setFields", ...syncMethods) @@ -293,7 +346,7 @@ export function MapSheetFieldsPage({ disabled={isDisabled || !isAllowedToManage} value={field.type} onChange={e => { - handleFieldTypeChange(field.id, e.target.value as VirtualFieldType) + handleFieldTypeChange(field.id, e.target.value as FieldType) }} title={isAllowedToManage ? undefined : "Insufficient permissions"} > diff --git a/plugins/google-sheets/src/sheets.ts b/plugins/google-sheets/src/sheets.ts index cc449ee90..d48fd7070 100644 --- a/plugins/google-sheets/src/sheets.ts +++ b/plugins/google-sheets/src/sheets.ts @@ -227,8 +227,14 @@ function fetchSheetWithClient(spreadsheetId: string, sheetTitle: string, range?: export type CollectionFieldType = ManagedCollectionFieldInput["type"] export type VirtualFieldType = CollectionFieldType | "dateTime" +export interface EnumCase { + id: string + name: string +} + export type SheetCollectionFieldInput = Omit & { type: VirtualFieldType + cases?: EnumCase[] } export interface PluginContextNew { @@ -380,6 +386,12 @@ function getFieldDataEntryInput(type: VirtualFieldType, cellValue: CellValue): F return null } + case "enum": { + if (!isDefined(cellValue)) return null + const enumValue = String(cellValue).trim() + if (!enumValue) return null + return { type: "enum", value: generateHashId(enumValue) } + } case "image": case "link": case "file": @@ -460,6 +472,9 @@ function processSheetRow({ type: "date", } break + case "enum": + // Empty enum cells are valid (no selection) — skip silently + continue } } @@ -600,6 +615,40 @@ export async function syncSheet({ ) } + // Update enum field definitions with cases derived from column data so new + // values that appeared since the last sync are recognised. + const hasEnumFields = colFieldTypes.includes("enum") + if (hasEnumFields && framer.isAllowedTo("ManagedCollection.setFields")) { + const updatedFields = fields.map(field => { + if (field.type !== "enum") return mapFieldToFramer(field) + + const colIndex = uniqueHeaderRowNames.indexOf(field.id) + if (colIndex === -1) return mapFieldToFramer(field) + + const uniqueValues = [ + ...new Set( + rows + .map(row => row[colIndex]) + .filter(isDefined) + .map(v => String(v).trim()) + .filter(Boolean) + ), + ] + + return { + id: field.id, + name: field.name, + type: "enum" as const, + cases: uniqueValues.map(value => ({ + id: generateHashId(value), + name: value, + })), + } as ManagedCollectionFieldInput + }) + + await collection.setFields(updatedFields) + } + const { collectionItems, status } = processSheet(rows, { uniqueHeaderRowNames, fieldTypes: colFieldTypes, @@ -855,5 +904,14 @@ function mapFieldToFramer(field: SheetCollectionFieldInput): ManagedCollectionFi } } + if (field.type === "enum") { + return { + id: field.id, + name: field.name, + type: "enum", + cases: field.cases ?? [], + } as ManagedCollectionFieldInput + } + return field as ManagedCollectionFieldInput } From e11d9f2ed52aa5ffa45ef49f84308738f9482fb4 Mon Sep 17 00:00:00 2001 From: Kaloyan Videlov Date: Wed, 25 Mar 2026 11:18:15 +0100 Subject: [PATCH 2/4] Tidy up --- .../src/pages/MapSheetFields.tsx | 39 +++++++------------ plugins/google-sheets/src/sheets.ts | 39 +++++++++++-------- 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/plugins/google-sheets/src/pages/MapSheetFields.tsx b/plugins/google-sheets/src/pages/MapSheetFields.tsx index 5a7114c8f..fb9f8d2b2 100644 --- a/plugins/google-sheets/src/pages/MapSheetFields.tsx +++ b/plugins/google-sheets/src/pages/MapSheetFields.tsx @@ -6,33 +6,14 @@ import { IconChevron } from "../components/Icons" import type { CellValue, HeaderRow, PluginContext, PluginContextUpdate, Row, SyncMutationOptions } from "../sheets" import { generateHashId, generateUniqueNames, isDefined, syncMethods } from "../utils" -type FieldType = - | "string" - | "number" - | "boolean" - | "formattedText" - | "enum" - | "date" - | "dateTime" - | "link" - | "image" - | "color" - | "file" - | "collectionReference" - | "multiCollectionReference" - | "array" +type FieldType = SyncMutationOptions["colFieldTypes"][number] interface FieldTypeOption { type: FieldType label: string } -interface EditableField { - id: string - name: string - type: FieldType - cases?: { id: string; name: string }[] -} +type EditableField = SyncMutationOptions["fields"][number] const fieldTypeOptions: FieldTypeOption[] = [ { type: "string", label: "Plain Text" }, @@ -48,6 +29,10 @@ const fieldTypeOptions: FieldTypeOption[] = [ { type: "file", label: "File" }, ] +function isFieldType(value: string): value is FieldType { + return fieldTypeOptions.some(option => option.type === value) +} + function isUpdateContext(context: PluginContext): context is PluginContextUpdate { return context.type === "update" } @@ -240,7 +225,7 @@ export function MapSheetFieldsPage({ if (isPending) return - const allFields: EditableField[] = [] + const allFields: SyncMutationOptions["fields"] = [] for (const field of fieldConfig) { if (disabledColumns.has(field.id)) continue @@ -275,7 +260,7 @@ export function MapSheetFieldsPage({ allFields.push(result) } - const colFieldTypes: FieldType[] = [] + const colFieldTypes: SyncMutationOptions["colFieldTypes"] = [] for (const field of fieldConfig) { colFieldTypes.push(field.type) } @@ -283,10 +268,10 @@ export function MapSheetFieldsPage({ await framer.setCloseWarning("Synchronization in progress. Closing will cancel the sync.") const submitOptions: SyncMutationOptions = { - fields: allFields as SyncMutationOptions["fields"], + fields: allFields, spreadsheetId, sheetTitle, - colFieldTypes: colFieldTypes as SyncMutationOptions["colFieldTypes"], + colFieldTypes, ignoredColumns: Array.from(disabledColumns), slugColumn, lastSyncedTime: getLastSyncedTime(pluginContext, slugColumn), @@ -346,7 +331,9 @@ export function MapSheetFieldsPage({ disabled={isDisabled || !isAllowedToManage} value={field.type} onChange={e => { - handleFieldTypeChange(field.id, e.target.value as FieldType) + if (isFieldType(e.target.value)) { + handleFieldTypeChange(field.id, e.target.value) + } }} title={isAllowedToManage ? undefined : "Insufficient permissions"} > diff --git a/plugins/google-sheets/src/sheets.ts b/plugins/google-sheets/src/sheets.ts index d48fd7070..78681f3ce 100644 --- a/plugins/google-sheets/src/sheets.ts +++ b/plugins/google-sheets/src/sheets.ts @@ -625,25 +625,32 @@ export async function syncSheet({ const colIndex = uniqueHeaderRowNames.indexOf(field.id) if (colIndex === -1) return mapFieldToFramer(field) - const uniqueValues = [ - ...new Set( - rows - .map(row => row[colIndex]) - .filter(isDefined) - .map(v => String(v).trim()) - .filter(Boolean) - ), - ] + const uniqueValues: string[] = [] + const seenValues = new Set() + for (const row of rows) { + const rawValue = row[colIndex] + if (!isDefined(rawValue)) continue - return { + const normalizedValue = String(rawValue).trim() + if (!normalizedValue || seenValues.has(normalizedValue)) continue + + seenValues.add(normalizedValue) + uniqueValues.push(normalizedValue) + } + + const cases: Extract["cases"] = uniqueValues.map(value => ({ + id: generateHashId(value), + name: value, + })) + + const enumField: Extract = { id: field.id, name: field.name, - type: "enum" as const, - cases: uniqueValues.map(value => ({ - id: generateHashId(value), - name: value, - })), - } as ManagedCollectionFieldInput + type: "enum", + cases, + } + + return enumField }) await collection.setFields(updatedFields) From e3a54fddcd392e00b4f86cb10f4ca3f76dfbb245 Mon Sep 17 00:00:00 2001 From: Kaloyan Videlov Date: Wed, 25 Mar 2026 11:38:31 +0100 Subject: [PATCH 3/4] Fix empty enum case handling in Google Sheets sync. Preserve existing enum cases during mapping and sync, and fail fast when an enum field has no non-empty values instead of writing an invalid empty case list. Made-with: Cursor --- .../src/pages/MapSheetFields.tsx | 90 ++++++++++++++----- plugins/google-sheets/src/sheets.ts | 73 +++++++++++---- 2 files changed, 124 insertions(+), 39 deletions(-) diff --git a/plugins/google-sheets/src/pages/MapSheetFields.tsx b/plugins/google-sheets/src/pages/MapSheetFields.tsx index fb9f8d2b2..76371d555 100644 --- a/plugins/google-sheets/src/pages/MapSheetFields.tsx +++ b/plugins/google-sheets/src/pages/MapSheetFields.tsx @@ -33,6 +33,60 @@ function isFieldType(value: string): value is FieldType { return fieldTypeOptions.some(option => option.type === value) } +function getConfiguredField( + context: PluginContext, + columnId: string +): PluginContextUpdate["collectionFields"][number] | undefined { + if (!isUpdateContext(context)) return undefined + + return context.collectionFields.find(field => field.id === columnId) +} + +function getEnumCasesForColumn(rows: Row[], colIndex: number): NonNullable { + const cases: NonNullable = [] + const seenValues = new Set() + + for (const row of rows) { + const rawValue = row[colIndex] + if (!isDefined(rawValue)) continue + + const normalizedValue = String(rawValue).trim() + if (!normalizedValue || seenValues.has(normalizedValue)) continue + + seenValues.add(normalizedValue) + cases.push({ + id: generateHashId(normalizedValue), + name: normalizedValue, + }) + } + + return cases +} + +function mergeEnumCases( + existingCases: EditableField["cases"], + derivedCases: NonNullable +): NonNullable { + const mergedCases: NonNullable = [] + const seenCaseNames = new Set() + + for (const existingCase of existingCases ?? []) { + if (seenCaseNames.has(existingCase.name)) continue + + seenCaseNames.add(existingCase.name) + mergedCases.push(existingCase) + } + + for (const derivedCase of derivedCases) { + if (seenCaseNames.has(derivedCase.name)) continue + + seenCaseNames.add(derivedCase.name) + mergedCases.push(derivedCase) + } + + return mergedCases +} + function isUpdateContext(context: PluginContext): context is PluginContextUpdate { return context.type === "update" } @@ -110,10 +164,8 @@ const inferFieldType = (cellValue: CellValue): FieldType => { const getFieldType = (context: PluginContext, columnId: string, cellValue?: CellValue): FieldType => { // Determine if the field type is already configured - if (isUpdateContext(context)) { - const field = context.collectionFields.find(field => field.id === columnId) - return field?.type ?? "string" - } + const configuredField = getConfiguredField(context, columnId) + if (configuredField) return configuredField.type // Otherwise, infer the field type from the cell value return cellValue ? inferFieldType(cellValue) : "string" @@ -130,12 +182,14 @@ const createFieldConfig = ( for (const [columnIndex] of headerRow.entries()) { const sanitizedName = uniqueColumnNames[columnIndex] if (!sanitizedName) continue + const configuredField = getConfiguredField(context, sanitizedName) const initialType: FieldType = getFieldType(context, sanitizedName, row?.[columnIndex]) fields.push({ id: sanitizedName, name: sanitizedName, type: initialType, + cases: configuredField?.cases, }) } @@ -238,22 +292,18 @@ export function MapSheetFieldsPage({ if (result.type === "enum") { const colIndex = uniqueColumnNames.indexOf(result.id) if (colIndex !== -1) { - const uniqueValues: string[] = [] - const seenValues = new Set() - for (const row of rows) { - const rawValue = row[colIndex] - if (!isDefined(rawValue)) continue - - const normalizedValue = String(rawValue).trim() - if (!normalizedValue || seenValues.has(normalizedValue)) continue - - seenValues.add(normalizedValue) - uniqueValues.push(normalizedValue) - } - result.cases = uniqueValues.map(value => ({ - id: generateHashId(value), - name: value, - })) + result.cases = mergeEnumCases(result.cases, getEnumCasesForColumn(rows, colIndex)) + } + + if (!result.cases || result.cases.length === 0) { + framer.notify( + `"${result.name}" needs at least one non-empty value before it can be imported as an Option field.`, + { + variant: "error", + durationMs: 5000, + } + ) + return } } diff --git a/plugins/google-sheets/src/sheets.ts b/plugins/google-sheets/src/sheets.ts index 78681f3ce..7aaa2e8a7 100644 --- a/plugins/google-sheets/src/sheets.ts +++ b/plugins/google-sheets/src/sheets.ts @@ -314,6 +314,48 @@ export interface SyncMutationOptions { lastSyncedTime: string | null } +function getEnumCasesForColumn(rows: Row[], colIndex: number): EnumCase[] { + const cases: EnumCase[] = [] + const seenValues = new Set() + + for (const row of rows) { + const rawValue = row[colIndex] + if (!isDefined(rawValue)) continue + + const normalizedValue = String(rawValue).trim() + if (!normalizedValue || seenValues.has(normalizedValue)) continue + + seenValues.add(normalizedValue) + cases.push({ + id: generateHashId(normalizedValue), + name: normalizedValue, + }) + } + + return cases +} + +function mergeEnumCases(existingCases: EnumCase[] | undefined, derivedCases: EnumCase[]): EnumCase[] { + const mergedCases: EnumCase[] = [] + const seenCaseNames = new Set() + + for (const existingCase of existingCases ?? []) { + if (seenCaseNames.has(existingCase.name)) continue + + seenCaseNames.add(existingCase.name) + mergedCases.push(existingCase) + } + + for (const derivedCase of derivedCases) { + if (seenCaseNames.has(derivedCase.name)) continue + + seenCaseNames.add(derivedCase.name) + mergedCases.push(derivedCase) + } + + return mergedCases +} + const BASE_DATE_1900 = new Date(Date.UTC(1899, 11, 30)) const BASE_DATE_1904 = new Date(Date.UTC(1904, 0, 1)) const MS_PER_DAY = 24 * 60 * 60 * 1000 // hours * minutes * seconds * milliseconds @@ -625,24 +667,13 @@ export async function syncSheet({ const colIndex = uniqueHeaderRowNames.indexOf(field.id) if (colIndex === -1) return mapFieldToFramer(field) - const uniqueValues: string[] = [] - const seenValues = new Set() - for (const row of rows) { - const rawValue = row[colIndex] - if (!isDefined(rawValue)) continue - - const normalizedValue = String(rawValue).trim() - if (!normalizedValue || seenValues.has(normalizedValue)) continue - - seenValues.add(normalizedValue) - uniqueValues.push(normalizedValue) + const cases = mergeEnumCases(field.cases, getEnumCasesForColumn(rows, colIndex)) + if (cases.length === 0) { + throw new Error( + `Enum field "${field.name}" requires at least one non-empty value before it can be synced.` + ) } - const cases: Extract["cases"] = uniqueValues.map(value => ({ - id: generateHashId(value), - name: value, - })) - const enumField: Extract = { id: field.id, name: field.name, @@ -912,12 +943,16 @@ function mapFieldToFramer(field: SheetCollectionFieldInput): ManagedCollectionFi } if (field.type === "enum") { - return { + assert(field.cases && field.cases.length > 0, `Enum field "${field.name}" requires at least one case`) + + const enumField: Extract = { id: field.id, name: field.name, type: "enum", - cases: field.cases ?? [], - } as ManagedCollectionFieldInput + cases: field.cases, + } + + return enumField } return field as ManagedCollectionFieldInput From be1ad10fc4df8b93550ff986c95f57f95dd74fdb Mon Sep 17 00:00:00 2001 From: Kaloyan Videlov Date: Wed, 25 Mar 2026 11:56:39 +0100 Subject: [PATCH 4/4] Refine enum sync field updates. Share enum case helpers between mapping and sync, avoid unnecessary field updates, and fail clearly when enum values require collection field changes without the required permission. Made-with: Cursor --- plugins/google-sheets/src/enumCases.ts | 76 +++++++++++++++++++ .../src/pages/MapSheetFields.tsx | 48 +----------- plugins/google-sheets/src/sheets.ts | 62 +++++---------- 3 files changed, 96 insertions(+), 90 deletions(-) create mode 100644 plugins/google-sheets/src/enumCases.ts diff --git a/plugins/google-sheets/src/enumCases.ts b/plugins/google-sheets/src/enumCases.ts new file mode 100644 index 000000000..aa5c086bb --- /dev/null +++ b/plugins/google-sheets/src/enumCases.ts @@ -0,0 +1,76 @@ +import { generateHashId, isDefined } from "./utils" + +type EnumCaseLike = { + id: string + name: string +} + +type EnumCaseCellValue = string | number | boolean | null + +export function getEnumCasesForColumn( + rows: ReadonlyArray>, + colIndex: number +): EnumCaseLike[] { + const cases: EnumCaseLike[] = [] + const seenValues = new Set() + + for (const row of rows) { + const rawValue = row[colIndex] + if (!isDefined(rawValue)) continue + + const normalizedValue = String(rawValue).trim() + if (!normalizedValue || seenValues.has(normalizedValue)) continue + + seenValues.add(normalizedValue) + cases.push({ + id: generateHashId(normalizedValue), + name: normalizedValue, + }) + } + + return cases +} + +export function mergeEnumCases( + existingCases: readonly EnumCaseLike[] | undefined, + derivedCases: readonly EnumCaseLike[] +): EnumCaseLike[] { + const mergedCases: EnumCaseLike[] = [] + const seenCaseNames = new Set() + + for (const existingCase of existingCases ?? []) { + if (seenCaseNames.has(existingCase.name)) continue + + seenCaseNames.add(existingCase.name) + mergedCases.push(existingCase) + } + + for (const derivedCase of derivedCases) { + if (seenCaseNames.has(derivedCase.name)) continue + + seenCaseNames.add(derivedCase.name) + mergedCases.push(derivedCase) + } + + return mergedCases +} + +export function areEnumCasesEqual( + leftCases: readonly EnumCaseLike[] | undefined, + rightCases: readonly EnumCaseLike[] | undefined +): boolean { + const left = leftCases ?? [] + const right = rightCases ?? [] + + if (left.length !== right.length) return false + + for (let i = 0; i < left.length; i++) { + const leftCase = left[i] + const rightCase = right[i] + + if (!leftCase || !rightCase) return false + if (leftCase.id !== rightCase.id || leftCase.name !== rightCase.name) return false + } + + return true +} diff --git a/plugins/google-sheets/src/pages/MapSheetFields.tsx b/plugins/google-sheets/src/pages/MapSheetFields.tsx index 76371d555..71f5bd8a3 100644 --- a/plugins/google-sheets/src/pages/MapSheetFields.tsx +++ b/plugins/google-sheets/src/pages/MapSheetFields.tsx @@ -2,9 +2,10 @@ import cx from "classnames" import { framer, useIsAllowedTo } from "framer-plugin" import { Fragment, useMemo, useState } from "react" import { CheckboxTextfield } from "../components/CheckboxTextField" +import { getEnumCasesForColumn, mergeEnumCases } from "../enumCases" import { IconChevron } from "../components/Icons" import type { CellValue, HeaderRow, PluginContext, PluginContextUpdate, Row, SyncMutationOptions } from "../sheets" -import { generateHashId, generateUniqueNames, isDefined, syncMethods } from "../utils" +import { generateUniqueNames, syncMethods } from "../utils" type FieldType = SyncMutationOptions["colFieldTypes"][number] @@ -42,51 +43,6 @@ function getConfiguredField( return context.collectionFields.find(field => field.id === columnId) } -function getEnumCasesForColumn(rows: Row[], colIndex: number): NonNullable { - const cases: NonNullable = [] - const seenValues = new Set() - - for (const row of rows) { - const rawValue = row[colIndex] - if (!isDefined(rawValue)) continue - - const normalizedValue = String(rawValue).trim() - if (!normalizedValue || seenValues.has(normalizedValue)) continue - - seenValues.add(normalizedValue) - cases.push({ - id: generateHashId(normalizedValue), - name: normalizedValue, - }) - } - - return cases -} - -function mergeEnumCases( - existingCases: EditableField["cases"], - derivedCases: NonNullable -): NonNullable { - const mergedCases: NonNullable = [] - const seenCaseNames = new Set() - - for (const existingCase of existingCases ?? []) { - if (seenCaseNames.has(existingCase.name)) continue - - seenCaseNames.add(existingCase.name) - mergedCases.push(existingCase) - } - - for (const derivedCase of derivedCases) { - if (seenCaseNames.has(derivedCase.name)) continue - - seenCaseNames.add(derivedCase.name) - mergedCases.push(derivedCase) - } - - return mergedCases -} - function isUpdateContext(context: PluginContext): context is PluginContextUpdate { return context.type === "update" } diff --git a/plugins/google-sheets/src/sheets.ts b/plugins/google-sheets/src/sheets.ts index 7aaa2e8a7..ddb5afeea 100644 --- a/plugins/google-sheets/src/sheets.ts +++ b/plugins/google-sheets/src/sheets.ts @@ -9,6 +9,7 @@ import { import * as v from "valibot" import auth from "./auth" import { logSyncResult } from "./debug.ts" +import { areEnumCasesEqual, getEnumCasesForColumn, mergeEnumCases } from "./enumCases" import { queryClient } from "./main.tsx" import { assert, columnToLetter, generateHashId, generateUniqueNames, isDefined, listFormatter, slugify } from "./utils" @@ -314,48 +315,6 @@ export interface SyncMutationOptions { lastSyncedTime: string | null } -function getEnumCasesForColumn(rows: Row[], colIndex: number): EnumCase[] { - const cases: EnumCase[] = [] - const seenValues = new Set() - - for (const row of rows) { - const rawValue = row[colIndex] - if (!isDefined(rawValue)) continue - - const normalizedValue = String(rawValue).trim() - if (!normalizedValue || seenValues.has(normalizedValue)) continue - - seenValues.add(normalizedValue) - cases.push({ - id: generateHashId(normalizedValue), - name: normalizedValue, - }) - } - - return cases -} - -function mergeEnumCases(existingCases: EnumCase[] | undefined, derivedCases: EnumCase[]): EnumCase[] { - const mergedCases: EnumCase[] = [] - const seenCaseNames = new Set() - - for (const existingCase of existingCases ?? []) { - if (seenCaseNames.has(existingCase.name)) continue - - seenCaseNames.add(existingCase.name) - mergedCases.push(existingCase) - } - - for (const derivedCase of derivedCases) { - if (seenCaseNames.has(derivedCase.name)) continue - - seenCaseNames.add(derivedCase.name) - mergedCases.push(derivedCase) - } - - return mergedCases -} - const BASE_DATE_1900 = new Date(Date.UTC(1899, 11, 30)) const BASE_DATE_1904 = new Date(Date.UTC(1904, 0, 1)) const MS_PER_DAY = 24 * 60 * 60 * 1000 // hours * minutes * seconds * milliseconds @@ -660,7 +619,9 @@ export async function syncSheet({ // Update enum field definitions with cases derived from column data so new // values that appeared since the last sync are recognised. const hasEnumFields = colFieldTypes.includes("enum") - if (hasEnumFields && framer.isAllowedTo("ManagedCollection.setFields")) { + if (hasEnumFields) { + const canSetFields = framer.isAllowedTo("ManagedCollection.setFields") + let shouldUpdateFields = false const updatedFields = fields.map(field => { if (field.type !== "enum") return mapFieldToFramer(field) @@ -674,6 +635,17 @@ export async function syncSheet({ ) } + if (!areEnumCasesEqual(field.cases, cases)) { + if (!canSetFields) { + throw new Error( + "This sheet has enum columns with values that are not configured on the collection, " + + "but the plugin does not have permission to update collection fields." + ) + } + + shouldUpdateFields = true + } + const enumField: Extract = { id: field.id, name: field.name, @@ -684,7 +656,9 @@ export async function syncSheet({ return enumField }) - await collection.setFields(updatedFields) + if (canSetFields && shouldUpdateFields) { + await collection.setFields(updatedFields) + } } const { collectionItems, status } = processSheet(rows, {