diff --git a/plugins/notion/src/MapFields.tsx b/plugins/notion/src/MapFields.tsx index d386cad3..b1bdb0d3 100644 --- a/plugins/notion/src/MapFields.tsx +++ b/plugins/notion/src/MapFields.tsx @@ -1,26 +1,26 @@ +import { isFullDatabase } from "@notionhq/client" import { GetDatabaseResponse } from "@notionhq/client/build/src/api-endpoints" +import classNames from "classnames" import { ManagedCollectionField } from "framer-plugin" -import { assert } from "./utils" +import { Fragment, useMemo, useState } from "react" +import { Button } from "./components/Button" +import { CheckboxTextfield } from "./components/CheckboxTexfield" +import { IconChevron } from "./components/Icons" import { NotionProperty, PluginContext, SynchronizeMutationOptions, SynchronizeProgress, getCollectionFieldForProperty, + getNotionProperties, getPossibleSlugFields, hasDatabaseFieldsChanged, + hasFieldConfigurationChanged, isSupportedNotionProperty, - supportedCMSTypeByNotionPropertyType, richTextToPlainText, - getNotionProperties, - hasFieldConfigurationChanged, + supportedCMSTypeByNotionPropertyType, } from "./notion" -import { Fragment, useMemo, useState } from "react" -import classNames from "classnames" -import { IconChevron } from "./components/Icons" -import { Button } from "./components/Button" -import { isFullDatabase } from "@notionhq/client" -import { CheckboxTextfield } from "./components/CheckboxTexfield" +import { assert } from "./utils" function getSortedProperties(database: GetDatabaseResponse): NotionProperty[] { return getNotionProperties(database).sort((propertyA, propertyB) => { @@ -108,6 +108,8 @@ const labelByFieldTypeOption: Record = { image: "Image", link: "Link", string: "String", + collectionReference: "Reference", + multiCollectionReference: "Multi Reference", } export function MapDatabaseFields({ @@ -182,7 +184,7 @@ export function MapDatabaseFields({ const fieldType = fieldTypeByFieldId[property.id] assert(fieldType) - const field = getCollectionFieldForProperty(property, fieldType) + const field = getCollectionFieldForProperty(property, fieldType, pluginContext.databaseIdMap) if (!field) continue const nameOverride = fieldNameOverrides[property.id] diff --git a/plugins/notion/src/notion.ts b/plugins/notion/src/notion.ts index 5da45043..6f6a56f7 100644 --- a/plugins/notion/src/notion.ts +++ b/plugins/notion/src/notion.ts @@ -7,17 +7,17 @@ import { isFullPage, isNotionClientError, } from "@notionhq/client" -import pLimit from "p-limit" import { BlockObjectResponse, GetDatabaseResponse, PageObjectResponse, RichTextItemResponse, } from "@notionhq/client/build/src/api-endpoints" -import { assert, assertNever, formatDate, isDefined, isString, slugify } from "./utils" -import { ManagedCollectionField, CollectionItemData, framer, ManagedCollection } from "framer-plugin" import { useMutation, useQuery } from "@tanstack/react-query" +import { CollectionItemData, framer, ManagedCollection, ManagedCollectionField } from "framer-plugin" +import pLimit from "p-limit" import { blocksToHtml, richTextToHTML } from "./blocksToHTML" +import { assert, assertNever, formatDate, isDefined, isString, slugify } from "./utils" export type FieldId = string @@ -35,6 +35,7 @@ const pluginLastSyncedKey = "notionPluginLastSynced" const ignoredFieldIdsKey = "notionPluginIgnoredFieldIds" const pluginSlugIdKey = "notionPluginSlugId" const databaseNameKey = "notionDatabaseName" +const databaseIdKey = "notionDatabaseId" // Maximum number of concurrent requests to Notion API // This is to prevent rate limiting. @@ -185,6 +186,7 @@ export const supportedNotionPropertyTypes = [ "status", "url", "files", + "relation", ] satisfies ReadonlyArray type SupportedPropertyType = (typeof supportedNotionPropertyTypes)[number] @@ -207,6 +209,7 @@ export const supportedCMSTypeByNotionPropertyType = { url: ["link"], email: ["formattedText", "string"], files: ["file", "image"], + relation: ["multiCollectionReference"], } satisfies Record> function assertFieldTypeMatchesPropertyType( @@ -226,7 +229,11 @@ function assertFieldTypeMatchesPropertyType( */ export function getCollectionFieldForProperty< TProperty extends Extract, ->(property: TProperty, fieldType: ManagedCollectionField["type"]): ManagedCollectionField | null { +>( + property: TProperty, + fieldType: ManagedCollectionField["type"], + databaseIdMap: DatabaseIdMap +): ManagedCollectionField | null { switch (property.type) { case "email": case "rich_text": { @@ -350,6 +357,26 @@ export function getCollectionFieldForProperty< userEditable: false, } } + case "relation": { + assertFieldTypeMatchesPropertyType(property.type, fieldType) + + const collectionId = databaseIdMap.get(property.relation.database_id) + + if (!collectionId) { + // Database includes a relation to a database that hasn't been synced to Framer. + // TODO: It would be better to surface this error to the user in + // the UI instead of just skipping the field. + return null + } + + return { + type: "multiCollectionReference", + id: property.id, + name: property.name, + collectionId: collectionId, + userEditable: false, + } + } default: { assertNever(property) } @@ -404,6 +431,9 @@ export function getPropertyValue( case "date": { return property.date?.start } + case "relation": { + return property.relation.map(({ id }) => id) + } case "files": { const firstFile = property.files[0] if (!firstFile) return null @@ -654,6 +684,7 @@ export async function synchronizeDatabase( collection.setPluginData(pluginLastSyncedKey, new Date().toISOString()), collection.setPluginData(pluginSlugIdKey, slugFieldId), collection.setPluginData(databaseNameKey, richTextToPlainText(database.title)), + collection.setPluginData(databaseIdKey, database.id), ]) return { @@ -701,6 +732,7 @@ export interface PluginContextNew { type: "new" collection: ManagedCollection isAuthenticated: boolean + databaseIdMap: DatabaseIdMap } export interface PluginContextUpdate { @@ -713,12 +745,14 @@ export interface PluginContextUpdate { ignoredFieldIds: FieldId[] slugFieldId: string | null isAuthenticated: boolean + databaseIdMap: DatabaseIdMap } export interface PluginContextError { type: "error" message: string isAuthenticated: false + databaseIdMap: DatabaseIdMap } export type PluginContext = PluginContextNew | PluginContextUpdate | PluginContextError @@ -735,17 +769,27 @@ function getIgnoredFieldIds(rawIgnoredFields: string | null) { return parsed } +export type DatabaseIdMap = Map + export async function getPluginContext(): Promise { const collection = await framer.getManagedCollection() const collectionFields = await collection.getFields() const databaseId = await collection.getPluginData(pluginDatabaseIdKey) const hasAuthToken = isAuthenticated() + const databaseIdMap: DatabaseIdMap = new Map() + + for (const collection of await framer.getCollections()) { + const collectionDatabaseId = await collection.getPluginData(databaseIdKey) + if (collectionDatabaseId) databaseIdMap.set(collectionDatabaseId, collection.id) + } + if (!databaseId || !hasAuthToken) { return { type: "new", collection, isAuthenticated: hasAuthToken, + databaseIdMap, } } @@ -773,6 +817,7 @@ export async function getPluginContext(): Promise { slugFieldId, hasChangedFields: hasDatabaseFieldsChanged(collectionFields, database, ignoredFieldIds), isAuthenticated: hasAuthToken, + databaseIdMap, } } catch (error) { if (isNotionClientError(error) && error.code === APIErrorCode.ObjectNotFound) { @@ -782,6 +827,7 @@ export async function getPluginContext(): Promise { type: "error", message: `The database "${databaseName}" was not found. Log in with Notion and select the Database to sync.`, isAuthenticated: false, + databaseIdMap, } }