Skip to content

Commit c9f85db

Browse files
committed
Create FieldMapping.tsx
1 parent 576abc4 commit c9f85db

File tree

1 file changed

+229
-0
lines changed

1 file changed

+229
-0
lines changed

plugins/notion/src/FieldMapping.tsx

+229
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { type ManagedCollectionFieldInput, framer, type ManagedCollection } from "framer-plugin"
2+
import { useEffect, useState } from "react"
3+
import { type DataSource, dataSourceOptions, mergeFieldsWithExistingFields, syncCollection } from "./data"
4+
5+
interface FieldMappingRowProps {
6+
field: ManagedCollectionFieldInput
7+
originalFieldName: string | undefined
8+
disabled: boolean
9+
onToggleDisabled: (fieldId: string) => void
10+
onNameChange: (fieldId: string, name: string) => void
11+
}
12+
13+
function FieldMappingRow({ field, originalFieldName, disabled, onToggleDisabled, onNameChange }: FieldMappingRowProps) {
14+
return (
15+
<>
16+
<button
17+
type="button"
18+
className="source-field"
19+
aria-disabled={disabled}
20+
onClick={() => onToggleDisabled(field.id)}
21+
tabIndex={0}
22+
>
23+
<input type="checkbox" checked={!disabled} tabIndex={-1} readOnly />
24+
<span>{originalFieldName ?? field.id}</span>
25+
</button>
26+
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" fill="none">
27+
<path
28+
fill="transparent"
29+
stroke="#999"
30+
stroke-linecap="round"
31+
stroke-linejoin="round"
32+
stroke-width="1.5"
33+
d="m2.5 7 3-3-3-3"
34+
/>
35+
</svg>
36+
<input
37+
type="text"
38+
style={{ width: "100%", opacity: disabled ? 0.5 : 1 }}
39+
disabled={disabled}
40+
placeholder={field.id}
41+
value={field.name}
42+
onChange={event => onNameChange(field.id, event.target.value)}
43+
onKeyDown={event => {
44+
if (event.key === "Enter") {
45+
event.preventDefault()
46+
}
47+
}}
48+
/>
49+
</>
50+
)
51+
}
52+
53+
const initialManagedCollectionFields: ManagedCollectionFieldInput[] = []
54+
const initialFieldIds: ReadonlySet<string> = new Set()
55+
56+
interface FieldMappingProps {
57+
collection: ManagedCollection
58+
dataSource: DataSource
59+
initialSlugFieldId: string | null
60+
}
61+
62+
export function FieldMapping({ collection, dataSource, initialSlugFieldId }: FieldMappingProps) {
63+
const [status, setStatus] = useState<"mapping-fields" | "loading-fields" | "syncing-collection">(
64+
initialSlugFieldId ? "loading-fields" : "mapping-fields"
65+
)
66+
const isSyncing = status === "syncing-collection"
67+
const isLoadingFields = status === "loading-fields"
68+
69+
const [possibleSlugFields] = useState(() => dataSource.fields.filter(field => field.type === "string"))
70+
71+
const [selectedSlugField, setSelectedSlugField] = useState<ManagedCollectionFieldInput | null>(
72+
possibleSlugFields.find(field => field.id === initialSlugFieldId) ?? possibleSlugFields[0] ?? null
73+
)
74+
75+
const [fields, setFields] = useState(initialManagedCollectionFields)
76+
const [ignoredFieldIds, setIgnoredFieldIds] = useState(initialFieldIds)
77+
78+
const dataSourceName = dataSourceOptions.find(option => option.id === dataSource.id)?.name ?? dataSource.id
79+
80+
useEffect(() => {
81+
const abortController = new AbortController()
82+
83+
collection
84+
.getFields()
85+
.then(collectionFields => {
86+
if (abortController.signal.aborted) return
87+
88+
setFields(mergeFieldsWithExistingFields(dataSource.fields, collectionFields))
89+
90+
const existingFieldIds = new Set(collectionFields.map(field => field.id))
91+
const ignoredFields = dataSource.fields.filter(sourceField => !existingFieldIds.has(sourceField.id))
92+
93+
if (initialSlugFieldId) {
94+
setIgnoredFieldIds(new Set(ignoredFields.map(field => field.id)))
95+
}
96+
97+
setStatus("mapping-fields")
98+
})
99+
.catch(error => {
100+
if (!abortController.signal.aborted) {
101+
console.error("Failed to fetch collection fields:", error)
102+
framer.notify("Failed to load collection fields", { variant: "error" })
103+
}
104+
})
105+
106+
return () => {
107+
abortController.abort()
108+
}
109+
}, [initialSlugFieldId, dataSource, collection])
110+
111+
const changeFieldName = (fieldId: string, name: string) => {
112+
setFields(prevFields => {
113+
const updatedFields = prevFields.map(field => {
114+
if (field.id !== fieldId) return field
115+
return { ...field, name }
116+
})
117+
return updatedFields
118+
})
119+
}
120+
121+
const toggleFieldDisabledState = (fieldId: string) => {
122+
setIgnoredFieldIds(previousIgnoredFieldIds => {
123+
const updatedIgnoredFieldIds = new Set(previousIgnoredFieldIds)
124+
125+
if (updatedIgnoredFieldIds.has(fieldId)) {
126+
updatedIgnoredFieldIds.delete(fieldId)
127+
} else {
128+
updatedIgnoredFieldIds.add(fieldId)
129+
}
130+
131+
return updatedIgnoredFieldIds
132+
})
133+
}
134+
135+
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
136+
event.preventDefault()
137+
138+
if (!selectedSlugField) {
139+
// This can't happen because the form will not submit if no slug field is selected
140+
// but TypeScript can't infer that.
141+
console.error("There is no slug field selected. Sync will not be performed")
142+
framer.notify("Please select a slug field before importing.", { variant: "warning" })
143+
return
144+
}
145+
146+
try {
147+
setStatus("syncing-collection")
148+
149+
const fieldsToSync = fields.filter(field => !ignoredFieldIds.has(field.id))
150+
151+
await syncCollection(collection, dataSource, fieldsToSync, selectedSlugField)
152+
await framer.closePlugin("Synchronization successful", { variant: "success" })
153+
} catch (error) {
154+
console.error(error)
155+
framer.notify(`Failed to sync collection “${dataSource.id}”. Check the logs for more details.`, {
156+
variant: "error",
157+
})
158+
} finally {
159+
setStatus("mapping-fields")
160+
}
161+
}
162+
163+
if (isLoadingFields) {
164+
return (
165+
<main className="loading">
166+
<div className="framer-spinner" />
167+
</main>
168+
)
169+
}
170+
171+
return (
172+
<main className="framer-hide-scrollbar mapping">
173+
<hr className="sticky-divider" />
174+
<form onSubmit={handleSubmit}>
175+
<label className="slug-field" htmlFor="slugField">
176+
Slug Field
177+
<select
178+
required
179+
name="slugField"
180+
className="field-input"
181+
value={selectedSlugField ? selectedSlugField.id : ""}
182+
onChange={event => {
183+
const selectedFieldId = event.target.value
184+
const selectedField = possibleSlugFields.find(field => field.id === selectedFieldId)
185+
if (!selectedField) return
186+
setSelectedSlugField(selectedField)
187+
}}
188+
>
189+
{possibleSlugFields.map(possibleSlugField => {
190+
return (
191+
<option key={`slug-field-${possibleSlugField.id}`} value={possibleSlugField.id}>
192+
{possibleSlugField.name}
193+
</option>
194+
)
195+
})}
196+
</select>
197+
</label>
198+
199+
<div className="fields">
200+
<span className="fields-column">Column</span>
201+
<span>Field</span>
202+
{fields.map(field => (
203+
<FieldMappingRow
204+
key={`field-${field.id}`}
205+
field={field}
206+
originalFieldName={dataSource.fields.find(sourceField => sourceField.id === field.id)?.name}
207+
disabled={ignoredFieldIds.has(field.id)}
208+
onToggleDisabled={toggleFieldDisabledState}
209+
onNameChange={changeFieldName}
210+
/>
211+
))}
212+
</div>
213+
214+
<footer>
215+
<hr className="sticky-top" />
216+
<button disabled={isSyncing} tabIndex={0}>
217+
{isSyncing ? (
218+
<div className="framer-spinner" />
219+
) : (
220+
<span>
221+
Import <span style={{ textTransform: "capitalize" }}>{dataSourceName}</span>
222+
</span>
223+
)}
224+
</button>
225+
</footer>
226+
</form>
227+
</main>
228+
)
229+
}

0 commit comments

Comments
 (0)