Skip to content

Commit

Permalink
parsing support for nested components (#36)
Browse files Browse the repository at this point in the history
This PR adds the ability to parse nested components that use
props.children, and represent them in the UI as a tree.
Saving to file does not work.
Nested components do not show up in the preview.

Updated uuid usages from v1 to v4, since v1 uuids looked too similar
and were annoying to test with.

TEST=manual

check in react dev tools that the parentUUIDsFromRoot is correct
  • Loading branch information
oshi97 committed Sep 8, 2022
1 parent e91c511 commit 323412d
Show file tree
Hide file tree
Showing 35 changed files with 398 additions and 246 deletions.
18 changes: 18 additions & 0 deletions src/components/Card.tsx
@@ -0,0 +1,18 @@
import { ReactNode } from 'react';
import { HexColor } from '../../studio/types';

export interface CardProps {
bgColor?: HexColor
text?: string
children?: ReactNode
}

export default function Card(props: CardProps) {
return (
<div style={{backgroundColor: props.bgColor}}>
hi this is a card
{props.text}
{props.children}
</div>
)
}
8 changes: 8 additions & 0 deletions src/templates/index.tsx
Expand Up @@ -10,6 +10,7 @@ import {
import '../index.css'
import Footer, { globalProps as footerProps } from '../components/Footer.global'
import Header, { globalProps as headerProps } from '../components/Header.global'
import Card from '../components/Card'

export const config: TemplateConfig = {
stream: {
Expand All @@ -28,6 +29,13 @@ const IndexTemplate: Template<TemplateRenderProps> = ({ document }) => {
return (
<TestLayout>
<Header {...headerProps} />
<Card bgColor='#45de0d'>
<Card bgColor='#abcdef'>
<Card bgColor='#ffffee'>
<Card/>
</Card>
</Card>
</Card>
<Banner
randomNum={document.address.city.bob}
subtitleUsingStreams={document.id}
Expand Down
4 changes: 2 additions & 2 deletions studio/client/components/AddComponentButton.tsx
@@ -1,5 +1,5 @@
import { ComponentState, useCallback, useState } from 'react'
import { v1 } from 'uuid'
import { v4 } from 'uuid'
import { ModuleMetadata, PossibleModuleNames, StandardComponentMetaData } from '../../shared/models'
import { useStudioContext } from './useStudioContext'

Expand All @@ -15,7 +15,7 @@ export default function AddComponentButton() {
const newComponentState: ComponentState = {
name: componentName,
props: componentMetadata.initialProps || {},
uuid: v1(),
uuid: v4(),
moduleName
}
let i = pageState.componentsState.length - 1
Expand Down
44 changes: 26 additions & 18 deletions studio/client/components/ComponentTree.tsx
@@ -1,7 +1,8 @@
import classNames from 'classnames'
import { isEqual } from 'lodash'
import { useRef } from 'react'
import { useCallback, useRef } from 'react'
import { ComponentState, PageState } from '../../shared/models'
import findComponentState from '../utils/findComponentState'
import CustomContextMenu from './CustomContextMenu'
import { useStudioContext } from './useStudioContext'

Expand All @@ -18,20 +19,22 @@ export default function ComponentTree() {
function ComponentNode({ c }: { c: ComponentState }) {
const ref = useRef<HTMLDivElement>(null)
const {
activeComponentUUID,
setActiveComponentUUID,
activeComponentState,
setActiveComponentState,
pageStateOnFile,
moduleNameToComponentMetadata
} = useStudioContext()
const activeComponentUUID = activeComponentState?.uuid

const updateActiveComponent = (uuid: string) => {
if (activeComponentUUID !== uuid) {
setActiveComponentUUID(uuid)
const updateActiveComponent = useCallback(() => {
if (activeComponentUUID !== c.uuid) {
setActiveComponentState(c)
} else {
setActiveComponentUUID(undefined)
setActiveComponentState(undefined)
}
}
const className = classNames('flex cursor-pointer select-none border-solid border-2', {
}, [activeComponentUUID, c, setActiveComponentState])

const className = classNames('flex border-solid border-2 ', {
'border-indigo-600': activeComponentUUID === c.uuid,
'border-transparent': activeComponentUUID !== c.uuid
})
Expand All @@ -40,19 +43,24 @@ function ComponentNode({ c }: { c: ComponentState }) {

return (
<div
ref={ref}
key={c.uuid}
className={className}
onClick={() => updateActiveComponent(c.uuid)}
className='cursor-pointer select-none ml-4'
>
{!isGlobal && <CustomContextMenu elementRef={ref} componentUUID={c.uuid} />}
{c.name}
{hasUnsavedChanges(c, pageStateOnFile) && <div className='red'>*</div>}
<div
className={className}
ref={ref}
onClick={updateActiveComponent}
>
{!isGlobal && <CustomContextMenu elementRef={ref} componentUUID={c.uuid} />}
{c.name}
{hasUnsavedChanges(c, pageStateOnFile) && <div className='red'>*</div>}
</div>
{c.children?.map(c => <ComponentNode c={c} key={c.uuid}/>)}
</div>
)
}

function hasUnsavedChanges(c: ComponentState, pageStateOnFile: PageState) {
const initialProps = pageStateOnFile.componentsState.find(({ uuid }) => uuid === c.uuid)?.props
return !isEqual(c.props, initialProps)
function hasUnsavedChanges(componentState: ComponentState, pageStateOnFile: PageState) {
const initialComponentState = findComponentState(componentState, pageStateOnFile.componentsState)
return !isEqual(componentState.props, initialComponentState?.props)
}
23 changes: 14 additions & 9 deletions studio/client/components/PageEditor.tsx
@@ -1,30 +1,35 @@
import { ComponentMetadata, ComponentState, PropState } from '../../shared/models'
import { ComponentMetadata, PropState } from '../../shared/models'
import findComponentState from '../utils/findComponentState'
import iterateOverComponentsState from '../utils/iterateOverComponentsState'
import PropEditor from './PropEditor'
import { useStudioContext } from './useStudioContext'

export function PageEditor(): JSX.Element | null {
const { pageState, setPageState, moduleNameToComponentMetadata, activeComponentUUID } = useStudioContext()
const activeComponentState: ComponentState | undefined =
pageState.componentsState.find(c => c.uuid === activeComponentUUID)

const { pageState, setPageState, moduleNameToComponentMetadata, activeComponentState } = useStudioContext()
if (!activeComponentState) {
return null
}

const componentMetadata: ComponentMetadata =
moduleNameToComponentMetadata[activeComponentState.moduleName][activeComponentState.name]

const setPropState = (val: PropState) => {
// TODO(oshi): we cannot use cloneDeep here over the spread operator.
// If we do then activeComponentState will get out of sync and point to a ComponentState BEFORE the clone.
// We should probably switch to using Redux instead of simple Context since the state is becoming complex.
const copy = [...pageState.componentsState]
if (componentMetadata.global) {
// update propState for other instances of the same global functional component
copy.forEach((c, i) => {
iterateOverComponentsState(copy, c => {
if (c.name === activeComponentState.name) {
copy[i].props = val
c.props = val
}
})
} else {
const i = copy.findIndex(c => c.uuid === activeComponentUUID)
copy[i].props = val
const c = findComponentState(activeComponentState, copy)
if (c) {
c.props = val
}
}
setPageState({
...pageState,
Expand Down
3 changes: 0 additions & 3 deletions studio/client/components/PropEditor.tsx
Expand Up @@ -4,14 +4,12 @@ import { PropTypes } from '../../types'
import StreamsProp from './StreamsProp'

export interface PropEditorProps {
componentName?: string,
propState: PropState,
componentMetadata: ComponentMetadata,
setPropState: (val: PropState) => void
}

export default function PropEditor({
componentName,
propState,
setPropState,
componentMetadata
Expand All @@ -35,7 +33,6 @@ export default function PropEditor({
})
return (
<div className={className}>
{componentName && <h1 className='font-bold pb-1'>{componentName}</h1>}
{
Object.keys(propShape).map((propName, index) => {
const propDoc = propShape[propName].doc
Expand Down
8 changes: 4 additions & 4 deletions studio/client/components/Studio.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { SiteSettingsProps } from './SiteSettings'
import { PageState, ModuleNameToComponentMetadata } from '../../shared/models'
import { PageState, ModuleNameToComponentMetadata, ComponentState } from '../../shared/models'
import { StudioContext } from './useStudioContext'
import RightSidebar from './RightSidebar'
import PagePreview from './PagePreview'
Expand All @@ -20,7 +20,7 @@ export default function Studio(props: StudioProps) {
const { componentsOnPage, moduleNameToComponentMetadata, siteSettings } = props
const [pageState, setPageState] = useState(componentsOnPage.index)
const [streamDocument, setStreamDocument] = useState({})
const [activeComponentUUID, setActiveComponentUUID] = useState<string | undefined>()
const [activeComponentState, setActiveComponentState] = useState<ComponentState | undefined>()
const [pageStateOnFile, setPageStateOnFile] = useState<PageState>(cloneDeep(componentsOnPage.index))

const value = {
Expand All @@ -30,8 +30,8 @@ export default function Studio(props: StudioProps) {
siteSettings,
streamDocument,
setStreamDocument,
activeComponentUUID,
setActiveComponentUUID,
activeComponentState,
setActiveComponentState,
pageStateOnFile,
setPageStateOnFile
}
Expand Down
1 change: 0 additions & 1 deletion studio/client/components/useMessageListener.ts
Expand Up @@ -22,5 +22,4 @@ export default function useMessageListener(messageID: MessageID, options: Listen
import.meta.hot?.on(messageID, payloadHandler)
return () => { isUnmounted = true }
}, [options, messageID])

}
6 changes: 3 additions & 3 deletions studio/client/components/useStudioContext.tsx
@@ -1,6 +1,6 @@
import { TemplateProps } from '@yext/pages'
import { createContext, useContext, Dispatch, SetStateAction } from 'react'
import { PageState } from '../../shared/models'
import { ComponentState, PageState } from '../../shared/models'
import { StudioProps } from './Studio'

export interface StudioContextType {
Expand All @@ -10,8 +10,8 @@ export interface StudioContextType {
siteSettings: StudioProps['siteSettings'],
streamDocument: TemplateProps['document'],
setStreamDocument: Dispatch<SetStateAction<TemplateProps['document']>>,
activeComponentUUID: string | undefined,
setActiveComponentUUID: Dispatch<SetStateAction<string | undefined>>,
activeComponentState: ComponentState | undefined,
setActiveComponentState: Dispatch<SetStateAction<ComponentState | undefined>>,
pageStateOnFile: PageState,
setPageStateOnFile: Dispatch<SetStateAction<PageState>>
}
Expand Down
22 changes: 22 additions & 0 deletions studio/client/utils/findComponentState.ts
@@ -0,0 +1,22 @@
import { ComponentState } from '../../shared/models'

export default function findComponentState(
componentStateToFind: Pick<ComponentState, 'uuid' | 'parentUUIDsFromRoot' | 'props'>,
componentsState: ComponentState[]
): ComponentState | undefined {
let parentComponentState: ComponentState | undefined
for (const uuid of componentStateToFind.parentUUIDsFromRoot ?? []) {
parentComponentState = getComponentState(uuid)
if (!parentComponentState) {
console.error(
'Unable to find parent uuid', uuid, 'for parentUUIDsFromRoot', componentStateToFind.parentUUIDsFromRoot)
return undefined
}
}
return getComponentState(componentStateToFind.uuid)

function getComponentState(uuid: string) {
return parentComponentState?.children?.find(c => c.uuid === uuid) ??
componentsState.find(c => c.uuid === uuid)
}
}
17 changes: 17 additions & 0 deletions studio/client/utils/iterateOverComponentsState.ts
@@ -0,0 +1,17 @@
import { ComponentState } from '../../shared/models'

type Handler = (c: ComponentState) => void

export default function iterateOverComponentsState(
componentsState: ComponentState[],
handler: Handler
): void {
componentsState.forEach(c => handleComponentState(c, handler))
}

function handleComponentState(componentState: ComponentState, handler: Handler) {
handler(componentState)
if (componentState.children) {
iterateOverComponentsState(componentState.children, handler)
}
}
22 changes: 15 additions & 7 deletions studio/shared/models.ts
@@ -1,4 +1,3 @@
import studioConfig from '../../src/studio'
import { PropStateTypes, PropTypes } from '../types'

export type PageState = {
Expand All @@ -10,17 +9,20 @@ export interface ComponentState {
name: string,
props: PropState,
uuid: string,
moduleName: PossibleModuleNames | 'builtIn'
moduleName: PossibleModuleNames,
children?: ComponentState[],
isFragment?: true,
parentUUIDsFromRoot?: string[]
}

export type PropState = {
[propName: string]: PropStateTypes
}

export type ModuleNameToComponentMetadata = {
[moduleName in PossibleModuleNames]: ModuleMetadata
[moduleName in Exclude<PossibleModuleNames, 'builtIn'>]: ModuleMetadata
}
export type PossibleModuleNames = keyof typeof studioConfig['npmComponents'] | 'localComponents' | 'localLayouts'
export type PossibleModuleNames = 'localComponents' | 'localLayouts' | 'builtIn'
export type ModuleMetadata = {
[componentName: string]: ComponentMetadata
}
Expand All @@ -29,19 +31,25 @@ export type ComponentMetadata = StandardComponentMetaData | GlobalComponentMetaD
type CommonComponentMetaData = {
propShape?: PropShape,
editable: boolean,
importIdentifier: string
importIdentifier: string,
acceptsChildren: boolean
}
export type StandardComponentMetaData = {
global: false,
global?: false,
initialProps?: PropState
} & CommonComponentMetaData
export type GlobalComponentMetaData = {
global: true,
globalProps?: PropState
} & CommonComponentMetaData

export type PropShape = {
export enum SpecialReactProps {
Children = 'children'
}
export type PropShape = Omit<{
[propName: string]: PropMetadata
}, SpecialReactProps> & {
[propName in SpecialReactProps]?: never
}
export type PropMetadata = {
type: PropTypes,
Expand Down
12 changes: 12 additions & 0 deletions studio/studio-plugin/__fixtures__/nestedComponents.tsx
@@ -0,0 +1,12 @@
import Card from '../components/Card'

export default function Page() {
return (
<>
<Card bgColor='#453d0d'>
<Card/>
</Card>
</>
)
}

13 changes: 13 additions & 0 deletions studio/studio-plugin/__mocks__/componentMetadata.ts
Expand Up @@ -4,7 +4,20 @@ import { PropTypes } from '../../types'
export const moduleNameToComponentMetadata: ModuleNameToComponentMetadata = {
localLayouts: {},
localComponents: {
Card: {
global: false,
acceptsChildren: true,
editable: true,
importIdentifier: './components/Bard',
propShape: {
bgColor: {
type: PropTypes.HexColor
}
}
},
Banner: {
global: false,
acceptsChildren: false,
editable: true,
importIdentifier: './components/Banner',
global: false,
Expand Down

0 comments on commit 323412d

Please sign in to comment.