diff --git a/examples/cms-starter/src/App.css b/examples/cms-starter/src/App.css index 5ec6ea77..40287244 100644 --- a/examples/cms-starter/src/App.css +++ b/examples/cms-starter/src/App.css @@ -42,3 +42,131 @@ main { [data-framer-theme="dark"] input[type="checkbox"]:checked { background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiI+PHBhdGggZD0iTSAzIDYgTCA1IDggTCA5IDQiIGZpbGw9InRyYW5zcGFyZW50IiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlPSIjMmIyYjJiIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1kYXNoYXJyYXk9IiI+PC9wYXRoPjwvc3ZnPg=="); } + +.field-mapping-container { + display: flex; + flex-direction: column; + height: 100%; + gap: 10px; + padding: 0 15px; +} + +.field-mapping-form { + display: flex; + flex-direction: column; + flex: 1; + gap: 10px; + width: 100%; +} + +.divider { + height: 1px; + border-bottom: 1px solid var(--framer-color-divider); +} + +.sticky-divider { + composes: divider; + position: sticky; + top: 0; +} + +.field-grid { + display: grid; + grid-template-columns: 1fr 8px 1fr 1fr; + gap: 10px; + margin-bottom: auto; + align-items: center; + overflow: hidden; + color: var(--framer-color-text-tertiary); +} + +.column-span-2 { + grid-column: span 2 / span 2; +} + +.column-row { + display: flex; + flex-direction: row; + align-items: center; + white-space: nowrap; + height: 30px; + padding: 0px 10px; + font-size: 12px; + color: var(--framer-color-text); + background-color: var(--framer-color-bg-tertiary); + border-radius: 8px; + cursor: pointer; + gap: 8px; + user-select: none; +} + +.column-row[aria-disabled="true"] { + opacity: 0.5; +} + +.column-row input[type="checkbox"] { + cursor: pointer; +} + +.field-label { + display: flex; + flex-direction: column; + width: 100%; + justify-content: space-between; + gap: 4px; + color: var(--framer-color-text-tertiary); +} + +.field-input { + width: 100%; + flex-shrink: 1; +} + +.field-input--disabled { + pointer-events: none; + user-select: none; +} + +.sticky-footer { + position: sticky; + bottom: 0; + left: 0; + width: 100%; + background-color: var(--framer-color-bg); + padding-bottom: 15px; + margin-top: auto; + display: flex; + flex-direction: column; + gap: 10px; +} + +.intro-container { + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 15px 15px; + width: 100%; +} + +.collection-form { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.collection-label { + display: flex; + flex-direction: row; + align-items: center; + height: 30px; + width: 100%; + justify-content: space-between; + padding-left: 15px; + color: var(--framer-color-text-tertiary); +} + +.collection-select { + width: 164px; + text-transform: capitalize; +} diff --git a/examples/cms-starter/src/App.tsx b/examples/cms-starter/src/App.tsx index 309ca171..3457e8b3 100644 --- a/examples/cms-starter/src/App.tsx +++ b/examples/cms-starter/src/App.tsx @@ -1,217 +1,19 @@ -import { CollectionItemData, framer, ManagedCollectionField } from "framer-plugin" +import { framer } from "framer-plugin" import "./App.css" -import { Fragment, useLayoutEffect, useMemo, useState } from "react" -import { DataSource, DataSourceFieldType, getDataSources, listDataSourcesIds } from "./data" - -// Plugin keys -const PLUGIN_PREFIX = "cms_starter" -const LOCAL_STORAGE_LAST_LAUNCH_KEY = `${PLUGIN_PREFIX}.lastLaunched` - -const PLUGIN_COLLECTION_SYNC_REFERENCE_KEY = `collectionSyncReference` -const PLUGIN_COLLECTION_SYNC_SLUG_KEY = `collectionSyncSlug` - -// Match everything except for letters, numbers and parentheses. -const nonSlugCharactersRegExp = /[^\p{Letter}\p{Number}()]+/gu -// Match leading/trailing dashes, for trimming purposes. -const trimSlugRegExp = /^-+|-+$/gu - -type SupportedCollectionFieldType = ManagedCollectionField["type"] -type SupportedCollectionFieldTypeWithoutReference = Exclude< - SupportedCollectionFieldType, - "collectionReference" | "multiCollectionReference" -> - -/** - * Reference fields are special fields that reference other collections. - */ -interface ReferenceField { - type: "collectionReference" | "multiCollectionReference" - source: string - destination: string | null -} - -/** - * Field configuration for a managed collection field. - */ -interface FieldConfig { - source: { - name: string - type: DataSourceFieldType - ignored: boolean - } - field: ManagedCollectionField | null - reference: ReferenceField | null -} - -/** - * Mapping of data source field types to managed collection field types. - */ -const FIELD_MAPPING: Record = { - string: [], - date: ["date"], - image: ["image", "file", "link"], - // Special case for reference fields - we need to map the reference field to the collection it references - reference: [], - richText: ["formattedText"], - number: ["number"], - boolean: ["boolean"], - enum: ["enum"], - color: ["color"], -} - -const TYPE_NAMES: Record = { - string: "String", - date: "Date", - image: "Image", - link: "Link", - file: "File", - number: "Number", - boolean: "Boolean", - enum: "Option", - color: "Color", - formattedText: "Formatted Text", -} - -const COLLECTIONS_SYNC_MAP: Map< - string, - { - id: string - name: string - }[] -> = new Map() - -const allExistingCollections = await framer.getCollections() -for (const collection of allExistingCollections) { - const reference = await collection.getPluginData(PLUGIN_COLLECTION_SYNC_REFERENCE_KEY) - if (reference) { - const collectionReferences = COLLECTIONS_SYNC_MAP.get(reference) ?? [] - COLLECTIONS_SYNC_MAP.set(reference, [ - ...collectionReferences, - { - id: collection.id, - name: collection.name, - }, - ]) - } -} - -function computeFieldConfig(existingFields: ManagedCollectionField[], dataSource: DataSource) { - const result: FieldConfig[] = [] - const fields = dataSource.fields - - for (const [name, field] of Object.entries(fields)) { - const fieldId = generateHashId(name) - let newField: ManagedCollectionField | null = null - - const existingField = existingFields.find(field => field.id === fieldId) - if (existingField) { - newField = existingField - } else if (field.type === "reference") { - newField = { - id: fieldId, - name, - type: field.multiple ? "multiCollectionReference" : "collectionReference", - collectionId: COLLECTIONS_SYNC_MAP.get(field.reference)?.[0].id ?? "", - userEditable: false, - } - } else { - const fieldType = FIELD_MAPPING[field.type][0] ?? "string" - newField = { - id: fieldId, - name, - type: fieldType, - userEditable: false, - } as ManagedCollectionField - } - - let reference: ReferenceField | null = null - if (newField && field.type === "reference") { - if (newField.type === "string") { - reference = { - type: field.multiple ? "multiCollectionReference" : "collectionReference", - source: field.reference, - destination: COLLECTIONS_SYNC_MAP.get(field.reference)?.[0].id ?? null, - } - } else if (newField.type === "collectionReference" || newField.type === "multiCollectionReference") { - reference = { - type: newField.type, - source: field.reference, - destination: newField.collectionId || null, - } - } - - assert( - true, - "Expected reference field to be mapped to a collection reference or multi collection reference" - ) - } - - if (field.type === "enum") { - assert(newField?.type === "enum", "Expected enum field to be mapped to an enum field") - newField.cases = field.options.map(option => ({ - id: option, - name: option, - })) - } - - result.push({ - source: { - name, - type: field.type, - ignored: !existingField, - }, - field: newField, - reference, - }) - } - - return result -} - -const CELL_BOOLEAN_VALUES = ["Y", "yes", "true", "TRUE", "Yes", 1, true] - -function getFieldValue(field: FieldConfig, value: unknown) { - switch (field.source.type) { - case "number": { - const num = Number(value) - if (isNaN(num)) { - return null - } - - return num - } - case "boolean": { - if (typeof value !== "boolean" && typeof value !== "string" && typeof value !== "number") { - return null - } - - return CELL_BOOLEAN_VALUES.includes(value) - } - case "date": { - if (typeof value !== "string") return null - return new Date(value).toUTCString() - } - case "reference": { - if (field.field?.type === "multiCollectionReference") { - return String(value) - .split(",") - .map(id => generateHashId(id)) - } else if (field.field?.type === "string" || field.field?.type === "collectionReference") { - return Array.isArray(value) ? generateHashId(value[0]) : generateHashId(String(value)) - } - return null - } - case "enum": - case "image": - case "richText": - case "color": - case "string": { - return String(value) - } - default: - return null - } -} +import { useLayoutEffect, useState } from "react" +import { + COLLECTIONS_SYNC_MAP, + computeFieldConfig, + DataSource, + FieldConfig, + getDataSources, + listDataSourcesIds, + LOCAL_STORAGE_LAST_LAUNCH_KEY, + PLUGIN_COLLECTION_SYNC_REFERENCE_KEY, + PLUGIN_COLLECTION_SYNC_SLUG_KEY, + syncCollection, +} from "./data" +import { FieldMapping } from "./FieldMapping" const activeCollection = await framer.getManagedCollection() const existingFields = activeCollection ? await activeCollection.getFields() : [] @@ -221,8 +23,6 @@ const syncSlugFieldId = await activeCollection.getPluginData(PLUGIN_COLLECTION_S const syncDataSource = syncDataSourceId ? await getDataSources(syncDataSourceId) : null -const canSync = false - let savedFieldsConfig: FieldConfig[] | undefined if (syncDataSource) { @@ -241,369 +41,6 @@ if (framer.mode === "syncManagedCollection" && savedFieldsConfig && syncDataSour const allDataSources = await listDataSourcesIds() -function FieldMapping({ - existingFields, - dataSource, - savedSlugFieldId, - onSubmit, -}: { - existingFields: ManagedCollectionField[] - dataSource: DataSource - savedSlugFieldId: string | null - onSubmit: (dataSource: DataSource, fields: FieldConfig[], slugFieldId: string) => Promise -}) { - const [fields, setFields] = useState( - savedFieldsConfig ?? computeFieldConfig(existingFields, dataSource) - ) - const [disabledFieldIds, setDisabledFieldIds] = useState>( - new Set(savedFieldsConfig?.filter(field => field.source.ignored).map(field => field.field!.id)) - ) - - const slugFields = useMemo( - () => - fields.filter( - field => field.field && !disabledFieldIds.has(field.field.id) && field.field.type === "string" - ), - [fields, disabledFieldIds] - ) - const [slugFieldId, setSlugFieldId] = useState(savedSlugFieldId ?? slugFields[0]?.field?.id ?? null) - - const handleFieldNameChange = (fieldId: string, name: string) => { - setFields(prev => - prev.map(field => (field.field?.id === fieldId ? { ...field, field: { ...field.field, name } } : field)) - ) - } - - const handleFieldToggle = (fieldId: string) => { - setDisabledFieldIds(current => { - const nextSet = new Set(current) - if (nextSet.has(fieldId)) { - nextSet.delete(fieldId) - - // If we're re-enabling a string field and there's no valid slug field, - // set this field as the slug field - const field = fields.find(field => field.field?.id === fieldId) - if (field?.field?.type === "string") { - const currentSlugField = fields.find(field => field.field?.id === slugFieldId) - if (!currentSlugField || nextSet.has(slugFieldId ?? "")) { - setSlugFieldId(fieldId) - } - } - } else { - nextSet.add(fieldId) - - // If the disabled field is the slug field, update it to the next - // possible slug field - if (fieldId === slugFieldId) { - const nextSlugField = slugFields.find(field => field.field?.id !== fieldId) - if (nextSlugField?.field && !nextSet.has(nextSlugField.field.id)) { - setSlugFieldId(nextSlugField.field.id) - } - } - } - return nextSet - }) - } - - const handleFieldTypeChange = (id: string, type: DataSourceFieldType) => { - setFields(current => - current.map(field => { - if (field.field?.id !== id) { - return field - } - // If this is a reference field and we're changing to a string type, - // preserve the reference information but clear the destination - if (field.reference && type === "string") { - return { - ...field, - field: { - id: field.field?.id, - type: "string", - name: field.field?.name, - userEditable: false, - } as ManagedCollectionField, - } - } - - // If this is a reference field and we're changing to a collection reference, - // use the original reference type and set the destination to the new type - if (field.reference && type !== "string") { - return { - ...field, - field: { - id: field.field?.id, - type: field.reference.type, - name: field.field?.name, - collectionId: type, - userEditable: false, - } as ManagedCollectionField, - } - } - - // Default case - just update the type - return { ...field, field: { ...field.field, type } as ManagedCollectionField } - }) - ) - } - - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault() - assert(slugFieldId, "Slug field is required") - onSubmit( - dataSource, - fields - .filter(field => field.field && !disabledFieldIds.has(field.field!.id)) - .filter(field => !field.reference || field.reference.destination !== null), - slugFieldId - ) - } - - useLayoutEffect(() => { - framer.showUI({ - width: 360, - height: 425, - minWidth: 360, - minHeight: 425, - resizable: true, - }) - }, []) - - return ( -
-
-
-
-
- -
-
-
- - Column - - Field - Type - {fields.map((field, i) => { - const isUnsupported = !field.field - const isMissingReference = !isUnsupported && field.reference?.destination === null - const isDisabled = isUnsupported || isMissingReference || disabledFieldIds.has(field.field!.id) - - const hasFieldNameChanged = field.field?.name !== field.source.name - const fieldName = hasFieldNameChanged ? field.field?.name : "" - - let placeholder = field.source.name - if (isMissingReference) { - placeholder = "Missing Reference" - } else if (isUnsupported) { - placeholder = "Unsupported Field" - } - - const selectedType = - field.reference && field.field?.type === "string" - ? "string" - : field.reference?.destination || field.field!.type - - return ( - -
- { - assert(field.field) - handleFieldToggle(field.field.id) - }} - /> - -
- - - - { - assert(field.field) - if (!e.target.value.trim()) { - handleFieldNameChange(field.field.id, field.source.name) - } else { - handleFieldNameChange(field.field.id, e.target.value) - } - }} - /> - -
- ) - })} - {/* {fieldConfig.length > 6 &&
} */} -
-
-
- -
-
-
- ) -} - export function App() { const [isFirstTime, setIsFirstTime] = useState(localStorage.getItem(LOCAL_STORAGE_LAST_LAUNCH_KEY) === null) const [isLoadingFields, setIsLoadingFields] = useState(false) @@ -621,29 +58,37 @@ export function App() { const showFieldsMapping = async (event: React.FormEvent) => { event.preventDefault() - if (!selectedDataSourceId) return + if (!selectedDataSourceId) { + framer.notify("Please select a data source", { variant: "error" }) + return + } setIsLoadingFields(true) - const dataSource = await getDataSources(selectedDataSourceId) - if (!dataSource) { - return framer.notify(`Failed to load collection ${selectedDataSourceId}`, { + try { + const dataSource = await getDataSources(selectedDataSourceId) + if (!dataSource) { + throw new Error("Failed to load data source") + } + + setSelectDataSource(dataSource) + + const collectionReferences = COLLECTIONS_SYNC_MAP.get(dataSource.id) ?? [] + if (!collectionReferences.find(reference => reference.id === activeCollection.id)) { + COLLECTIONS_SYNC_MAP.set(dataSource.id, [ + ...collectionReferences, + { + id: activeCollection.id, + name: "This Collection", + }, + ]) + } + } catch (error) { + framer.notify(`Failed to load collection: ${error instanceof Error ? error.message : "Unknown error"}`, { variant: "error", }) - } - - setIsLoadingFields(false) - setSelectDataSource(dataSource) - - const collectionReferences = COLLECTIONS_SYNC_MAP.get(dataSource.id) ?? [] - if (!collectionReferences.find(reference => reference.id === activeCollection.id)) { - COLLECTIONS_SYNC_MAP.set(dataSource.id, [ - ...collectionReferences, - { - id: activeCollection.id, - name: "This Collection", - }, - ]) + } finally { + setIsLoadingFields(false) } } @@ -661,15 +106,7 @@ export function App() { if (isFirstTime) { return ( -
+

This is a starter for the CMS plugin. Laboris duis dolore culpa culpa sint do. In commodo aliquip consequat qui sit laboris cillum veniam voluptate irure. @@ -681,39 +118,17 @@ export function App() { if (!selectDataSource) { return ( -

+

Select a collection to sync with Framer.

-
-