diff --git a/packages/fluentui/react-builder/src/components/Designer.tsx b/packages/fluentui/react-builder/src/components/Designer.tsx index 4f490044f3a216..3c9f3d8aa5ac14 100644 --- a/packages/fluentui/react-builder/src/components/Designer.tsx +++ b/packages/fluentui/react-builder/src/components/Designer.tsx @@ -1,14 +1,10 @@ import * as React from 'react'; -import { useImmerReducer, Reducer } from 'use-immer'; import DocumentTitle from 'react-document-title'; -import { Text, Button, Divider, Accordion } from '@fluentui/react-northstar'; -import { FilesCodeIcon, AcceptIcon, ErrorIcon } from '@fluentui/react-icons-northstar'; +import { Text, Button, Divider } from '@fluentui/react-northstar'; +import { FilesCodeIcon, AcceptIcon } from '@fluentui/react-icons-northstar'; import { EventListener } from '@fluentui/react-component-event-listener'; import { renderElementToJSX, CodeSandboxExporter, CodeSandboxState } from '@fluentui/docs-components'; - import { componentInfoContext } from '../componentInfo/componentInfoContext'; -import { ComponentInfo } from '../componentInfo/types'; - // import Anatomy from './Anatomy'; import { BrowserWindow } from './BrowserWindow'; import { Canvas } from './Canvas'; @@ -16,33 +12,19 @@ import { Description } from './Description'; import { Knobs } from './Knobs'; import { List } from './List'; import { Toolbar } from './Toolbar'; - -import { - jsonTreeCloneElement, - jsonTreeDeleteElement, - jsonTreeFindElement, - jsonTreeFindParent, - renderJSONTreeToJSXElement, - getCodeSandboxInfo, - resolveDraggingElement, - resolveDrop, -} from '../config'; -import { readTreeFromStore, readTreeFromURL, writeTreeToStore, writeTreeToURL } from '../utils/treeStore'; - -import { DesignerMode, JSONTreeElement } from './types'; +import { jsonTreeFindElement, renderJSONTreeToJSXElement, getCodeSandboxInfo, resolveDraggingElement } from '../config'; +import { writeTreeToStore, writeTreeToURL } from '../utils/treeStore'; +import { JSONTreeElement } from './types'; import { ComponentTree } from './ComponentTree'; import { GetShareableLink } from './GetShareableLink'; import { ErrorBoundary } from './ErrorBoundary'; import { InsertComponent } from './InsertComponent'; - -import * as axeCore from 'axe-core'; +import { debug, useDesignerState } from '../state'; +import { useAxeOnElement, useMode } from '../hooks'; +import { ErrorPanel } from './ErrorPanel'; const HEADER_HEIGHT = '3rem'; -function debug(...args) { - console.log('--Designer', ...args); -} - const CodeEditor = React.lazy(async () => { const _CodeEditor = (await import(/* webpackChunkName: "codeeditor" */ './CodeEditor')).CodeEditor; return { @@ -50,280 +32,6 @@ const CodeEditor = React.lazy(async () => { }; }); -function getDefaultJSONTree(): JSONTreeElement { - return { uuid: 'builder-root', type: 'div' }; -} - -const focusTreeTitle = uuid => { - // TODO: use refs - const element = document.querySelector(`#${uuid} [data-is-focusable]`) as HTMLElement; - element && element.focus(); -}; - -type JSONTreeOrigin = 'store' | 'url'; - -type DesignerState = { - draggingElement: JSONTreeElement; - jsonTree: JSONTreeElement; - jsonTreeOrigin: JSONTreeOrigin; - selectedComponentInfo: ComponentInfo; // FIXME: should be computed in render? - selectedJSONTreeElementUuid: JSONTreeElement['uuid']; - showCode: boolean; - code: string | null; // only valid if showCode is set to true - codeError: string | null; - history: Array; - redo: Array; - insertComponent: { uuid: string; where: string; parentUuid?: string }; -}; - -type DesignerAction = - | { type: 'DRAG_START'; component: JSONTreeElement } - | { type: 'DRAG_ABORT' } - | { type: 'DRAG_DROP'; dropParent: JSONTreeElement; dropIndex: number } - | { type: 'DRAG_CLONE' } - | { type: 'DRAG_MOVE' } - | { type: 'SELECT_COMPONENT'; component: JSONTreeElement } - | { type: 'SELECT_PARENT' } - | { type: 'DELETE_SELECTED_COMPONENT' } - | { type: 'PROP_CHANGE'; component: JSONTreeElement; propName: string; propValue: any } - | { type: 'PROP_DELETE'; component: JSONTreeElement; propName: string } - | { type: 'SWITCH_TO_STORE' } - | { type: 'RESET_STORE' } - | { type: 'SHOW_CODE'; show: boolean } - | { type: 'SOURCE_CODE_CHANGE'; code: string; jsonTree: JSONTreeElement } - | { type: 'SOURCE_CODE_ERROR'; code: string; error: string } - | { type: 'UNDO' } - | { type: 'REDO' } - | { type: 'OPEN_ADD_DIALOG'; uuid: string; where: string; parent?: string } - | { type: 'CLOSE_ADD_DIALOG' } - | { type: 'ADD_COMPONENT'; component: string; module: string }; - -const stateReducer: Reducer = (draftState, action) => { - debug(`stateReducer: ${action.type}`, { action, draftState: JSON.parse(JSON.stringify(draftState)) }); - let treeChanged = false; - - switch (action.type) { - case 'DRAG_START': - draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); - draftState.redo = []; - - draftState.draggingElement = action.component; - break; - - case 'DRAG_ABORT': - draftState.history.pop(); - - draftState.draggingElement = null; - break; - - case 'DRAG_DROP': - if (action.dropParent) { - const dropParent = jsonTreeFindElement(draftState.jsonTree, action.dropParent.uuid); - resolveDrop(draftState.draggingElement, dropParent, action.dropIndex); - treeChanged = true; - } - - const addedComponent = jsonTreeFindElement(draftState.jsonTree, draftState.draggingElement.uuid); - - draftState.draggingElement = null; - if (addedComponent) { - draftState.selectedJSONTreeElementUuid = addedComponent.uuid; - draftState.selectedComponentInfo = componentInfoContext.byDisplayName[addedComponent.displayName]; - } - break; - - case 'DRAG_CLONE': - draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); - draftState.redo = []; - - draftState.draggingElement = jsonTreeCloneElement( - draftState.jsonTree, - jsonTreeFindElement(draftState.jsonTree, draftState.selectedJSONTreeElementUuid), - ); - break; - - case 'DRAG_MOVE': - draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); - draftState.redo = []; - - draftState.draggingElement = jsonTreeCloneElement( - draftState.jsonTree, - jsonTreeFindElement(draftState.jsonTree, draftState.selectedJSONTreeElementUuid), - ); - jsonTreeDeleteElement(draftState.jsonTree, draftState.selectedJSONTreeElementUuid); - treeChanged = true; - break; - - case 'SELECT_COMPONENT': - if (action.component && draftState.selectedJSONTreeElementUuid !== action.component.uuid) { - draftState.selectedJSONTreeElementUuid = action.component.uuid; - draftState.selectedComponentInfo = componentInfoContext.byDisplayName[action.component.displayName]; - } else { - draftState.selectedJSONTreeElementUuid = null; - draftState.selectedComponentInfo = null; - } - break; - - case 'SELECT_PARENT': - const parent = jsonTreeFindParent(draftState.jsonTree, draftState.selectedJSONTreeElementUuid); - if (parent) { - draftState.selectedJSONTreeElementUuid = parent.uuid; - draftState.selectedComponentInfo = componentInfoContext.byDisplayName[parent.displayName]; - } - break; - - case 'DELETE_SELECTED_COMPONENT': - draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); - draftState.redo = []; - - if (draftState.selectedJSONTreeElementUuid) { - jsonTreeDeleteElement(draftState.jsonTree, draftState.selectedJSONTreeElementUuid); - draftState.selectedJSONTreeElementUuid = null; - draftState.selectedComponentInfo = null; - treeChanged = true; - } - break; - - case 'PROP_CHANGE': - draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); - draftState.redo = []; - - const editedComponent = jsonTreeFindElement(draftState.jsonTree, action.component.uuid); - if (editedComponent) { - if (!editedComponent.props) { - editedComponent.props = {}; - } - editedComponent.props[action.propName] = action.propValue; - treeChanged = true; - } - break; - - case 'PROP_DELETE': - draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); - draftState.redo = []; - - const component = jsonTreeFindElement(draftState.jsonTree, action.component.uuid); - if (component) { - if (!component.props) { - component.props = {}; - } - delete component.props[action.propName]; - treeChanged = true; - } - break; - - case 'SWITCH_TO_STORE': - draftState.jsonTree = readTreeFromStore() || getDefaultJSONTree(); - draftState.jsonTreeOrigin = 'store'; - treeChanged = true; - break; - - case 'RESET_STORE': - draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); - draftState.redo = []; - - draftState.jsonTree = getDefaultJSONTree(); - draftState.jsonTreeOrigin = 'store'; - treeChanged = true; - break; - - case 'SHOW_CODE': - try { - draftState.showCode = action.show; - draftState.code = action.show ? renderElementToJSX(renderJSONTreeToJSXElement(draftState.jsonTree)) : null; - } catch (e) { - console.error('Failed to convert tree to code.', e.toString()); - } - break; - - case 'SOURCE_CODE_CHANGE': - draftState.code = action.code; - draftState.selectedJSONTreeElementUuid = null; - draftState.selectedComponentInfo = null; - draftState.jsonTree = action.jsonTree; - draftState.codeError = null; - break; - - case 'SOURCE_CODE_ERROR': - draftState.code = action.code; - draftState.selectedJSONTreeElementUuid = null; - draftState.selectedComponentInfo = null; - draftState.codeError = action.error; - break; - - case 'UNDO': - if (draftState.history.length > 0) { - draftState.redo.push(JSON.parse(JSON.stringify(draftState.jsonTree))); - draftState.jsonTree = draftState.history.pop(); - } - break; - - case 'REDO': - if (draftState.redo.length > 0) { - draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); - draftState.jsonTree = draftState.redo.pop(); - } - break; - - case 'OPEN_ADD_DIALOG': { - const parent = jsonTreeFindParent(draftState.jsonTree, action.uuid); - draftState.insertComponent = { where: action.where, uuid: action.uuid, parentUuid: `${parent?.uuid}` }; - break; - } - - case 'CLOSE_ADD_DIALOG': - draftState.insertComponent = null; - break; - - case 'ADD_COMPONENT': { - const element = resolveDraggingElement(action.component, action.module); - - let parent: JSONTreeElement = undefined; - let index = 0; - const { where, uuid, parentUuid } = draftState.insertComponent; - draftState.insertComponent = null; - - if (where === 'first') { - parent = draftState.jsonTree; - } else if (where === 'child') { - parent = jsonTreeFindElement(draftState.jsonTree, uuid); - } else { - parent = jsonTreeFindElement(draftState.jsonTree, parentUuid); - index = parent.props.children.findIndex(c => c['uuid'] === uuid); - if (index === -1) { - index = 0; - } else { - where === 'after' && index++; - } - } - - resolveDrop(element, parent, index); - - draftState.selectedJSONTreeElementUuid = element.uuid; - draftState.selectedComponentInfo = componentInfoContext.byDisplayName[element.displayName]; - treeChanged = true; - setTimeout(() => focusTreeTitle(element.uuid)); - break; - } - - default: - throw new Error(`Invalid action ${action}`); - } - - if (treeChanged && draftState.showCode) { - draftState.code = renderElementToJSX(renderJSONTreeToJSXElement(draftState.jsonTree)); - draftState.codeError = null; - } -}; - -function useMode(): [{ mode: DesignerMode; isExpanding: boolean; isSelecting: boolean }, (mode: DesignerMode) => void] { - const [mode, setMode] = React.useState('build'); - const isExpanding = mode === 'build'; - const isSelecting = mode === 'build' || mode === 'design'; - - return [{ mode, isExpanding, isSelecting }, setMode]; -} - export const Designer: React.FunctionComponent = () => { debug('render'); @@ -335,67 +43,12 @@ export const Designer: React.FunctionComponent = () => { const draggingElementRef = React.useRef(); - const [state, dispatch] = useImmerReducer(stateReducer, null, () => { - let jsonTreeOrigin: JSONTreeOrigin = 'url'; - let jsonTree = readTreeFromURL(window.location.href); - if (!jsonTree) { - jsonTree = readTreeFromStore() || getDefaultJSONTree(); - jsonTreeOrigin = 'store'; - } - - return { - draggingElement: null, - jsonTree, - jsonTreeOrigin, - selectedComponentInfo: null, - selectedJSONTreeElementUuid: null, - showCode: false, - code: null, - codeError: null, - history: [], - redo: [], - insertComponent: null, - }; - }); - + const [state, dispatch] = useDesignerState(); const [{ mode, isExpanding, isSelecting }, setMode] = useMode(); const [showJSONTree, handleShowJSONTreeChange] = React.useState(false); - const [axeErrors, setAxeErrors] = React.useState([]); const [headerMessage, setHeaderMessage] = React.useState(''); - const runAxeOnElement = React.useCallback(selectedElementUuid => { - const iframe = document.getElementsByTagName('iframe')[0]; - const selectedComponentAxeErrors = []; - axeCore.run( - iframe, - { - rules: { - // excluding rules which are related to the whole page not to components - 'page-has-heading-one': { enabled: false }, - region: { enabled: false }, - 'landmark-one-main': { enabled: false }, - }, - }, - (err, result) => { - if (err) { - console.error('Axe failed', err); - } else { - result.violations.forEach(violation => { - violation.nodes.forEach(node => { - if (node.html.includes(`data-builder-id="${selectedElementUuid}"`)) { - selectedComponentAxeErrors.push( - node.failureSummary - .replace('Fix all of the following:', '-') - .replace('Fix any of the following:', '-'), - ); - } - }); - }); - } - setAxeErrors(selectedComponentAxeErrors); - }, - ); - }, []); + const [axeErrors, runAxeOnElement] = useAxeOnElement(); React.useEffect(() => { if (state.selectedJSONTreeElementUuid) { @@ -915,37 +568,3 @@ export const Designer: React.FunctionComponent = () => { ); }; - -const ErrorPanel = ({ axeErrors }) => { - const panels = [ - { - key: 'axe', - title: { - 'aria-level': 4, - content: ( - - {axeErrors.length} Accessibility{' '} - {axeErrors.length > 1 ? 'Errors' : 'Error'} - - ), - }, - content: ( -
    - {axeErrors.map(error => ( -
  • {error}
  • - ))} -
