diff --git a/plugins/csv-import/src/App.tsx b/plugins/csv-import/src/App.tsx index bab6d6029..ac75dad62 100644 --- a/plugins/csv-import/src/App.tsx +++ b/plugins/csv-import/src/App.tsx @@ -17,14 +17,20 @@ import { ImportError, type ImportItem, prepareImportPayload } from "./utils/prep export function App({ initialCollection }: { initialCollection: Collection | null }) { const [collection, setCollection] = useState(initialCollection) - const hasAllPermissions = useIsAllowedTo("Collection.addItems", "Collection.addFields", "Collection.removeFields") + const canAddItems = useIsAllowedTo("Collection.addItems") + const canEditFields = useIsAllowedTo("Collection.addFields", "Collection.removeFields") const { currentRoute, navigate } = useMiniRouter() const handleFileSelected = useCallback( async (csvContent: string) => { if (!collection) return - if (!hasAllPermissions) return + if (!canAddItems) { + framer.notify("You do not have permission to edit CMS collections.", { + variant: "error", + }) + return + } try { const csvRecords = await parseCSV(csvContent) @@ -57,7 +63,7 @@ export function App({ initialCollection }: { initialCollection: Collection | nul }) } }, - [collection, hasAllPermissions, navigate] + [collection, canAddItems, navigate] ) const handleFieldMapperSubmit = useCallback( @@ -68,11 +74,15 @@ export function App({ initialCollection }: { initialCollection: Collection | nul slugFieldName: string missingFields: MissingFieldItem[] }) => { - if (!hasAllPermissions) return + if (!canAddItems) return try { - await applyFieldRemovalsToCms(opts.collection, opts.missingFields) - await applyFieldCreationsToCms(opts.collection, opts.mappings) + // Field creation/removal requires elevated permissions. Users who can only add + // items still import by mapping CSV columns onto existing fields. + if (canEditFields) { + await applyFieldRemovalsToCms(opts.collection, opts.missingFields) + await applyFieldCreationsToCms(opts.collection, opts.mappings) + } // Process records with field mapping const payload = await prepareImportPayload({ @@ -129,7 +139,7 @@ export function App({ initialCollection }: { initialCollection: Collection | nul }) } }, - [hasAllPermissions, navigate] + [canAddItems, canEditFields, navigate] ) switch (currentRoute.uid) { @@ -137,6 +147,7 @@ export function App({ initialCollection }: { initialCollection: Collection | nul return ( handleFieldMapperSubmit({ collection: currentRoute.opts.collection, diff --git a/plugins/csv-import/src/components/FieldMapperRow.tsx b/plugins/csv-import/src/components/FieldMapperRow.tsx index 5001c8371..ce4344ec4 100644 --- a/plugins/csv-import/src/components/FieldMapperRow.tsx +++ b/plugins/csv-import/src/components/FieldMapperRow.tsx @@ -21,6 +21,8 @@ export interface FieldMappingItem { interface FieldMapperRowProps { item: FieldMappingItem existingFields: Field[] + /** When false, the column can only be mapped onto an existing field (no new fields). */ + canEditFields: boolean slugFieldName: string | null onToggleIgnored: () => void onSetIgnored: (ignored: boolean) => void @@ -31,6 +33,7 @@ interface FieldMapperRowProps { export function FieldMapperRow({ item, existingFields, + canEditFields, slugFieldName, onToggleIgnored, onSetIgnored, @@ -86,8 +89,12 @@ export function FieldMapperRow({ } }} > - - {isIgnored && } + {canEditFields && } + {isIgnored && ( + + )} {existingFields.length > 0 &&
} {existingFields.map(field => ( diff --git a/plugins/csv-import/src/routes/FieldMapper.tsx b/plugins/csv-import/src/routes/FieldMapper.tsx index 99e2e3259..518eea295 100644 --- a/plugins/csv-import/src/routes/FieldMapper.tsx +++ b/plugins/csv-import/src/routes/FieldMapper.tsx @@ -26,6 +26,11 @@ export interface FieldMapperSubmitOpts { interface FieldMapperProps { collection: Collection csvRecords: Record[] + /** + * When false, the user can only map CSV columns onto existing fields. Creating new + * fields and removing existing fields is disabled (e.g. content-editing-only permissions). + */ + canEditFields: boolean onSubmit: (opts: FieldMapperSubmitOpts) => Promise } @@ -40,7 +45,43 @@ function calculatePossibleSlugFields(mappings: FieldMappingItem[], csvRecords: R const caseInsensitiveEquals = (a: string | undefined | null, b: string | undefined | null) => a?.toLocaleLowerCase() === b?.toLocaleLowerCase() -export function FieldMapper({ collection, csvRecords, onSubmit }: FieldMapperProps) { +/** + * Determine how a column should be restored when it is un-ignored. With field-editing + * permissions it becomes a new field. Without them the user can only map onto existing + * fields, so we pick the first unmapped existing field (or keep it ignored if none remain). + */ +function buildUnignoreUpdate( + item: FieldMappingItem, + canEditFields: boolean, + existingFields: Field[], + allMappings: FieldMappingItem[] +): Partial { + if (canEditFields) { + return { action: "create", targetFieldId: undefined, hasTypeMismatch: false } + } + + const usedFieldIds = new Set( + allMappings + .filter( + m => + m.action === "map" && + m.targetFieldId && + m.inferredField.columnName !== item.inferredField.columnName + ) + .map(m => m.targetFieldId) + ) + const target = existingFields.find(field => !usedFieldIds.has(field.id)) + if (!target) { + return { action: "ignore", targetFieldId: undefined, hasTypeMismatch: false } + } + + const targetVirtualType = sdkTypeToVirtual(target) + const hasTypeMismatch = !isTypeCompatible(item.inferredField.inferredType, targetVirtualType) + + return { action: "map", targetFieldId: target.id, hasTypeMismatch } +} + +export function FieldMapper({ collection, csvRecords, canEditFields, onSubmit }: FieldMapperProps) { const [existingFields, setExistingFields] = useState([]) const [mappings, setMappings] = useState([]) const possibleSlugFields = useMemo(() => calculatePossibleSlugFields(mappings, csvRecords), [csvRecords, mappings]) @@ -91,10 +132,12 @@ export function FieldMapper({ collection, csvRecords, onSubmit }: FieldMapperPro } } - // No match - create new field or ignore if it's the slug field + // No match - create a new field, or ignore it when it's the slug field + // or when the user isn't allowed to create fields (they can only map + // onto existing fields, so unmatched columns stay unmapped). return { inferredField, - action: isSlugField ? "ignore" : "create", + action: isSlugField || !canEditFields ? "ignore" : "create", hasTypeMismatch: false, } }) @@ -124,43 +167,68 @@ export function FieldMapper({ collection, csvRecords, onSubmit }: FieldMapperPro } void loadFields() - }, [collection, csvRecords]) + }, [collection, csvRecords, canEditFields]) + + // Keep the "Unmapped CMS Fields" list in sync with the current mappings, preserving any + // action the user already chose for a field that remains unmapped. + const recomputeMissingFields = useCallback( + (newMappings: FieldMappingItem[]) => { + const mappedFieldIds = new Set( + newMappings.filter(m => m.action === "map" && m.targetFieldId).map(m => m.targetFieldId) + ) + setMissingFields(prev => { + const prevActionMap = new Map(prev.map(item => [item.field.id, item.action])) + + return existingFields + .filter(field => !mappedFieldIds.has(field.id)) + .map(field => ({ + field, + action: prevActionMap.get(field.id) ?? ("ignore" as MissingFieldAction), + })) + }) + }, + [existingFields] + ) - const toggleIgnored = useCallback((columnName: string) => { - setMappings(prev => { - const newMappings = prev.map(item => { - if (item.inferredField.columnName !== columnName) return item + const toggleIgnored = useCallback( + (columnName: string) => { + setMappings(prev => { + const newMappings = prev.map(item => { + if (item.inferredField.columnName !== columnName) return item - if (item.action === "ignore") { - // Un-ignore: restore to create mode - return { ...item, action: "create" as const, targetFieldId: undefined, hasTypeMismatch: false } - } else { - // Ignore + if (item.action === "ignore") { + return { ...item, ...buildUnignoreUpdate(item, canEditFields, existingFields, prev) } + } return { ...item, action: "ignore" as const, targetFieldId: undefined, hasTypeMismatch: false } - } + }) + + recomputeMissingFields(newMappings) + return newMappings }) + }, + [canEditFields, existingFields, recomputeMissingFields] + ) - return newMappings - }) - }, []) + const setIgnored = useCallback( + (columnName: string, ignored: boolean) => { + setMappings(prev => { + const newMappings = prev.map(item => { + if (item.inferredField.columnName !== columnName) return item - const setIgnored = useCallback((columnName: string, ignored: boolean) => { - setMappings(prev => { - const newMappings = prev.map(item => { - if (item.inferredField.columnName !== columnName) return item + if (ignored) { + return { ...item, action: "ignore" as const, targetFieldId: undefined, hasTypeMismatch: false } + } else if (item.action === "ignore") { + return { ...item, ...buildUnignoreUpdate(item, canEditFields, existingFields, prev) } + } + return item + }) - if (ignored) { - return { ...item, action: "ignore" as const, targetFieldId: undefined, hasTypeMismatch: false } - } else if (item.action === "ignore") { - // Un-ignore: restore to create mode - return { ...item, action: "create" as const, targetFieldId: undefined, hasTypeMismatch: false } - } - return item + recomputeMissingFields(newMappings) + return newMappings }) - - return newMappings - }) - }, []) + }, + [canEditFields, existingFields, recomputeMissingFields] + ) const updateTarget = useCallback( (columnName: string, targetFieldId: string | null) => { @@ -194,25 +262,11 @@ export function FieldMapper({ collection, csvRecords, onSubmit }: FieldMapperPro } }) - // Update missing fields based on new mappings - const mappedFieldIds = new Set( - newMappings.filter(m => m.action === "map" && m.targetFieldId).map(m => m.targetFieldId) - ) - setMissingFields(prev => { - const prevActionMap = new Map(prev.map(item => [item.field.id, item.action])) - - return existingFields - .filter(field => !mappedFieldIds.has(field.id)) - .map(field => ({ - field, - action: prevActionMap.get(field.id) ?? ("ignore" as MissingFieldAction), - })) - }) - + recomputeMissingFields(newMappings) return newMappings }) }, - [existingFields] + [existingFields, recomputeMissingFields] ) const updateMissingFieldAction = useCallback((fieldId: string, action: MissingFieldAction) => { @@ -328,6 +382,7 @@ export function FieldMapper({ collection, csvRecords, onSubmit }: FieldMapperPro key={item.inferredField.columnName} item={item} existingFields={existingFields} + canEditFields={canEditFields} slugFieldName={selectedSlugFieldName} onToggleIgnored={() => { toggleIgnored(item.inferredField.columnName) @@ -359,13 +414,14 @@ export function FieldMapper({ collection, csvRecords, onSubmit }: FieldMapperPro ))} diff --git a/plugins/csv-import/src/routes/Home.tsx b/plugins/csv-import/src/routes/Home.tsx index abf24604d..0e61dd837 100644 --- a/plugins/csv-import/src/routes/Home.tsx +++ b/plugins/csv-import/src/routes/Home.tsx @@ -4,12 +4,19 @@ import { SelectCSVFile } from "../components/SelectCSVFile" interface HomeProps { collection: Collection | null + canAddItems: boolean forceCreateCollection?: boolean onCollectionChange: (collection: Collection) => void onFileSelected: (csvContent: string) => Promise } -export function Home({ collection, onCollectionChange, onFileSelected, forceCreateCollection }: HomeProps) { +export function Home({ + collection, + canAddItems, + onCollectionChange, + onFileSelected, + forceCreateCollection, +}: HomeProps) { return (

@@ -22,7 +29,7 @@ export function Home({ collection, onCollectionChange, onFileSelected, forceCrea
- +
) }