From 3080439b6dfae014bc740539ade7279e307bd82c Mon Sep 17 00:00:00 2001 From: foyarash Date: Wed, 29 Jul 2020 18:35:46 +0200 Subject: [PATCH] Add component naming (#92) * Add component naming * Fix tests * Add component name in children inspector --- src/components/inspector/Inspector.tsx | 119 +++++++++++- .../elements-list/ElementListItem.tsx | 13 +- .../ElementListItemDraggable.tsx | 3 + .../inspector/elements-list/ElementsList.tsx | 1 + src/components/sidebar/Sidebar.tsx | 108 +---------- src/componentsList.ts | 172 ++++++++++++++++++ src/core/models/components.ts | 10 + src/core/selectors/components.ts | 10 + src/react-app-env.d.ts | 1 + src/utils/code.test.ts | 25 ++- src/utils/code.ts | 72 ++++++-- src/utils/recursive.ts | 27 +++ 12 files changed, 425 insertions(+), 136 deletions(-) create mode 100644 src/componentsList.ts diff --git a/src/components/inspector/Inspector.tsx b/src/components/inspector/Inspector.tsx index da3fc8c0a4..f9adacb282 100644 --- a/src/components/inspector/Inspector.tsx +++ b/src/components/inspector/Inspector.tsx @@ -1,5 +1,24 @@ -import React, { useState, memo, useEffect } from 'react' -import { Link, Box, Stack } from '@chakra-ui/core' +import React, { useState, memo, useEffect, useMemo } from 'react' +import { + Link, + Box, + Stack, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + FormControl, + FormLabel, + Input, + FormErrorMessage, + FormHelperText, + ModalFooter, + Button, + useDisclosure, + Text, +} from '@chakra-ui/core' import Panels from './panels/Panels' import { GoRepo, GoCode } from 'react-icons/go' import { FiTrash2 } from 'react-icons/fi' @@ -11,11 +30,13 @@ import { getSelectedComponent, getComponents, getSelectedComponentId, + getComponentNames, } from '../../core/selectors/components' import ActionButton from './ActionButton' -import { generateComponentCode } from '../../utils/code' +import { generateComponentCode, formatCode } from '../../utils/code' import useClipboard from '../../hooks/useClipboard' import { useInspectorUpdate } from '../../contexts/inspector-context' +import { componentsList } from '../../componentsList' const CodeActionButton = memo(() => { const [isLoading, setIsLoading] = useState(false) @@ -36,8 +57,13 @@ const CodeActionButton = memo(() => { variantColor={hasCopied ? 'green' : 'gray'} onClick={async () => { setIsLoading(true) - const code = await generateComponentCode(parent, components) - onCopy(code) + const code = await generateComponentCode({ + component: parent, + components, + componentName: components[selectedId].componentName, + forceBuildBlock: true, + }) + onCopy(await formatCode(code)) setIsLoading(false) }} icon={hasCopied ? 'check' : GoCode} @@ -48,9 +74,30 @@ const CodeActionButton = memo(() => { const Inspector = () => { const dispatch = useDispatch() const component = useSelector(getSelectedComponent) + const { isOpen, onOpen, onClose } = useDisclosure() + const [componentName, onChangeComponentName] = useState('') + const componentsNames = useSelector(getComponentNames) const { clearActiveProps } = useInspectorUpdate() + const saveComponent = (e: React.FormEvent) => { + e.preventDefault() + dispatch.components.setComponentName({ + componentId: component.id, + name: componentName, + }) + onClose() + onChangeComponentName('') + } + const isValidComponentName = useMemo(() => { + return ( + !!componentName.match(/^[A-Z]\w*$/g) && + !componentsNames.includes(componentName) && + // @ts-ignore + !componentsList.includes(componentName) + ) + }, [componentName, componentsNames]) + const { type, rootParentType, id, children } = component const isRoot = id === 'root' @@ -75,10 +122,15 @@ const Inspector = () => { shadow="sm" bg="yellow.100" display="flex" - alignItems="center" justifyContent="space-between" + flexDir="column" > {isRoot ? 'Document' : type} + {!!component.componentName && ( + + {component.componentName} + + )} {!isRoot && ( { justify="flex-end" > + {!component.componentName && ( + + )} dispatch.components.duplicate()} @@ -132,6 +191,54 @@ const Inspector = () => { showChildren={componentHasChildren} parentIsRoot={parentIsRoot} /> + + + +
+ Save this component + + + + Component name + ) => + onChangeComponentName(e.target.value) + } + /> + {!isValidComponentName && ( + + Component name must start with a capital character and must + not contain space or special character, and name should not + be already taken (including existing chakra-ui components). + + )} + + This will name your component that you will see in the code + panel as a separated component. + + + + + + + + +
+
) } diff --git a/src/components/inspector/elements-list/ElementListItem.tsx b/src/components/inspector/elements-list/ElementListItem.tsx index 7e4db30359..e39274b930 100644 --- a/src/components/inspector/elements-list/ElementListItem.tsx +++ b/src/components/inspector/elements-list/ElementListItem.tsx @@ -8,11 +8,20 @@ interface Props extends Pick { onMouseOver: PseudoBoxProps['onMouseOver'] onMouseOut: PseudoBoxProps['onMouseOut'] draggable?: boolean + name?: string } const ElementListItem = forwardRef( ( - { type, opacity = 1, onSelect, onMouseOut, onMouseOver, draggable }: Props, + { + type, + opacity = 1, + onSelect, + onMouseOut, + onMouseOver, + draggable, + name, + }: Props, ref: React.Ref, ) => { return ( @@ -34,7 +43,7 @@ const ElementListItem = forwardRef( {draggable && } - {type} + {name || type} { onSelect: (id: IComponent['id']) => void onHover: (id: IComponent['id']) => void onUnhover: () => void + name?: string } const ITEM_TYPE = 'elementItem' @@ -20,6 +21,7 @@ const ElementListItemDraggable: React.FC = ({ index, onHover, onUnhover, + name, }) => { const ref = useRef(null) const [, drop] = useDrop({ @@ -80,6 +82,7 @@ const ElementListItemDraggable: React.FC = ({ onMouseOut={onUnhover} type={type} draggable + name={name} /> ) } diff --git a/src/components/inspector/elements-list/ElementsList.tsx b/src/components/inspector/elements-list/ElementsList.tsx index efdfe0efbc..95123cee0b 100644 --- a/src/components/inspector/elements-list/ElementsList.tsx +++ b/src/components/inspector/elements-list/ElementsList.tsx @@ -31,6 +31,7 @@ const ElementsList: React.FC = ({ onSelect={onSelect} onHover={onHover} onUnhover={onUnhover} + name={element.componentName} /> ), )} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 7248957af3..0f9332932e 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -9,113 +9,7 @@ import { IconButton, } from '@chakra-ui/core' import DragItem from './DragItem' - -type MenuItem = { - children?: MenuItems - soon?: boolean - rootParentType?: ComponentType -} - -type MenuItems = Partial< - { - [k in ComponentType]: MenuItem - } -> - -const menuItems: MenuItems = { - Accordion: { - children: { - Accordion: {}, - AccordionItem: {}, - AccordionHeader: {}, - AccordionPanel: {}, - AccordionIcon: {}, - }, - }, - Alert: { - children: { - Alert: {}, - AlertDescription: {}, - AlertIcon: {}, - AlertTitle: {}, - }, - }, - AspectRatioBox: {}, - AvatarGroup: { - rootParentType: 'Avatar', - }, - Avatar: {}, - AvatarBadge: { - rootParentType: 'Avatar', - }, - Badge: {}, - Box: {}, - Breadcrumb: { - children: { - BreadcrumbItem: {}, - BreadcrumbLink: {}, - }, - }, - Button: {}, - Checkbox: {}, - CircularProgress: {}, - CloseButton: {}, - Code: {}, - Divider: {}, - Flex: {}, - FormControl: { - children: { - FormControl: {}, - FormLabel: {}, - FormHelperText: {}, - FormErrorMessage: {}, - }, - }, - Grid: {}, - Heading: {}, - Icon: {}, - IconButton: {}, - Image: {}, - Input: {}, - InputGroup: { - rootParentType: 'Input', - children: { - InputGroup: {}, - Input: {}, - InputLeftAddon: {}, - InputRightAddon: {}, - InputRightElement: {}, - InputLeftElement: {}, - }, - }, - Link: {}, - List: { - children: { - List: {}, - ListItem: {}, - }, - }, - NumberInput: {}, - Progress: {}, - Radio: {}, - RadioGroup: { - rootParentType: 'Radio', - }, - SimpleGrid: {}, - Spinner: {}, - Select: {}, - Stack: {}, - Switch: {}, - Tag: {}, - Text: {}, - Textarea: {}, - Menu: { soon: true }, - Tab: { soon: true }, - /*"Tabs", - "TabList", - "TabPanel", - "TabPanels"*/ -} +import { menuItems, MenuItem } from '../../componentsList' const Menu = () => { const [searchTerm, setSearchTerm] = useState('') diff --git a/src/componentsList.ts b/src/componentsList.ts new file mode 100644 index 0000000000..579b7b35fd --- /dev/null +++ b/src/componentsList.ts @@ -0,0 +1,172 @@ +export type MenuItem = { + children?: MenuItems + soon?: boolean + rootParentType?: ComponentType +} + +type MenuItems = Partial< + { + [k in ComponentType]: MenuItem + } +> + +export const menuItems: MenuItems = { + Accordion: { + children: { + Accordion: {}, + AccordionItem: {}, + AccordionHeader: {}, + AccordionPanel: {}, + AccordionIcon: {}, + }, + }, + Alert: { + children: { + Alert: {}, + AlertDescription: {}, + AlertIcon: {}, + AlertTitle: {}, + }, + }, + AspectRatioBox: {}, + AvatarGroup: { + rootParentType: 'Avatar', + }, + Avatar: {}, + AvatarBadge: { + rootParentType: 'Avatar', + }, + Badge: {}, + Box: {}, + Breadcrumb: { + children: { + BreadcrumbItem: {}, + BreadcrumbLink: {}, + }, + }, + Button: {}, + Checkbox: {}, + CircularProgress: {}, + CloseButton: {}, + Code: {}, + Divider: {}, + Flex: {}, + FormControl: { + children: { + FormControl: {}, + FormLabel: {}, + FormHelperText: {}, + FormErrorMessage: {}, + }, + }, + Grid: {}, + Heading: {}, + Icon: {}, + IconButton: {}, + Image: {}, + Input: {}, + InputGroup: { + rootParentType: 'Input', + children: { + InputGroup: {}, + Input: {}, + InputLeftAddon: {}, + InputRightAddon: {}, + InputRightElement: {}, + InputLeftElement: {}, + }, + }, + Link: {}, + List: { + children: { + List: {}, + ListItem: {}, + }, + }, + NumberInput: {}, + Progress: {}, + Radio: {}, + RadioGroup: { + rootParentType: 'Radio', + }, + SimpleGrid: {}, + Spinner: {}, + Select: {}, + Stack: {}, + Switch: {}, + Tag: {}, + Text: {}, + Textarea: {}, + Menu: { soon: true }, + Tab: { soon: true }, + /*"Tabs", + "TabList", + "TabPanel", + "TabPanels"*/ +} + +export const componentsList: ComponentType[] = [ + 'Accordion', + 'AccordionHeader', + 'AccordionIcon', + 'AccordionItem', + 'AccordionPanel', + 'Alert', + 'AlertDescription', + 'AlertIcon', + 'AlertTitle', + 'AspectRatioBox', + 'Avatar', + 'AvatarBadge', + 'AvatarGroup', + 'Badge', + 'Box', + 'Breadcrumb', + 'BreadcrumbItem', + 'BreadcrumbLink', + 'Button', + 'Checkbox', + 'CircularProgress', + 'CloseButton', + 'Code', + 'Divider', + 'Editable', + 'Flex', + 'FormControl', + 'FormErrorMessage', + 'FormHelperText', + 'FormLabel', + 'Grid', + 'Heading', + 'Icon', + 'IconButton', + 'Image', + 'Input', + 'InputGroup', + 'InputLeftAddon', + 'InputLeftElement', + 'InputRightAddon', + 'InputRightElement', + 'Link', + 'List', + 'ListIcon', + 'ListItem', + 'Menu', + 'NumberInput', + 'Progress', + 'Radio', + 'RadioGroup', + 'Select', + 'SimpleGrid', + 'Spinner', + 'Stack', + 'Switch', + 'Tab', + 'TabList', + 'TabPanel', + 'TabPanels', + 'Tabs', + 'Tag', + 'Text', + 'Textarea', +] diff --git a/src/core/models/components.ts b/src/core/models/components.ts index 5ddf745ad0..41e99bd795 100644 --- a/src/core/models/components.ts +++ b/src/core/models/components.ts @@ -226,6 +226,16 @@ const components = createModel({ } }) }, + setComponentName( + state: ComponentsState, + payload: { componentId: string; name: string }, + ): ComponentsState { + return produce(state, draftState => { + const component = draftState.components[payload.componentId] + + component.componentName = payload.name + }) + }, hover( state: ComponentsState, componentId: IComponent['id'], diff --git a/src/core/selectors/components.ts b/src/core/selectors/components.ts index 9c8078be2b..a5cf5a58ea 100644 --- a/src/core/selectors/components.ts +++ b/src/core/selectors/components.ts @@ -1,3 +1,4 @@ +import map from 'lodash/map' import { RootState } from '../store' export const getComponents = (state: RootState) => @@ -38,3 +39,12 @@ export const getHoveredId = (state: RootState) => export const getIsHovered = (id: IComponent['id']) => (state: RootState) => getHoveredId(state) === id + +export const getComponentNames = (state: RootState) => { + const names = map( + state.components.present.components, + comp => comp.componentName, + ).filter(comp => !!comp) + + return Array.from(new Set(names)) +} diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 13440e092e..6ffc748e3f 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -83,6 +83,7 @@ interface IComponent { id: string props: any rootParentType?: ComponentType + componentName?: string } interface IComponents { diff --git a/src/utils/code.test.ts b/src/utils/code.test.ts index bff7b9e8ed..f64c67245c 100644 --- a/src/utils/code.test.ts +++ b/src/utils/code.test.ts @@ -1,4 +1,4 @@ -import { generateComponentCode, generateCode } from './code' +import { generateComponentCode, generateCode, formatCode } from './code' const componentFixtures: IComponents = { root: { @@ -17,6 +17,7 @@ const componentFixtures: IComponents = { type: 'Box', parent: 'root', rootParentType: 'Box', + componentName: 'MyBox', }, 'comp-2': { id: 'comp-2', @@ -32,12 +33,14 @@ const componentFixtures: IComponents = { describe('Code utils', () => { it('should generate component code', async () => { - const code = await generateComponentCode( - componentFixtures['root'], - componentFixtures, - ) + const code = await generateComponentCode({ + component: componentFixtures['root'], + components: componentFixtures, + componentName: 'MyBox', + forceBuildBlock: true, + }) - expect(code).toEqual(`const MyBox = () => ( + expect(await formatCode(code)).toEqual(`const MyBox = () => ( Lorem Ipsum @@ -51,12 +54,16 @@ describe('Code utils', () => { expect(code).toEqual(`import React from 'react' import { ThemeProvider, CSSReset, theme, Box, Text } from '@chakra-ui/core' +const MyBox = () => ( + + Lorem Ipsum + +) + const App = () => ( - - Lorem Ipsum - + ) diff --git a/src/utils/code.ts b/src/utils/code.ts index a0bcd262c6..7fc00d294a 100644 --- a/src/utils/code.ts +++ b/src/utils/code.ts @@ -1,10 +1,11 @@ import isBoolean from 'lodash/isBoolean' +import filter from 'lodash/filter' const capitalize = (value: string) => { return value.charAt(0).toUpperCase() + value.slice(1) } -const formatCode = async (code: string) => { +export const formatCode = async (code: string) => { let formattedCode = `// 🚨 Your props contains invalid code` const prettier = await import('prettier/standalone') @@ -22,14 +23,24 @@ const formatCode = async (code: string) => { return formattedCode } -const buildBlock = (component: IComponent, components: IComponents) => { +type BuildBlockParams = { + component: IComponent + components: IComponents + forceBuildBlock?: boolean +} + +const buildBlock = ({ + component, + components, + forceBuildBlock = false, +}: BuildBlockParams) => { let content = '' component.children.forEach((key: string) => { let childComponent = components[key] if (!childComponent) { console.error(`invalid component ${key}`) - } else { + } else if (forceBuildBlock || !childComponent.componentName) { const componentName = capitalize(childComponent.type) let propsContent = '' @@ -62,33 +73,68 @@ const buildBlock = (component: IComponent, components: IComponents) => { content += `<${componentName} ${propsContent}>${childComponent.props.children}` } else if (childComponent.children.length) { content += `<${componentName} ${propsContent}> - ${buildBlock(childComponent, components)} + ${buildBlock({ component: childComponent, components, forceBuildBlock })} ` } else { content += `<${componentName} ${propsContent} />` } + } else { + content += `<${childComponent.componentName} />` } }) return content } -export const generateComponentCode = async ( - component: IComponent, - components: IComponents, -) => { - let code = buildBlock(component, components) +const buildComponents = (components: IComponents) => { + const codes = filter(components, comp => !!comp.componentName).map(comp => { + return generateComponentCode({ + component: { ...components[comp.parent], children: [comp.id] }, + components, + forceBuildBlock: true, + componentName: comp.componentName, + }) + }) + + return codes.reduce((acc, val) => { + return ` + ${acc} + + ${val} + ` + }, '') +} + +type GenerateComponentCode = { + component: IComponent + components: IComponents + componentName?: string + forceBuildBlock?: boolean +} + +export const generateComponentCode = ({ + component, + components, + componentName, + forceBuildBlock, +}: GenerateComponentCode) => { + let code = buildBlock({ + component, + components, + forceBuildBlock, + }) code = ` -const My${component.type} = () => ( +const ${componentName} = () => ( ${code} )` - return await formatCode(code) + return code } export const generateCode = async (components: IComponents) => { - let code = buildBlock(components.root, components) + let code = buildBlock({ component: components.root, components }) + let componentsCodes = buildComponents(components) const imports = [ ...new Set( @@ -106,6 +152,8 @@ import { ${imports.join(',')} } from "@chakra-ui/core"; +${componentsCodes} + const App = () => ( diff --git a/src/utils/recursive.ts b/src/utils/recursive.ts index 4e1889266d..150ff132fe 100644 --- a/src/utils/recursive.ts +++ b/src/utils/recursive.ts @@ -1,4 +1,5 @@ import omit from 'lodash/omit' +import filter from 'lodash/filter' import { generateId } from './generateId' export const duplicateComponent = ( @@ -13,11 +14,37 @@ export const duplicateComponent = ( return cloneComponent(components[child]) }) + let newComponentName = component.componentName + if (newComponentName) { + const matches = /^([a-zA-Z]*)(\d+)?$/g.exec(newComponentName) + // Get all components with a similar name (same base component name + number suffix) + const similarComponents = filter( + components, + comp => !!comp.componentName?.includes(matches![1]), + ) + let highestNumber = 0 + // Get the highest suffix number + similarComponents.forEach(comp => { + const nameMatches = /^([a-zA-Z]*)(\d+)?$/g.exec(comp.componentName!) + const number = nameMatches?.length === 2 ? 0 : Number(nameMatches![2]) + + if (number > highestNumber) { + highestNumber = number + } + }) + // Use the suffix number + 1 to name our duplicated component + newComponentName = newComponentName.replace( + /^([a-zA-Z]*)(\d+)?$/g, + `$1${highestNumber + 1}`, + ) + } + clonedComponents[newid] = { ...component, id: newid, props: { ...component.props }, children, + componentName: newComponentName, } children.forEach(child => {