- ), - }, - ]; - - return ( -
- -
- ); -}; diff --git a/packages/fluentui/react-builder/src/components/ErrorPanel.tsx b/packages/fluentui/react-builder/src/components/ErrorPanel.tsx new file mode 100644 index 00000000000000..4c7095bc0f60d7 --- /dev/null +++ b/packages/fluentui/react-builder/src/components/ErrorPanel.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { ErrorIcon } from '@fluentui/react-icons-northstar'; +import { Text, Accordion } from '@fluentui/react-northstar'; + +export const ErrorPanel = ({ axeErrors }) => { + const panels = [ + { + key: 'axe', + title: { + 'aria-level': 4, + content: ( + + {axeErrors.length} Accessibility{' '} + {axeErrors.length > 1 ? 'Errors' : 'Error'} + + ), + }, + content: ( +
    + {axeErrors.map(error => ( +
  • {error}
  • + ))} +
+ ), + }, + ]; + + return ( +
+ +
+ ); +}; diff --git a/packages/fluentui/react-builder/src/components/Knobs.tsx b/packages/fluentui/react-builder/src/components/Knobs.tsx index 53d30eac6691ad..80c23950cbf2a9 100644 --- a/packages/fluentui/react-builder/src/components/Knobs.tsx +++ b/packages/fluentui/react-builder/src/components/Knobs.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { Menu, tabListBehavior } from '@fluentui/react-northstar'; import { ComponentInfo, ComponentProp } from '../componentInfo/types'; import { JSONTreeElement } from './types'; -import { MultiTypeKnob } from '../config'; +import { MultiTypeKnob } from './MultiTypeKnob'; // const designUnit = 4; // const sizeRamp = [ diff --git a/packages/fluentui/react-builder/src/components/MultiTypeKnob.tsx b/packages/fluentui/react-builder/src/components/MultiTypeKnob.tsx new file mode 100644 index 00000000000000..52ab0f8df9d919 --- /dev/null +++ b/packages/fluentui/react-builder/src/components/MultiTypeKnob.tsx @@ -0,0 +1,99 @@ +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'; + +/** + * 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; + onRemoveProp: () => void; + options: string[]; + required: boolean; +}> = ({ label, types, value, onChange, onRemoveProp, options, required }) => { + const defaultType = types[0]; + const [type, setType] = React.useState(defaultType); + + const knob = knobs[type]; + const handleChangeType = React.useCallback( + e => setType(e.target.value), // @ts-ignore + [], + ); + + const propId = `prop-${label}`; + + return ( +
+
+ {type !== 'boolean' && } + {types.length === 1 ? ( + {type} + ) : ( + types.map(t => ( + + )) + )} +
+ {knob && knob({ options, value, onChange, id: propId })} + {type === 'boolean' && } + {!required && type === 'literal' && value && ( + + )} +
+ ); +}; + +export const knobs = { + boolean: ({ value, onChange, id }) => ( + onChange(!!e.target.checked)} /> + ), + + number: ({ value, onChange, id }) => ( + onChange(Number(e.target.value))} + /> + ), + + string: ({ value, onChange, id }) => ( + onChange(e.target.value)} /> + ), + + literal: ({ options, value, onChange, id }) => ( + + ), + + ReactText: ({ value, onChange, id }) => knobs.string({ value, onChange, id }), + 'React.ElementType': ({ value, onChange, id }) => knobs.string({ value, onChange, id }), +}; diff --git a/packages/fluentui/react-builder/src/config.tsx b/packages/fluentui/react-builder/src/config.tsx index b251228a0e87b1..0a69c6e521235c 100644 --- a/packages/fluentui/react-builder/src/config.tsx +++ b/packages/fluentui/react-builder/src/config.tsx @@ -769,98 +769,3 @@ export const jsonTreeCloneElement = (tree: JSONTreeElement, element: any): JSONT } 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; - onRemoveProp: () => void; - options: string[]; - required: boolean; -}> = ({ label, types, value, onChange, onRemoveProp, options, required }) => { - const defaultType = 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 }); - - const propId = `prop-${label}`; - - return ( -
-
- {type !== 'boolean' && } - {types.length === 1 ? ( - {type} - ) : ( - types.map(t => ( - - )) - )} -
- {knob && knob({ options, value, onChange, id: propId })} - {type === 'boolean' && } - {!required && type === 'literal' && value && ( - - )} -
- ); -}; - -export const knobs = { - boolean: ({ value, onChange, id }) => ( - onChange(!!e.target.checked)} /> - ), - - number: ({ value, onChange, id }) => ( - onChange(Number(e.target.value))} - /> - ), - - string: ({ value, onChange, id }) => ( - onChange(e.target.value)} /> - ), - - literal: ({ options, value, onChange, id }) => ( - - ), - - ReactText: ({ value, onChange, id }) => knobs.string({ value, onChange, id }), - 'React.ElementType': ({ value, onChange, id }) => knobs.string({ value, onChange, id }), -}; diff --git a/packages/fluentui/react-builder/src/hooks/index.ts b/packages/fluentui/react-builder/src/hooks/index.ts new file mode 100644 index 00000000000000..cd6b5c6aa6672e --- /dev/null +++ b/packages/fluentui/react-builder/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useAxeOnElement'; +export * from './useMode'; diff --git a/packages/fluentui/react-builder/src/hooks/useAxeOnElement.ts b/packages/fluentui/react-builder/src/hooks/useAxeOnElement.ts new file mode 100644 index 00000000000000..ba361a97b71184 --- /dev/null +++ b/packages/fluentui/react-builder/src/hooks/useAxeOnElement.ts @@ -0,0 +1,41 @@ +import * as React from 'react'; +import * as axeCore from 'axe-core'; + +export function useAxeOnElement(): [any[], (selectedElementUuid: any) => void] { + const [axeErrors, setAxeErrors] = React.useState([]); + const runAxeOnElement = React.useCallback(selectedElementUuid => { + const iframe = document.getElementsByTagName('iframe')[0]; + const selectedComponentAxeErrors = []; + axeCore.run( + iframe, + { + rules: { + // excluding rules which are related to the whole page not to components + 'page-has-heading-one': { enabled: false }, + region: { enabled: false }, + 'landmark-one-main': { enabled: false }, + }, + }, + (err, result) => { + if (err) { + console.error('Axe failed', err); + } else { + result.violations.forEach(violation => { + violation.nodes.forEach(node => { + if (node.html.includes(`data-builder-id="${selectedElementUuid}"`)) { + selectedComponentAxeErrors.push( + node.failureSummary + .replace('Fix all of the following:', '-') + .replace('Fix any of the following:', '-'), + ); + } + }); + }); + } + setAxeErrors(selectedComponentAxeErrors); + }, + ); + }, []); + + return [axeErrors, runAxeOnElement]; +} diff --git a/packages/fluentui/react-builder/src/hooks/useMode.ts b/packages/fluentui/react-builder/src/hooks/useMode.ts new file mode 100644 index 00000000000000..d05205c35c549e --- /dev/null +++ b/packages/fluentui/react-builder/src/hooks/useMode.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { DesignerMode } from '../components/types'; + +export function useMode(): [ + { mode: DesignerMode; isExpanding: boolean; isSelecting: boolean }, + (mode: DesignerMode) => void, +] { + const [mode, setMode] = React.useState('build'); + const isExpanding = mode === 'build'; + const isSelecting = mode === 'build' || mode === 'design'; + + return [{ mode, isExpanding, isSelecting }, setMode]; +} diff --git a/packages/fluentui/react-builder/src/state/index.tsx b/packages/fluentui/react-builder/src/state/index.tsx new file mode 100644 index 00000000000000..1b7f99387d778b --- /dev/null +++ b/packages/fluentui/react-builder/src/state/index.tsx @@ -0,0 +1,2 @@ +export * from './state'; +export * from './utils'; diff --git a/packages/fluentui/react-builder/src/state/state.ts b/packages/fluentui/react-builder/src/state/state.ts new file mode 100644 index 00000000000000..fd4e6e8d21a04b --- /dev/null +++ b/packages/fluentui/react-builder/src/state/state.ts @@ -0,0 +1,300 @@ +import * as React from 'react'; +import { Reducer, useImmerReducer } from 'use-immer'; +import { JSONTreeElement } from '../components/types'; +import { ComponentInfo } from '../componentInfo/types'; +import { debug, focusTreeTitle, getDefaultJSONTree } from './utils'; +import { + jsonTreeFindElement, + resolveDrop, + jsonTreeCloneElement, + jsonTreeDeleteElement, + jsonTreeFindParent, + renderJSONTreeToJSXElement, + resolveDraggingElement, +} from '../config'; +import { componentInfoContext } from '../componentInfo/componentInfoContext'; +import { readTreeFromStore, readTreeFromURL } from '../utils/treeStore'; +import { renderElementToJSX } from '../../../docs-components/src/index'; + +export type JSONTreeOrigin = 'store' | 'url'; + +export type DesignerState = { + draggingElement: JSONTreeElement; + jsonTree: JSONTreeElement; + jsonTreeOrigin: JSONTreeOrigin; + selectedComponentInfo: ComponentInfo; // FIXME: should be computed in render? + selectedJSONTreeElementUuid: JSONTreeElement['uuid']; + showCode: boolean; + code: string | null; // only valid if showCode is set to true + codeError: string | null; + history: Array; + redo: Array; + insertComponent: { uuid: string; where: string; parentUuid?: string }; +}; + +export type DesignerAction = + | { type: 'DRAG_START'; component: JSONTreeElement } + | { type: 'DRAG_ABORT' } + | { type: 'DRAG_DROP'; dropParent: JSONTreeElement; dropIndex: number } + | { type: 'DRAG_CLONE' } + | { type: 'DRAG_MOVE' } + | { type: 'SELECT_COMPONENT'; component: JSONTreeElement } + | { type: 'SELECT_PARENT' } + | { type: 'DELETE_SELECTED_COMPONENT' } + | { type: 'PROP_CHANGE'; component: JSONTreeElement; propName: string; propValue: any } + | { type: 'PROP_DELETE'; component: JSONTreeElement; propName: string } + | { type: 'SWITCH_TO_STORE' } + | { type: 'RESET_STORE' } + | { type: 'SHOW_CODE'; show: boolean } + | { type: 'SOURCE_CODE_CHANGE'; code: string; jsonTree: JSONTreeElement } + | { type: 'SOURCE_CODE_ERROR'; code: string; error: string } + | { type: 'UNDO' } + | { type: 'REDO' } + | { type: 'OPEN_ADD_DIALOG'; uuid: string; where: string; parent?: string } + | { type: 'CLOSE_ADD_DIALOG' } + | { type: 'ADD_COMPONENT'; component: string; module: string }; + +export const stateReducer: Reducer = (draftState, action) => { + debug(`stateReducer: ${action.type}`, { action, draftState: JSON.parse(JSON.stringify(draftState)) }); + let treeChanged = false; + + switch (action.type) { + case 'DRAG_START': + draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); + draftState.redo = []; + + draftState.draggingElement = action.component; + break; + + case 'DRAG_ABORT': + draftState.history.pop(); + + draftState.draggingElement = null; + break; + + case 'DRAG_DROP': + if (action.dropParent) { + const dropParent = jsonTreeFindElement(draftState.jsonTree, action.dropParent.uuid); + resolveDrop(draftState.draggingElement, dropParent, action.dropIndex); + treeChanged = true; + } + + const addedComponent = jsonTreeFindElement(draftState.jsonTree, draftState.draggingElement.uuid); + + draftState.draggingElement = null; + if (addedComponent) { + draftState.selectedJSONTreeElementUuid = addedComponent.uuid; + draftState.selectedComponentInfo = componentInfoContext.byDisplayName[addedComponent.displayName]; + } + break; + + case 'DRAG_CLONE': + draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); + draftState.redo = []; + + draftState.draggingElement = jsonTreeCloneElement( + draftState.jsonTree, + jsonTreeFindElement(draftState.jsonTree, draftState.selectedJSONTreeElementUuid), + ); + break; + + case 'DRAG_MOVE': + draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); + draftState.redo = []; + + draftState.draggingElement = jsonTreeCloneElement( + draftState.jsonTree, + jsonTreeFindElement(draftState.jsonTree, draftState.selectedJSONTreeElementUuid), + ); + jsonTreeDeleteElement(draftState.jsonTree, draftState.selectedJSONTreeElementUuid); + treeChanged = true; + break; + + case 'SELECT_COMPONENT': + if (action.component && draftState.selectedJSONTreeElementUuid !== action.component.uuid) { + draftState.selectedJSONTreeElementUuid = action.component.uuid; + draftState.selectedComponentInfo = componentInfoContext.byDisplayName[action.component.displayName]; + } else { + draftState.selectedJSONTreeElementUuid = null; + draftState.selectedComponentInfo = null; + } + break; + + case 'SELECT_PARENT': + const parent = jsonTreeFindParent(draftState.jsonTree, draftState.selectedJSONTreeElementUuid); + if (parent) { + draftState.selectedJSONTreeElementUuid = parent.uuid; + draftState.selectedComponentInfo = componentInfoContext.byDisplayName[parent.displayName]; + } + break; + + case 'DELETE_SELECTED_COMPONENT': + draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); + draftState.redo = []; + + if (draftState.selectedJSONTreeElementUuid) { + jsonTreeDeleteElement(draftState.jsonTree, draftState.selectedJSONTreeElementUuid); + draftState.selectedJSONTreeElementUuid = null; + draftState.selectedComponentInfo = null; + treeChanged = true; + } + break; + + case 'PROP_CHANGE': + draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); + draftState.redo = []; + + const editedComponent = jsonTreeFindElement(draftState.jsonTree, action.component.uuid); + if (editedComponent) { + if (!editedComponent.props) { + editedComponent.props = {}; + } + editedComponent.props[action.propName] = action.propValue; + treeChanged = true; + } + break; + + case 'PROP_DELETE': + draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); + draftState.redo = []; + + const component = jsonTreeFindElement(draftState.jsonTree, action.component.uuid); + if (component) { + if (!component.props) { + component.props = {}; + } + delete component.props[action.propName]; + treeChanged = true; + } + break; + + case 'SWITCH_TO_STORE': + draftState.jsonTree = readTreeFromStore() || getDefaultJSONTree(); + draftState.jsonTreeOrigin = 'store'; + treeChanged = true; + break; + + case 'RESET_STORE': + draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); + draftState.redo = []; + + draftState.jsonTree = getDefaultJSONTree(); + draftState.jsonTreeOrigin = 'store'; + treeChanged = true; + break; + + case 'SHOW_CODE': + try { + draftState.showCode = action.show; + draftState.code = action.show ? renderElementToJSX(renderJSONTreeToJSXElement(draftState.jsonTree)) : null; + } catch (e) { + console.error('Failed to convert tree to code.', e.toString()); + } + break; + + case 'SOURCE_CODE_CHANGE': + draftState.code = action.code; + draftState.selectedJSONTreeElementUuid = null; + draftState.selectedComponentInfo = null; + draftState.jsonTree = action.jsonTree; + draftState.codeError = null; + break; + + case 'SOURCE_CODE_ERROR': + draftState.code = action.code; + draftState.selectedJSONTreeElementUuid = null; + draftState.selectedComponentInfo = null; + draftState.codeError = action.error; + break; + + case 'UNDO': + if (draftState.history.length > 0) { + draftState.redo.push(JSON.parse(JSON.stringify(draftState.jsonTree))); + draftState.jsonTree = draftState.history.pop(); + } + break; + + case 'REDO': + if (draftState.redo.length > 0) { + draftState.history.push(JSON.parse(JSON.stringify(draftState.jsonTree))); + draftState.jsonTree = draftState.redo.pop(); + } + break; + + case 'OPEN_ADD_DIALOG': { + const parent = jsonTreeFindParent(draftState.jsonTree, action.uuid); + draftState.insertComponent = { where: action.where, uuid: action.uuid, parentUuid: `${parent?.uuid}` }; + break; + } + + case 'CLOSE_ADD_DIALOG': + draftState.insertComponent = null; + break; + + case 'ADD_COMPONENT': { + const element = resolveDraggingElement(action.component, action.module); + + let parent: JSONTreeElement = undefined; + let index = 0; + const { where, uuid, parentUuid } = draftState.insertComponent; + draftState.insertComponent = null; + + if (where === 'first') { + parent = draftState.jsonTree; + } else if (where === 'child') { + parent = jsonTreeFindElement(draftState.jsonTree, uuid); + } else { + parent = jsonTreeFindElement(draftState.jsonTree, parentUuid); + index = parent.props.children.findIndex(c => c['uuid'] === uuid); + if (index === -1) { + index = 0; + } else { + where === 'after' && index++; + } + } + + resolveDrop(element, parent, index); + + draftState.selectedJSONTreeElementUuid = element.uuid; + draftState.selectedComponentInfo = componentInfoContext.byDisplayName[element.displayName]; + treeChanged = true; + setTimeout(() => focusTreeTitle(element.uuid)); + break; + } + + default: + throw new Error(`Invalid action ${action}`); + } + + if (treeChanged && draftState.showCode) { + draftState.code = renderElementToJSX(renderJSONTreeToJSXElement(draftState.jsonTree)); + draftState.codeError = null; + } +}; + +export function useDesignerState(): [DesignerState, React.Dispatch] { + const [state, dispatch] = useImmerReducer(stateReducer, null, () => { + let jsonTreeOrigin: JSONTreeOrigin = 'url'; + let jsonTree = readTreeFromURL(window.location.href); + if (!jsonTree) { + jsonTree = readTreeFromStore() || getDefaultJSONTree(); + jsonTreeOrigin = 'store'; + } + + return { + draggingElement: null, + jsonTree, + jsonTreeOrigin, + selectedComponentInfo: null, + selectedJSONTreeElementUuid: null, + showCode: false, + code: null, + codeError: null, + history: [], + redo: [], + insertComponent: null, + }; + }); + + return [state, dispatch]; +} diff --git a/packages/fluentui/react-builder/src/state/utils.ts b/packages/fluentui/react-builder/src/state/utils.ts new file mode 100644 index 00000000000000..03cda807e4f80c --- /dev/null +++ b/packages/fluentui/react-builder/src/state/utils.ts @@ -0,0 +1,15 @@ +import { JSONTreeElement } from '../components/types'; + +export const focusTreeTitle = uuid => { + // TODO: use refs + const element = document.querySelector(`#${uuid} [data-is-focusable]`) as HTMLElement; + element && element.focus(); +}; + +export function debug(...args) { + console.log('--Designer', ...args); +} + +export function getDefaultJSONTree(): JSONTreeElement { + return { uuid: 'builder-root', type: 'div' }; +}