Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions plugins/csv-import/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,20 @@ import { ImportError, type ImportItem, prepareImportPayload } from "./utils/prep

export function App({ initialCollection }: { initialCollection: Collection | null }) {
const [collection, setCollection] = useState<Collection | null>(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)
Expand Down Expand Up @@ -57,7 +63,7 @@ export function App({ initialCollection }: { initialCollection: Collection | nul
})
}
},
[collection, hasAllPermissions, navigate]
[collection, canAddItems, navigate]
)

const handleFieldMapperSubmit = useCallback(
Expand All @@ -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({
Expand Down Expand Up @@ -129,14 +139,15 @@ export function App({ initialCollection }: { initialCollection: Collection | nul
})
}
},
[hasAllPermissions, navigate]
[canAddItems, canEditFields, navigate]
)

switch (currentRoute.uid) {
case "home": {
return (
<Home
collection={collection}
canAddItems={canAddItems}
forceCreateCollection={currentRoute.opts?.forceCreateCollection}
onCollectionChange={setCollection}
onFileSelected={handleFileSelected}
Expand All @@ -148,6 +159,7 @@ export function App({ initialCollection }: { initialCollection: Collection | nul
<FieldMapper
collection={currentRoute.opts.collection}
csvRecords={currentRoute.opts.csvRecords}
canEditFields={canEditFields}
onSubmit={opts =>
handleFieldMapperSubmit({
collection: currentRoute.opts.collection,
Expand Down
11 changes: 9 additions & 2 deletions plugins/csv-import/src/components/FieldMapperRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +33,7 @@ interface FieldMapperRowProps {
export function FieldMapperRow({
item,
existingFields,
canEditFields,
slugFieldName,
onToggleIgnored,
onSetIgnored,
Expand Down Expand Up @@ -86,8 +89,12 @@ export function FieldMapperRow({
}
}}
>
<option value="__create__">New Field...</option>
{isIgnored && <option value="__ignore__">{isSlugField ? "Slug Field" : ""}</option>}
{canEditFields && <option value="__create__">New Field...</option>}
{isIgnored && (
<option value="__ignore__">
{isSlugField ? "Slug Field" : canEditFields ? "" : "Don't Import"}
</option>
)}

{existingFields.length > 0 && <hr />}
{existingFields.map(field => (
Expand Down
154 changes: 105 additions & 49 deletions plugins/csv-import/src/routes/FieldMapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export interface FieldMapperSubmitOpts {
interface FieldMapperProps {
collection: Collection
csvRecords: Record<string, string>[]
/**
* 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<void>
}

Expand All @@ -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<FieldMappingItem> {
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<Field[]>([])
const [mappings, setMappings] = useState<FieldMappingItem[]>([])
const possibleSlugFields = useMemo(() => calculatePossibleSlugFields(mappings, csvRecords), [csvRecords, mappings])
Expand Down Expand Up @@ -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,
}
})
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -359,13 +414,14 @@ export function FieldMapper({ collection, csvRecords, onSubmit }: FieldMapperPro
<ChevronIcon />
<select
className="missing-field-action"
disabled={!canEditFields}
value={item.action}
onChange={e => {
updateMissingFieldAction(item.field.id, e.target.value as MissingFieldAction)
}}
>
<option value="ignore">Keep Empty</option>
<option value="remove">Remove</option>
{canEditFields && <option value="remove">Remove</option>}
</select>
</div>
))}
Expand Down
11 changes: 9 additions & 2 deletions plugins/csv-import/src/routes/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
}

export function Home({ collection, onCollectionChange, onFileSelected, forceCreateCollection }: HomeProps) {
export function Home({
collection,
canAddItems,
onCollectionChange,
onFileSelected,
forceCreateCollection,
}: HomeProps) {
return (
<div className="import-collection">
<hr />
Expand All @@ -22,7 +29,7 @@ export function Home({ collection, onCollectionChange, onFileSelected, forceCrea

<hr />

<SelectCSVFile disabled={!collection} onFileSelected={onFileSelected} />
<SelectCSVFile disabled={!collection || !canAddItems} onFileSelected={onFileSelected} />
</div>
)
}
Loading