diff --git a/plugins/google-sheets/src/App.tsx b/plugins/google-sheets/src/App.tsx index 10cdc0239..53b3c885e 100644 --- a/plugins/google-sheets/src/App.tsx +++ b/plugins/google-sheets/src/App.tsx @@ -209,6 +209,7 @@ export function App({ pluginContext }: AppProps) { slugColumn, lastSyncedTime, sheet, + altTextAssignments, } = context const [headerRow] = sheet.values @@ -224,10 +225,14 @@ export function App({ pluginContext }: AppProps) { spreadsheetId, sheetTitle, fields, - // Determine if the field type is already configured, otherwise default to "string" - colFieldTypes: headerRow.map(colName => { - const field = fields.find(field => field.name === colName) - return field?.type ?? "string" + // Determine if the field type is already configured, otherwise default to "string". + // Alt text columns never become real CMS fields, so `fields` won't have them. + columnConfigs: headerRow.map(columnId => { + if (Object.hasOwn(altTextAssignments, columnId)) { + return { type: "altText" as const, imageColumnId: altTextAssignments[columnId] } + } + const field = fields.find(field => field.id === columnId) + return { type: field?.type ?? "string" } }), configureFields: false, }) diff --git a/plugins/google-sheets/src/components/FieldTypeSelectField.tsx b/plugins/google-sheets/src/components/FieldTypeSelectField.tsx new file mode 100644 index 000000000..181171fd4 --- /dev/null +++ b/plugins/google-sheets/src/components/FieldTypeSelectField.tsx @@ -0,0 +1,109 @@ +import cx from "classnames" +import { framer, type MenuItem } from "framer-plugin" +import { useMemo } from "react" +import { isAltTextColumn, type SheetCollectionFieldInput, type VirtualFieldType } from "../sheets" +import { IconChevron, IconChevronDown } from "./Icons" + +interface FieldTypeOption { + type: VirtualFieldType + label: string +} + +const fieldTypeOptions: FieldTypeOption[] = [ + { type: "string", label: "Plain Text" }, + { type: "formattedText", label: "Formatted Text" }, + { type: "date", label: "Date" }, + { type: "dateTime", label: "Date & Time" }, + { type: "link", label: "Link" }, + { type: "image", label: "Image" }, + { type: "color", label: "Color" }, + { type: "boolean", label: "Toggle" }, + { type: "number", label: "Number" }, + { type: "enum", label: "Option" }, + { type: "file", label: "File" }, +] + +const contextMenuOffset = 4 + +interface Props { + field: SheetCollectionFieldInput + fields: SheetCollectionFieldInput[] + isDisabled: boolean + disabledFieldIds: Set + onFieldTypeChange: (id: string, type: VirtualFieldType, imageColumnId?: string) => void +} + +export function FieldTypeSelectField({ field, fields, isDisabled, disabledFieldIds, onFieldTypeChange }: Props) { + const imageColumn = fields.find(candidate => candidate.id === field.imageColumnId) + + const fieldTypeLabel = useMemo(() => { + if (isAltTextColumn(field)) { + return imageColumn ? `Alt Text → ${imageColumn.name}` : "Alt Text" + } + + return fieldTypeOptions.find(option => option.type === field.type)?.label ?? field.type + }, [field, imageColumn]) + + const handleMenuOpen = (event: React.MouseEvent) => { + const availableImageFields = fields.filter( + candidate => candidate.type === "image" && candidate.id !== field.id && !disabledFieldIds.has(candidate.id) + ) + + const items: MenuItem[] = [ + ...fieldTypeOptions.map(({ type, label }) => ({ + label, + checked: field.type === type, + onAction: () => { + onFieldTypeChange(field.id, type) + }, + })), + { + label: "Alt Text", + checked: isAltTextColumn(field), + enabled: availableImageFields.length > 0, + submenu: availableImageFields.map(candidate => ({ + label: candidate.name, + checked: isAltTextColumn(field) && field.imageColumnId === candidate.id, + onAction: () => { + onFieldTypeChange(field.id, "altText", candidate.id) + }, + })), + }, + ] + + const { left, bottom, width } = event.currentTarget.getBoundingClientRect() + void framer.showContextMenu(items, { + location: { + x: left + width - contextMenuOffset, + y: bottom + contextMenuOffset, + }, + placement: "bottom-left", + width, + }) + } + + return ( + + ) +} diff --git a/plugins/google-sheets/src/components/Icons.tsx b/plugins/google-sheets/src/components/Icons.tsx index c358ccf3a..87a93103a 100644 --- a/plugins/google-sheets/src/components/Icons.tsx +++ b/plugins/google-sheets/src/components/Icons.tsx @@ -10,3 +10,16 @@ export const IconChevron = () => ( > ) + +export const IconChevronDown = () => ( + +) diff --git a/plugins/google-sheets/src/pages/MapSheetFields.tsx b/plugins/google-sheets/src/pages/MapSheetFields.tsx index e9cc36bf5..69f3e12fa 100644 --- a/plugins/google-sheets/src/pages/MapSheetFields.tsx +++ b/plugins/google-sheets/src/pages/MapSheetFields.tsx @@ -2,6 +2,7 @@ import cx from "classnames" import { framer, useIsAllowedTo } from "framer-plugin" import { Fragment, useMemo, useState } from "react" import { CheckboxTextfield } from "../components/CheckboxTextField" +import { FieldTypeSelectField } from "../components/FieldTypeSelectField" import { IconChevron } from "../components/Icons" import type { CellValue, @@ -9,30 +10,13 @@ import type { PluginContext, Row, SheetCollectionFieldInput, + SheetColumnConfig, SyncMutationOptions, VirtualFieldType, } from "../sheets" +import { isAltTextColumn } from "../sheets" import { generateUniqueNames, isDefined, syncMethods } from "../utils" -interface FieldTypeOption { - type: VirtualFieldType - label: string -} - -const fieldTypeOptions: FieldTypeOption[] = [ - { type: "string", label: "Plain Text" }, - { type: "formattedText", label: "Formatted Text" }, - { type: "date", label: "Date" }, - { type: "dateTime", label: "Date & Time" }, - { type: "link", label: "Link" }, - { type: "image", label: "Image" }, - { type: "color", label: "Color" }, - { type: "boolean", label: "Toggle" }, - { type: "number", label: "Number" }, - { type: "enum", label: "Option" }, - { type: "file", label: "File" }, -] - const getInitialSlugColumn = (context: PluginContext, slugFields: SheetCollectionFieldInput[]): string => { if (context.type === "update" && context.slugColumn) { return context.slugColumn @@ -103,15 +87,19 @@ const inferFieldType = (cellValue: CellValue): VirtualFieldType => { return "string" } -const getFieldType = (context: PluginContext, columnId: string, cellValue?: CellValue): VirtualFieldType => { +const getColumnConfig = (context: PluginContext, columnId: string, cellValue?: CellValue): SheetColumnConfig => { + if (context.type === "update" && Object.hasOwn(context.altTextAssignments, columnId)) { + return { type: "altText", imageColumnId: context.altTextAssignments[columnId] } + } + // Determine if the field type is already configured if ("collectionFields" in context) { const field = context.collectionFields.find(field => field.id === columnId) - return field?.type ?? "string" + return { type: field?.type ?? "string" } } // Otherwise, infer the field type from the cell value - return cellValue ? inferFieldType(cellValue) : "string" + return { type: cellValue ? inferFieldType(cellValue) : "string" } } const createFieldConfig = ( @@ -125,10 +113,13 @@ const createFieldConfig = ( const sanitizedName = uniqueColumnNames[columnIndex] if (!sanitizedName) return null + const { type, imageColumnId } = getColumnConfig(context, sanitizedName, row?.[columnIndex]) + return { id: sanitizedName, name: sanitizedName, - type: getFieldType(context, sanitizedName, row?.[columnIndex]), + type, + imageColumnId, } as SheetCollectionFieldInput }) .filter(isDefined) @@ -200,15 +191,25 @@ export function MapSheetFieldsPage({ })) } - const handleFieldTypeChange = (id: string, type: VirtualFieldType) => { + const handleFieldTypeChange = (id: string, type: VirtualFieldType, imageColumnId?: string) => { setFieldConfig(current => current.map(field => { if (field.id === id) { return { ...field, type, - } as SheetCollectionFieldInput + imageColumnId: type === "altText" ? imageColumnId : undefined, + } } + + if (type !== "image" && isAltTextColumn(field) && field.imageColumnId === id) { + return { + ...field, + type: "string", + imageColumnId: undefined, + } + } + return field }) ) @@ -220,7 +221,8 @@ export function MapSheetFieldsPage({ if (isPending) return const allFields = fieldConfig - .filter(field => !disabledColumns.has(field.id)) + // Alt text columns are virtual: they never become their own CMS field. + .filter(field => !disabledColumns.has(field.id) && !isAltTextColumn(field)) .map(field => { const maybeOverride = fieldNameOverrides[field.id] if (maybeOverride) { @@ -236,7 +238,7 @@ export function MapSheetFieldsPage({ fields: allFields, spreadsheetId, sheetTitle, - colFieldTypes: fieldConfig.map(field => field.type), + columnConfigs: fieldConfig.map(field => ({ type: field.type, imageColumnId: field.imageColumnId })), ignoredColumns: Array.from(disabledColumns), slugColumn, lastSyncedTime: getLastSyncedTime(pluginContext, slugColumn), @@ -290,31 +292,27 @@ export function MapSheetFieldsPage({
- - { - handleFieldNameChange(field.id, e.target.value) - }} + + {!isAltTextColumn(field) ? ( + { + handleFieldNameChange(field.id, e.target.value) + }} + /> + ) : ( +
+ )} ) })} diff --git a/plugins/google-sheets/src/sheets.ts b/plugins/google-sheets/src/sheets.ts index 106d4ae52..2faa75583 100644 --- a/plugins/google-sheets/src/sheets.ts +++ b/plugins/google-sheets/src/sheets.ts @@ -31,6 +31,7 @@ const PLUGIN_LAST_SYNCED_KEY = "sheetsPluginLastSynced" const PLUGIN_IGNORED_COLUMNS_KEY = "sheetsPluginIgnoredColumns" const PLUGIN_SHEET_HEADER_ROW_HASH_KEY = "sheetsPluginSheetHeaderRowHash" const PLUGIN_SLUG_COLUMN_KEY = "sheetsPluginSlugColumn" +const PLUGIN_ALT_TEXT_ASSIGNMENTS_KEY = "sheetsPluginAltTextAssignments" const CELL_BOOLEAN_VALUES = ["Y", "yes", "true", "TRUE", "Yes", 1, true] const HEADER_ROW_DELIMITER = "OIhpKTpp" @@ -235,11 +236,17 @@ function fetchSheetWithClient(spreadsheetId: string, sheetTitle: string, range?: } export type CollectionFieldType = ManagedCollectionFieldInput["type"] -export type VirtualFieldType = CollectionFieldType | "dateTime" -export type SheetCollectionFieldInput = Omit & { +export type VirtualFieldType = CollectionFieldType | "dateTime" | "altText" + +export interface SheetColumnConfig { type: VirtualFieldType + imageColumnId?: string } +export type SheetCollectionFieldInput = Omit & SheetColumnConfig + +export const isAltTextColumn = (column: Pick): boolean => column.type === "altText" + export interface PluginContextNew { type: "new" collection: ManagedCollection @@ -259,6 +266,7 @@ export interface PluginContextUpdate { sheet: Sheet lastSyncedTime: string sheetHeaderRow: string[] + altTextAssignments: Record } export interface PluginContextNoSheetAccess { @@ -296,13 +304,14 @@ export interface SyncResult extends SyncStatus { } interface ProcessSheetRowParams { - fieldTypes: VirtualFieldType[] + columnConfigs: SheetColumnConfig[] row: Row rowIndex: number columnCount: number uniqueHeaderRowNames: string[] slugFieldColumnIndex: number ignoredFieldColumnIndexes: number[] + altColumnIndexByImageColumnIndex: Map status: SyncStatus } @@ -313,7 +322,7 @@ export interface SyncMutationOptions { fields: SheetCollectionFieldInput[] slugColumn: string | null ignoredColumns: string[] - colFieldTypes: VirtualFieldType[] + columnConfigs: SheetColumnConfig[] lastSyncedTime: string | null /** * When false (sync-only mode), schema is only applied when enum fields need refreshed cases. @@ -410,7 +419,56 @@ function enrichFieldsWithEnumCases( }) } -function getFieldDataEntryInput(type: VirtualFieldType, cellValue: CellValue): FieldDataEntryInput | null { +function buildAltTextAssignments( + columnConfigs: SheetColumnConfig[], + uniqueHeaderRowNames: string[] +): Record { + const assignments: Record = {} + + for (const [altColumnIndex, columnConfig] of columnConfigs.entries()) { + if (!isAltTextColumn(columnConfig)) continue + + const altColumnId = uniqueHeaderRowNames[altColumnIndex] + const imageColumnId = columnConfig.imageColumnId + if (!altColumnId || !imageColumnId) continue + + const imageColumnIndex = uniqueHeaderRowNames.indexOf(imageColumnId) + if (imageColumnIndex === -1 || columnConfigs[imageColumnIndex]?.type !== "image") continue + + assignments[altColumnId] = imageColumnId + } + + return assignments +} + +function buildAltColumnIndexByImageColumnIndex( + columnConfigs: SheetColumnConfig[], + uniqueHeaderRowNames: string[], + ignoredColumns: string[] +): Map { + const altColumnIndexByImageColumnIndex = new Map() + + for (const [altColumnIndex, columnConfig] of columnConfigs.entries()) { + const altColumnId = uniqueHeaderRowNames[altColumnIndex] + if (!altColumnId || ignoredColumns.includes(altColumnId) || !isAltTextColumn(columnConfig)) continue + + const imageColumnId = columnConfig.imageColumnId + if (!imageColumnId || ignoredColumns.includes(imageColumnId)) continue + + const imageColumnIndex = uniqueHeaderRowNames.indexOf(imageColumnId) + if (imageColumnIndex === -1 || columnConfigs[imageColumnIndex]?.type !== "image") continue + + altColumnIndexByImageColumnIndex.set(imageColumnIndex, altColumnIndex) + } + + return altColumnIndexByImageColumnIndex +} + +function getFieldDataEntryInput( + type: VirtualFieldType, + cellValue: CellValue, + altCellValue?: CellValue +): FieldDataEntryInput | null { switch (type) { case "number": { const num = Number(cellValue) @@ -442,7 +500,14 @@ function getFieldDataEntryInput(type: VirtualFieldType, cellValue: CellValue): F return null } - case "image": + case "image": { + if (!isDefined(cellValue)) return null + return { + type: "image", + value: String(cellValue), + alt: isDefined(altCellValue) ? String(altCellValue) : undefined, + } + } case "link": case "file": case "formattedText": @@ -467,8 +532,9 @@ function processSheetRow({ uniqueHeaderRowNames, ignoredFieldColumnIndexes, slugFieldColumnIndex, + altColumnIndexByImageColumnIndex, status, - fieldTypes, + columnConfigs, }: ProcessSheetRowParams) { const fieldData: FieldDataInput = {} let slugValue: string | null = null @@ -477,8 +543,10 @@ function processSheetRow({ for (let i = 0; i < columnCount; i++) { const cell = row[i] ?? null - const fieldType = fieldTypes[i] - if (!fieldType) continue + const columnConfig = columnConfigs[i] + // Alt text columns are virtual and never become their own field. + if (!columnConfig || isAltTextColumn(columnConfig)) continue + const fieldType = columnConfig.type // Skip processing ignored columns unless they are the slug field const isIgnored = ignoredFieldColumnIndexes.includes(i) @@ -486,7 +554,10 @@ function processSheetRow({ if (isIgnored && !isSlugField) continue - let fieldDataEntryInput = getFieldDataEntryInput(fieldType, cell) + const altColumnIndex = altColumnIndexByImageColumnIndex.get(i) + const altCell = isDefined(altColumnIndex) ? (row[altColumnIndex] ?? null) : undefined + + let fieldDataEntryInput = getFieldDataEntryInput(fieldType, cell, altCell) // Set to default value for type if no value is provided if (!fieldDataEntryInput) { @@ -636,7 +707,7 @@ export async function syncSheet({ fields, ignoredColumns, slugColumn, - colFieldTypes, + columnConfigs, configureFields, }: SyncMutationOptions) { if (fields.length === 0) { @@ -649,6 +720,9 @@ export async function syncSheet({ const [headerRow, ...rows] = sheet.values const uniqueHeaderRowNames = generateUniqueNames(headerRow) + + const altTextAssignments = buildAltTextAssignments(columnConfigs, uniqueHeaderRowNames) + const enrichedFields = enrichFieldsWithEnumCases(fields, uniqueHeaderRowNames, rows) const needsFieldSchemaUpdate = configureFields || enrichedFields.some(field => field.type === "enum") @@ -680,11 +754,18 @@ export async function syncSheet({ ) } + const altColumnIndexByImageColumnIndex = buildAltColumnIndexByImageColumnIndex( + columnConfigs, + uniqueHeaderRowNames, + ignoredColumns + ) + const { collectionItems, status } = processSheet(rows, { uniqueHeaderRowNames, - fieldTypes: colFieldTypes, + columnConfigs, ignoredFieldColumnIndexes: ignoredColumns.map(col => uniqueHeaderRowNames.indexOf(col)), slugFieldColumnIndex: slugColumn ? uniqueHeaderRowNames.indexOf(slugColumn) : -1, + altColumnIndexByImageColumnIndex, columnCount: headerRow.length, }) @@ -712,6 +793,7 @@ export async function syncSheet({ collection.setPluginData(PLUGIN_IGNORED_COLUMNS_KEY, JSON.stringify(ignoredColumns)), collection.setPluginData(PLUGIN_SHEET_HEADER_ROW_HASH_KEY, headerRowHash), collection.setPluginData(PLUGIN_SLUG_COLUMN_KEY, slugColumn), + collection.setPluginData(PLUGIN_ALT_TEXT_ASSIGNMENTS_KEY, JSON.stringify(altTextAssignments)), collection.setPluginData(PLUGIN_LAST_SYNCED_KEY, new Date().toISOString()), ]) @@ -747,12 +829,20 @@ export async function getPluginContext(): Promise { } // Fetch both new and legacy data - const [rawIgnoredColumns, rawSheetHeaderRowHash, storedSlugColumn, lastSyncedTime] = await Promise.all([ - collection.getPluginData(PLUGIN_IGNORED_COLUMNS_KEY), - collection.getPluginData(PLUGIN_SHEET_HEADER_ROW_HASH_KEY), - collection.getPluginData(PLUGIN_SLUG_COLUMN_KEY), - collection.getPluginData(PLUGIN_LAST_SYNCED_KEY), - ]) + const [rawIgnoredColumns, rawSheetHeaderRowHash, storedSlugColumn, lastSyncedTime, rawAltTextAssignments] = + await Promise.all([ + collection.getPluginData(PLUGIN_IGNORED_COLUMNS_KEY), + collection.getPluginData(PLUGIN_SHEET_HEADER_ROW_HASH_KEY), + collection.getPluginData(PLUGIN_SLUG_COLUMN_KEY), + collection.getPluginData(PLUGIN_LAST_SYNCED_KEY), + collection.getPluginData(PLUGIN_ALT_TEXT_ASSIGNMENTS_KEY), + ]) + + const altTextAssignmentsResult = v.safeParse( + v.pipe(v.string(), v.parseJson(), v.record(v.string(), v.string())), + rawAltTextAssignments + ) + const altTextAssignments = altTextAssignmentsResult.success ? altTextAssignmentsResult.output : {} let spreadsheetInfo @@ -865,6 +955,7 @@ export async function getPluginContext(): Promise { collectionFields, sheetHeaderRow, ignoredColumns, + altTextAssignments, hasChangedFields: storedSheetHeaderRowHash !== currentSheetHeaderRowHash, } }