diff --git a/examples/cms/src/App.css b/examples/cms/src/App.css index 4f6af30f..c967d6c0 100644 --- a/examples/cms/src/App.css +++ b/examples/cms/src/App.css @@ -132,6 +132,10 @@ form { cursor: pointer; } +.mapping .source-field input[type="checkbox"]:focus { + box-shadow: none; +} + .mapping footer { position: sticky; bottom: 0; diff --git a/examples/cms/src/App.tsx b/examples/cms/src/App.tsx index 5e9c2431..5bf16eb0 100644 --- a/examples/cms/src/App.tsx +++ b/examples/cms/src/App.tsx @@ -1,64 +1,86 @@ import "./App.css" -import { framer, ManagedCollection, ManagedCollectionField } from "framer-plugin" -import { useEffect, useLayoutEffect, useState } from "react" -import { DataSource, getDataSource, getDataSourcesIds, syncCollection } from "./data" +import { framer, ManagedCollection } from "framer-plugin" +import { useEffect, useLayoutEffect, useState, useRef } from "react" +import { DataSource, getDataSource, getDataSourcesIds } from "./data" import { FieldMapping } from "./FieldMapping" import { SelectDataSource } from "./SelectDataSource" -import { UI_DEFAULTS } from "./constants" +import { Spinner } from "./components/Spinner" interface AppProps { collection: ManagedCollection - dataSourceId: string | null - slugFieldId: string | null + previouslyConfiguredDataSourceId: string | null + previouslyConfiguredSlugFieldId: string | null } -export function App({ collection, dataSourceId, slugFieldId }: AppProps) { +export function App({ collection, previouslyConfiguredDataSourceId, previouslyConfiguredSlugFieldId }: AppProps) { const [dataSource, setDataSource] = useState(null) - const [existingFields, setExistingFields] = useState([]) - useLayoutEffect(() => { - if (!dataSource) { - framer.showUI({ - width: UI_DEFAULTS.SETUP_WIDTH, - height: UI_DEFAULTS.SETUP_HEIGHT, - resizable: false, - }) - return - } + const hasStartedLoadingDataSource = useRef(false) + const [isLoadingConfiguredDataSource, setIsLoadingConfiguredDataSource] = useState( + Boolean(previouslyConfiguredDataSourceId) + ) + const hasDataSourceSelected = Boolean(isLoadingConfiguredDataSource || dataSource) + useLayoutEffect(() => { framer.showUI({ - width: UI_DEFAULTS.MAPPING_WIDTH, - height: UI_DEFAULTS.MAPPING_HEIGHT, - minWidth: UI_DEFAULTS.MAPPING_WIDTH, - minHeight: UI_DEFAULTS.MAPPING_HEIGHT, - resizable: true, + width: hasDataSourceSelected ? 360 : 320, + height: hasDataSourceSelected ? 425 : 305, + minWidth: hasDataSourceSelected ? 360 : undefined, + minHeight: hasDataSourceSelected ? 425 : undefined, + resizable: dataSource !== null, }) - }, [dataSource]) - - useEffect(() => { - collection.getFields().then(setExistingFields) - }, [collection]) + }, [hasDataSourceSelected, dataSource]) useEffect(() => { - if (!dataSourceId) { + if (!previouslyConfiguredDataSourceId || hasStartedLoadingDataSource.current) { return } - getDataSource(dataSourceId).then(setDataSource) - }, [dataSourceId]) + hasStartedLoadingDataSource.current = true + getDataSource(previouslyConfiguredDataSourceId) + .then(setDataSource) + .catch(error => { + console.error(error) + framer.notify( + `Error loading previously configured data source "${previouslyConfiguredDataSourceId}". Check the logs for more details.`, + { + variant: "error", + } + ) + }) + .finally(() => { + setIsLoadingConfiguredDataSource(false) + }) + }, [previouslyConfiguredDataSourceId]) + + if (!isLoadingConfiguredDataSource && !dataSource) { + return + } + + if (isLoadingConfiguredDataSource) { + return + } - if (!dataSource) { - return - } else { + if (dataSource) { return ( ) } + + assertNever( + `Invalid state: ${JSON.stringify({ + previouslyConfiguredDataSourceId, + isLoadingConfiguredDataSource, + dataSource, + })}` + ) +} + +const assertNever = (message: string): never => { + throw new Error(message) } diff --git a/examples/cms/src/FieldMapping.tsx b/examples/cms/src/FieldMapping.tsx index 55636d6b..1c7010a0 100644 --- a/examples/cms/src/FieldMapping.tsx +++ b/examples/cms/src/FieldMapping.tsx @@ -1,191 +1,178 @@ -import type { DataSource, syncCollection } from "./data" +import { DataSource, syncCollection } from "./data" import { ManagedCollection, ManagedCollectionField, framer } from "framer-plugin" -import { useState, useMemo, useLayoutEffect } from "react" +import { useState, useMemo, useEffect, memo } from "react" import { computeFieldsFromDataSource, mergeFieldsWithExistingFields } from "./data" import { Spinner } from "./components/Spinner" -import { UI_DEFAULTS } from "./constants" + +function ChevronIcon() { + return ( + + + + ) +} interface FieldMappingRowProps { - originalField: ManagedCollectionField field: ManagedCollectionField isIgnored: boolean onFieldToggle: (fieldId: string) => void onFieldNameChange: (fieldId: string, name: string) => void } -function FieldMappingRow({ originalField, field, isIgnored, onFieldToggle, onFieldNameChange }: FieldMappingRowProps) { - const isUnsupported = !field - const hasFieldNameChanged = field!.name !== originalField.name - const fieldName = hasFieldNameChanged ? field!.name : "" - const placeholder = isUnsupported ? "Unsupported Field" : originalField.name - const isDisabled = isUnsupported || isIgnored +const FieldMappingRow = memo(({ field, isIgnored, onFieldToggle, onFieldNameChange }: FieldMappingRowProps) => { + const [hasCustomName, setHasCustomName] = useState(field.id !== field.name) return ( <>
onFieldToggle(field!.id)} + aria-disabled={isIgnored} + onClick={() => onFieldToggle(field.id)} role="button" > { event.stopPropagation() }} /> - {originalField.name} + {field.id}
- - - + { - if (!field) return - const value = event.target.value if (!value.trim()) { - onFieldNameChange(field.id, originalField.name) + setHasCustomName(false) + onFieldNameChange(field.id, field.id) } else { + setHasCustomName(true) onFieldNameChange(field.id, value.trimStart()) } }} /> ) -} +}) interface FieldMappingProps { collection: ManagedCollection - existingFields: ManagedCollectionField[] dataSource: DataSource - slugFieldId: string | null - onImport: typeof syncCollection + initialSlugFieldId: string | null } -export function FieldMapping({ collection, existingFields, dataSource, slugFieldId, onImport }: FieldMappingProps) { - const originalFields = useMemo(() => computeFieldsFromDataSource(dataSource), [dataSource]) - const [fields, setFields] = useState(mergeFieldsWithExistingFields(originalFields, existingFields)) +type FieldMappingStatus = "mapping-fields" | "loading-fields" | "syncing-collection" - const [disabledFieldIds, setDisabledFieldIds] = useState>(() => { - if (existingFields.length === 0) { - return new Set() - } +export function FieldMapping({ collection, dataSource, initialSlugFieldId }: FieldMappingProps) { + const [status, setStatus] = useState("loading-fields") + const isSyncing = status === "syncing-collection" + const isLoadingFields = status === "loading-fields" - return new Set( - fields - .filter(field => !existingFields.find(existingField => existingField.id === field.id)) - .map(field => field.id) - ) - }) + const sourceFields = useMemo(() => computeFieldsFromDataSource(dataSource), [dataSource]) + const possibleSlugFields = useMemo(() => sourceFields.filter(field => field.type === "string"), [sourceFields]) - const possibleSlugFields = useMemo(() => { - return fields.filter(field => { - const isStringType = field.type === "string" - const isEnabled = !disabledFieldIds.has(field.id) + const [selectedSlugField, setSelectedSlugField] = useState( + possibleSlugFields.find(field => field.id === initialSlugFieldId) ?? possibleSlugFields[0] ?? null + ) - return isStringType && isEnabled - }) - }, [fields, disabledFieldIds]) + const [fields, setFields] = useState([]) + const [ignoredFieldIds, setIgnoredFieldIds] = useState>(new Set()) - const [selectedSlugFieldId, setSelectedSlugFieldId] = useState( - slugFieldId ?? possibleSlugFields[0]?.id ?? null - ) + useEffect(() => { + collection + .getFields() + .then(fields => { + setFields(mergeFieldsWithExistingFields(sourceFields, fields)) + + if (Boolean(initialSlugFieldId) === false && fields.length === 0) { + return + } + + const ignoredFields = sourceFields.filter( + field => !fields.some(existingField => existingField.id === field.id) + ) - const [isSyncing, setIsSyncing] = useState(false) + setIgnoredFieldIds(new Set(ignoredFields.map(field => field.id))) + }) + .catch(error => { + console.error(error) + framer.notify("Failed to load existing fields.", { variant: "error" }) + }) + .finally(() => { + setStatus("mapping-fields") + }) + }, [collection, sourceFields, initialSlugFieldId]) const handleFieldNameChange = (fieldId: string, name: string) => { - setFields(prev => prev.map(field => (field.id === fieldId ? { ...field, name } : field))) + setFields(previousFields => previousFields.map(field => (field.id === fieldId ? { ...field, name } : field))) } const handleFieldToggle = (fieldId: string) => { - setDisabledFieldIds(prev => { - const updatedDisabledFieldIds = new Set(prev) - const isEnabling = updatedDisabledFieldIds.has(fieldId) + setIgnoredFieldIds(previousIgnoredFieldIds => { + const updatedIgnoredFieldIds = new Set(previousIgnoredFieldIds) - if (isEnabling) { - updatedDisabledFieldIds.delete(fieldId) + if (updatedIgnoredFieldIds.has(fieldId)) { + updatedIgnoredFieldIds.delete(fieldId) } else { - updatedDisabledFieldIds.add(fieldId) - } - - // Handle slug field updates - const field = fields.find(field => field.id === fieldId) - if (field?.type !== "string") { - return updatedDisabledFieldIds - } - - if (isEnabling && (!slugFieldId || prev.has(slugFieldId))) { - // When enabling a string field and there is no valid slug field, make it the slug - setSelectedSlugFieldId(fieldId) - } else if (!isEnabling && fieldId === slugFieldId) { - // When disabling the current slug field, find next available one - const nextSlugField = possibleSlugFields.find(f => f.id !== fieldId) - setSelectedSlugFieldId(nextSlugField?.id ?? null) + updatedIgnoredFieldIds.add(fieldId) } - return updatedDisabledFieldIds + return updatedIgnoredFieldIds }) } const handleSubmit = async (event: React.FormEvent) => { event.preventDefault() - if (!selectedSlugFieldId) { - framer.notify("Slug field is required", { - variant: "error", - }) + if (!selectedSlugField) { + // This can't happen because the form will not submit if no slug field is selected + // but TypeScript can't infer that. + console.error("There is no slug field selected. Sync will not be performed") return } try { - setIsSyncing(true) - await onImport( + setStatus("syncing-collection") + + await syncCollection( collection, dataSource, - fields.filter(field => !disabledFieldIds.has(field.id)), - selectedSlugFieldId + fields.filter(field => !ignoredFieldIds.has(field.id)), + selectedSlugField ) await framer.closePlugin(`Synchronization successful`, { variant: "success", }) } catch (error) { console.error(error) - framer.notify(`Failed to sync collection ${dataSource.id}`, { + framer.notify(`Failed to sync collection ${dataSource.id}. Check the logs for more details.`, { variant: "error", }) } finally { - setIsSyncing(false) + setStatus("mapping-fields") } } - useLayoutEffect(() => { - framer.showUI({ - width: UI_DEFAULTS.MAPPING_WIDTH, - height: UI_DEFAULTS.MAPPING_HEIGHT, - minWidth: UI_DEFAULTS.MAPPING_WIDTH, - minHeight: UI_DEFAULTS.MAPPING_HEIGHT, - resizable: true, - }) - }, []) + if (isLoadingFields) { + return + } return (
@@ -196,15 +183,18 @@ export function FieldMapping({ collection, existingFields, dataSource, slugField setSelectedDataSourceId(event.target.value)} - value={selectedDataSourceId || ""} + value={selectedDataSourceId} > - {dataSources.map(dataSourceId => ( + {dataSourceIds.map(dataSourceId => ( diff --git a/examples/cms/src/constants.ts b/examples/cms/src/constants.ts index df61feca..637783e0 100644 --- a/examples/cms/src/constants.ts +++ b/examples/cms/src/constants.ts @@ -1,11 +1,4 @@ export const PLUGIN_KEYS = { - COLLECTION_ID: "collectionId", + DATA_SOURCE_ID: "dataSourceId", SLUG_FIELD_ID: "slugFieldId", } as const - -export const UI_DEFAULTS = { - SETUP_WIDTH: 320, - SETUP_HEIGHT: 305, - MAPPING_WIDTH: 360, - MAPPING_HEIGHT: 425, -} as const diff --git a/examples/cms/src/data.ts b/examples/cms/src/data.ts index e22ee5c1..6ef6be3d 100644 --- a/examples/cms/src/data.ts +++ b/examples/cms/src/data.ts @@ -29,61 +29,65 @@ export function computeFieldsFromDataSource(dataSource: DataSource): ManagedColl const fields: ManagedCollectionField[] = [] for (const [fieldId, field] of Object.entries(dataSource.fields)) { - let type = field.type - - /** - * Here you can add support for other types, usually you would want to convert your APIs types to Framer types. - */ - - if (type === "html") { - type = "formattedText" + switch (field.type) { + case "html": + fields.push({ + id: fieldId, + name: fieldId, + type: "formattedText", + userEditable: false, + }) + break + case "string": + case "number": + case "boolean": + case "color": + case "formattedText": + fields.push({ + id: fieldId, + name: fieldId, + type: field.type, + userEditable: false, + }) + break + default: { + throw new Error(`Field type ${field.type} is not supported`) + } } - - fields.push({ - id: fieldId, - name: fieldId, - type, - userEditable: false, - } as ManagedCollectionField) } return fields } export function mergeFieldsWithExistingFields( - originalFields: ManagedCollectionField[], + sourceFields: ManagedCollectionField[], existingFields: ManagedCollectionField[] ): ManagedCollectionField[] { - return originalFields.map(field => { - const existingField = existingFields.find(existingField => existingField.id === field.id) + return sourceFields.map(sourceField => { + const existingField = existingFields.find(existingField => existingField.id === sourceField.id) - return { ...field, name: existingField?.name ?? field.name } + return { ...sourceField, name: existingField?.name ?? sourceField.name } }) } +interface SyncResult { + didSync: boolean +} + export async function syncCollection( collection: ManagedCollection, dataSource: DataSource, fields: ManagedCollectionField[], - slugFieldId: string -): Promise { + slugField: ManagedCollectionField +) { const items: CollectionItemData[] = [] const unsyncedItems = new Set(await collection.getItemIds()) - const slugField = fields.find(field => field.id === slugFieldId) - if (!slugField) { - framer.notify("Slug field not found", { - variant: "error", - }) - return - } - - for (const item of dataSource.items) { + for (let i = 0; i < dataSource.items.length; i++) { + const item = dataSource.items[i] const slugValue = item[slugField.id] if (typeof slugValue !== "string" || !slugValue) { - framer.notify(`Skipping item ${item.id} because it doesn't have a valid slug`, { - variant: "warning", - }) + console.warn(`Skipping item at index ${i} because it doesn't have a valid slug`) continue } @@ -95,11 +99,14 @@ export async function syncCollection( // Field is in the data but should not be synced if (!field) { - console.warn(`Skipping field ${fieldName} because it may have been ignored`) + console.warn( + `Skipping field "${fieldName}" for item with slug "${slugValue}" because it may have been ignored` + ) continue } - // In a real-world scenario, we would need to convert the value to the correct type + // For details on expected field value types, see: + // https://www.framer.com/developers/plugins/cms#collections fieldData[field.id] = value } @@ -115,36 +122,48 @@ export async function syncCollection( await collection.removeItems(Array.from(unsyncedItems)) await collection.addItems(items) - await collection.setPluginData(PLUGIN_KEYS.COLLECTION_ID, dataSource.id) - await collection.setPluginData(PLUGIN_KEYS.SLUG_FIELD_ID, slugFieldId) + await collection.setPluginData(PLUGIN_KEYS.DATA_SOURCE_ID, dataSource.id) + await collection.setPluginData(PLUGIN_KEYS.SLUG_FIELD_ID, slugField.id) } -type SyncResult = "success" | "needsSetup" | "needsConfiguration" - export async function syncExistingCollection( collection: ManagedCollection, - dataSourceId: string | null, - slugFieldId: string | null + previouslyConfiguredDataSourceId: string | null, + previouslyConfiguredSlugFieldId: string | null ): Promise { - if (!dataSourceId) { - return "needsSetup" + if (!previouslyConfiguredDataSourceId) { + return { didSync: false } } - if (framer.mode !== "syncManagedCollection" || !slugFieldId) { - return "needsConfiguration" + if (framer.mode !== "syncManagedCollection" || !previouslyConfiguredSlugFieldId) { + return { didSync: false } } - const dataSource = await getDataSource(dataSourceId) - const existingFields = await collection.getFields() - try { - await syncCollection(collection, dataSource, existingFields, slugFieldId) - return "success" + const dataSource = await getDataSource(previouslyConfiguredDataSourceId) + const existingFields = await collection.getFields() + + const slugField = existingFields.find(field => field.id === previouslyConfiguredSlugFieldId) + if (!slugField) { + framer.notify( + `There is no field matching the slug field id "${previouslyConfiguredSlugFieldId}" in the collection. Sync will not be performed.`, + { + variant: "error", + } + ) + return { didSync: false } + } + + await syncCollection(collection, dataSource, existingFields, slugField) + return { didSync: true } } catch (error) { console.error(error) - framer.notify(`Failed to sync collection`, { - variant: "error", - }) - return "needsConfiguration" + framer.notify( + `Failed to sync collection ${previouslyConfiguredDataSourceId}. Check the logs for more details.`, + { + variant: "error", + } + ) + return { didSync: false } } } diff --git a/examples/cms/src/main.tsx b/examples/cms/src/main.tsx index edb11aeb..8741cb31 100644 --- a/examples/cms/src/main.tsx +++ b/examples/cms/src/main.tsx @@ -9,12 +9,16 @@ import { syncExistingCollection } from "./data" const activeCollection = await framer.getManagedCollection() -const configuredCollectionId = await activeCollection.getPluginData(PLUGIN_KEYS.COLLECTION_ID) -const configuredSlugFieldId = await activeCollection.getPluginData(PLUGIN_KEYS.SLUG_FIELD_ID) +const previouslyConfiguredDataSourceId = await activeCollection.getPluginData(PLUGIN_KEYS.DATA_SOURCE_ID) +const previouslyConfiguredSlugFieldId = await activeCollection.getPluginData(PLUGIN_KEYS.SLUG_FIELD_ID) -const result = await syncExistingCollection(activeCollection, configuredCollectionId, configuredSlugFieldId) +const { didSync } = await syncExistingCollection( + activeCollection, + previouslyConfiguredDataSourceId, + previouslyConfiguredSlugFieldId +) -if (result === "success") { +if (didSync) { await framer.closePlugin(`Synchronization successful`, { variant: "success", }) @@ -26,8 +30,8 @@ if (result === "success") { )