import React, {FC, ReactNode, useContext, useMemo, useRef} from "react"; import {TabField} from "./tree/fields/TabField"; import {ComponentRuntimeData, ComponentRuntimeDataWithParentType,} from "./tree/components"; import {type ComponentMeta, getComponentMeta, getComponentMetaData} from "./tree/ComponentsMeta"; import {WithLabels} from "./tree/fields/WithLabels"; import {cloneDeep, get, map} from "lodash"; import {CardType, Note} from "./tree/cards"; import {ToolTips} from "./tree/fields/ToolTip"; import {FieldItem, FieldRow, FieldType} from "./tree/fields"; import {MultiSelect} from "./tree/fields/MultiSelect"; import {Number} from "./tree/fields/Number"; import classNames from "classnames"; import {Button} from "./tree/cards/Button"; import {MultiSelectSearch} from "./tree/fields/MultiSelectSearch"; import {convertDictToArrayOfOptions} from "./tree/cards/FieldHelpers"; import {Text} from "./tree/fields/Text"; import {Calendar} from "./tree/fields/Calendar"; import {TimeField} from "./tree/fields/TimeField"; import dayjs from "dayjs"; import {__} from "./globals"; import {ToolTip} from "./ToolTip"; import {PopupWindowStateContext} from "./tree/atoms"; import {getLazyTestables} from "./tree/hooks"; import {TestableData} from "./tree/store"; import {RowOptions} from "./tree/cards/Fields/RowOptions"; import {FieldSection} from "./tree/FieldSection"; import {SuggestedScopeContext} from "./tree/suggestedScopes"; export enum FieldUpdateAction { // for single value fields Update = 'update', // for multi-value fields Add = 'add', Remove = 'remove', } export type FieldsProps }> = { componentRuntimeData: ComponentRuntimeDataWithParentType, onUpdate: (componentRuntimeData: ComponentRuntimeData, field: FieldItem, value: Value, action: Action, extraNestedFieldPathRelative?: string) => void, notes: Note[], setNotes: (notes: Note[]) => void, scope: PopupWindowStateContext['scope'], origin?: string | 'testables' } export type UpdateField = (value: Value, action?: Action, extraNestedFieldPathRelative?: string) => void export type RenderFieldContext = { componentRuntimeData: ComponentRuntimeDataWithParentType, fields: Record, options: any, onUpdate: FieldsProps['onUpdate'], toolTips: ToolTips, notes: Note[], setNotes: (notes: Note[]) => void, lastNoteAddedOnHover: React.MutableRefObject, getNoteForFieldValue: (fieldId: string, fieldValue: any) => Note | undefined, getNoteForCurrentFieldValue: (fieldId: string) => Note | undefined, renderFieldRow?: (fieldRow: FieldRow) => ReactNode, renderFieldsGrid?: (fields: FieldRow) => ReactNode, } export type RenderFieldProps = { field: FieldItem, context: RenderFieldContext, extraOptionalStructure?: boolean, } export const FieldRenderer: FC = ({field, context, extraOptionalStructure = true}) => { return <>{renderField(field, context, undefined, extraOptionalStructure)} } export type SingleFieldRendererProps = FieldsProps & { field: FieldItem, extraOptionalStructure?: boolean, } /** * Hook to create the render context for fields. * This contains all the logic needed to render fields and can be used * both by the Fields component and SingleFieldRenderer. */ export function useFieldRenderContext( componentRuntimeData: FieldsProps['componentRuntimeData'], onUpdate: FieldsProps['onUpdate'], notes: FieldsProps['notes'], setNotes: FieldsProps['setNotes'], scope: FieldsProps['scope'] ): { context: RenderFieldContext, renderFieldRow: (fieldRow: FieldRow) => ReactNode } { let {parentType, type, options} = componentRuntimeData if (scope === 'tier-subchild') { const testables = getLazyTestables() const baseTestableData = testables.getParent(componentRuntimeData.id) as TestableData | undefined parentType = baseTestableData?.testableType ? `${baseTestableData.testableType}s` : parentType type = baseTestableData?.type || type } const componentMeta = getComponentMeta(parentType, type); const cardType = getComponentMetaData(componentMeta) const lastNoteAddedOnHover = useRef(null) const bottomNoticesPortalRef = useRef(null) const toolTips = useMemo(() => { const toolTips = new ToolTips() return toolTips }, [options]) const fields = cardType.fields const getNoteForFieldValue = (fieldId: string, fieldValue: any) => { const notesForThisField = fields[fieldId]?.meta?.notes return notesForThisField?.[fieldValue] } const getNoteForCurrentFieldValue = (fieldId: string) => { return getNoteForFieldValue(fieldId, get(options, fieldId)); } // Initialize notes for current field values Object.keys(fields).forEach(fieldId => { const noteForThisValue = getNoteForCurrentFieldValue(fieldId); if (noteForThisValue) { const added = toolTips.add(noteForThisValue) if (added) { setNotes([...notes, noteForThisValue]) } } }) const renderFieldRow = (fieldRow: FieldRow) => { function renderFieldsGrid(fields: FieldRow) { if (!fields) { return []; } return
{fields.map((field) => { const renderedField = renderField(field, renderContext, undefined, false, bottomNoticesPortalRef) return <>

{field.title}

{field.description &&

{field.description}

}
{renderedField}
})}
; } const renderContext: RenderFieldContext = { componentRuntimeData, fields, options, onUpdate, toolTips, notes, setNotes, lastNoteAddedOnHover, getNoteForFieldValue, getNoteForCurrentFieldValue, renderFieldRow: undefined, renderFieldsGrid: undefined, } renderContext.renderFieldRow = renderFieldRow renderContext.renderFieldsGrid = renderFieldsGrid const actualFieldRow = fieldRow?.filter?.(field => !(field instanceof RowOptions)) as FieldItem[] || [] const fieldRowOptions = fieldRow?.find?.(field => (field instanceof RowOptions)) as RowOptions | undefined const autoWidthCols = fieldRowOptions?.options.colWidth === 'auto' if (!actualFieldRow.length) { return } return ( <>
{actualFieldRow.map(field => { if (Array.isArray(field)) { return
{field.map(f => renderField(f, renderContext, undefined, true, bottomNoticesPortalRef))}
} return renderField(field, renderContext, undefined, !autoWidthCols, bottomNoticesPortalRef) })}
); }; const context: RenderFieldContext = { componentRuntimeData, fields, options, onUpdate, toolTips, notes, setNotes, lastNoteAddedOnHover, getNoteForFieldValue, getNoteForCurrentFieldValue, renderFieldRow, renderFieldsGrid: (fieldsToRender: FieldRow) => { if (!fieldsToRender) { return []; } return
{fieldsToRender.map((field) => { const renderedField = renderField(field, context, undefined, false, bottomNoticesPortalRef) return <>

{field.title}

{field.description &&

{field.description}

}
{renderedField}
})}
; }, } return {context, renderFieldRow} } /** * Renders a single field with all the business logic from Fields component. * Use this when you need to render an individual field outside of the Fields component. */ export const SingleFieldRenderer: FC = ({ componentRuntimeData, onUpdate, notes = [], setNotes = () => { }, scope, field, extraOptionalStructure = true }) => { const {context} = useFieldRenderContext(componentRuntimeData, onUpdate, notes, setNotes, scope) return <>{renderField(field, context, undefined, extraOptionalStructure)} } function getInternalExtraFieldsMap(componentRuntimeData: ComponentRuntimeDataWithParentType, componentMeta: ComponentMeta, cardType: CardType): FieldRow[] { // add the recursive fields map if supported if (cardType.supports?.recursiveness) { const isRecursiveField = get(cardType.fields, '__BASE__.isRecursive'); return [ [ { id: '__BASE__.isRecursive', renderType: FieldType.DualBooleanTab, fieldOptions: { direction: 'vertical', }, title: __('How many times can this be applied?'), fieldOptions: { booleanLabels: isRecursiveField?.meta?.booleanLabels } } ] ] } return [] } export const Fields: FC = ({componentRuntimeData, onUpdate, notes, setNotes, scope, origin}) => { let {parentType, type, options} = componentRuntimeData const suggestedScope = useContext(SuggestedScopeContext) if (scope === 'tier-subchild') { /** * Tier subchild is always in 'edit' mode so we know we can get its parent from the store */ // the parent and type we should get it from the parent as this is a testable partial const testables = getLazyTestables() const baseTestableData = testables.getParent(componentRuntimeData.id) as TestableData | undefined parentType = baseTestableData?.testableType ? `${baseTestableData.testableType}s` : parentType type = baseTestableData?.type || type } const componentMeta = getComponentMeta(parentType, type); const cardType = getComponentMetaData(componentMeta) const fieldsMap = ((typeof componentMeta?.fieldsMap === 'function' ? componentMeta.fieldsMap(componentRuntimeData, scope, suggestedScope) : componentMeta?.fieldsMap) || []).filter(Boolean) const fieldsMapLast = ((typeof componentMeta?.fieldsMapLast === 'function' ? componentMeta.fieldsMapLast(componentRuntimeData, scope) : componentMeta?.fieldsMapLast) || []).filter(Boolean) const internalExtraFieldsMap = getInternalExtraFieldsMap(componentRuntimeData, componentMeta!, cardType) const clientNotes = typeof componentMeta?.fieldsNotes === 'function' ? componentMeta.fieldsNotes(componentRuntimeData, scope) : (componentMeta?.fieldsNotes || []) const {renderFieldRow} = useFieldRenderContext(componentRuntimeData, onUpdate, notes, setNotes, scope) // one p-1 to account for the scaled items on hover (they would be clipped otherwise because of the overflow-y-scroll situation) return
{[...fieldsMap, internalExtraFieldsMap, ...fieldsMapLast].filter(i => !!i).map(renderFieldRow)} {[...(cardType?.notes || []), ...clientNotes]?.map(note => )}
} function renderFieldWithTitle(field: FieldItem, fieldToRender: JSX.Element, fieldOptions) { return {fieldToRender} ; } export const renderField = (field: FieldItem, context: RenderFieldContext, extraOptionalStructure = true, fullWidth: boolean = true, bottomNoticesPortalRef?: React.MutableRefObject): React.ReactNode => { const { componentRuntimeData, fields, options, onUpdate, toolTips, setNotes, lastNoteAddedOnHover, getNoteForFieldValue, getNoteForCurrentFieldValue, } = context const renderFieldRow = context.renderFieldRow || (() => null) const renderFieldsGrid = context.renderFieldsGrid || (() => null) let renderedField: ReactNode const renderType = typeof field.renderType === 'function' ? field.renderType(options) : field.renderType const extraFieldProps = (typeof field.fieldProps === 'function' ? field.fieldProps(options, onUpdate, { componentRuntimeData, field }) : field?.fieldProps || {}); const fieldOptions = (typeof field.fieldOptions === 'function' ? field.fieldOptions(options, onUpdate, { componentRuntimeData, field }) : field?.fieldOptions || {}); const fieldMeta = get(fields, field.id)?.meta; const updateField: UpdateField = (value, action, extraNestedFieldPathRelative?: string) => { onUpdate( componentRuntimeData, field, typeof extraFieldProps?.beforeUpdateValue === 'function' ? extraFieldProps.beforeUpdateValue(value) : value, // @ts-ignore action, extraNestedFieldPathRelative ); // this might not get called after the update because of async/queues fieldOptions?.onUpdate?.(value, action, extraNestedFieldPathRelative) } const optionValue = get(options, field.id); /** * const options: Option[] = ([ * {value: 'one', label: 'Hoddies', id: '7855'}, * { * value: { * type: 'hierarchy', * value: ['food', 'italian', 'pizza'], * }, * label: 'food italian pizza', * id: '5572', * }, * {id: 'three', value: 'three', label: 'Jackets'}, * {id: 'four', value: 'four', label: 'T-shirts'}, * {id: 'five', value: 'five', label: 'Jeans'}, * {id: 'six', value: 'six', label: 'Shoes'}, * {id: 'seven', value: 'seven', label: 'Seven - a'}, * {id: 'eight', value: 'eight', label: 'Eight - a'}, * {id: 'nine', value: 'nine', label: 'Nine - a'}, * {id: 'ten', value: 'ten', label: 'Ten - a'} * ] as Option[]); */ switch (renderType) { case FieldType.Tab: case FieldType.BooleanTab: let tabOptionValues: string[] = [] const useTabValues = fieldOptions?.multiple && fieldOptions?.format === 'booleanDictionary'; if (useTabValues) { tabOptionValues = map(optionValue, (value, id) => { return value ? id : false; }).filter(value => typeof value == 'string') as unknown as string[] } const field1 = get(fields, field.id); let isBooleanTab = renderType === FieldType.BooleanTab; let selectedTabId: string if (useTabValues) { selectedTabId = tabOptionValues } else if (isBooleanTab) { selectedTabId = optionValue ? 'true' : 'false' } else { selectedTabId = optionValue } renderedField = { if (isBooleanTab) { value = !optionValue; } else if (useTabValues) { // @ts-ignore const newBooleanDictionary: Record = cloneDeep(field1 as Record) for (const id in newBooleanDictionary) { newBooleanDictionary[id] = (value as string[]).includes(id) } value = newBooleanDictionary; } updateField(value) }} onTabHover={value => { const note = getNoteForFieldValue(field.id, value); if (note) { toolTips.set(note); lastNoteAddedOnHover.current = note; setNotes([...toolTips.all()]) } }} onTabUnhover={() => { const note = getNoteForCurrentFieldValue(field.id) if (note) { toolTips.set(note); setNotes([...toolTips.all()]) } else { if (lastNoteAddedOnHover.current) { toolTips.remove(lastNoteAddedOnHover.current.id); setNotes([...toolTips.all()]) } } }} {...extraFieldProps} />; break; case FieldType.DualBooleanTab: const dualBooleanField = get(fields, field.id); const dualBooleanLabels = fieldOptions?.booleanLabels || dualBooleanField?.meta?.booleanLabels || {}; const dualSelectedTabId = optionValue ? 'true' : 'false'; renderedField = { updateField(value === 'true') }} onTabHover={value => { const note = getNoteForFieldValue(field.id, value === 'true'); if (note) { toolTips.set(note); lastNoteAddedOnHover.current = note; setNotes([...toolTips.all()]) } }} onTabUnhover={() => { const note = getNoteForCurrentFieldValue(field.id) if (note) { toolTips.set(note); setNotes([...toolTips.all()]) } else { if (lastNoteAddedOnHover.current) { toolTips.remove(lastNoteAddedOnHover.current.id); setNotes([...toolTips.all()]) } } }} {...extraFieldProps} />; break; case FieldType.Select: let optionsKey = fieldOptions?.optionsKey || '_allowed'; const rawOptions = fieldOptions?.options ?? (fieldMeta?.[optionsKey] || {}); const options = convertDictToArrayOfOptions(rawOptions, false) renderedField = { updateField(value, {action: 'add'}) }} {...extraFieldProps} /> break case FieldType.MultiSelect: // @ts-ignore renderedField = { updateField(value, {action: 'add'}) }} onRemovedValue={value => updateField(value, {action: 'remove'})} {...(extraFieldProps)} /> break case FieldType.Number: const numberFieldMeta = get( fields, field.id // don't try to pass the '${field.id}.amount' as it will break because we wil no longer have the meta of the .type (amount and type (type is a select menu)) ) console.log('fieldMeta', numberFieldMeta, fieldMeta, field, get(fields, field.id)) renderedField =
{ updateField(newValue, {action: 'update'}, fieldName) } } labels={numberFieldMeta.meta?.labels} {...(extraFieldProps)} bottomNoticesPortalRef={bottomNoticesPortalRef} />
; break; case FieldType.Text: case FieldType.MultiLineText: renderedField =
updateField(newValue, {action: 'update'})} name={fieldMeta?.name} type={renderType === FieldType.Text ? 'input' : 'textarea'} placeholder={fieldMeta?.placeholder ?? fieldMeta?._default} {...extraFieldProps} />
break case FieldType.Button: renderedField =