From 9d893adb7420b5a238e27193469dd85de4731f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81dric=20Boirard?= Date: Fri, 27 Dec 2024 10:03:59 +0100 Subject: [PATCH] Cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cédric Boirard --- examples/cms/datasources/articles.json | 10 +- examples/cms/datasources/categories.json | 7 + examples/cms/src/App.css | 29 ++- examples/cms/src/App.tsx | 95 ++++---- examples/cms/src/FieldMapping.tsx | 273 +++++++++++------------ examples/cms/src/SelectDataSource.tsx | 52 +++-- examples/cms/src/constants.ts | 9 +- examples/cms/src/data.ts | 151 +++++++------ examples/cms/src/main.tsx | 12 +- package-lock.json | 53 ++--- 10 files changed, 365 insertions(+), 326 deletions(-) diff --git a/examples/cms/datasources/articles.json b/examples/cms/datasources/articles.json index 6442d61c..33d30309 100644 --- a/examples/cms/datasources/articles.json +++ b/examples/cms/datasources/articles.json @@ -17,31 +17,31 @@ "items": [ { "Title": "Getting Started", - "Reading time": 5, + "Reading time": 3, "Featured": true, "Content": "

Editing Content

You can choose to set up different types of input fields depending on your content...

..." }, { "Title": "What's New", - "Reading time": 5, + "Reading time": 2, "Featured": false, "Content": "

Reference Fields

..." }, { "Title": "Styling Elements", - "Reading time": 5, + "Reading time": 4, "Featured": false, "Content": "

Reference Fields

..." }, { "Title": "Importing Content", - "Reading time": 5, + "Reading time": 6, "Featured": false, "Content": "

Prepare your CSV file

Make sure your file is exported as a \"CSV\" file...

" }, { "Title": "Best Practices", - "Reading time": 5, + "Reading time": 8, "Featured": true, "Content": "

Choose Compelling Topics

Use analytics tools to understand demographic data and user behavior...

" } diff --git a/examples/cms/datasources/categories.json b/examples/cms/datasources/categories.json index 863e274c..635097af 100644 --- a/examples/cms/datasources/categories.json +++ b/examples/cms/datasources/categories.json @@ -4,6 +4,9 @@ "Title": { "type": "string" }, + "Description": { + "type": "string" + }, "Color": { "type": "color" } @@ -11,18 +14,22 @@ "items": [ { "Title": "CMS", + "Description": "Content Management System", "Color": "orange" }, { "Title": "Basics", + "Description": "Basic content management", "Color": "red" }, { "Title": "Updates", + "Description": "Updates to the CMS", "Color": "blue" }, { "Title": "Pro Tips", + "Description": "Tips for using the CMS", "Color": "green" } ] diff --git a/examples/cms/src/App.css b/examples/cms/src/App.css index 4f6af30f..1b7f7aef 100644 --- a/examples/cms/src/App.css +++ b/examples/cms/src/App.css @@ -7,6 +7,9 @@ main { padding: 0 15px 15px; height: 100%; gap: 15px; + + user-select: none; + -webkit-user-select: none; } form { @@ -35,10 +38,6 @@ form { background: linear-gradient(180deg, rgba(18, 18, 18, 0) 0%, rgb(17, 17, 17) 97.8%); } -[data-framer-theme="dark"] input[type="checkbox"]:checked { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiI+PHBhdGggZD0iTSAzIDYgTCA1IDggTCA5IDQiIGZpbGw9InRyYW5zcGFyZW50IiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlPSIjMmIyYjJiIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1kYXNoYXJyYXk9IiI+PC9wYXRoPjwvc3ZnPg=="); -} - .sticky-top { position: sticky; top: 0; @@ -53,11 +52,6 @@ form { grid-column: span 2 / span 2; } -.setup, -.mapping { - composes: no-scrollbar; -} - .setup .logo { display: flex; flex-direction: column; @@ -113,25 +107,28 @@ form { flex-direction: row; align-items: center; white-space: nowrap; - height: 30px; - padding: 0px 8px; - font-size: 12px; - color: var(--framer-color-text); + font-weight: 500; background-color: var(--framer-color-bg-tertiary); - border-radius: 8px; - cursor: pointer; gap: 8px; - user-select: none; } .mapping .source-field[aria-disabled="true"] { opacity: 0.5; } +.mapping .source-field:focus-visible { + outline: none; + box-shadow: inset 0 0 0 1px var(--framer-color-tint); +} + .mapping .source-field input[type="checkbox"] { 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..62680dc2 100644 --- a/examples/cms/src/App.tsx +++ b/examples/cms/src/App.tsx @@ -1,64 +1,79 @@ import "./App.css" -import { framer, ManagedCollection, ManagedCollectionField } from "framer-plugin" +import type { ManagedCollection } from "framer-plugin" + +import { framer } from "framer-plugin" import { useEffect, useLayoutEffect, useState } from "react" -import { DataSource, getDataSource, getDataSourcesIds, syncCollection } from "./data" +import { DataSource, getDataSource } 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 + previousDataSourceId: string | null + previousSlugFieldId: string | null } -export function App({ collection, dataSourceId, slugFieldId }: AppProps) { +export function App({ collection, previousDataSourceId, previousSlugFieldId }: 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 [isLoadingDataSource, setIsLoadingDataSource] = useState(Boolean(previousDataSourceId)) + const hasDataSourceSelected = Boolean(isLoadingDataSource || 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 (!previousDataSourceId) { return } - getDataSource(dataSourceId).then(setDataSource) - }, [dataSourceId]) + const abortController = new AbortController() + + setIsLoadingDataSource(true) + getDataSource(previousDataSourceId, abortController.signal) + .then(setDataSource) + .catch(error => { + if (abortController.signal.aborted) { + return + } + + console.error(error) + framer.notify( + `Error loading previously configured data source "${previousDataSourceId}". Check the logs for more details.`, + { + variant: "error", + } + ) + }) + .finally(() => { + if (abortController.signal.aborted) { + return + } + + setIsLoadingDataSource(false) + }) + + return () => { + abortController.abort() + } + }, [previousDataSourceId]) + + if (isLoadingDataSource) { + return + } if (!dataSource) { - return - } else { - return ( - - ) + return } + + return } diff --git a/examples/cms/src/FieldMapping.tsx b/examples/cms/src/FieldMapping.tsx index 55636d6b..36252a3e 100644 --- a/examples/cms/src/FieldMapping.tsx +++ b/examples/cms/src/FieldMapping.tsx @@ -1,210 +1,202 @@ -import type { DataSource, syncCollection } from "./data" +import type { ManagedCollection, ManagedCollectionField } from "framer-plugin" +import type { DataSource, FieldIds, ManagedCollectionFields } from "./data" -import { ManagedCollection, ManagedCollectionField, framer } from "framer-plugin" -import { useState, useMemo, useLayoutEffect } from "react" -import { computeFieldsFromDataSource, mergeFieldsWithExistingFields } from "./data" +import { framer } from "framer-plugin" +import { useState, useMemo, useEffect, memo } from "react" +import { computeFieldsFromDataSource, mergeFieldsWithExistingFields, syncCollection } 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 + disabled: boolean + onDisable: (fieldId: string) => void + onNameChange: (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, disabled, onDisable, onNameChange }: FieldMappingRowProps) => { return ( <> -
onFieldToggle(field!.id)} - role="button" + aria-disabled={disabled} + onClick={() => onDisable(field.id)} + tabIndex={0} > - { - event.stopPropagation() - }} - /> - {originalField.name} -
- - - + + {field.id} + + { - if (!field) return - - const value = event.target.value - if (!value.trim()) { - onFieldNameChange(field.id, originalField.name) - } else { - onFieldNameChange(field.id, value.trimStart()) + disabled={disabled} + placeholder={field.id} + value={field.name} + onChange={event => onNameChange(field.id, event.target.value)} + onKeyDown={event => { + if (event.key === "Enter") { + event.preventDefault() } }} /> ) -} +}) + +const initialManagedCollectionFields: ManagedCollectionFields = [] +const initialFieldIds: FieldIds = new Set() 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)) +export function FieldMapping({ collection, dataSource, initialSlugFieldId }: FieldMappingProps) { + const [status, setStatus] = useState<"mapping-fields" | "loading-fields" | "syncing-collection">("loading-fields") + const isSyncing = status === "syncing-collection" + const isLoadingFields = status === "loading-fields" - const [disabledFieldIds, setDisabledFieldIds] = useState>(() => { - if (existingFields.length === 0) { - return new Set() - } + const sourceFields = useMemo(() => computeFieldsFromDataSource(dataSource), [dataSource]) + const possibleSlugFields = useMemo(() => sourceFields.filter(field => field.type === "string"), [sourceFields]) - return new Set( - fields - .filter(field => !existingFields.find(existingField => existingField.id === field.id)) - .map(field => field.id) - ) - }) + const [selectedSlugField, setSelectedSlugField] = useState( + possibleSlugFields.find(field => field.id === initialSlugFieldId) ?? possibleSlugFields[0] ?? null + ) - const possibleSlugFields = useMemo(() => { - return fields.filter(field => { - const isStringType = field.type === "string" - const isEnabled = !disabledFieldIds.has(field.id) + const [fields, setFields] = useState(initialManagedCollectionFields) + const [ignoredFieldIds, setIgnoredFieldIds] = useState(initialFieldIds) - return isStringType && isEnabled - }) - }, [fields, disabledFieldIds]) + useEffect(() => { + const abortController = new AbortController() - const [selectedSlugFieldId, setSelectedSlugFieldId] = useState( - slugFieldId ?? possibleSlugFields[0]?.id ?? null - ) + collection + .getFields() + .then(collectionFields => { + if (abortController.signal.aborted) return + + setFields(mergeFieldsWithExistingFields(sourceFields, collectionFields)) - const [isSyncing, setIsSyncing] = useState(false) + const existingFieldIds = new Set(collectionFields.map(field => field.id)) + const ignoredFields = sourceFields.filter(sourceField => !existingFieldIds.has(sourceField.id)) + + if (initialSlugFieldId) { + setIgnoredFieldIds(new Set(ignoredFields.map(field => field.id))) + } + + setStatus("mapping-fields") + }) + .catch(error => { + if (!abortController.signal.aborted) { + console.error("Failed to fetch collection fields:", error) + framer.notify("Failed to load collection fields", { variant: "error" }) + } + }) + + return () => { + abortController.abort() + } + }, [initialSlugFieldId, sourceFields, collection]) const handleFieldNameChange = (fieldId: string, name: string) => { - setFields(prev => prev.map(field => (field.id === fieldId ? { ...field, name } : field))) + setFields(prevFields => { + const updatedFields = prevFields.map(field => { + if (field.id !== fieldId) return field + return { ...field, name } + }) + return updatedFields + }) } - const handleFieldToggle = (fieldId: string) => { - setDisabledFieldIds(prev => { - const updatedDisabledFieldIds = new Set(prev) - const isEnabling = updatedDisabledFieldIds.has(fieldId) + const handleFieldDisable = (fieldId: string) => { + 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") + framer.notify("Please select a slug field before importing.", { variant: "warning" }) return } try { - setIsSyncing(true) - await onImport( - collection, - dataSource, - fields.filter(field => !disabledFieldIds.has(field.id)), - selectedSlugFieldId - ) + setStatus("syncing-collection") + + const fieldsToSync = fields.filter(field => !ignoredFieldIds.has(field.id)) + + await syncCollection(collection, dataSource, fieldsToSync, 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 ( -
+