diff --git a/src/ai/models/openai/index.ts b/src/ai/models/openai/index.ts index 946d74d..11c4a40 100644 --- a/src/ai/models/openai/index.ts +++ b/src/ai/models/openai/index.ts @@ -23,12 +23,6 @@ export const OpenAIConfig: GenerationConfig = { prompt: string, options: { locale: string; model: string; system: string }, ) => { - // const finalPrompt = `Output language code: ${options.locale} - // Prompt: ${prompt} - // Output: - // ` - - // console.log('finalPrompt: ', finalPrompt) const streamTextResult = await streamText({ model: openai(options.model), prompt, @@ -192,7 +186,6 @@ export const OpenAIConfig: GenerationConfig = { name: 'OpenAI GPT', fields: ['richText'], handler: (text: string, options) => { - //TODO: change it to open ai text to speech api return generateRichText(text, options) }, output: 'text', @@ -216,10 +209,10 @@ export const OpenAIConfig: GenerationConfig = { name: 'system', type: 'textarea', defaultValue: `INSTRUCTIONS: - You are a highly skilled and professional blog writer, - renowned for crafting engaging and well-organized articles. - When given a title, you meticulously create blogs that are not only - informative and accurate but also captivating and beautifully structured.`, +You are a highly skilled and professional blog writer, +renowned for crafting engaging and well-organized articles. +When given a title, you meticulously create blogs that are not only +informative and accurate but also captivating and beautifully structured.`, label: 'System prompt', }, { @@ -231,11 +224,11 @@ export const OpenAIConfig: GenerationConfig = { name: 'layout', type: 'textarea', defaultValue: `[paragraph] - A short introduction to the topic. - [horizontalrule] - [list] - A section with headings and a paragraph. - [horizontalrule] - [paragraph] - A short conclusion. - [quote] - A quote from a famous person based on the topic. +[horizontalrule] +[list] - A section with headings and a paragraph. +[horizontalrule] +[paragraph] - A short conclusion. +[quote] - A quote from a famous person based on the topic. `, label: 'Layout', }, diff --git a/src/collections/Instructions.ts b/src/collections/Instructions.ts index 045bf10..1589209 100644 --- a/src/collections/Instructions.ts +++ b/src/collections/Instructions.ts @@ -1,6 +1,7 @@ import type { CollectionConfig, GroupField } from 'payload' import { GenerationModels } from '../ai/models/index.js' +import { PLUGIN_INSTRUCTIONS_TABLE } from '../defaults.js' import { PromptEditorField } from '../fields/PromptEditorField/PromptEditorField.js' import { SelectField } from '../fields/SelectField/SelectField.js' @@ -20,12 +21,17 @@ const modelOptions = GenerationModels.map((model) => { }) export const Instructions: CollectionConfig = { - slug: 'instructions', + slug: PLUGIN_INSTRUCTIONS_TABLE, + + // TODO: Revisit permissions, better if end user can provide this access: { create: () => true, read: () => true, update: () => true, }, + admin: { + hidden: true, + }, fields: [ { name: 'schema-path', diff --git a/src/defaults.ts b/src/defaults.ts index 4188ae5..a089e2b 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -1,6 +1,7 @@ export const PLUGIN_NAME = 'plugin-ai' -export const PLUGIN_INSTRUCTIONS_TABLE = 'instructions' -export const PLUGIN_INSTRUCTIONS_MAP_GLOBAL = `${PLUGIN_NAME}__${PLUGIN_INSTRUCTIONS_TABLE}_map` +export const PLUGIN_INSTRUCTIONS_TABLE = `${PLUGIN_NAME}-instructions` +export const PLUGIN_INSTRUCTIONS_MAP_GLOBAL = `${PLUGIN_NAME}-${PLUGIN_INSTRUCTIONS_TABLE}-map` +export const PLUGIN_LEXICAL_EDITOR_FEATURE = `${PLUGIN_NAME}-actions-feature` // Endpoint defaults export const PLUGIN_API_ENDPOINT_BASE = '/plugin-ai' diff --git a/src/endpoints/index.ts b/src/endpoints/index.ts index 172545e..0eb9610 100644 --- a/src/endpoints/index.ts +++ b/src/endpoints/index.ts @@ -1,20 +1,24 @@ import type { SerializedEditorState } from 'lexical' -import type { PayloadRequest } from 'payload' +import type { BasePayload, PayloadRequest } from 'payload' import Handlebars from 'handlebars' import asyncHelpers from 'handlebars-async-helpers' -import { flattenTopLevelFields } from 'payload' import type { Endpoints, MenuItems } from '../types.js' import { GenerationModels } from '../ai/models/index.js' -import { PLUGIN_API_ENDPOINT_GENERATE, PLUGIN_API_ENDPOINT_GENERATE_UPLOAD } from '../defaults.js' +import { + PLUGIN_API_ENDPOINT_GENERATE, + PLUGIN_API_ENDPOINT_GENERATE_UPLOAD, + PLUGIN_INSTRUCTIONS_TABLE, +} from '../defaults.js' +import { getFieldBySchemaPath } from '../utilities/getFieldBySchemaPath.js' import { lexicalToHTML } from '../utilities/lexicalToHTML.js' const asyncHandlebars = asyncHelpers(Handlebars) const replacePlaceholders = (prompt: string, values: object) => { - return asyncHandlebars.compile(prompt)(values) + return asyncHandlebars.compile(prompt, { trackIds: true })(values) } const assignPrompt = async ( @@ -123,14 +127,46 @@ const assignPrompt = async ( } } +const registerEditorHelper = (payload, schemaPath) => { + //TODO: add autocomplete ability using handlebars template on PromptEditorField and include custom helpers in dropdown + + let fieldInfo = getFieldInfo(payload.collections, schemaPath) + const schemaPathChunks = schemaPath.split('.') + + asyncHandlebars.registerHelper( + 'toLexicalHTML', + async function (content: SerializedEditorState, options) { + const collectionSlug = schemaPathChunks[0] + const { ids } = options + for (const id of ids) { + //TODO: Find a better to get schemaPath of defined field in prompt editor + const path = `${collectionSlug}.${id}` + fieldInfo = getFieldInfo(payload.collections, path) + } + + const html = await lexicalToHTML(content, fieldInfo.editor?.editorConfig) + return new asyncHandlebars.SafeString(html) + }, + ) +} + +const getFieldInfo = (collections: BasePayload['collections'], schemaPath: string) => { + let fieldInfo = null + //TODO: Only run below for enabled collections + for (const collectionsKey in collections) { + const collection = collections[collectionsKey] + fieldInfo = getFieldBySchemaPath(collection.config, schemaPath) + if (fieldInfo) { + return fieldInfo + } + } +} + export const endpoints: Endpoints = { textarea: { handler: async (req: PayloadRequest) => { const data = await req.json?.() - // console.log('req.payload.config.editor : ', req.payload.config.editor.editorConfig) - - console.log('incoming data -----> ', JSON.stringify(data, null, 2)) const { locale = 'en', options } = data const { action, instructionId } = options const contextData = data.doc @@ -141,13 +177,16 @@ export const endpoints: Endpoints = { // @ts-expect-error instructions = await req.payload.findByID({ id: instructionId, - collection: 'instructions', + collection: PLUGIN_INSTRUCTIONS_TABLE, }) } const { prompt: promptTemplate = '' } = instructions - const fieldName = instructions['schema-path']?.split('.').pop() + const schemaPath = instructions['schema-path'] + const fieldName = schemaPath?.split('.').pop() + + registerEditorHelper(req.payload, schemaPath) const prompts = await assignPrompt(action, { context: contextData, @@ -191,19 +230,6 @@ export const endpoints: Endpoints = { handler: async (req: PayloadRequest) => { const data = await req.json?.() - const postsCollection = req.payload.collections['posts'] - const flattenFields = flattenTopLevelFields(postsCollection.config.fields) - - //TODO: Important find a way to use lexcial editor in more generic way - const fieldConfig = flattenFields.find((f) => { - return f.name === 'content' - }) - - //TODO: Important - // @ts-expect-error - const { editor } = fieldConfig || { editor: {} } - - // console.log('fieldConfig : ', fieldConfig) const { options } = data const { instructionId, uploadCollectionSlug } = options const contextData = data.doc @@ -214,20 +240,14 @@ export const endpoints: Endpoints = { // @ts-expect-error instructions = await req.payload.findByID({ id: instructionId, - collection: 'instructions', + collection: PLUGIN_INSTRUCTIONS_TABLE, }) } const { prompt: promptTemplate = '' } = instructions + const schemaPath = instructions['schema-path'] - //TODO: add autocomplete ability using handlebars template on PromptEditorField and include custom helpers in dropdown - asyncHandlebars.registerHelper( - 'toLexicalHTML', - async function (content: SerializedEditorState) { - const html = await lexicalToHTML(content, editor.editorConfig) - return new asyncHandlebars.SafeString(html) - }, - ) + registerEditorHelper(req.payload, schemaPath) const text = await replacePlaceholders(promptTemplate, contextData) const modelId = instructions['model-id'] diff --git a/src/fields/LexicalEditor/ActionsFeatureComponent.tsx b/src/fields/LexicalEditor/ActionsFeatureComponent.tsx new file mode 100644 index 0000000..5a307db --- /dev/null +++ b/src/fields/LexicalEditor/ActionsFeatureComponent.tsx @@ -0,0 +1,14 @@ +import { useFieldProps } from '@payloadcms/ui' + +import { useInstructions } from '../../providers/InstructionsProvider/hook.js' +import { Actions } from '../../ui/Actions/Actions.js' + +export const ActionsFeatureComponent = () => { + const { schemaPath } = useFieldProps() + + const { id: instructionId } = useInstructions({ + path: schemaPath, + }) + + return +} diff --git a/src/fields/LexicalEditor/feature.client.tsx b/src/fields/LexicalEditor/feature.client.tsx new file mode 100644 index 0000000..1d5fdcc --- /dev/null +++ b/src/fields/LexicalEditor/feature.client.tsx @@ -0,0 +1,14 @@ +'use client' + +import { createClientFeature } from '@payloadcms/richtext-lexical/client' + +import { ActionsFeatureComponent } from './ActionsFeatureComponent.js' + +export const LexicalEditorFeatureClient = createClientFeature({ + plugins: [ + { + Component: ActionsFeatureComponent, + position: 'belowContainer', + }, + ], +}) diff --git a/src/fields/LexicalEditor/feature.server.ts b/src/fields/LexicalEditor/feature.server.ts new file mode 100644 index 0000000..498e25f --- /dev/null +++ b/src/fields/LexicalEditor/feature.server.ts @@ -0,0 +1,23 @@ +import { createServerFeature } from '@payloadcms/richtext-lexical' + +import { PLUGIN_LEXICAL_EDITOR_FEATURE } from '../../defaults.js' +import { LexicalEditorFeatureClient } from './feature.client.js' + +export const PayloadAiPluginLexicalEditorFeature = createServerFeature({ + feature: ({ props }: Record) => { + const sanitizedProps = { + applyToFocusedEditor: + props?.applyToFocusedEditor === undefined ? false : props.applyToFocusedEditor, + disableIfParentHasFixedToolbar: + props?.disableIfParentHasFixedToolbar === undefined + ? false + : props.disableIfParentHasFixedToolbar, + } + return { + ClientFeature: LexicalEditorFeatureClient, + clientFeatureProps: sanitizedProps, + sanitizedServerFeatureProps: sanitizedProps, + } + }, + key: PLUGIN_LEXICAL_EDITOR_FEATURE, +}) diff --git a/src/index.ts b/src/index.ts index 4940a51..f88654f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,5 @@ * without appropriate licensing is prohibited. */ +export { PayloadAiPluginLexicalEditorFeature } from './fields/LexicalEditor/feature.server.js' export { payloadAiPlugin } from './plugin.js' diff --git a/src/init.ts b/src/init.ts index 219c404..58bdb3e 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,4 +1,8 @@ -export const init = async (payload, fieldSchemaPaths) => { +import type { Payload } from 'payload' + +import { PLUGIN_INSTRUCTIONS_MAP_GLOBAL, PLUGIN_INSTRUCTIONS_TABLE } from './defaults.js' + +export const init = async (payload: Payload, fieldSchemaPaths) => { payload.logger.info(`— AI Plugin: Initializing...`) const paths = Object.keys(fieldSchemaPaths) @@ -8,7 +12,7 @@ export const init = async (payload, fieldSchemaPaths) => { const path = paths[i] const fieldType = fieldSchemaPaths[path] const entry = await payload.find({ - collection: 'instructions', + collection: PLUGIN_INSTRUCTIONS_TABLE, where: { 'field-type': { equals: fieldType, @@ -21,7 +25,7 @@ export const init = async (payload, fieldSchemaPaths) => { if (!entry?.docs?.length) { const instructions = await payload.create({ - collection: 'instructions', + collection: PLUGIN_INSTRUCTIONS_TABLE, data: { 'field-type': fieldType, 'schema-path': path, @@ -34,8 +38,11 @@ export const init = async (payload, fieldSchemaPaths) => { } } + payload.logger.info( + `— AI Plugin: Enabled fieldMap: ${JSON.stringify(fieldInstructionsMap, null, 2)}`, + ) await payload.updateGlobal({ - slug: 'ai-plugin__instructions_map', // required + slug: PLUGIN_INSTRUCTIONS_MAP_GLOBAL, // required data: { map: fieldInstructionsMap, }, diff --git a/src/plugin.ts b/src/plugin.ts index 60915de..213cfcb 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -5,6 +5,7 @@ import { deepMerge } from 'payload/shared' import type { PluginConfig } from './types.js' import { Instructions } from './collections/Instructions.js' +import { PLUGIN_INSTRUCTIONS_MAP_GLOBAL } from './defaults.js' import { endpoints } from './endpoints/index.js' import { init } from './init.js' import { InstructionsProvider } from './providers/InstructionsProvider/index.js' @@ -42,12 +43,12 @@ const payloadAiPlugin = globals: [ ...incomingConfig.globals, { - slug: 'ai-plugin__instructions_map', + slug: PLUGIN_INSTRUCTIONS_MAP_GLOBAL, access: { read: () => true, }, admin: { - hidden: true, + hidden: false, }, fields: [ { diff --git a/src/providers/InstructionsProvider/InstructionsProvider.tsx b/src/providers/InstructionsProvider/InstructionsProvider.tsx index 1296360..4101bed 100644 --- a/src/providers/InstructionsProvider/InstructionsProvider.tsx +++ b/src/providers/InstructionsProvider/InstructionsProvider.tsx @@ -2,6 +2,8 @@ import React, { createContext, useEffect, useState } from 'react' +import { PLUGIN_INSTRUCTIONS_MAP_GLOBAL } from '../../defaults.js' + const initialContext = { instructions: undefined, } @@ -15,7 +17,7 @@ export const InstructionsProvider = ({ children }: { children: React.ReactNode } // their ID is needed to edit them for Drawer, so instead of fetching it // one by one its map is saved in globals during build useEffect(() => { - fetch('/api/globals/ai-plugin__instructions_map') + fetch(`/api/globals/${PLUGIN_INSTRUCTIONS_MAP_GLOBAL}`) .then((res) => { res.json().then((data) => { setInstructionsState(data.map) diff --git a/src/ui/Actions/Actions.tsx b/src/ui/Actions/Actions.tsx index b5ae452..f13ab88 100644 --- a/src/ui/Actions/Actions.tsx +++ b/src/ui/Actions/Actions.tsx @@ -3,9 +3,9 @@ import type { LexicalEditor } from 'lexical' import { FieldDescription, Popup, useDocumentDrawer, useField, useFieldProps } from '@payloadcms/ui' -import { $getRoot } from 'lexical' import React, { useCallback, useEffect, useRef, useState } from 'react' +import { PLUGIN_INSTRUCTIONS_TABLE } from '../../defaults.js' import { PluginIcon } from '../Icons/Icons.js' import styles from './actions.module.css' import { useGenerate } from './hooks/useGenerate.js' @@ -28,10 +28,10 @@ function findParentWithClass(element, className) { } //TODO: Add undo/redo to the actions toolbar -export const Actions = ({ descriptionProps, instructionId }) => { +export const Actions = ({ descriptionProps = {}, instructionId }) => { const [DocumentDrawer, _, { closeDrawer, openDrawer }] = useDocumentDrawer({ id: instructionId, - collectionSlug: 'instructions', + collectionSlug: PLUGIN_INSTRUCTIONS_TABLE, }) const fieldProps = useFieldProps() @@ -82,46 +82,43 @@ export const Actions = ({ descriptionProps, instructionId }) => { const [isProcessing, setIsProcessing] = useState(false) - const { generate, isLoading } = useGenerate({ lexicalEditor }) - const { ActiveComponent, Menu } = useMenu( - { lexicalEditor }, - { - onCompose: async () => { - console.log('Composing...') - setIsProcessing(true) - await generate({ - action: 'Compose', - }).finally(() => { - setIsProcessing(false) - }) - }, - onExpand: async () => { - console.log('Expanding...') - await generate({ - action: 'Expand', - }) - }, - onProofread: async () => { - console.log('Proofreading...') - await generate({ - action: 'Proofread', - }) - }, - onRephrase: async () => { - console.log('Rephrasing...') - await generate({ - action: 'Rephrase', - }) - }, - onSettings: openDrawer, - onSimplify: async () => { - console.log('Simplifying...') - await generate({ - action: 'Simplify', - }) - }, + const { generate, isLoading } = useGenerate() + const { ActiveComponent, Menu } = useMenu({ + onCompose: async () => { + console.log('Composing...') + setIsProcessing(true) + await generate({ + action: 'Compose', + }).finally(() => { + setIsProcessing(false) + }) }, - ) + onExpand: async () => { + console.log('Expanding...') + await generate({ + action: 'Expand', + }) + }, + onProofread: async () => { + console.log('Proofreading...') + await generate({ + action: 'Proofread', + }) + }, + onRephrase: async () => { + console.log('Rephrasing...') + await generate({ + action: 'Rephrase', + }) + }, + onSettings: openDrawer, + onSimplify: async () => { + console.log('Simplifying...') + await generate({ + action: 'Simplify', + }) + }, + }) const { setValue } = useField({ path: pathFromContext, diff --git a/src/ui/Actions/hooks/useGenerate.ts b/src/ui/Actions/hooks/useGenerate.ts index d608e8e..69024fa 100644 --- a/src/ui/Actions/hooks/useGenerate.ts +++ b/src/ui/Actions/hooks/useGenerate.ts @@ -1,6 +1,7 @@ -import type { LexicalEditor } from 'lexical' +import type { ClientCollectionConfig, UploadField } from 'payload' -import { useField, useFieldProps, useForm, useLocale } from '@payloadcms/ui' +import { useEditorConfigContext } from '@payloadcms/richtext-lexical/client' +import { useDocumentInfo, useField, useFieldProps, useForm, useLocale } from '@payloadcms/ui' import { useCompletion, experimental_useObject as useObject } from 'ai/react' import { useCallback, useEffect } from 'react' @@ -12,19 +13,18 @@ import { PLUGIN_API_ENDPOINT_GENERATE_UPLOAD, } from '../../../defaults.js' import { useInstructions } from '../../../providers/InstructionsProvider/hook.js' +import { getFieldBySchemaPath } from '../../../utilities/getFieldBySchemaPath.js' import { setSafeLexicalState } from '../../../utilities/setSafeLexicalState.js' import { useHistory } from './useHistory.js' -type UseGenerate = { - lexicalEditor: LexicalEditor -} - //TODO: DONATION IDEA - Add a url to donate in cli when user installs the plugin and uses it for couple of times. -export const useGenerate = ({ lexicalEditor }: UseGenerate) => { +export const useGenerate = () => { const { type, path: pathFromContext, schemaPath } = useFieldProps() - //TODO: This should be dynamic, i think it was the part of component props but its not inside useFieldProps - const relationTo = 'media' + const editorConfigContext = useEditorConfigContext() + const { editor } = editorConfigContext + + const { docConfig } = useDocumentInfo() const { setValue } = useField({ path: pathFromContext, @@ -58,13 +58,13 @@ export const useGenerate = ({ lexicalEditor }: UseGenerate) => { if (!object) return requestAnimationFrame(() => { - if (!lexicalEditor) { + if (!editor) { setValue(object) return } // Currently this is being used as setValue for RichText component does not render new changes right away. - setSafeLexicalState(object, lexicalEditor) + setSafeLexicalState(object, editor) }) }, [object]) @@ -130,13 +130,19 @@ export const useGenerate = ({ lexicalEditor }: UseGenerate) => { const generateUpload = useCallback(async () => { const doc = getData() + + const fieldInfo = getFieldBySchemaPath( + docConfig as ClientCollectionConfig, + schemaPath, + ) as UploadField + return fetch(`/api${PLUGIN_API_ENDPOINT_GENERATE_UPLOAD}`, { body: JSON.stringify({ doc, locale: localFromContext?.code, options: { instructionId, - uploadCollectionSlug: relationTo, + uploadCollectionSlug: fieldInfo.relationTo || 'media', }, } satisfies Parameters[0]), credentials: 'include', @@ -159,7 +165,7 @@ export const useGenerate = ({ lexicalEditor }: UseGenerate) => { .catch((error) => { console.error('Error generating image', error) }) - }, [getData, localFromContext?.code, instructionId, relationTo, setValue]) + }, [getData, localFromContext?.code, instructionId, setValue]) const generate = useCallback( async (options?: { action: MenuItems }) => { @@ -170,6 +176,7 @@ export const useGenerate = ({ lexicalEditor }: UseGenerate) => { if (['text', 'textarea'].includes(type)) { return streamText(options) } + if (type === 'upload') { return generateUpload() } diff --git a/src/ui/Actions/hooks/useHistory.ts b/src/ui/Actions/hooks/useHistory.ts index 195c585..9483991 100644 --- a/src/ui/Actions/hooks/useHistory.ts +++ b/src/ui/Actions/hooks/useHistory.ts @@ -1,7 +1,7 @@ 'use client' -import { useDocumentInfo, useField, useFieldProps, useForm } from '@payloadcms/ui' -import { useCallback, useEffect, useState } from 'react' +import { useDocumentInfo, useField, useFieldProps } from '@payloadcms/ui' +import { useCallback, useEffect } from 'react' import { PLUGIN_NAME } from '../../../defaults.js' @@ -25,7 +25,11 @@ export const useHistory = () => { const getLatestHistory = useCallback((): HistoryState => { try { - return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') + // This condition is applied, as it was somehow triggering on server side + if (typeof localStorage !== 'undefined') { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') + } + return {} } catch (e) { console.error('Error parsing history:', e) return {} diff --git a/src/ui/Actions/hooks/useMenu.tsx b/src/ui/Actions/hooks/useMenu.tsx index b64b509..43f6a15 100644 --- a/src/ui/Actions/hooks/useMenu.tsx +++ b/src/ui/Actions/hooks/useMenu.tsx @@ -1,7 +1,5 @@ 'use client' -import type { LexicalEditor } from 'lexical' - import { useField, useFieldProps } from '@payloadcms/ui' import React, { memo, useEffect, useMemo, useState } from 'react' @@ -84,11 +82,7 @@ const getActiveComponent = (ac) => { } } -type UseMenuProps = { - lexicalEditor: LexicalEditor -} - -export const useMenu = ({ lexicalEditor }: UseMenuProps, menuEvents: UseMenuEvents) => { +export const useMenu = (menuEvents: UseMenuEvents) => { const { type: fieldType, path: pathFromContext } = useFieldProps() const field = useField({ path: pathFromContext }) const [activeComponent, setActiveComponent] = useState('Rephrase') @@ -111,7 +105,7 @@ export const useMenu = ({ lexicalEditor }: UseMenuProps, menuEvents: UseMenuEven } else { setActiveComponent('Rephrase') } - }, [initialValue, value, fieldType, lexicalEditor]) + }, [initialValue, value, fieldType]) const MemoizedActiveComponent = useMemo(() => { return ({ isLoading }) => { diff --git a/src/utilities/getFieldBySchemaPath.ts b/src/utilities/getFieldBySchemaPath.ts new file mode 100644 index 0000000..5ef3eb2 --- /dev/null +++ b/src/utilities/getFieldBySchemaPath.ts @@ -0,0 +1,47 @@ +import type { ClientCollectionConfig, ClientFieldConfig, CollectionConfig, Field } from 'payload' + +export const getFieldBySchemaPath = ( + collectionConfig: ClientCollectionConfig | CollectionConfig, + schemaPath: string, // e.g., "posts.content" +): ClientFieldConfig | Field | null => { + const pathParts = schemaPath.split('.') + const targetFieldName = pathParts[pathParts.length - 1] + + const findField = (fields, remainingPath: string[]): Field | null => { + for (const field of fields) { + if (remainingPath.length === 1 && field.name === targetFieldName) { + return field + } + + if (field.type === 'group' && field.fields) { + const result = findField(field.fields, remainingPath.slice(1)) + if (result) return result + } + + if (field.type === 'array' && field.fields) { + const result = findField(field.fields, remainingPath.slice(1)) + if (result) return result + } + + if (field.type === 'tabs') { + for (const tab of field.tabs) { + const result = findField(tab.fields, remainingPath) + if (result) return result + } + } + + if (field.type === 'blocks') { + for (const block of field.blocks) { + if (block.slug === remainingPath[0]) { + const result = findField(block.fields, remainingPath.slice(1)) + if (result) return result + } + } + } + } + + return null + } + + return findField(collectionConfig.fields, pathParts.slice(1)) +} diff --git a/src/utilities/updateFieldsConfig.ts b/src/utilities/updateFieldsConfig.ts index b123495..e7701cd 100644 --- a/src/utilities/updateFieldsConfig.ts +++ b/src/utilities/updateFieldsConfig.ts @@ -17,12 +17,18 @@ export const updateFieldsConfig = (collectionConfig: CollectionConfig): UpdateFi return field } - if (field.type && ['richText', 'text', 'textarea', 'upload'].includes(field.type)) { + // Map field path for global fieldInstructionsMap to load related instructions + // This is done due to save extra API call to get instructions when Field components are loaded in admin + // Doing is will only call instructions data when user clicks on settings + if (['richText', 'text', 'textarea', 'upload'].includes(field.type)) { schemaPathMap = { ...schemaPathMap, [currentSchemaPath]: field.type, } + } + // Inject AI actions, richText is not included here as it has to be explicitly defined by user + if (['text', 'textarea', 'upload'].includes(field.type)) { return { ...field, admin: {