From 323412daba33f60359a9c3f1a15b0ca4ba191ff7 Mon Sep 17 00:00:00 2001 From: Oliver Shi Date: Thu, 8 Sep 2022 15:06:52 -0400 Subject: [PATCH] parsing support for nested components (#36) 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 --- src/components/Card.tsx | 18 +++ src/templates/index.tsx | 8 ++ .../client/components/AddComponentButton.tsx | 4 +- studio/client/components/ComponentTree.tsx | 44 ++++--- studio/client/components/PageEditor.tsx | 23 ++-- studio/client/components/PropEditor.tsx | 3 - studio/client/components/Studio.tsx | 8 +- .../client/components/useMessageListener.ts | 1 - studio/client/components/useStudioContext.tsx | 6 +- studio/client/utils/findComponentState.ts | 22 ++++ .../utils/iterateOverComponentsState.ts | 17 +++ studio/shared/models.ts | 22 ++-- .../__fixtures__/nestedComponents.tsx | 12 ++ .../__mocks__/componentMetadata.ts | 13 +++ studio/studio-plugin/common/common.ts | 9 -- .../studio-plugin/common/getComponentNodes.ts | 12 -- studio/studio-plugin/common/getPropName.ts | 5 + .../studio-plugin/common/getPropShape.test.ts | 4 +- studio/studio-plugin/common/getPropShape.ts | 11 +- studio/studio-plugin/common/index.ts | 3 +- .../common/parsePropertyStructures.ts | 23 +++- studio/studio-plugin/componentMetadata.ts | 13 +-- .../streams/getUpdatedStreamConfig.ts | 4 +- .../ts-morph/getComponentModuleName.ts | 2 +- .../ts-morph/parseComponentMetadata.test.ts | 1 + .../ts-morph/parseComponentMetadata.ts | 4 +- .../ts-morph/parseComponentState.test.ts | 58 +++++++++ .../ts-morph/parseComponentState.ts | 100 ++++++++++++++++ .../ts-morph/parseJsxAttributes.ts | 9 +- .../ts-morph/parseLayoutState.ts | 14 +-- .../ts-morph/parseNpmComponents.ts | 110 ------------------ .../ts-morph/parsePageFile.test.ts | 23 +++- .../studio-plugin/ts-morph/parsePageFile.ts | 33 +----- .../ts-morph/updatePageFile.test.ts | 2 +- studio/types.ts | 3 +- 35 files changed, 398 insertions(+), 246 deletions(-) create mode 100644 src/components/Card.tsx create mode 100644 studio/client/utils/findComponentState.ts create mode 100644 studio/client/utils/iterateOverComponentsState.ts create mode 100644 studio/studio-plugin/__fixtures__/nestedComponents.tsx delete mode 100644 studio/studio-plugin/common/common.ts delete mode 100644 studio/studio-plugin/common/getComponentNodes.ts create mode 100644 studio/studio-plugin/common/getPropName.ts create mode 100644 studio/studio-plugin/ts-morph/parseComponentState.test.ts create mode 100644 studio/studio-plugin/ts-morph/parseComponentState.ts delete mode 100644 studio/studio-plugin/ts-morph/parseNpmComponents.ts diff --git a/src/components/Card.tsx b/src/components/Card.tsx new file mode 100644 index 000000000..dad5f996d --- /dev/null +++ b/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 ( +
+ hi this is a card + {props.text} + {props.children} +
+ ) +} \ No newline at end of file diff --git a/src/templates/index.tsx b/src/templates/index.tsx index f3db40a97..e4994029c 100644 --- a/src/templates/index.tsx +++ b/src/templates/index.tsx @@ -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: { @@ -28,6 +29,13 @@ const IndexTemplate: Template = ({ document }) => { return (
+ + + + + + + (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 }) @@ -40,19 +43,24 @@ function ComponentNode({ c }: { c: ComponentState }) { return (
updateActiveComponent(c.uuid)} + className='cursor-pointer select-none ml-4' > - {!isGlobal && } - {c.name} - {hasUnsavedChanges(c, pageStateOnFile) &&
*
} +
+ {!isGlobal && } + {c.name} + {hasUnsavedChanges(c, pageStateOnFile) &&
*
} +
+ {c.children?.map(c => )}
) } -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) } \ No newline at end of file diff --git a/studio/client/components/PageEditor.tsx b/studio/client/components/PageEditor.tsx index 3fb6088cc..ebdccc076 100644 --- a/studio/client/components/PageEditor.tsx +++ b/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, diff --git a/studio/client/components/PropEditor.tsx b/studio/client/components/PropEditor.tsx index 75bfd779a..b0f29a2af 100644 --- a/studio/client/components/PropEditor.tsx +++ b/studio/client/components/PropEditor.tsx @@ -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 @@ -35,7 +33,6 @@ export default function PropEditor({ }) return (
- {componentName &&

{componentName}

} { Object.keys(propShape).map((propName, index) => { const propDoc = propShape[propName].doc diff --git a/studio/client/components/Studio.tsx b/studio/client/components/Studio.tsx index 1db5ecff0..fb064d56e 100644 --- a/studio/client/components/Studio.tsx +++ b/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' @@ -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() + const [activeComponentState, setActiveComponentState] = useState() const [pageStateOnFile, setPageStateOnFile] = useState(cloneDeep(componentsOnPage.index)) const value = { @@ -30,8 +30,8 @@ export default function Studio(props: StudioProps) { siteSettings, streamDocument, setStreamDocument, - activeComponentUUID, - setActiveComponentUUID, + activeComponentState, + setActiveComponentState, pageStateOnFile, setPageStateOnFile } diff --git a/studio/client/components/useMessageListener.ts b/studio/client/components/useMessageListener.ts index 05e0a0dc7..22051ad3e 100644 --- a/studio/client/components/useMessageListener.ts +++ b/studio/client/components/useMessageListener.ts @@ -22,5 +22,4 @@ export default function useMessageListener(messageID: MessageID, options: Listen import.meta.hot?.on(messageID, payloadHandler) return () => { isUnmounted = true } }, [options, messageID]) - } \ No newline at end of file diff --git a/studio/client/components/useStudioContext.tsx b/studio/client/components/useStudioContext.tsx index 7008e9cfc..3cee840cf 100644 --- a/studio/client/components/useStudioContext.tsx +++ b/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 { @@ -10,8 +10,8 @@ export interface StudioContextType { siteSettings: StudioProps['siteSettings'], streamDocument: TemplateProps['document'], setStreamDocument: Dispatch>, - activeComponentUUID: string | undefined, - setActiveComponentUUID: Dispatch>, + activeComponentState: ComponentState | undefined, + setActiveComponentState: Dispatch>, pageStateOnFile: PageState, setPageStateOnFile: Dispatch> } diff --git a/studio/client/utils/findComponentState.ts b/studio/client/utils/findComponentState.ts new file mode 100644 index 000000000..5a599ae3c --- /dev/null +++ b/studio/client/utils/findComponentState.ts @@ -0,0 +1,22 @@ +import { ComponentState } from '../../shared/models' + +export default function findComponentState( + componentStateToFind: Pick, + 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) + } +} \ No newline at end of file diff --git a/studio/client/utils/iterateOverComponentsState.ts b/studio/client/utils/iterateOverComponentsState.ts new file mode 100644 index 000000000..4f49319b6 --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/studio/shared/models.ts b/studio/shared/models.ts index 4e1c213c3..bf9d65982 100644 --- a/studio/shared/models.ts +++ b/studio/shared/models.ts @@ -1,4 +1,3 @@ -import studioConfig from '../../src/studio' import { PropStateTypes, PropTypes } from '../types' export type PageState = { @@ -10,7 +9,10 @@ export interface ComponentState { name: string, props: PropState, uuid: string, - moduleName: PossibleModuleNames | 'builtIn' + moduleName: PossibleModuleNames, + children?: ComponentState[], + isFragment?: true, + parentUUIDsFromRoot?: string[] } export type PropState = { @@ -18,9 +20,9 @@ export type PropState = { } export type ModuleNameToComponentMetadata = { - [moduleName in PossibleModuleNames]: ModuleMetadata + [moduleName in Exclude]: ModuleMetadata } -export type PossibleModuleNames = keyof typeof studioConfig['npmComponents'] | 'localComponents' | 'localLayouts' +export type PossibleModuleNames = 'localComponents' | 'localLayouts' | 'builtIn' export type ModuleMetadata = { [componentName: string]: ComponentMetadata } @@ -29,10 +31,11 @@ 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 = { @@ -40,8 +43,13 @@ export type GlobalComponentMetaData = { 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, diff --git a/studio/studio-plugin/__fixtures__/nestedComponents.tsx b/studio/studio-plugin/__fixtures__/nestedComponents.tsx new file mode 100644 index 000000000..69a1edd82 --- /dev/null +++ b/studio/studio-plugin/__fixtures__/nestedComponents.tsx @@ -0,0 +1,12 @@ +import Card from '../components/Card' + +export default function Page() { + return ( + <> + + + + + ) +} + diff --git a/studio/studio-plugin/__mocks__/componentMetadata.ts b/studio/studio-plugin/__mocks__/componentMetadata.ts index ad112607c..976bd0cb2 100644 --- a/studio/studio-plugin/__mocks__/componentMetadata.ts +++ b/studio/studio-plugin/__mocks__/componentMetadata.ts @@ -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, diff --git a/studio/studio-plugin/common/common.ts b/studio/studio-plugin/common/common.ts deleted file mode 100644 index c02569999..000000000 --- a/studio/studio-plugin/common/common.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { JsxOpeningElement, JsxSelfClosingElement, ts, Node } from 'ts-morph' - -export function getComponentName(n: JsxOpeningElement | JsxSelfClosingElement): string { - return n.getTagNameNode().getText() -} - -export function getPropName(n: Node): string | undefined { - return n.getFirstDescendantByKind(ts.SyntaxKind.Identifier)?.compilerNode.text -} diff --git a/studio/studio-plugin/common/getComponentNodes.ts b/studio/studio-plugin/common/getComponentNodes.ts deleted file mode 100644 index 385389de5..000000000 --- a/studio/studio-plugin/common/getComponentNodes.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { JsxOpeningElement, JsxSelfClosingElement, ts, JsxElement, JsxFragment } from 'ts-morph' - -export function getComponentNodes( - parentNode: JsxElement | JsxFragment -): (JsxOpeningElement | JsxSelfClosingElement)[] { - const nodes = parentNode - .getDescendants() - .filter(n => { - return n.isKind(ts.SyntaxKind.JsxOpeningElement) || n.isKind(ts.SyntaxKind.JsxSelfClosingElement) - }) as (JsxOpeningElement | JsxSelfClosingElement)[] - return nodes -} diff --git a/studio/studio-plugin/common/getPropName.ts b/studio/studio-plugin/common/getPropName.ts new file mode 100644 index 000000000..f6a82d325 --- /dev/null +++ b/studio/studio-plugin/common/getPropName.ts @@ -0,0 +1,5 @@ +import { Node, SyntaxKind } from 'ts-morph' + +export function getPropName(n: Node): string | undefined { + return n.getFirstDescendantByKind(SyntaxKind.Identifier)?.compilerNode.text +} diff --git a/studio/studio-plugin/common/getPropShape.test.ts b/studio/studio-plugin/common/getPropShape.test.ts index 0f2275490..4fb784656 100644 --- a/studio/studio-plugin/common/getPropShape.test.ts +++ b/studio/studio-plugin/common/getPropShape.test.ts @@ -8,7 +8,7 @@ jest.spyOn(console, 'error').mockImplementation(jest.fn()) jest.mock('../getRootPath') it('gets prop interface defined in the provided file path correctly', () => { - const propShape = getPropShape( + const { propShape } = getPropShape( getSourceFile(getRootPath('components/Banner.tsx')), getRootPath('components/Banner.tsx'), 'BannerProps' @@ -33,7 +33,7 @@ it('gets prop interface defined in the provided file path correctly', () => { }) it('gets prop interface from an import correctly', () => { - const propShape = getPropShape( + const { propShape } = getPropShape( getSourceFile(getRootPath('components/SpecificHeader.global.tsx')), getRootPath('components/SpecificHeader.global.tsx'), 'SpecificHeaderProps' diff --git a/studio/studio-plugin/common/getPropShape.ts b/studio/studio-plugin/common/getPropShape.ts index b037cb280..5c0a5c878 100644 --- a/studio/studio-plugin/common/getPropShape.ts +++ b/studio/studio-plugin/common/getPropShape.ts @@ -1,5 +1,4 @@ -import { PropShape } from '../../shared/models' -import { ts, SourceFile, ImportSpecifier, InterfaceDeclaration } from 'ts-morph' +import { ts, SourceFile, ImportSpecifier, InterfaceDeclaration, OptionalKind, PropertySignatureStructure } from 'ts-morph' import path from 'path' import { getSourceFile } from './getSourceFile' import { parsePropertyStructures } from './parsePropertyStructures' @@ -53,15 +52,19 @@ function getPropsInterfaceDeclaration( return getPropsInterfaceDeclaration(importSourceFile, filePath, importedInterfaceName) } +/** + * Returns the {@link PropShape} and also whether or not the component accepts React children. + */ export function getPropShape( sourceFile: SourceFile, filePath: string, interfaceName: string -): PropShape { +) { const { propsInterfaceNode, propsFilePath } = getPropsInterfaceDeclaration(sourceFile, filePath, interfaceName) - const properties = propsInterfaceNode.getStructure().properties ?? [] + const properties: OptionalKind[] + = propsInterfaceNode.getStructure().properties ?? [] return parsePropertyStructures(properties, propsFilePath) } \ No newline at end of file diff --git a/studio/studio-plugin/common/index.ts b/studio/studio-plugin/common/index.ts index f9b4c9f92..aa70ce50f 100644 --- a/studio/studio-plugin/common/index.ts +++ b/studio/studio-plugin/common/index.ts @@ -1,4 +1,3 @@ -export * from './getComponentNodes' export * from './getDefaultExport' export * from './getJsxAttributeValue' export * from './getPropValue' @@ -12,4 +11,4 @@ export * from './updatePropsObjectLiteral' export * from './prettify' export * from './resolveNpmModule' export * from './validatePropState' -export * from './common' \ No newline at end of file +export * from './getPropName' \ No newline at end of file diff --git a/studio/studio-plugin/common/parsePropertyStructures.ts b/studio/studio-plugin/common/parsePropertyStructures.ts index d1a28538e..29509c6ae 100644 --- a/studio/studio-plugin/common/parsePropertyStructures.ts +++ b/studio/studio-plugin/common/parsePropertyStructures.ts @@ -2,28 +2,41 @@ import { JSDocableNodeStructure, PropertyNamedNodeStructure, TypedNodeStructure import { PropTypes } from '../../types' import parseImports from '../ts-morph/parseImports' import { resolve } from 'path' -import { PropShape } from '../../shared/models' +import { PropShape, SpecialReactProps } from '../../shared/models' interface ParseablePropertyStructure extends JSDocableNodeStructure, TypedNodeStructure, PropertyNamedNodeStructure {} -export function parsePropertyStructures(properties: ParseablePropertyStructure[], filePath: string) { - const props: PropShape = {} +/** + * Returns the {@link PropShape} and also whether or not the component accepts React children. + */ +export function parsePropertyStructures( + properties: ParseablePropertyStructure[], + filePath: string +): { propShape: PropShape, acceptsChildren: boolean } { + const propShape: PropShape = {} let imports: Record + let acceptsChildren = false properties.forEach(p => { + if (Object.values(SpecialReactProps).includes(p.name as SpecialReactProps)) { + if (p.name === SpecialReactProps.Children) { + acceptsChildren = true + } + return + } const jsdoc = p.docs?.map(doc => typeof doc === 'string' ? doc : doc.description).join('\n') const propType = p.type if (!isPropType(propType) || !isRecognized(propType)) { console.error(`Prop type ${propType} is not one of the recognized PropTypes. Skipping.`) return } - props[p.name] = { + propShape[p.name] = { type: propType, ...(jsdoc && { doc: jsdoc }) } }) - return props + return { propShape, acceptsChildren } function isRecognized(type: PropTypes): boolean { if (!imports) { diff --git a/studio/studio-plugin/componentMetadata.ts b/studio/studio-plugin/componentMetadata.ts index fb849dfcc..21ed08368 100644 --- a/studio/studio-plugin/componentMetadata.ts +++ b/studio/studio-plugin/componentMetadata.ts @@ -1,5 +1,3 @@ -import studioConfig from '../../src/studio' -import parseNpmComponents from './ts-morph/parseNpmComponents' import { ModuleMetadata, ModuleNameToComponentMetadata } from '../shared/models' import fs from 'fs' import getRootPath from './getRootPath' @@ -7,13 +5,6 @@ import { getSourceFile } from './common' import path from 'path' import parseComponentMetadata, { pathToPagePreview } from './ts-morph/parseComponentMetadata' -const npmComponentProps = - Object.keys(studioConfig['npmComponents']).reduce((shapes, moduleName) => { - const matchers = studioConfig.npmComponents[moduleName] - shapes[moduleName] = parseNpmComponents(moduleName, matchers) - return shapes - }, {} as Record) - const localComponents: ModuleMetadata = fs .readdirSync(getRootPath('src/components'), 'utf-8') .reduce((prev, curr) => { @@ -31,6 +22,7 @@ const localLayouts: ModuleMetadata = fs .reduce((prev, curr) => { const componentName = curr.substring(0, curr.indexOf('.')) prev[componentName] = { + acceptsChildren: true, global: false, editable: false, importIdentifier: path.relative(pathToPagePreview, getRootPath(`src/layouts/${curr}`)) @@ -40,6 +32,5 @@ const localLayouts: ModuleMetadata = fs export const moduleNameToComponentMetadata: ModuleNameToComponentMetadata = { localComponents, - localLayouts, - ...npmComponentProps + localLayouts } \ No newline at end of file diff --git a/studio/studio-plugin/streams/getUpdatedStreamConfig.ts b/studio/studio-plugin/streams/getUpdatedStreamConfig.ts index 116741931..5a9495431 100644 --- a/studio/studio-plugin/streams/getUpdatedStreamConfig.ts +++ b/studio/studio-plugin/streams/getUpdatedStreamConfig.ts @@ -1,6 +1,6 @@ import { TemplateConfig } from '@yext/pages' import { ComponentState } from '../../shared/models' -import { v1 } from 'uuid' +import { v4 } from 'uuid' import { STREAMS_TEMPLATE_REGEX } from '../../shared/constants' import { PropTypes, StreamsDataExpression, StreamsStringExpression } from '../../types' @@ -35,7 +35,7 @@ export default function getUpdatedStreamConfig( return { ...currentConfig, stream: { - $id: 'studio-stream-id_' + v1(), + $id: 'studio-stream-id_' + v4(), filter: {}, localization: { locales: ['en'], diff --git a/studio/studio-plugin/ts-morph/getComponentModuleName.ts b/studio/studio-plugin/ts-morph/getComponentModuleName.ts index 80f62dce5..3749c23f0 100644 --- a/studio/studio-plugin/ts-morph/getComponentModuleName.ts +++ b/studio/studio-plugin/ts-morph/getComponentModuleName.ts @@ -10,7 +10,7 @@ export default function getComponentModuleName( return importedNames.includes(name) }) if (!moduleName) { - throw new Error(`Could not find import path/module for component "${name}"`) + return 'builtIn' } if (moduleName.startsWith('.')) { moduleName = isLayout ? 'localLayouts' : 'localComponents' diff --git a/studio/studio-plugin/ts-morph/parseComponentMetadata.test.ts b/studio/studio-plugin/ts-morph/parseComponentMetadata.test.ts index a3b49268d..8efb04bba 100644 --- a/studio/studio-plugin/ts-morph/parseComponentMetadata.test.ts +++ b/studio/studio-plugin/ts-morph/parseComponentMetadata.test.ts @@ -48,6 +48,7 @@ it('updates correctly', () => { type: PropTypes.HexColor } }, + acceptsChildren: false, editable: true, global: false }) diff --git a/studio/studio-plugin/ts-morph/parseComponentMetadata.ts b/studio/studio-plugin/ts-morph/parseComponentMetadata.ts index d5135776b..58ce17ac1 100644 --- a/studio/studio-plugin/ts-morph/parseComponentMetadata.ts +++ b/studio/studio-plugin/ts-morph/parseComponentMetadata.ts @@ -11,10 +11,11 @@ export default function parseComponentMetadata( interfaceName: string, importIdentifier?: string ): ComponentMetadata { - const propShape = getPropShape(sourceFile, filePath, interfaceName) + const { propShape, acceptsChildren } = getPropShape(sourceFile, filePath, interfaceName) if (isGlobalComponent()) { return { propShape, + acceptsChildren, global: true, editable: false, globalProps: parseComponentPropsValue('globalProps'), @@ -23,6 +24,7 @@ export default function parseComponentMetadata( } else { return { propShape, + acceptsChildren, global: false, editable: true, initialProps: parseComponentPropsValue('initialProps'), diff --git a/studio/studio-plugin/ts-morph/parseComponentState.test.ts b/studio/studio-plugin/ts-morph/parseComponentState.test.ts new file mode 100644 index 000000000..0bd16bdef --- /dev/null +++ b/studio/studio-plugin/ts-morph/parseComponentState.test.ts @@ -0,0 +1,58 @@ +import { Project, SourceFile, SyntaxKind } from 'ts-morph' +import { PropTypes } from '../../types' +import { tsCompilerOptions } from '../common' +import parseComponentState from './parseComponentState' + +jest.mock('../componentMetadata') +jest.mock('uuid', () => ({ v4: () => 'mock-uuid' })) + +it('can parse nested components', () => { + const source = getSource(` + + + +`) + const topLevelNode = source.getFirstDescendantByKindOrThrow(SyntaxKind.JsxElement) + const imports = { + './components': ['Card'] + } + expect(parseComponentState(topLevelNode, imports)).toEqual({ + moduleName: 'localComponents', + name: 'Card', + uuid: 'mock-uuid', + props: { + bgColor: { + type: PropTypes.HexColor, + value: '#abcdef' + } + }, + children: [ + { + moduleName: 'localComponents', + name: 'Card', + uuid: 'mock-uuid', + props: {}, + parentUUIDsFromRoot: ['mock-uuid'], + } + ] + }) +}) + +it('errors if detects non whitespace JsxText', () => { + const source = getSource(` + + JsxText IS NOT SUPPORTED YET + +`) + const topLevelNode = source.getFirstDescendantByKindOrThrow(SyntaxKind.JsxElement) + const imports = { + './components': ['Card'] + } + expect(() => parseComponentState(topLevelNode, imports)).toThrow(/JsxText IS NOT SUPPORTED YET/) +}) + +function getSource(code: string): SourceFile { + const p = new Project(tsCompilerOptions) + p.createSourceFile('testPage.tsx', code) + return p.getSourceFileOrThrow('testPage.tsx') +} \ No newline at end of file diff --git a/studio/studio-plugin/ts-morph/parseComponentState.ts b/studio/studio-plugin/ts-morph/parseComponentState.ts new file mode 100644 index 000000000..b7e28cdf3 --- /dev/null +++ b/studio/studio-plugin/ts-morph/parseComponentState.ts @@ -0,0 +1,100 @@ +import { JsxAttributeLike, JsxElement, JsxExpression, JsxFragment, JsxSelfClosingElement, JsxText, SyntaxKind } from 'ts-morph' +import { v4 } from 'uuid' +import { ComponentState, PossibleModuleNames, PropState } from '../../shared/models' +import { moduleNameToComponentMetadata } from '../componentMetadata' +import getComponentModuleName from './getComponentModuleName' +import parseJsxAttributes from './parseJsxAttributes' + +export default function parseComponentState( + c: JsxText | JsxExpression | JsxFragment | JsxElement | JsxSelfClosingElement, + imports: Record, + parentUUIDsFromRoot?: string[] +): ComponentState | null { + return deleteChildrenIfEmpty(undecoratedParseComponentState(c, imports, parentUUIDsFromRoot)) +} + +function undecoratedParseComponentState( + c: JsxText | JsxExpression | JsxFragment | JsxElement | JsxSelfClosingElement, + imports: Record, + parentUUIDsFromRoot?: string[] +): ComponentState | null { + if (c.isKind(SyntaxKind.JsxText)) { + if (c.getLiteralText().trim() !== '') { + throw new Error(`Found JsxText with content "${c.getLiteralText()}". JsxText is not currently supported`) + } + return null + } + if (c.isKind(SyntaxKind.JsxExpression)) { + throw new Error( + `Jsx nodes of kind "${c.getKindName()}" are not supported for direct use in page files.`) + } + + const uuid = v4() + if (c.isKind(SyntaxKind.JsxSelfClosingElement)) { + const name = c.getTagNameNode().getText() + return { + ...parseElement(c, name, imports), + name, + uuid, + parentUUIDsFromRoot + } + } + + const nextParentUUIDs = (parentUUIDsFromRoot ?? []).concat([ uuid ]) + const children = parseChildren(c, imports, nextParentUUIDs) + if (c.isKind(SyntaxKind.JsxFragment)) { + return { + name: '', + isFragment: true, + uuid, + props: {}, + moduleName: 'builtIn', + parentUUIDsFromRoot, + children + } + } + const name = c.getOpeningElement().getTagNameNode().getText() + return { + ...parseElement(c, name, imports), + name, + uuid, + parentUUIDsFromRoot, + children + } +} + +function parseElement( + c: JsxElement | JsxSelfClosingElement, + name: string, + imports: Record +): { moduleName: PossibleModuleNames, props: PropState } { + const moduleName = getComponentModuleName(name, imports, false) + if (moduleName === 'builtIn') { + throw new Error('parseComponentState does not currently support builtIn elements.') + } + const attributes: JsxAttributeLike[] = c.isKind(SyntaxKind.JsxSelfClosingElement) + ? c.getAttributes() + : c.getOpeningElement().getAttributes() + const componentMetaData = moduleNameToComponentMetadata[moduleName][name] + const props = componentMetaData.global + ? componentMetaData.globalProps ?? {} + : parseJsxAttributes(attributes, componentMetaData) + return { moduleName, props } +} + +function parseChildren( + c: JsxFragment | JsxElement, + imports: Record, + parentUUIDsFromRoot: string[] +): ComponentState[] { + return c.getJsxChildren() + .map(c => parseComponentState(c, imports, parentUUIDsFromRoot)) + .filter((c): c is ComponentState => !!c) +} + +function deleteChildrenIfEmpty(data: ComponentState | null): ComponentState | null { + if (data?.children?.length === 0) { + delete data.children + } + return data +} \ No newline at end of file diff --git a/studio/studio-plugin/ts-morph/parseJsxAttributes.ts b/studio/studio-plugin/ts-morph/parseJsxAttributes.ts index a43678cfa..0df90f6ae 100644 --- a/studio/studio-plugin/ts-morph/parseJsxAttributes.ts +++ b/studio/studio-plugin/ts-morph/parseJsxAttributes.ts @@ -1,13 +1,16 @@ -import { JsxOpeningElement, JsxSelfClosingElement, ts, JsxAttribute } from 'ts-morph' +import { JsxAttributeLike, SyntaxKind } from 'ts-morph' import { ComponentMetadata, PropState } from '../../shared/models' import { getPropName, getJsxAttributeValue, validatePropState } from '../common' export default function parseJsxAttributes( - n: JsxOpeningElement | JsxSelfClosingElement, + attributes: JsxAttributeLike[], componentMetaData: ComponentMetadata ): PropState { const props = {} - n.getDescendantsOfKind(ts.SyntaxKind.JsxAttribute).forEach((jsxAttribute: JsxAttribute) => { + attributes.forEach((jsxAttribute: JsxAttributeLike) => { + if (jsxAttribute.isKind(SyntaxKind.JsxSpreadAttribute)) { + throw new Error('JsxSpreadAttribute is not currently supported') + } const propName = getPropName(jsxAttribute) if (!propName) { throw new Error('Could not parse jsx attribute prop name: ' + jsxAttribute.getFullText()) diff --git a/studio/studio-plugin/ts-morph/parseLayoutState.ts b/studio/studio-plugin/ts-morph/parseLayoutState.ts index 7fbaf5b9a..0303f492e 100644 --- a/studio/studio-plugin/ts-morph/parseLayoutState.ts +++ b/studio/studio-plugin/ts-morph/parseLayoutState.ts @@ -1,7 +1,7 @@ -import { ComponentState } from 'react' +import { ComponentState } from '../../shared/models' import { SourceFile, JsxElement, JsxFragment, ts } from 'ts-morph' -import { v1 } from 'uuid' -import { getDefaultExport, getComponentName } from '../common' +import { v4 } from 'uuid' +import { getDefaultExport } from '../common' import getComponentModuleName from './getComponentModuleName' export default function parseLayoutState( @@ -22,12 +22,12 @@ export default function parseLayoutState( } let layoutState: ComponentState - if (topLevelJsxNode.getKind() === ts.SyntaxKind.JsxElement) { - const name = getComponentName((topLevelJsxNode as JsxElement).getOpeningElement()) + if (topLevelJsxNode.isKind(ts.SyntaxKind.JsxElement)) { + const name = topLevelJsxNode.getOpeningElement().getTagNameNode().getText() layoutState = { name, props: {}, - uuid: v1(), + uuid: v4(), moduleName: 'builtIn' } const isBuiltinJsxElement = name.charAt(0) === name.charAt(0).toLowerCase() @@ -39,7 +39,7 @@ export default function parseLayoutState( layoutState = { name: '', props: {}, - uuid: v1(), + uuid: v4(), moduleName: 'builtIn' } } diff --git a/studio/studio-plugin/ts-morph/parseNpmComponents.ts b/studio/studio-plugin/ts-morph/parseNpmComponents.ts deleted file mode 100644 index ac836cfab..000000000 --- a/studio/studio-plugin/ts-morph/parseNpmComponents.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { ts } from 'ts-morph' -import { getSourceFile, parsePropertyStructures, resolveNpmModule } from '../common' -import { ComponentMetadata, ModuleMetadata } from '../../shared/models' -import parseComponentMetadata from './parseComponentMetadata' -import path from 'path' - -/** - * Parses out the prop structure for a particular npm module. - * Currently only supports functional react components. - */ -export default function parseNpmComponents( - moduleName: string, - matchers: (string | RegExp)[] -): ModuleMetadata { - const absPath = resolveNpmModule(moduleName) - const sourceFile = getSourceFile(absPath) - // const importIdentifier = resolve(moduleName) - const importIdentifier = path.resolve('/src/search-ui-react-reexport.ts') - - // We may want to not use the same object reference over in the future - // But for now this should never be mutated - const errorMetadataValue: ComponentMetadata = { - global: false, - propShape: {}, - initialProps: {}, - editable: false, - importIdentifier - } - const componentsToProps: ModuleMetadata = {} - sourceFile.getDescendantStatements().forEach(n => { - if (!n.isKind(ts.SyntaxKind.FunctionDeclaration)) { - return - } - const componentName = n.getName() - if (!componentName || !testComponentName(componentName, matchers)) { - return - } - const parameters = n.getParameters() - if (parameters.length !== 1) { - if (parameters.length > 1) { - console.error(`Found ${parameters.length} number of arguments for functional component ${componentName}, expected only 1. Ignoring this component's props.`) - } - componentsToProps[componentName] = errorMetadataValue - return - } - const typeNode = parameters[0].getTypeNode() - if (!typeNode) { - console.error(`No type information found for "${componentName}"'s props. Ignoring this component's props.`) - componentsToProps[componentName] = errorMetadataValue - return - } - if (typeNode.isKind(ts.SyntaxKind.TypeLiteral)) { - const properties = typeNode.getProperties().map(p => p.getStructure()) - const propShape = parsePropertyStructures(properties, absPath) - componentsToProps[componentName] = { - global: false, - propShape, - initialProps: {}, - importIdentifier, - editable: true - } - } else if (typeNode.isKind(ts.SyntaxKind.TypeReference)) { - try { - // TODO(oshi): currently assumes that the prop interface is in the same file as the component itself - // This is not necessarily the case. Deferring the import tracing logic for now, since an imported - // interface may live several imports deep. - const typeName = typeNode.getTypeName().getText() - const componentMetadata = parseComponentMetadata(sourceFile, absPath, typeName, importIdentifier) - componentsToProps[componentName] = componentMetadata - } catch (err) { - console.error('Caught an error, likely with regards to nested interfaces. Ignoring props for ', componentName) - console.error(err) - componentsToProps[componentName] = errorMetadataValue - } - } else { - console.error(`Unhandled parameter type "${typeNode.getKindName()}" found for "${componentName}". Ignoring this component's props.`) - componentsToProps[componentName] = errorMetadataValue - } - }) - return componentsToProps -} - -/** - * React components must start with an uppercase letter. We can use this as a first - * pass to reduce some of the parsing we have to do. - * - * It would be more robust to also check that the return type is JSX.Element or something of that nature. - */ -function firstCharacterIsUpperCase(componentName: string) { - return componentName[0] === componentName[0].toUpperCase() -} - -function testComponentName(componentName: string, matchers: (string | RegExp)[]): boolean { - if (!firstCharacterIsUpperCase(componentName)) { - return false - } - - for (const m of matchers) { - if (typeof m === 'string') { - if (m === componentName) { - return true - } - } else { - if (m.test(componentName)) { - return true - } - } - } - return false -} \ No newline at end of file diff --git a/studio/studio-plugin/ts-morph/parsePageFile.test.ts b/studio/studio-plugin/ts-morph/parsePageFile.test.ts index e51bd7582..d863bfcd4 100644 --- a/studio/studio-plugin/ts-morph/parsePageFile.test.ts +++ b/studio/studio-plugin/ts-morph/parsePageFile.test.ts @@ -5,7 +5,7 @@ import { PropTypes } from '../../types' jest.mock('../componentMetadata') jest.mock('../getRootPath') -jest.mock('uuid', () => ({ v1: () => 'mock-uuid' })) +jest.mock('uuid', () => ({ v4: () => 'mock-uuid' })) const componentsState: ComponentState[] = [ { @@ -150,3 +150,24 @@ it('correctly parses page using streams paths', () => { ] }) }) + +it('parses nested components props and children correctly', () => { + const result = parsePageFile(getRootPath('nestedComponents.tsx')) + expect(result.componentsState).toHaveLength(1) + expect(result.componentsState[0]).toEqual(expect.objectContaining({ + props: { + bgColor: { + type: PropTypes.HexColor, + value: '#453d0d' + } + } + })) + expect(result.componentsState[0].children).toHaveLength(1) + expect(result.componentsState[0]?.children?.[0]).toEqual({ + moduleName: 'localComponents', + name: 'Card', + props: {}, + uuid: 'mock-uuid', + parentUUIDsFromRoot: ['mock-uuid'] + }) +}) \ No newline at end of file diff --git a/studio/studio-plugin/ts-morph/parsePageFile.ts b/studio/studio-plugin/ts-morph/parsePageFile.ts index acfbc3e67..8202066e7 100644 --- a/studio/studio-plugin/ts-morph/parsePageFile.ts +++ b/studio/studio-plugin/ts-morph/parsePageFile.ts @@ -1,42 +1,21 @@ -import { ts, JsxElement, JsxFragment } from 'ts-morph' import { ComponentState, PageState } from '../../shared/models' -import { getComponentName, getComponentNodes, getSourceFile } from '../common' -import { v1 } from 'uuid' +import { getSourceFile } from '../common' import parseImports from './parseImports' -import { moduleNameToComponentMetadata } from '../componentMetadata' -import getComponentModuleName from './getComponentModuleName' import parseLayoutState from './parseLayoutState' -import parseJsxAttributes from './parseJsxAttributes' +import parseComponentState from './parseComponentState' export default function parsePageFile(filePath: string): PageState { const sourceFile = getSourceFile(filePath) const imports = parseImports(sourceFile) const { layoutState, layoutNode } = parseLayoutState(sourceFile, imports) - const usedComponents = getComponentNodes(layoutNode) - - const layoutJsxOpeningElement = layoutNode.getKind() === ts.SyntaxKind.JsxElement - ? (layoutNode as JsxElement).getOpeningElement() - : (layoutNode as JsxFragment).getOpeningFragment() const componentsState: ComponentState[] = [] - usedComponents.forEach(n => { - if (n === layoutJsxOpeningElement) { - return - } - const name = getComponentName(n) - const moduleName = getComponentModuleName(name, imports, false) - const componentData: ComponentState = { - name, - props: {}, - uuid: v1(), - moduleName + layoutNode.getJsxChildren().forEach(n => { + const componentState = parseComponentState(n, imports) + if (componentState) { + componentsState.push(componentState) } - const componentMetaData = moduleNameToComponentMetadata[moduleName][name] - componentData.props = componentMetaData.global - ? componentMetaData.globalProps ?? {} - : parseJsxAttributes(n, componentMetaData) - componentsState.push(componentData) }) return { diff --git a/studio/studio-plugin/ts-morph/updatePageFile.test.ts b/studio/studio-plugin/ts-morph/updatePageFile.test.ts index fbdc2b8dd..25ac23e2b 100644 --- a/studio/studio-plugin/ts-morph/updatePageFile.test.ts +++ b/studio/studio-plugin/ts-morph/updatePageFile.test.ts @@ -4,7 +4,7 @@ import { PropTypes } from '../../types' import getRootPath from '../getRootPath' import updatePageFile from './updatePageFile' -jest.mock('uuid', () => ({ v1: () => 'mock-uuid' })) +jest.mock('uuid', () => ({ v4: () => 'mock-uuid' })) jest.mock('../getRootPath') jest.mock('../componentMetadata') diff --git a/studio/types.ts b/studio/types.ts index d0e4a223a..f17c81e5e 100644 --- a/studio/types.ts +++ b/studio/types.ts @@ -4,7 +4,8 @@ export enum PropTypes { HexColor = 'HexColor', number = 'number', string = 'string', - boolean = 'boolean' + boolean = 'boolean', + ReactNode = 'ReactNode' } export type PropStateTypes =