import clsx from 'clsx' import cloneDeep from 'lodash.clonedeep' import { nanoid } from 'nanoid' import { ComponentType, MutableRefObject, useCallback, useEffect, useRef, useState, } from 'react' import { FieldErrors, UseFormClearErrors, UseFormSetValue, useFieldArray, useFormContext, } from 'react-hook-form' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { SuspenseLoaderProps } from '@dao-dao/types' import { ActionComponentProps, ActionKeyAndData, ActionKeyAndDataNoId, ActionMap, } from '@dao-dao/types/actions' import { useActionsContext, useDaoIfAvailable } from '../../contexts' import { useLoadingPromise } from '../../hooks' import { Loader } from '../logo' import { ActionCard } from './ActionCard' import { ActionLibrary } from './ActionLibrary' // The props needed to render an action from a message. export type ActionsEditorProps = { actionDataFieldName: string className?: string hideEmptyPlaceholder?: boolean SuspenseLoader: ComponentType } & Pick // Renders editable cards. export const ActionsEditor = ({ actionDataFieldName: _actionDataFieldName, actionDataErrors, className, hideEmptyPlaceholder, SuspenseLoader, }: ActionsEditorProps) => { const { t } = useTranslation() const { setValue, control, watch, clearErrors } = useFormContext<{ actionData: ActionKeyAndData[] }>() const { actionMap } = useActionsContext() const isDao = !!useDaoIfAvailable() // Type assertion assumes the passed in field name is correct. const actionDataFieldName = _actionDataFieldName as 'actionData' // All actions from the form. const actionData = watch(actionDataFieldName) || [] const { append, insert, remove, move } = useFieldArray({ name: actionDataFieldName, control, }) // Remove actions with no valid action for the given key. useEffect(() => { actionData .flatMap((action, index) => !(action.actionKey in actionMap) ? [index] : [] ) // Remove in descending order so indexes don't change. .sort((a, b) => b - a) .forEach((index) => remove(index)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionData.length, actionMap, remove]) const addAction = useCallback( async (data: ActionKeyAndDataNoId, insertIndex?: number) => { const action = actionMap[data.actionKey] if (!action) { toast.error(t('errors.actionNotFound', { key: data.actionKey })) return } if (!action.ready) { await action.init() } const actionData: ActionKeyAndData = { // See `ActionKeyAndData` comment in `packages/types/actions.ts` for an // explanation of why we need to append with a unique ID. _id: nanoid(), // Allow overriding ID if passed. ...data, } return insertIndex !== undefined ? insert(insertIndex, actionData) : append(actionData) }, [actionMap, append, insert, t] ) // Start with scroll to new actions disabled to prevent scrolling on initial // page load. Only enable once an action is selected from the library. const [scrollToNewActions, setScrollToNewActions] = useState(false) // IDs already seen. This is used to prevent scrolling to the same action more // than once. const idsSeenRef = useRef>(new Set()) return ( <> {actionData.length > 0 ? (
{actionData.map(({ _id, actionKey }, index) => ( move(index, to)} remove={remove} scrollToNewActions={scrollToNewActions} setValue={setValue} /> ))}
) : ( !hideEmptyPlaceholder && (

{t('info.noActionsAdded', { context: isDao ? 'dao' : undefined, })}

) )} { // Enable scrolling to new actions once an action is selected for the // first time. setScrollToNewActions(true) }} /> ) } type ActionEditorProps = { actionMap: ActionMap actionData: ActionKeyAndData[] actionDataFieldName: 'actionData' actionDataErrors: FieldErrors | undefined index: number scrollToNewActions: boolean idsSeenRef: MutableRefObject> SuspenseLoader: ComponentType clearErrors: UseFormClearErrors<{ actionData: ActionKeyAndData[] }> remove: (index: number) => void addAction: Required['addAction'] moveTo: (index: number) => void setValue: UseFormSetValue<{ actionData: ActionKeyAndData[] }> } const ActionEditor = ({ actionMap, actionData, actionDataFieldName, actionDataErrors, index, idsSeenRef, scrollToNewActions, SuspenseLoader, clearErrors, remove, addAction, moveTo, setValue, }: ActionEditorProps) => { const { _id, actionKey, data, readOnly } = actionData[index] const action = actionMap[actionKey] // Make sure action is initialized before rendering. const { loading } = useLoadingPromise({ promise: async () => action?.init(), deps: [action], }) if (!action) { return null } // Clear all errors when the action is removed, in case any manual // errors were not cleaned up. If manual errors persist, the form // gets stuck. const onRemove = () => { clearErrors(`${actionDataFieldName}.${index}`) remove(index) } return (
addAction( { actionKey, data: cloneDeep(action.defaults) }, index + 1 ) : undefined } onDuplicate={() => addAction({ actionKey, data: cloneDeep(data) }, index + 1) } onEdit={ readOnly ? () => setValue(`${actionDataFieldName}.${index}.readOnly`, false) : undefined } onMoveDown={ index < actionData.length - 1 ? () => moveTo(index + 1) : undefined } onMoveUp={index > 0 ? () => moveTo(index - 1) : undefined} onRemove={onRemove} >
{ // Scroll into view when added. If not scrolling, still // register we saw it so we don't scroll later. if (node && _id && !idsSeenRef.current.has(_id)) { idsSeenRef.current.add(_id) if (scrollToNewActions) { node.scrollIntoView({ behavior: 'smooth', block: 'center', }) } } }} > }> {loading ? ( ) : readOnly ? ( ) : ( )}
) }