import * as React from 'react'; import { isElement } from 'react-is'; import * as _ from 'lodash'; import * as FUI from '@fluentui/react-northstar'; import * as FUIIcons from '@fluentui/react-icons-northstar'; import { JSONTreeElement } from './components/types'; import { getUUID } from './utils/getUUID'; type FiberNavigator = FUI.FiberNavigator; export const EXCLUDED_COMPONENTS = ['Animation', 'Debug', 'Design', 'FocusZone', 'Portal', 'Provider', 'Ref']; export const DRAGGING_ELEMENTS = { // HTML ELEMENTS div: { children: 'I am a
' }, span: { children: 'I am a ' }, p: { children: 'I am a

' }, // TODO: sort ButtonGroup: ( , key: 'emoji', iconOnly: true, title: 'Emoji', }, { icon: , key: 'translation', iconOnly: true, title: 'Translation', }, { icon: , key: 'play', iconOnly: true, title: 'Play', }, ]} /> ), // Card: , // CardHeader: , // CardBody: , // CardFooter: , // CardPreview: , // CardColumn: , // CardTopControls: , // FLUENT v0 COMPONENTS Accordion: { props: { panels: [{ title: 'Accordion title', content: 'Accordion content' }], } as FUI.AccordionProps, }, Alert: { props: { warning: true, icon: , content: 'Info alert' } as FUI.AlertProps, }, // Animation: { props: { name: 'fade' } as FUI.AnimationProps }, Attachment: { props: { icon: , header: 'A file attachment', description: '100kb', } as FUI.AttachmentProps, }, Avatar: { props: { image: 'https://picsum.photos/100?random' } as FUI.AvatarProps, }, // Box: { // props: { content: 'A Box' } as FUI.BoxProps, // }, // this can be ReactElement directly 👍 Button: } />, Carousel: { props: { getItemPositionText: (index: number, size: number) => `${index + 1} of ${size}`, items: [ { key: 'one', id: 'one', content: , }, { key: 'two', id: 'two', content: , }, ], } as FUI.CarouselProps, }, Chat: { props: { items: [ { gutter: ( }} /> ), message: , attached: 'top', key: 'message-id-4', }, { message: , contentPosition: 'end', attached: true, key: 'message-id-2', }, ], } as FUI.ChatProps, }, Checkbox: { props: { label: 'Checkbox' } as FUI.CheckboxProps, }, // Debug: { props: { content: 'Debug' } as FUI.DebugProps }, // Design: { props: { config: {} } as FUI.DesignProps }, Dialog: { props: { trigger: Open Dialog, header: 'Header', content: 'Content', actions: [ { content: 'Cancel', }, ], } as FUI.DialogProps, }, Divider: { props: { content: 'Divider' } as FUI.DividerProps, }, Dropdown: { props: { placeholder: 'Dropdown', items: ['Item 1', 'Item 2', 'Item 3'], } as FUI.DropdownProps, }, // Embed: { props: { content: 'Embed' } as FUI.EmbedProps }, Flex: { props: {} as FUI.FlexProps, }, // FocusZone: { props: { content: 'FocusZone' } as FUI.FocusZoneProps }, Form: { props: { fields: [ { label: 'First name', name: 'firstName', id: 'first-name-shorthand', key: 'first-name', required: true, }, { label: 'Last name', name: 'lastName', id: 'last-name-shorthand', key: 'last-name', required: true, }, { label: 'I agree to the Terms and Conditions', control: { as: 'input' }, type: 'checkbox', id: 'conditions-shorthand', key: 'conditions', }, { control: { as: FUI.Button, content: 'Submit' }, key: 'submit', }, ], } as FUI.FormProps, }, // Grid: { props: { content: 'Grid' } as FUI.GridProps}, Header: { props: { content: 'Header', description: 'Description' } as FUI.HeaderProps, }, // HierarchicalTree: { props: { content: 'HierarchicalTree' } as FUI.HierarchicalTreeProps }, // Icon: { props: { name: 'like' } as FUI.IconProps }, Image: { props: { src: 'https://picsum.photos/200' } as FUI.ImageProps, }, Input: { props: { placeholder: 'Type...' } as FUI.InputProps, }, // ItemLayout: { props: { content: 'ItemLayout' } as FUI.ItemLayoutProps }, Label: { props: { icon: , content: '23 Messages' } as FUI.LabelProps, }, Layout: { props: { debug: true, start: 'Start content.', main: 'Main content.', end: 'End content.', } as FUI.LayoutProps, }, List: { props: { items: [ { key: 'irving', media: , header: 'Irving Kuhic', headerMedia: '7:26:56 AM', content: 'Program the sensor to the SAS alarm through the haptic SQL card!', }, { key: 'skyler', media: , header: 'Skyler Parks', headerMedia: '11:30:17 PM', content: 'Use the online FTP application to input the multi-byte application!', }, { key: 'dante', media: , header: 'Dante Schneider', headerMedia: '5:22:40 PM', content: 'The GB pixel is down, navigate the virtual interface!', }, ], } as FUI.ListProps, }, Loader: { props: { label: 'Loading...', } as FUI.LoaderProps, }, Menu: { props: { items: ['Item 1', 'Item 2', 'Item 3'], } as FUI.MenuProps, }, MenuButton: { props: { trigger: MenuButton, menu: [ 'Item 1', 'Item 2', 'Item 3', { content: 'Submenu', menu: ['4', '5'], }, ], } as FUI.MenuButtonProps, }, Popup: { props: { trigger: Show Popup, content: 'Hello from popup!', } as FUI.PopupProps, }, // Portal: { props: { content: 'Portal' } as FUI.PortalProps }, // Provider: { props: { content: 'Provider' } as FUI.ProviderProps}, RadioGroup: { props: { items: [ { name: 'pizza', key: 'Capricciosa', label: 'Capricciosa', value: 'capricciosa' }, { name: 'pizza', key: 'Prosciutto', label: 'Prosciutto', value: 'prosciutto' }, { name: 'pizza', key: 'Custom', label: 'Custom', value: 'custom' }, ], } as FUI.RadioGroupProps, }, Reaction: { props: { icon: , content: 10 } as FUI.ReactionProps, }, Segment: { props: { content: 'Segment' } as FUI.SegmentProps, }, Slider: { props: {} as FUI.SliderProps, }, // SplitButton: { props: { content: 'SplitButton' } as FUI.SplitButtonProps}, Status: { props: { state: 'success' } as FUI.StatusProps, }, // Table: { props: { content: 'Table' } as FUI.TableProps }, Text: { props: { content: 'Text' } as FUI.TextProps, }, TextArea: { props: { defaultValue: 'Hello there!' } as FUI.TextAreaProps, }, // Toolbar: { props: { content: 'Toolbar' } as FUI.ToolbarProps }, // Tooltip: { props: { content: 'Tooltip' } as FUI.TooltipProps }, Tree: { props: { items: [ { id: '1', title: 'Level 1', items: [ { id: '11', title: 'Level 1.1', items: [ { id: '111', title: 'Level 1.1.1' }, { id: '112', title: 'Level 1.1.2' }, { id: '113', title: 'Level 1.1.3' }, ], }, { id: '12', title: 'Level 1.2', items: [ { id: '121', title: 'Level 1.2.1' }, { id: '122', title: 'Level 1.2.2' }, { id: '123', title: 'Level 1.2.3' }, ], }, ], }, { id: '2', title: 'Level 2', items: [ { id: '21', title: 'Level 2.1', items: [ { id: '211', title: 'Level 2.1.1' }, { id: '212', title: 'Level 2.1.2' }, { id: '213', title: 'Level 2.1.3' }, ], }, ], }, ], } as FUI.TreeProps, }, // Video: { props: { content: 'Video' } as FUI.VideoProps }, }; export const resolveComponent = (displayName): React.ElementType => { return FUI[displayName] || FUIIcons[displayName] || displayName; }; // FIXME: breaks for const toJSONTreeElement = input => { if (isElement(input)) { return { $$typeof: 'Symbol(react.element)', type: typeof (input as React.ReactElement).type === 'string' ? (input as React.ReactElement).type : ((input as React.ReactElement).type as any).displayName, props: toJSONTreeElement(input.props), }; } const result = _.transform(input, (acc, value, key) => { if (Array.isArray(value)) { acc[key] = toJSONTreeElement(value); } else if (_.isPlainObject(value)) { acc[key] = toJSONTreeElement(value); } else { acc[key] = value; } }); return result; }; export const resolveDraggingElement: (displayName: string, draggingElements?) => JSONTreeElement = ( displayName, draggingElements = DRAGGING_ELEMENTS, ) => { const jsonTreeElement = toJSONTreeElement(draggingElements[displayName]); return { uuid: getUUID(), $$typeof: 'Symbol(react.element)', type: displayName, displayName, ...jsonTreeElement, }; }; // TODO: this allows mutating `target`, OK? /** * Handles dropping a new child element into a parent element. * Should mutate the parent as a result of the drop. */ export const resolveDrop = (newChild: JSONTreeElement, parent: JSONTreeElement, childIndex: number) => { console.log('config:resolveDrop', JSON.parse(JSON.stringify({ source: newChild, target: parent }))); if (!newChild || !parent) { return; } // TODO: prevent invalid drops, prob before they happen (ie isValidDrop() ) // TODO: This is where we'd handle special drop logic and json tree updates // Right now, we just update the children of the element that was dropped on. // TODO: Icon component was removed if (parent.type === 'Button' && newChild.type === 'Icon') { parent.props.icon = newChild.props; return; } // TODO: handle dropping before/after the target as well as "into" the target's children if (!parent.props) { parent.props = {}; } if (!parent.props.children) { parent.props.children = []; } parent.props.children = [].concat(parent.props.children); parent.props.children.splice(childIndex, 0, newChild); // clean up props that are incompatible with element children delete parent.props?.content; console.log('config:resolveDrop RESULT', JSON.parse(JSON.stringify({ source: newChild, target: parent }))); }; // /////////////////////////////////////////////////////////////////////////////////////// // TODO: From here down probably does not belong in client config. // It is internal implementation details. // /////////////////////////////////////////////////////////////////////////////////////// const resolveProps = (input, cb) => { const result = _.transform(input, (acc, value, key) => { if (_.isPlainObject(value)) { if ((value as any).$$typeof === 'Symbol(react.element)') { acc[key] = renderJSONTreeToJSXElement(value as any, cb); } else { acc[key] = resolveProps(value, cb); } } else if (Array.isArray(value)) { acc[key] = resolveProps(value, cb); } else { acc[key] = value; } }); return result; }; export const renderJSONTreeToJSXElement = ( tree: JSONTreeElement, iterator: (jsonTreeElement: JSONTreeElement) => JSONTreeElement = x => x, ) => { if (tree === null) { return null; } let { props = null } = tree; props = resolveProps(props, iterator); const modifiedTree = iterator({ ...tree, props }); return React.createElement(resolveComponent(modifiedTree.type), { ...modifiedTree.props, key: modifiedTree.uuid, 'data-builder-id': modifiedTree.uuid, }); }; /** * Converts a fiberNav into a JSON Tree element */ export const fiberNavFindJSONTreeElement = (jsonTree, fiberNav: FiberNavigator): JSONTreeElement | null => { if (!fiberNav) { return null; } return jsonTreeFindElement(jsonTree, keyToUUID(fiberNav.key)); }; /** * Given a fiberNav, return the corresponding JSON Tree element from the jsonTree. */ export const fiberNavFindOwnerInJSONTree = (fiberNav: FiberNavigator, jsonTree: JSONTreeElement): FiberNavigator => { // The user should only be able to select react components that are created by the builder. // This way, they can't "reach in" to a component and manipulate its children. // This is analogous to how a developer can only import a component and use its props but not // edit the components children directly. // We need to traverse the parent fibers and find one that has a uuid that exists in the json tree return fiberNav.findParent(parent => { return !!jsonTreeFindElement(jsonTree, parent.key); }); }; export const jsonTreeMap = (tree: JSONTreeElement, cb) => { const newTree = { ...cb(tree) }; if (Array.isArray(newTree.children) && newTree.children.length > 0) { newTree.children = newTree.children.map(child => { return jsonTreeMap(child, cb); }); } return newTree; }; // TODO: When dropping components into Flex, React prefixes the fiber key with ".$" // This prefix must be stripped to find the element in the JSON tree by its original uuid. // Otherwise, we're looking for `".$" + uuid` in the JSON tree // -- WHY? // This is apparently due to the use of Reach.children.map which prefixes the new children's keys. // If you replace React.Children.map from Flex and just return `children`, no prefixing occurs. // -- FIX? // The builder could use some other way of relating JSON tree elements to React elements besides key. const keyToUUID = key => (typeof key === 'string' ? key.replace(/^\.\$/, '') : key); export const jsonTreeFindElement = (tree: JSONTreeElement, uuid: string | number): JSONTreeElement | null => { const uuidFromKey = keyToUUID(uuid); if (typeof uuidFromKey === 'undefined' || uuidFromKey === null) { return null; } if (tree.uuid === uuidFromKey) return tree; let ret = null; if (Array.isArray(tree?.props?.children)) { for (let i = 0; i < tree?.props?.children.length && ret === null; ++i) { const e = tree?.props?.children[i]; if (typeof e !== 'string') { ret = jsonTreeFindElement(e, uuidFromKey); } } } return ret; }; export const jsonTreeFindParent = (tree: JSONTreeElement, uuid: string | number): JSONTreeElement | null => { const uuidFromKey = keyToUUID(uuid); if (typeof uuidFromKey === 'undefined' || uuidFromKey === null) { return null; } let ret = null; if (Array.isArray(tree?.props?.children)) { if (tree.props.children.find(jte => typeof jte !== 'string' && jte.uuid === uuidFromKey)) { return tree; } for (let i = 0; i < tree?.props?.children.length && ret === null; ++i) { const e = tree?.props?.children[i]; if (typeof e !== 'string') { ret = jsonTreeFindParent(e, uuidFromKey); } } } return ret; }; export const jsonTreeDeleteElement = (tree: JSONTreeElement, uuid: string | number): JSONTreeElement => { const uuidFromKey = keyToUUID(uuid); const omitChildWithUuid = (tree: JSONTreeElement, uuid: string | number): JSONTreeElement => { if (Array.isArray(tree?.props?.children)) { tree.props.children = tree.props.children .filter(jte => typeof jte === 'string' || jte.uuid !== uuid) .map(jte => (typeof jte === 'string' ? jte : omitChildWithUuid(jte, uuid))); } return tree; }; if (tree.uuid === uuidFromKey) { return null; } return omitChildWithUuid(tree, uuid); }; export const jsonTreeCloneElement = (tree: JSONTreeElement, element: any): JSONTreeElement => { const result = _.transform(element, (acc, value, key) => { if (_.isPlainObject(value)) { acc[key] = jsonTreeCloneElement(tree, value); } else if (Array.isArray(value)) { acc[key] = jsonTreeCloneElement(tree, value); } else { acc[key] = value; } }) as any; if (_.isPlainObject(result) && result.$$typeof === 'Symbol(react.element)' && result.uuid) { result.uuid = getUUID(); } return result; }; /** * Displays a knob with the ability to switch between data `types`. */ export const MultiTypeKnob: React.FunctionComponent<{ label: string; types: ('boolean' | 'number' | 'string' | 'literal')[]; value: any; onChange: (value: any) => void; literalOptions: string[]; }> = ({ label, types, value, onChange, literalOptions }) => { const valueType = typeof value; const defaultType = valueType !== 'undefined' ? valueType : types[0]; const [type, setType] = React.useState(defaultType); const knob = knobs[type]; const handleChangeType = React.useCallback( e => setType(e.target.value), // @ts-ignore [], ); // console.log('MultiTypeKnob', { label, value, type, types }); return (

{type !== 'boolean' && } {types.length === 1 ? ( {type} ) : ( types.map(t => ( )) )}
{knob && knob({ options: literalOptions, value, onChange })} {type === 'boolean' && }
); }; export const knobs = { boolean: ({ value, onChange }) => ( onChange(!!e.target.checked)} /> ), number: ({ value, onChange }) => ( onChange(Number(e.target.value))} /> ), string: ({ value, onChange }) => ( onChange(e.target.value)} /> ), literal: ({ options, value, onChange }) => ( ), ReactText: (value, onChange) => knobs.string({ value, onChange }), 'React.ElementType': (value, onChange) => knobs.string({ value, onChange }), };