From a0766219e181749535111d05172109487c85917e Mon Sep 17 00:00:00 2001 From: nmanu1 Date: Tue, 27 Dec 2022 09:43:36 -0500 Subject: [PATCH 1/8] Support adding pages --- package-lock.json | 105 ++++++++++++- .../studio-plugin/src/ParsingOrchestrator.ts | 35 ++--- .../src/parsers/ComponentTreeParser.ts | 2 +- .../src/parsers/getStudioPaths.ts | 5 +- .../studio-plugin/src/sourcefiles/PageFile.ts | 2 + packages/studio-plugin/src/types/State.ts | 1 + .../studio-plugin/src/types/StudioData.ts | 2 + .../studio-plugin/src/types/StudioPaths.ts | 6 + packages/studio-plugin/src/types/index.ts | 1 + .../tests/ParsingOrchestrator.test.ts | 2 + .../sourcefiles/PageFile.getPageState.test.ts | 7 + .../PageFile.updatePageFile.test.ts | 7 + packages/studio/package.json | 4 + packages/studio/src/App.tsx | 4 +- .../studio/src/components/AddPageButton.tsx | 60 +++++++ packages/studio/src/components/PagesPanel.tsx | 60 +++++++ .../studio/src/components/common/Divider.tsx | 2 +- .../studio/src/components/common/Modal.tsx | 92 +++++++++++ .../src/components/common/OptionPicker.tsx | 2 +- packages/studio/src/icons/check.svg | 3 + packages/studio/src/icons/plus.svg | 3 + packages/studio/src/icons/x.svg | 3 + .../src/store/models/slices/PageSlice.ts | 14 +- .../src/store/slices/createPageSlice.ts | 48 +++++- packages/studio/src/store/useStudioStore.ts | 3 + .../__utils__/mockActiveComponentState.ts | 2 +- .../tests/components/ComponentEditor.test.tsx | 1 - ...est.ts => createFileMetadataSlice.test.ts} | 0 ...eSlice.test.ts => createPageSlice.test.ts} | 147 +++++++++++++++++- ...ngs.test.ts => createSiteSettings.test.ts} | 0 30 files changed, 577 insertions(+), 46 deletions(-) create mode 100644 packages/studio-plugin/src/types/StudioPaths.ts create mode 100644 packages/studio/src/components/AddPageButton.tsx create mode 100644 packages/studio/src/components/PagesPanel.tsx create mode 100644 packages/studio/src/components/common/Modal.tsx create mode 100644 packages/studio/src/icons/check.svg create mode 100644 packages/studio/src/icons/plus.svg create mode 100644 packages/studio/src/icons/x.svg rename packages/studio/tests/store/{fileMetadataSlice.test.ts => createFileMetadataSlice.test.ts} (100%) rename packages/studio/tests/store/{pageSlice.test.ts => createPageSlice.test.ts} (68%) rename packages/studio/tests/store/{siteSettings.test.ts => createSiteSettings.test.ts} (100%) diff --git a/package-lock.json b/package-lock.json index c216a9a0b..77fff0350 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5277,6 +5277,12 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "node_modules/@types/path-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/path-browserify/-/path-browserify-1.0.0.tgz", + "integrity": "sha512-XMCcyhSvxcch8b7rZAtFAaierBYdeHXVvg2iYnxOV0MCQHmPuRRmGZPFDRzPayxcGiiSL1Te9UIO+f3cuj0tfw==", + "dev": true + }, "node_modules/@types/prettier": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz", @@ -5336,6 +5342,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-modal": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/@types/react-modal/-/react-modal-3.13.1.tgz", + "integrity": "sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/relateurl": { "version": "0.2.29", "resolved": "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.29.tgz", @@ -10182,6 +10197,11 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exenv": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" + }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -17277,6 +17297,29 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "node_modules/react-modal": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz", + "integrity": "sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==", + "dependencies": { + "exenv": "^1.2.0", + "prop-types": "^15.7.2", + "react-lifecycles-compat": "^3.0.0", + "warning": "^4.0.3" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18", + "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18" + } + }, "node_modules/react-reconciler": { "version": "0.26.2", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.26.2.tgz", @@ -19863,6 +19906,14 @@ "makeerror": "1.0.12" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -20857,8 +20908,10 @@ "@vitejs/plugin-react": "^3.0.0", "@yext/studio-plugin": "*", "classnames": "^2.3.2", + "path-browserify": "^1.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-modal": "3.16.1", "react-tooltip": "^5.2.0", "tailwind-merge": "^1.8.0", "tailwindcss": "^3.2.4", @@ -20879,8 +20932,10 @@ "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.2.4", "@types/node": "^18.11.15", + "@types/path-browserify": "^1.0.0", "@types/react": "^18.0.26", "@types/react-dom": "^18.0.9", + "@types/react-modal": "3.13.1", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", "prettier": "2.8.1" @@ -27388,6 +27443,12 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "@types/path-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/path-browserify/-/path-browserify-1.0.0.tgz", + "integrity": "sha512-XMCcyhSvxcch8b7rZAtFAaierBYdeHXVvg2iYnxOV0MCQHmPuRRmGZPFDRzPayxcGiiSL1Te9UIO+f3cuj0tfw==", + "dev": true + }, "@types/prettier": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz", @@ -27447,6 +27508,15 @@ "@types/react": "*" } }, + "@types/react-modal": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/@types/react-modal/-/react-modal-3.13.1.tgz", + "integrity": "sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/relateurl": { "version": "0.2.29", "resolved": "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.29.tgz", @@ -28211,19 +28281,23 @@ "@rollup/plugin-typescript": "^10.0.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "*", + "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.2.4", "@types/node": "^18.11.15", + "@types/path-browserify": "^1.0.0", "@types/react": "^18.0.26", "@types/react-dom": "^18.0.9", + "@types/react-modal": "3.13.1", "@vitejs/plugin-react": "^3.0.0", "@yext/studio-plugin": "*", "classnames": "^2.3.2", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", + "path-browserify": "^1.0.1", "prettier": "2.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-modal": "3.16.1", "react-tooltip": "^5.2.0", "tailwind-merge": "^1.8.0", "tailwindcss": "^3.2.4", @@ -33008,6 +33082,11 @@ "strip-final-newline": "^2.0.0" } }, + "exenv": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" + }, "exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -37917,6 +37996,22 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "react-modal": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz", + "integrity": "sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==", + "requires": { + "exenv": "^1.2.0", + "prop-types": "^15.7.2", + "react-lifecycles-compat": "^3.0.0", + "warning": "^4.0.3" + } + }, "react-reconciler": { "version": "0.26.2", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.26.2.tgz", @@ -39761,6 +39856,14 @@ "makeerror": "1.0.12" } }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/packages/studio-plugin/src/ParsingOrchestrator.ts b/packages/studio-plugin/src/ParsingOrchestrator.ts index c1b772786..72c96c101 100644 --- a/packages/studio-plugin/src/ParsingOrchestrator.ts +++ b/packages/studio-plugin/src/ParsingOrchestrator.ts @@ -1,5 +1,5 @@ import path from "path"; -import { FileMetadata, PageState } from "./types"; +import { FileMetadata, PageState, StudioPaths, StudioData } from "./types"; import fs from "fs"; import ComponentFile from "./sourcefiles/ComponentFile"; import ModuleFile from "./sourcefiles/ModuleFile"; @@ -7,7 +7,6 @@ import PageFile from "./sourcefiles/PageFile"; import SiteSettingsFile, { SiteSettings } from "./sourcefiles/SiteSettingsFile"; import { Project } from "ts-morph"; import typescript from "typescript"; -import { StudioData } from "./types/StudioData"; export function createTsMorphProject() { return new Project({ @@ -27,14 +26,7 @@ export default class ParsingOrchestrator { private project: Project; /** All paths are assumed to be absolute. */ - constructor( - private paths: { - components: string; - pages: string; - modules: string; - siteSettings: string; - } - ) { + constructor(private paths: StudioPaths) { this.project = createTsMorphProject(); this.getFileMetadata = this.getFileMetadata.bind(this); this.filepathToFileMetadata = this.setFilepathToFileMetadata(); @@ -55,6 +47,7 @@ export default class ParsingOrchestrator { pageNameToPageState, UUIDToFileMetadata, siteSettings, + studioPaths: this.paths, }; } @@ -106,16 +99,18 @@ export default class ParsingOrchestrator { `The pages directory does not exist, expected directory to be at "${this.paths.pages}".` ); } - return fs.readdirSync(this.paths.pages, "utf-8").reduce((prev, curr) => { - const pageName = path.basename(curr, ".tsx"); - const pageFile = new PageFile( - path.join(this.paths.pages, curr), - this.getFileMetadata, - this.project - ); - prev[pageName] = pageFile.getPageState(); - return prev; - }, {}); + return fs + .readdirSync(this.paths.pages, "utf-8") + .reduce((prev, curr) => { + const pageName = path.basename(curr, ".tsx"); + const pageFile = new PageFile( + path.join(this.paths.pages, curr), + this.getFileMetadata, + this.project + ); + prev[pageName] = pageFile.getPageState(); + return prev; + }, {}); } private getSiteSettings(): SiteSettings | undefined { diff --git a/packages/studio-plugin/src/parsers/ComponentTreeParser.ts b/packages/studio-plugin/src/parsers/ComponentTreeParser.ts index 0764af461..69ef40e7e 100644 --- a/packages/studio-plugin/src/parsers/ComponentTreeParser.ts +++ b/packages/studio-plugin/src/parsers/ComponentTreeParser.ts @@ -12,7 +12,7 @@ import { StandardOrModuleComponentState, } from "../types/State"; import { v4 } from "uuid"; -import { FileMetadataKind, PropValues } from "../types"; +import { FileMetadataKind } from "../types"; import StudioSourceFileParser from "./StudioSourceFileParser"; import StaticParsingHelpers from "./helpers/StaticParsingHelpers"; import TypeGuards from "./helpers/TypeGuards"; diff --git a/packages/studio-plugin/src/parsers/getStudioPaths.ts b/packages/studio-plugin/src/parsers/getStudioPaths.ts index 00011cbe3..7d185d167 100644 --- a/packages/studio-plugin/src/parsers/getStudioPaths.ts +++ b/packages/studio-plugin/src/parsers/getStudioPaths.ts @@ -1,12 +1,13 @@ import path from "path"; +import { StudioPaths } from "../types"; /** * Given an absolute path to the user's src folder, determine the filepaths Studio will * use for parsing files. * - * @param pathToSrc An absolute path to the src folder + * @param pathToSrc - An absolute path to the src folder */ -export default function getStudioPaths(pathToSrc: string) { +export default function getStudioPaths(pathToSrc: string): StudioPaths { return { pages: path.join(pathToSrc, "pages"), modules: path.join(pathToSrc, "modules"), diff --git a/packages/studio-plugin/src/sourcefiles/PageFile.ts b/packages/studio-plugin/src/sourcefiles/PageFile.ts index 66b145a5a..4c3f086d0 100644 --- a/packages/studio-plugin/src/sourcefiles/PageFile.ts +++ b/packages/studio-plugin/src/sourcefiles/PageFile.ts @@ -63,9 +63,11 @@ export default class PageFile { absPathDefaultImports ); const cssImports = this.studioSourceFileParser.parseCssImports(); + const filepath = this.studioSourceFileParser.getFilepath(); return { componentTree, cssImports, + filepath, }; } diff --git a/packages/studio-plugin/src/types/State.ts b/packages/studio-plugin/src/types/State.ts index 45634c218..2d708840e 100644 --- a/packages/studio-plugin/src/types/State.ts +++ b/packages/studio-plugin/src/types/State.ts @@ -3,6 +3,7 @@ import { PropValues } from "./PropValues"; export type PageState = { componentTree: ComponentState[]; cssImports: string[]; + filepath: string; }; export type ComponentState = diff --git a/packages/studio-plugin/src/types/StudioData.ts b/packages/studio-plugin/src/types/StudioData.ts index 6112f5d21..733aeb62d 100644 --- a/packages/studio-plugin/src/types/StudioData.ts +++ b/packages/studio-plugin/src/types/StudioData.ts @@ -1,9 +1,11 @@ import { SiteSettings } from "../sourcefiles/SiteSettingsFile"; import { ComponentMetadata } from "./ComponentMetadata"; import { PageState } from "./State"; +import { StudioPaths } from "./StudioPaths"; export interface StudioData { pageNameToPageState: Record; UUIDToFileMetadata: Record; siteSettings?: SiteSettings; + studioPaths: StudioPaths; } diff --git a/packages/studio-plugin/src/types/StudioPaths.ts b/packages/studio-plugin/src/types/StudioPaths.ts new file mode 100644 index 000000000..e7c7bb2d2 --- /dev/null +++ b/packages/studio-plugin/src/types/StudioPaths.ts @@ -0,0 +1,6 @@ +export interface StudioPaths { + components: string; + pages: string; + modules: string; + siteSettings: string; +} diff --git a/packages/studio-plugin/src/types/index.ts b/packages/studio-plugin/src/types/index.ts index af1090b6f..2288da7ed 100644 --- a/packages/studio-plugin/src/types/index.ts +++ b/packages/studio-plugin/src/types/index.ts @@ -5,3 +5,4 @@ export * from "./ModuleMetadata"; export * from "./FileMetadata"; export * from "./State"; export * from "./StudioData"; +export * from "./StudioPaths"; diff --git a/packages/studio-plugin/tests/ParsingOrchestrator.test.ts b/packages/studio-plugin/tests/ParsingOrchestrator.test.ts index c71452cf8..a3ce31e6c 100644 --- a/packages/studio-plugin/tests/ParsingOrchestrator.test.ts +++ b/packages/studio-plugin/tests/ParsingOrchestrator.test.ts @@ -67,6 +67,7 @@ describe("aggregates data as expected", () => { }), ], cssImports: [], + filepath: expect.anything(), }, pageWithModules: { componentTree: [ @@ -80,6 +81,7 @@ describe("aggregates data as expected", () => { }), ], cssImports: [], + filepath: expect.anything(), }, }); }); diff --git a/packages/studio-plugin/tests/sourcefiles/PageFile.getPageState.test.ts b/packages/studio-plugin/tests/sourcefiles/PageFile.getPageState.test.ts index 58673fe6e..55be0cabc 100644 --- a/packages/studio-plugin/tests/sourcefiles/PageFile.getPageState.test.ts +++ b/packages/studio-plugin/tests/sourcefiles/PageFile.getPageState.test.ts @@ -120,6 +120,13 @@ describe("getPageState", () => { ]); }); + it("correctly gets filepath", () => { + const pageFile = createPageFile("shortFragmentSyntaxPage"); + const result = pageFile.getPageState(); + + expect(result.filepath).toEqual(getPagePath("shortFragmentSyntaxPage")); + }); + describe("throws errors", () => { it("throws an error when no return statement is found in the default export", () => { const pageFile = createPageFile("noReturnStatementPage"); diff --git a/packages/studio-plugin/tests/sourcefiles/PageFile.updatePageFile.test.ts b/packages/studio-plugin/tests/sourcefiles/PageFile.updatePageFile.test.ts index 76484785d..f8f926ac4 100644 --- a/packages/studio-plugin/tests/sourcefiles/PageFile.updatePageFile.test.ts +++ b/packages/studio-plugin/tests/sourcefiles/PageFile.updatePageFile.test.ts @@ -33,9 +33,11 @@ describe("updatePageFile", () => { componentName: "ComplexBanner", props: {}, uuid: "mock-uuid-0", + metadataUUID: "mock-metadataUUID", }, ], cssImports: [], + filepath: "mock-filepath", }); expect(fs.writeFileSync).toHaveBeenCalledWith( expect.stringContaining("EmptyPage.tsx"), @@ -69,9 +71,11 @@ describe("updatePageFile", () => { valueType: PropValueType.string, }, }, + metadataUUID: "mock-metadataUUID", }, ], cssImports: [], + filepath: "mock-filepath", }, { updateStreamConfig: true } ); @@ -95,6 +99,7 @@ describe("updatePageFile", () => { { componentTree: streamConfigMultipleFieldsComponentTree, cssImports: [], + filepath: "mock-filepath", }, { updateStreamConfig: true } ); @@ -128,9 +133,11 @@ describe("updatePageFile", () => { valueType: PropValueType.string, }, }, + metadataUUID: "mock-metadataUUID", }, ], cssImports: [], + filepath: "mock-filepath", }, { updateStreamConfig: true } ); diff --git a/packages/studio/package.json b/packages/studio/package.json index 61eb4acd7..828310dab 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -21,8 +21,10 @@ "@vitejs/plugin-react": "^3.0.0", "@yext/studio-plugin": "*", "classnames": "^2.3.2", + "path-browserify": "^1.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-modal": "3.16.1", "react-tooltip": "^5.2.0", "tailwind-merge": "^1.8.0", "tailwindcss": "^3.2.4", @@ -40,8 +42,10 @@ "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.2.4", "@types/node": "^18.11.15", + "@types/path-browserify": "^1.0.0", "@types/react": "^18.0.26", "@types/react-dom": "^18.0.9", + "@types/react-modal": "3.13.1", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", "prettier": "2.8.1" diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b31cadf28..0ae65d106 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,5 +1,5 @@ -import ComponentTree from "./components/ComponentTree"; import EditorPanel from "./components/EditorPanel"; +import PagesPanel from "./components/PagesPanel"; import useStudioStore from "./store/useStudioStore"; export default function App() { @@ -10,7 +10,7 @@ export default function App() { return (
- +
Preview
activeComponentState: {JSON.stringify(activeComponentState, null, 2)}
diff --git a/packages/studio/src/components/AddPageButton.tsx b/packages/studio/src/components/AddPageButton.tsx new file mode 100644 index 000000000..75697d799 --- /dev/null +++ b/packages/studio/src/components/AddPageButton.tsx @@ -0,0 +1,60 @@ +import Modal from "./common/Modal"; +import useStudioStore from "../store/useStudioStore"; +import { ReactComponent as Plus } from "../icons/plus.svg"; +import { ChangeEvent, useState } from "react"; +import path from "path-browserify"; +import initialStudioData from "virtual:yext-studio"; + +export default function AddPageButton(): JSX.Element { + const addPage = useStudioStore((store) => store.pages.addPage); + + const [showModal, setShowModal] = useState(false); + const [pageName, setPageName] = useState(""); + const [isValidInput, setIsValidInput] = useState(false); + + function handleAddPage() { + setShowModal(true); + } + + function handleModalClose() { + setShowModal(false); + } + + function handleModalSave() { + const pagesPath = initialStudioData.studioPaths.pages; + const filepath = path.join(pagesPath, pageName + ".tsx"); + if (addPage(filepath)) { + handleModalClose(); + } else { + setIsValidInput(false); + } + } + + function handleModalInputChange(e: ChangeEvent) { + setPageName(e.target.value); + if (e.target.value) { + setIsValidInput(true); + } else { + setIsValidInput(false); + } + } + + return ( + <> + + + + ); +} diff --git a/packages/studio/src/components/PagesPanel.tsx b/packages/studio/src/components/PagesPanel.tsx new file mode 100644 index 000000000..aa1bcd3b1 --- /dev/null +++ b/packages/studio/src/components/PagesPanel.tsx @@ -0,0 +1,60 @@ +import ComponentTree from "./ComponentTree"; +import Divider from "./common/Divider"; +import AddPageButton from "./AddPageButton"; +import useStudioStore from "../store/useStudioStore"; +import { ReactComponent as Check } from "../icons/check.svg"; +import classNames from "classnames"; + +/** + * Renders the left panel of Studio, which lists all pages and displays the + * component tree for the active page. Allows navigation between pages and + * rearranging of components and modules in the component tree. + */ +export default function PagesPanel(): JSX.Element { + const { pages, setActivePageName, activePageName } = useStudioStore( + (store) => store.pages + ); + const pageNames = Object.keys(pages); + + function renderPageList(pageNames: string[]) { + return ( +
+ {pageNames.map((pageName) => { + const isActivePage = activePageName === pageName; + const pageNameClasses = classNames({ + "ml-2": isActivePage, + "pl-5": !isActivePage, + }); + function handleClick() { + setActivePageName(pageName); + } + return ( +
+ {isActivePage && } + +
+ ); + })} +
+ ); + } + + return ( +
+
+ Pages + +
+ {renderPageList(pageNames)} + +
Modules
+ +
+ ); +} diff --git a/packages/studio/src/components/common/Divider.tsx b/packages/studio/src/components/common/Divider.tsx index b2ec08bb5..9decd4549 100644 --- a/packages/studio/src/components/common/Divider.tsx +++ b/packages/studio/src/components/common/Divider.tsx @@ -1,3 +1,3 @@ export default function Divider(): JSX.Element { - return
; + return
; } diff --git a/packages/studio/src/components/common/Modal.tsx b/packages/studio/src/components/common/Modal.tsx new file mode 100644 index 000000000..fd68d4a18 --- /dev/null +++ b/packages/studio/src/components/common/Modal.tsx @@ -0,0 +1,92 @@ +import { ChangeEvent } from "react"; +import ReactModal from "react-modal"; +import { ReactComponent as X } from "../../icons/x.svg"; +import classNames from "classnames"; + +interface ModalProps { + isOpen: boolean; + disableSave: boolean; + showErrorMessage: boolean; + title: string; + description: string; + errorMessage: string; + onClose: () => void; + onSave: () => void; + onInputChange: (e: ChangeEvent) => void; +} + +const customReactModalStyles = { + content: { + top: "50%", + left: "50%", + right: "50%", + bottom: "auto", + marginRight: "-30%", + transform: "translate(-50%, -50%)", + }, +}; + +export default function Modal({ + isOpen, + disableSave, + showErrorMessage, + title, + description, + errorMessage, + onClose, + onSave, + onInputChange, +}: ModalProps) { + const footerClasses = classNames("mt-2 items-center", { + "flex justify-between": showErrorMessage, + }); + const saveButtonClasses = classNames( + "ml-2 bg-blue-600 py-1 px-3 text-white rounded-md", + { + "bg-gray-400": disableSave, + "bg-blue-600": !disableSave, + } + ); + + return ( + +
+ {title} + +
+
{description}
+ +
+ {showErrorMessage && ( +
{errorMessage}
+ )} +
+ + +
+
+
+ ); +} diff --git a/packages/studio/src/components/common/OptionPicker.tsx b/packages/studio/src/components/common/OptionPicker.tsx index 57109e2e5..219b8bbb6 100644 --- a/packages/studio/src/components/common/OptionPicker.tsx +++ b/packages/studio/src/components/common/OptionPicker.tsx @@ -14,7 +14,7 @@ interface OptionPickerCssClasses extends OptionCssClasses { } const builtInCssClasses: OptionPickerCssClasses = { - container: "min-w-fit bg-gray-300 flex flex-row p-1 rounded-md mb-10", + container: "min-w-fit bg-gray-300 flex flex-row p-1 rounded-md mb-6", option: "flex items-center justify-center grow rounded-md p-2 text-gray-500", selectedOption: "flex items-center justify-center grow rounded-md p-2 drop-shadow bg-white", diff --git a/packages/studio/src/icons/check.svg b/packages/studio/src/icons/check.svg new file mode 100644 index 000000000..f6e7ff5d1 --- /dev/null +++ b/packages/studio/src/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/studio/src/icons/plus.svg b/packages/studio/src/icons/plus.svg new file mode 100644 index 000000000..e9d45efc1 --- /dev/null +++ b/packages/studio/src/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/studio/src/icons/x.svg b/packages/studio/src/icons/x.svg new file mode 100644 index 000000000..91ce5846b --- /dev/null +++ b/packages/studio/src/icons/x.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/studio/src/store/models/slices/PageSlice.ts b/packages/studio/src/store/models/slices/PageSlice.ts index 07fdad713..96e22a727 100644 --- a/packages/studio/src/store/models/slices/PageSlice.ts +++ b/packages/studio/src/store/models/slices/PageSlice.ts @@ -11,14 +11,24 @@ export interface PageSliceStates { activePageName: string | undefined; /** The uuid of the current component display in Studio. */ activeComponentUUID?: string; + /** + * The part of state that tracks which pages have been interacted with from + * the UI and have changes pending on commit. + */ + pendingChanges: { + /** + * The names of pages (new or existing) that need to be updated in the + * user's file system. + */ + pagesToUpdate: Set; + }; } interface PageSliceActions { - setPages: (state: PagesRecord) => void; - setActivePageName: (pageName: string) => void; setActivePageState: (pageState: PageState) => void; getActivePageState: () => PageState | undefined; + addPage: (filepath: string) => boolean; setActiveComponentUUID: (activeComponentUUID: string | undefined) => void; setActiveComponentProps: (props: PropValues) => void; diff --git a/packages/studio/src/store/slices/createPageSlice.ts b/packages/studio/src/store/slices/createPageSlice.ts index a82e1e905..8da2a6c68 100644 --- a/packages/studio/src/store/slices/createPageSlice.ts +++ b/packages/studio/src/store/slices/createPageSlice.ts @@ -1,9 +1,7 @@ import { ComponentStateKind, PageState, PropValues } from "@yext/studio-plugin"; +import path from "path-browserify"; import initialStudioData from "virtual:yext-studio"; -import PageSlice, { - PageSliceStates, - PagesRecord, -} from "../models/slices/PageSlice"; +import PageSlice, { PageSliceStates } from "../models/slices/PageSlice"; import { SliceCreator } from "../models/utils"; const initialStates: PageSliceStates = { @@ -11,14 +9,16 @@ const initialStates: PageSliceStates = { activePageName: Object.keys(initialStudioData.pageNameToPageState)?.[0] ?? undefined, activeComponentUUID: undefined, + pendingChanges: { + pagesToUpdate: new Set(), + }, }; export const createPageSlice: SliceCreator = (set, get) => { const pagesActions = { - setPages: (pages: PagesRecord) => set({ pages }), setActivePageName: (activePageName: string) => { if (get().pages[activePageName]) { - set({ activePageName }); + set({ activePageName, activeComponentUUID: undefined }); } else { console.error( `Error in setActivePage: Page "${activePageName}" is not found in Store. Unable to set it as active page.` @@ -41,6 +41,7 @@ export const createPageSlice: SliceCreator = (set, get) => { store.activeComponentUUID = undefined; } store.pages[store.activePageName] = pageState; + store.pendingChanges.pagesToUpdate.add(store.activePageName); }), getActivePageState: () => { const { pages, activePageName } = get(); @@ -49,6 +50,35 @@ export const createPageSlice: SliceCreator = (set, get) => { } return pages[activePageName]; }, + addPage: (filepath: string) => { + if (!filepath) { + console.error("Error adding page: a filepath is required."); + return false; + } + const pagesPath = initialStudioData.studioPaths.pages; + if (!path.isAbsolute(filepath) || !filepath.includes(pagesPath)) { + console.error(`Error adding page: filepath is invalid: ${filepath}`); + return false; + } + const pageName = path.basename(filepath, ".tsx"); + if (get().pages[pageName]) { + console.error( + `Error adding page: page name "${pageName}" is already used.` + ); + return false; + } + + set((store) => { + store.pages[pageName] = { + componentTree: [], + cssImports: [], + filepath, + }; + store.pendingChanges.pagesToUpdate.add(pageName); + }); + get().setActivePageName(pageName); + return true; + }, }; const activeComponentActions = { @@ -66,7 +96,8 @@ export const createPageSlice: SliceCreator = (set, get) => { }, setActiveComponentProps: (props: PropValues) => set((store) => { - if (!store.activePageName) { + const activePageName = store.activePageName; + if (!activePageName) { console.error( "Tried to setActiveComponentProps when activePageName was undefined" ); @@ -79,7 +110,7 @@ export const createPageSlice: SliceCreator = (set, get) => { ); return; } - const components = store.pages[store.activePageName].componentTree; + const components = store.pages[activePageName].componentTree; components.forEach((c) => { if (c.uuid === activeComponent.uuid) { if (c.kind === ComponentStateKind.Fragment) { @@ -89,6 +120,7 @@ export const createPageSlice: SliceCreator = (set, get) => { return; } else { c.props = props; + store.pendingChanges.pagesToUpdate.add(activePageName); } } }); diff --git a/packages/studio/src/store/useStudioStore.ts b/packages/studio/src/store/useStudioStore.ts index 61382f780..286a0dfb0 100644 --- a/packages/studio/src/store/useStudioStore.ts +++ b/packages/studio/src/store/useStudioStore.ts @@ -6,6 +6,9 @@ import { StudioStore } from "./models/store"; import createFileMetadataSlice from "./slices/createFileMetadataSlice"; import createPageSlice from "./slices/createPageSlice"; import createSiteSettingSlice from "./slices/createSiteSettingsSlice"; +import { enableMapSet } from "immer"; + +enableMapSet(); /** * Studio's state manager in form of a hook to access and update states. diff --git a/packages/studio/tests/__utils__/mockActiveComponentState.ts b/packages/studio/tests/__utils__/mockActiveComponentState.ts index cbff170cd..a0a0f0cec 100644 --- a/packages/studio/tests/__utils__/mockActiveComponentState.ts +++ b/packages/studio/tests/__utils__/mockActiveComponentState.ts @@ -25,4 +25,4 @@ export default function mockStoreActiveComponent({ }, }, }); -} \ No newline at end of file +} diff --git a/packages/studio/tests/components/ComponentEditor.test.tsx b/packages/studio/tests/components/ComponentEditor.test.tsx index 63af96b3a..d697bdf51 100644 --- a/packages/studio/tests/components/ComponentEditor.test.tsx +++ b/packages/studio/tests/components/ComponentEditor.test.tsx @@ -181,4 +181,3 @@ function testStandardOrModuleComponentState( expect(getComponentProps()).toEqual(expectedComponentProps); }); } - diff --git a/packages/studio/tests/store/fileMetadataSlice.test.ts b/packages/studio/tests/store/createFileMetadataSlice.test.ts similarity index 100% rename from packages/studio/tests/store/fileMetadataSlice.test.ts rename to packages/studio/tests/store/createFileMetadataSlice.test.ts diff --git a/packages/studio/tests/store/pageSlice.test.ts b/packages/studio/tests/store/createPageSlice.test.ts similarity index 68% rename from packages/studio/tests/store/pageSlice.test.ts rename to packages/studio/tests/store/createPageSlice.test.ts index 520e9872d..37164d69c 100644 --- a/packages/studio/tests/store/pageSlice.test.ts +++ b/packages/studio/tests/store/createPageSlice.test.ts @@ -12,6 +12,16 @@ import { PagesRecord, } from "../../src/store/models/slices/PageSlice"; import mockStore from "../__utils__/mockStore"; +import path from "path"; + +jest.mock("virtual:yext-studio", () => { + return { + pageNameToPageState: {}, + studioPaths: { + pages: __dirname, + }, + }; +}); const fragmentComponent: ComponentState = { kind: ComponentStateKind.Fragment, @@ -60,25 +70,25 @@ const pages: PagesRecord = { universal: { componentTree: [searchBarComponent], cssImports: ["index.css"], + filepath: "mock-filepath", }, vertical: { componentTree: [resultsComponent], cssImports: [], + filepath: "mock-filepath", }, }; +const pendingChanges = { + pagesToUpdate: new Set(), +}; describe("PageSlice", () => { - it("updates pages using setPages", () => { - useStudioStore.getState().pages.setPages(pages); - const actualPages = useStudioStore.getState().pages.pages; - expect(actualPages).toEqual(pages); - }); - describe("active page actions", () => { beforeEach(() => { setInitialState({ pages, activePageName: "universal", + pendingChanges, }); }); @@ -88,6 +98,19 @@ describe("PageSlice", () => { expect(activePageName).toEqual("vertical"); }); + it("resets activeComponentUUID when setActivePageName is used", () => { + setInitialState({ + pages, + activePageName: "universal", + activeComponentUUID: "searchbar-uuid", + pendingChanges, + }); + useStudioStore.getState().pages.setActivePageName("vertical"); + const activeComponentUUID = + useStudioStore.getState().pages.activeComponentUUID; + expect(activeComponentUUID).toBeUndefined(); + }); + it("logs an error when using setActivePageName for a page not found in store", () => { const consoleErrorSpy = jest .spyOn(global.console, "error") @@ -104,6 +127,7 @@ describe("PageSlice", () => { const newActivePageState: PageState = { componentTree: [buttonComponent], cssImports: ["app.css"], + filepath: "mock-filepath", }; useStudioStore.getState().pages.setActivePageState(newActivePageState); const actualPages = useStudioStore.getState().pages.pages; @@ -113,15 +137,29 @@ describe("PageSlice", () => { }); }); + it("updates pagesToUpdate when setActivePageState is used", () => { + const newActivePageState: PageState = { + componentTree: [buttonComponent], + cssImports: ["app.css"], + filepath: "mock-filepath", + }; + useStudioStore.getState().pages.setActivePageState(newActivePageState); + const pagesToUpdate = + useStudioStore.getState().pages.pendingChanges.pagesToUpdate; + expect(pagesToUpdate).toEqual(new Set(["universal"])); + }); + it("resets active component uuid when it's remove from page state using setActivePageState", () => { setInitialState({ pages, activePageName: "universal", activeComponentUUID: "searchbar-uuid", + pendingChanges, }); const newActivePageState: PageState = { componentTree: [buttonComponent], cssImports: ["app.css"], + filepath: "mock-filepath", }; expect(useStudioStore.getState().pages.activeComponentUUID).toEqual( "searchbar-uuid" @@ -137,10 +175,12 @@ describe("PageSlice", () => { pages, activePageName: "universal", activeComponentUUID: "searchbar-uuid", + pendingChanges, }); const newActivePageState: PageState = { componentTree: [searchBarComponent, buttonComponent], cssImports: ["app.css"], + filepath: "mock-filepath", }; expect(useStudioStore.getState().pages.activeComponentUUID).toEqual( "searchbar-uuid" @@ -157,6 +197,77 @@ describe("PageSlice", () => { .pages.getActivePageState(); expect(activePageState).toEqual(pages["universal"]); }); + + describe("addPage", () => { + const filepath = path.resolve(__dirname, "./test.tsx"); + + it("adds a page to pages", () => { + useStudioStore.getState().pages.addPage(filepath); + const pagesRecord = useStudioStore.getState().pages.pages; + expect(pagesRecord).toEqual({ + ...pages, + test: { + componentTree: [], + cssImports: [], + filepath, + }, + }); + }); + + it("sets active page name to the new page", () => { + useStudioStore.getState().pages.addPage(filepath); + const activePageName = useStudioStore.getState().pages.activePageName; + expect(activePageName).toEqual("test"); + }); + + it("adds the new page name to pagesToUpdate", () => { + useStudioStore.getState().pages.addPage(filepath); + const pagesToUpdate = + useStudioStore.getState().pages.pendingChanges.pagesToUpdate; + expect(pagesToUpdate).toEqual(new Set(["test"])); + }); + + describe("errors", () => { + let consoleErrorSpy; + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + }); + + it("gives an error for an empty string filepath", () => { + useStudioStore.getState().pages.addPage(""); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error adding page: a filepath is required." + ); + }); + + it("gives an error for a relative filepath", () => { + useStudioStore.getState().pages.addPage("./test.tsx"); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error adding page: filepath is invalid: ./test.tsx" + ); + }); + + it("gives an error for a filepath outside the allowed path for pages", () => { + const filepath = path.join(__dirname, "../test.tsx"); + useStudioStore.getState().pages.addPage(filepath); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Error adding page: filepath is invalid: ${filepath}` + ); + }); + + it("gives an error for a filepath with a page name that already exists", () => { + const filepath = path.join(__dirname, "./universal.tsx"); + useStudioStore.getState().pages.addPage(filepath); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error adding page: page name "universal" is already used.' + ); + }); + }); + }); }); describe("when there is no active page", () => { @@ -165,6 +276,7 @@ describe("PageSlice", () => { setInitialState({ pages: {}, activePageName: undefined, + pendingChanges, }); consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); }); @@ -173,6 +285,7 @@ describe("PageSlice", () => { useStudioStore.getState().pages.setActivePageState({ componentTree: [], cssImports: [], + filepath: "mock-filepath", }); expect(consoleErrorSpy).toHaveBeenCalledTimes(1); expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -212,10 +325,12 @@ describe("PageSlice", () => { universal: { componentTree: [searchBarComponent, resultsComponent], cssImports: [], + filepath: "mock-filepath", }, }, activePageName: "universal", activeComponentUUID: "results-uuid", + pendingChanges, }); }); @@ -244,18 +359,34 @@ describe("PageSlice", () => { expect(actualPropValues).toEqual(newPropValues); }); + it("updates pagesToUpdate when setActiveComponentProps is used", () => { + const newPropValues: PropValues = { + clicked: { + kind: PropValueKind.Literal, + valueType: PropValueType.boolean, + value: true, + }, + }; + useStudioStore.getState().pages.setActiveComponentProps(newPropValues); + const pagesToUpdate = + useStudioStore.getState().pages.pendingChanges.pagesToUpdate; + expect(pagesToUpdate).toEqual(new Set(["universal"])); + }); + it("logs an error when using setActiveComponentProps if the active component is a fragment", () => { const initialPages = { universal: { pageName: "universal", componentTree: [fragmentComponent, searchBarComponent], cssImports: [], + filepath: "mock-filepath", }, }; setInitialState({ pages: initialPages, activePageName: "universal", activeComponentUUID: fragmentComponent.uuid, + pendingChanges, }); const newPropValues: PropValues = { clicked: { @@ -281,12 +412,14 @@ describe("PageSlice", () => { pageName: "universal", componentTree: [searchBarComponent], cssImports: [], + filepath: "mock-filepath", }, }; setInitialState({ pages: initialPages, activePageName: "universal", activeComponentUUID: undefined, + pendingChanges, }); const newPropValues: PropValues = { clicked: { @@ -319,10 +452,12 @@ describe("PageSlice", () => { universal: { componentTree: [searchBarComponent], cssImports: [], + filepath: "mock-filepath", }, }, activePageName: "universal", activeComponentUUID: undefined, + pendingChanges, }); const activeComponent = useStudioStore .getState() diff --git a/packages/studio/tests/store/siteSettings.test.ts b/packages/studio/tests/store/createSiteSettings.test.ts similarity index 100% rename from packages/studio/tests/store/siteSettings.test.ts rename to packages/studio/tests/store/createSiteSettings.test.ts From 83449eaa62a26227f41cd0d9924e8ac776046c2b Mon Sep 17 00:00:00 2001 From: nmanu1 Date: Tue, 27 Dec 2022 10:06:39 -0500 Subject: [PATCH 2/8] Tweak --- packages/studio/src/components/AddPageButton.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/studio/src/components/AddPageButton.tsx b/packages/studio/src/components/AddPageButton.tsx index 75697d799..8b224f616 100644 --- a/packages/studio/src/components/AddPageButton.tsx +++ b/packages/studio/src/components/AddPageButton.tsx @@ -18,6 +18,8 @@ export default function AddPageButton(): JSX.Element { function handleModalClose() { setShowModal(false); + setPageName(""); + setIsValidInput(false); } function handleModalSave() { From 7df95fe9e022c4ffb3214a08965bc27b8623c82c Mon Sep 17 00:00:00 2001 From: nmanu1 Date: Tue, 27 Dec 2022 19:59:43 -0500 Subject: [PATCH 3/8] Feedback --- .../studio-plugin/src/ParsingOrchestrator.ts | 22 +- .../studio-plugin/src/types/StudioPaths.ts | 8 + .../studio/src/components/AddPageButton.tsx | 31 ++- packages/studio/src/components/PagesPanel.tsx | 64 ++--- .../src/store/slices/createPageSlice.ts | 2 +- .../tests/__fixtures__/componentStates.ts | 53 ++++ .../__utils__/mockActiveComponentState.ts | 1 + .../tests/__utils__/mockPageSliceState.ts | 6 + ...tePageSlice.activeComponentActions.test.ts | 167 +++++++++++++ ...createPageSlice.activePageActions.test.ts} | 226 +----------------- 10 files changed, 309 insertions(+), 271 deletions(-) create mode 100644 packages/studio/tests/__fixtures__/componentStates.ts create mode 100644 packages/studio/tests/__utils__/mockPageSliceState.ts create mode 100644 packages/studio/tests/store/createPageSlice.activeComponentActions.test.ts rename packages/studio/tests/store/{createPageSlice.test.ts => createPageSlice.activePageActions.test.ts} (57%) diff --git a/packages/studio-plugin/src/ParsingOrchestrator.ts b/packages/studio-plugin/src/ParsingOrchestrator.ts index 72c96c101..028152cc0 100644 --- a/packages/studio-plugin/src/ParsingOrchestrator.ts +++ b/packages/studio-plugin/src/ParsingOrchestrator.ts @@ -99,18 +99,16 @@ export default class ParsingOrchestrator { `The pages directory does not exist, expected directory to be at "${this.paths.pages}".` ); } - return fs - .readdirSync(this.paths.pages, "utf-8") - .reduce((prev, curr) => { - const pageName = path.basename(curr, ".tsx"); - const pageFile = new PageFile( - path.join(this.paths.pages, curr), - this.getFileMetadata, - this.project - ); - prev[pageName] = pageFile.getPageState(); - return prev; - }, {}); + return fs.readdirSync(this.paths.pages, "utf-8").reduce((prev, curr) => { + const pageName = path.basename(curr, ".tsx"); + const pageFile = new PageFile( + path.join(this.paths.pages, curr), + this.getFileMetadata, + this.project + ); + prev[pageName] = pageFile.getPageState(); + return prev; + }, {}); } private getSiteSettings(): SiteSettings | undefined { diff --git a/packages/studio-plugin/src/types/StudioPaths.ts b/packages/studio-plugin/src/types/StudioPaths.ts index e7c7bb2d2..39de3767b 100644 --- a/packages/studio-plugin/src/types/StudioPaths.ts +++ b/packages/studio-plugin/src/types/StudioPaths.ts @@ -1,6 +1,14 @@ +/** + * Absolute paths for files and directories in the user's file system that are + * relevant for Studio. + */ export interface StudioPaths { + /** The absolute path to the directory with the user's components. */ components: string; + /** The absolute path to the directory with the user's pages. */ pages: string; + /** The absolute path to the directory with the user's modules. */ modules: string; + /** The absolute path to the file with the user's site settings. */ siteSettings: string; } diff --git a/packages/studio/src/components/AddPageButton.tsx b/packages/studio/src/components/AddPageButton.tsx index 8b224f616..11e4b61b1 100644 --- a/packages/studio/src/components/AddPageButton.tsx +++ b/packages/studio/src/components/AddPageButton.tsx @@ -1,7 +1,7 @@ import Modal from "./common/Modal"; import useStudioStore from "../store/useStudioStore"; import { ReactComponent as Plus } from "../icons/plus.svg"; -import { ChangeEvent, useState } from "react"; +import { ChangeEvent, useCallback, useState } from "react"; import path from "path-browserify"; import initialStudioData from "virtual:yext-studio"; @@ -12,17 +12,17 @@ export default function AddPageButton(): JSX.Element { const [pageName, setPageName] = useState(""); const [isValidInput, setIsValidInput] = useState(false); - function handleAddPage() { + const handleAddPage = useCallback(() => { setShowModal(true); - } + }, [setShowModal]); - function handleModalClose() { + const handleModalClose = useCallback(() => { setShowModal(false); setPageName(""); setIsValidInput(false); - } + }, [setShowModal, setPageName, setIsValidInput]); - function handleModalSave() { + const handleModalSave = useCallback(() => { const pagesPath = initialStudioData.studioPaths.pages; const filepath = path.join(pagesPath, pageName + ".tsx"); if (addPage(filepath)) { @@ -30,16 +30,15 @@ export default function AddPageButton(): JSX.Element { } else { setIsValidInput(false); } - } + }, [pageName, addPage, handleModalClose, setIsValidInput]); - function handleModalInputChange(e: ChangeEvent) { - setPageName(e.target.value); - if (e.target.value) { - setIsValidInput(true); - } else { - setIsValidInput(false); - } - } + const handleModalInputChange = useCallback( + (e: ChangeEvent) => { + setPageName(e.target.value); + setIsValidInput(e.target.value.length > 0); + }, + [setPageName, setIsValidInput] + ); return ( <> @@ -52,7 +51,7 @@ export default function AddPageButton(): JSX.Element { showErrorMessage={!isValidInput && !!pageName} title="Add Page" description="Give the page a name:" - errorMessage="Invalid page name." + errorMessage="Page name already used." onClose={handleModalClose} onSave={handleModalSave} onInputChange={handleModalInputChange} diff --git a/packages/studio/src/components/PagesPanel.tsx b/packages/studio/src/components/PagesPanel.tsx index aa1bcd3b1..345aaf8e7 100644 --- a/packages/studio/src/components/PagesPanel.tsx +++ b/packages/studio/src/components/PagesPanel.tsx @@ -4,6 +4,7 @@ import AddPageButton from "./AddPageButton"; import useStudioStore from "../store/useStudioStore"; import { ReactComponent as Check } from "../icons/check.svg"; import classNames from "classnames"; +import { useCallback } from "react"; /** * Renders the left panel of Studio, which lists all pages and displays the @@ -12,38 +13,43 @@ import classNames from "classnames"; */ export default function PagesPanel(): JSX.Element { const { pages, setActivePageName, activePageName } = useStudioStore( - (store) => store.pages + (store) => { + const { pages, setActivePageName, activePageName } = store.pages; + return { pages, setActivePageName, activePageName }; + } ); const pageNames = Object.keys(pages); - function renderPageList(pageNames: string[]) { - return ( -
- {pageNames.map((pageName) => { - const isActivePage = activePageName === pageName; - const pageNameClasses = classNames({ - "ml-2": isActivePage, - "pl-5": !isActivePage, - }); - function handleClick() { - setActivePageName(pageName); - } - return ( -
- {isActivePage && } - -
- ); - })} -
- ); - } + const renderPageList = useCallback( + (pageNames: string[]) => { + return ( +
+ {pageNames.map((pageName) => { + const isActivePage = activePageName === pageName; + const checkClasses = classNames({ + invisible: !isActivePage, + }); + function handleClick() { + setActivePageName(pageName); + } + return ( +
+ + +
+ ); + })} +
+ ); + }, + [activePageName, setActivePageName] + ); return (
diff --git a/packages/studio/src/store/slices/createPageSlice.ts b/packages/studio/src/store/slices/createPageSlice.ts index 8da2a6c68..76b8e57e2 100644 --- a/packages/studio/src/store/slices/createPageSlice.ts +++ b/packages/studio/src/store/slices/createPageSlice.ts @@ -56,7 +56,7 @@ export const createPageSlice: SliceCreator = (set, get) => { return false; } const pagesPath = initialStudioData.studioPaths.pages; - if (!path.isAbsolute(filepath) || !filepath.includes(pagesPath)) { + if (!path.isAbsolute(filepath) || !filepath.startsWith(pagesPath)) { console.error(`Error adding page: filepath is invalid: ${filepath}`); return false; } diff --git a/packages/studio/tests/__fixtures__/componentStates.ts b/packages/studio/tests/__fixtures__/componentStates.ts new file mode 100644 index 000000000..b582f6af6 --- /dev/null +++ b/packages/studio/tests/__fixtures__/componentStates.ts @@ -0,0 +1,53 @@ +import { + ComponentState, + ComponentStateKind, + PropValueKind, + PropValueType, +} from "@yext/studio-plugin"; + +export const fragmentComponent: ComponentState = { + kind: ComponentStateKind.Fragment, + uuid: "fragment-uuid", +}; + +export const searchBarComponent: ComponentState = { + kind: ComponentStateKind.Standard, + componentName: "SearchBar", + props: { + query: { + kind: PropValueKind.Literal, + valueType: PropValueType.string, + value: "what is Yext?", + }, + }, + uuid: "searchbar-uuid", + metadataUUID: "searchbar-metadata-uuid", +}; + +export const resultsComponent: ComponentState = { + kind: ComponentStateKind.Standard, + componentName: "results", + props: { + limit: { + kind: PropValueKind.Literal, + valueType: PropValueType.number, + value: 10, + }, + }, + uuid: "results-uuid", + metadataUUID: "results-metadata-uuid", +}; + +export const buttonComponent: ComponentState = { + kind: ComponentStateKind.Standard, + componentName: "Button", + props: { + clicked: { + kind: PropValueKind.Literal, + valueType: PropValueType.boolean, + value: false, + }, + }, + uuid: "button-uuid", + metadataUUID: "button-metadata-uuid", +}; diff --git a/packages/studio/tests/__utils__/mockActiveComponentState.ts b/packages/studio/tests/__utils__/mockActiveComponentState.ts index a0a0f0cec..cd79ce3cb 100644 --- a/packages/studio/tests/__utils__/mockActiveComponentState.ts +++ b/packages/studio/tests/__utils__/mockActiveComponentState.ts @@ -14,6 +14,7 @@ export default function mockStoreActiveComponent({ index: { componentTree: activeComponent ? [activeComponent] : [], cssImports: [], + filepath: "mock-filepath", }, }, ...(activeComponent && { activeComponentUUID: activeComponent.uuid }), diff --git a/packages/studio/tests/__utils__/mockPageSliceState.ts b/packages/studio/tests/__utils__/mockPageSliceState.ts new file mode 100644 index 000000000..26b59f179 --- /dev/null +++ b/packages/studio/tests/__utils__/mockPageSliceState.ts @@ -0,0 +1,6 @@ +import { PageSliceStates } from "../../src/store/models/slices/PageSlice"; +import mockStore from "./mockStore"; + +export function mockPageSliceStates(initialState: PageSliceStates): void { + mockStore({ pages: initialState }); +} diff --git a/packages/studio/tests/store/createPageSlice.activeComponentActions.test.ts b/packages/studio/tests/store/createPageSlice.activeComponentActions.test.ts new file mode 100644 index 000000000..2ca70704c --- /dev/null +++ b/packages/studio/tests/store/createPageSlice.activeComponentActions.test.ts @@ -0,0 +1,167 @@ +import useStudioStore from "../../src/store/useStudioStore"; +import { + ComponentStateKind, + PropValueKind, + PropValues, + PropValueType, +} from "@yext/studio-plugin"; +import { mockPageSliceStates } from "../__utils__/mockPageSliceState"; +import { + searchBarComponent, + resultsComponent, + fragmentComponent, +} from "../__fixtures__/componentStates"; + +const pendingChanges = { + pagesToUpdate: new Set(), +}; + +describe("PageSlice", () => { + describe("active component actions", () => { + beforeEach(() => { + mockPageSliceStates({ + pages: { + universal: { + componentTree: [searchBarComponent, resultsComponent], + cssImports: [], + filepath: "mock-filepath", + }, + }, + activePageName: "universal", + activeComponentUUID: "results-uuid", + pendingChanges, + }); + }); + + it("updates activeComponentUUID using setActiveComponentUUID", () => { + useStudioStore.getState().pages.setActiveComponentUUID("searchbar-uuid"); + const activeComponentUUID = + useStudioStore.getState().pages.activeComponentUUID; + expect(activeComponentUUID).toEqual("searchbar-uuid"); + }); + + it("updates active component's prop values using setActiveComponentProps", () => { + const newPropValues: PropValues = { + clicked: { + kind: PropValueKind.Literal, + valueType: PropValueType.boolean, + value: true, + }, + }; + useStudioStore.getState().pages.setActiveComponentProps(newPropValues); + const componentState = + useStudioStore.getState().pages.pages["universal"].componentTree[1]; + const actualPropValues = + componentState.kind === ComponentStateKind.Fragment + ? undefined + : componentState.props; + expect(actualPropValues).toEqual(newPropValues); + }); + + it("updates pagesToUpdate when setActiveComponentProps is used", () => { + const newPropValues: PropValues = { + clicked: { + kind: PropValueKind.Literal, + valueType: PropValueType.boolean, + value: true, + }, + }; + useStudioStore.getState().pages.setActiveComponentProps(newPropValues); + const pagesToUpdate = + useStudioStore.getState().pages.pendingChanges.pagesToUpdate; + expect(pagesToUpdate).toEqual(new Set(["universal"])); + }); + + it("logs an error when using setActiveComponentProps if the active component is a fragment", () => { + const initialPages = { + universal: { + pageName: "universal", + componentTree: [fragmentComponent, searchBarComponent], + cssImports: [], + filepath: "mock-filepath", + }, + }; + mockPageSliceStates({ + pages: initialPages, + activePageName: "universal", + activeComponentUUID: fragmentComponent.uuid, + pendingChanges, + }); + const newPropValues: PropValues = { + clicked: { + kind: PropValueKind.Literal, + valueType: PropValueType.boolean, + value: true, + }, + }; + const consoleErrorSpy = jest + .spyOn(global.console, "error") + .mockImplementation(); + useStudioStore.getState().pages.setActiveComponentProps(newPropValues); + const actualPages = useStudioStore.getState().pages.pages; + expect(actualPages).toEqual(initialPages); + expect(consoleErrorSpy).toBeCalledWith( + "Error in setActiveComponentProps: The active component is a fragment and does not accept props." + ); + }); + + it("logs an error when using setActiveComponentProps before an active component is selected", () => { + const initialPages = { + universal: { + pageName: "universal", + componentTree: [searchBarComponent], + cssImports: [], + filepath: "mock-filepath", + }, + }; + mockPageSliceStates({ + pages: initialPages, + activePageName: "universal", + activeComponentUUID: undefined, + pendingChanges, + }); + const newPropValues: PropValues = { + clicked: { + kind: PropValueKind.Literal, + valueType: PropValueType.boolean, + value: true, + }, + }; + const consoleErrorSpy = jest + .spyOn(global.console, "error") + .mockImplementation(); + useStudioStore.getState().pages.setActiveComponentProps(newPropValues); + const actualPages = useStudioStore.getState().pages.pages; + expect(actualPages).toEqual(initialPages); + expect(consoleErrorSpy).toBeCalledWith( + "Error in setActiveComponentProps: No active component selected in store." + ); + }); + + it("returns active component using getActiveComponentState", () => { + const activeComponent = useStudioStore + .getState() + .pages.getActiveComponentState(); + expect(activeComponent).toEqual(resultsComponent); + }); + + it("returns undefined when using getActiveComponentState before an active component is set in store", () => { + mockPageSliceStates({ + pages: { + universal: { + componentTree: [searchBarComponent], + cssImports: [], + filepath: "mock-filepath", + }, + }, + activePageName: "universal", + activeComponentUUID: undefined, + pendingChanges, + }); + const activeComponent = useStudioStore + .getState() + .pages.getActiveComponentState(); + expect(activeComponent).toEqual(undefined); + }); + }); +}); diff --git a/packages/studio/tests/store/createPageSlice.test.ts b/packages/studio/tests/store/createPageSlice.activePageActions.test.ts similarity index 57% rename from packages/studio/tests/store/createPageSlice.test.ts rename to packages/studio/tests/store/createPageSlice.activePageActions.test.ts index 37164d69c..e34bccf5c 100644 --- a/packages/studio/tests/store/createPageSlice.test.ts +++ b/packages/studio/tests/store/createPageSlice.activePageActions.test.ts @@ -1,18 +1,13 @@ import useStudioStore from "../../src/store/useStudioStore"; -import { - ComponentState, - ComponentStateKind, - PageState, - PropValueKind, - PropValues, - PropValueType, -} from "@yext/studio-plugin"; -import { - PageSliceStates, - PagesRecord, -} from "../../src/store/models/slices/PageSlice"; -import mockStore from "../__utils__/mockStore"; +import { PageState } from "@yext/studio-plugin"; +import { PagesRecord } from "../../src/store/models/slices/PageSlice"; import path from "path"; +import { mockPageSliceStates } from "../__utils__/mockPageSliceState"; +import { + searchBarComponent, + resultsComponent, + buttonComponent, +} from "../__fixtures__/componentStates"; jest.mock("virtual:yext-studio", () => { return { @@ -23,49 +18,6 @@ jest.mock("virtual:yext-studio", () => { }; }); -const fragmentComponent: ComponentState = { - kind: ComponentStateKind.Fragment, - uuid: "fragment-uuid", -}; -const searchBarComponent: ComponentState = { - kind: ComponentStateKind.Standard, - componentName: "SearchBar", - props: { - query: { - kind: PropValueKind.Literal, - valueType: PropValueType.string, - value: "what is Yext?", - }, - }, - uuid: "searchbar-uuid", - metadataUUID: "searchbar-metadata-uuid", -}; -const resultsComponent: ComponentState = { - kind: ComponentStateKind.Standard, - componentName: "results", - props: { - limit: { - kind: PropValueKind.Literal, - valueType: PropValueType.number, - value: 10, - }, - }, - uuid: "results-uuid", - metadataUUID: "results-metadata-uuid", -}; -const buttonComponent: ComponentState = { - kind: ComponentStateKind.Standard, - componentName: "Button", - props: { - clicked: { - kind: PropValueKind.Literal, - valueType: PropValueType.boolean, - value: false, - }, - }, - uuid: "button-uuid", - metadataUUID: "button-metadata-uuid", -}; const pages: PagesRecord = { universal: { componentTree: [searchBarComponent], @@ -85,7 +37,7 @@ const pendingChanges = { describe("PageSlice", () => { describe("active page actions", () => { beforeEach(() => { - setInitialState({ + mockPageSliceStates({ pages, activePageName: "universal", pendingChanges, @@ -99,7 +51,7 @@ describe("PageSlice", () => { }); it("resets activeComponentUUID when setActivePageName is used", () => { - setInitialState({ + mockPageSliceStates({ pages, activePageName: "universal", activeComponentUUID: "searchbar-uuid", @@ -150,7 +102,7 @@ describe("PageSlice", () => { }); it("resets active component uuid when it's remove from page state using setActivePageState", () => { - setInitialState({ + mockPageSliceStates({ pages, activePageName: "universal", activeComponentUUID: "searchbar-uuid", @@ -171,7 +123,7 @@ describe("PageSlice", () => { }); it("maintains active component uuid when it's still in page state using setActivePageState", () => { - setInitialState({ + mockPageSliceStates({ pages, activePageName: "universal", activeComponentUUID: "searchbar-uuid", @@ -273,7 +225,7 @@ describe("PageSlice", () => { describe("when there is no active page", () => { let consoleErrorSpy; beforeEach(() => { - setInitialState({ + mockPageSliceStates({ pages: {}, activePageName: undefined, pendingChanges, @@ -317,156 +269,4 @@ describe("PageSlice", () => { ); }); }); - - describe("active component actions", () => { - beforeEach(() => { - setInitialState({ - pages: { - universal: { - componentTree: [searchBarComponent, resultsComponent], - cssImports: [], - filepath: "mock-filepath", - }, - }, - activePageName: "universal", - activeComponentUUID: "results-uuid", - pendingChanges, - }); - }); - - it("updates activeComponentUUID using setActiveComponentUUID", () => { - useStudioStore.getState().pages.setActiveComponentUUID("searchbar-uuid"); - const activeComponentUUID = - useStudioStore.getState().pages.activeComponentUUID; - expect(activeComponentUUID).toEqual("searchbar-uuid"); - }); - - it("updates active component's prop values using setActiveComponentProps", () => { - const newPropValues: PropValues = { - clicked: { - kind: PropValueKind.Literal, - valueType: PropValueType.boolean, - value: true, - }, - }; - useStudioStore.getState().pages.setActiveComponentProps(newPropValues); - const componentState = - useStudioStore.getState().pages.pages["universal"].componentTree[1]; - const actualPropValues = - componentState.kind === ComponentStateKind.Fragment - ? undefined - : componentState.props; - expect(actualPropValues).toEqual(newPropValues); - }); - - it("updates pagesToUpdate when setActiveComponentProps is used", () => { - const newPropValues: PropValues = { - clicked: { - kind: PropValueKind.Literal, - valueType: PropValueType.boolean, - value: true, - }, - }; - useStudioStore.getState().pages.setActiveComponentProps(newPropValues); - const pagesToUpdate = - useStudioStore.getState().pages.pendingChanges.pagesToUpdate; - expect(pagesToUpdate).toEqual(new Set(["universal"])); - }); - - it("logs an error when using setActiveComponentProps if the active component is a fragment", () => { - const initialPages = { - universal: { - pageName: "universal", - componentTree: [fragmentComponent, searchBarComponent], - cssImports: [], - filepath: "mock-filepath", - }, - }; - setInitialState({ - pages: initialPages, - activePageName: "universal", - activeComponentUUID: fragmentComponent.uuid, - pendingChanges, - }); - const newPropValues: PropValues = { - clicked: { - kind: PropValueKind.Literal, - valueType: PropValueType.boolean, - value: true, - }, - }; - const consoleErrorSpy = jest - .spyOn(global.console, "error") - .mockImplementation(); - useStudioStore.getState().pages.setActiveComponentProps(newPropValues); - const actualPages = useStudioStore.getState().pages.pages; - expect(actualPages).toEqual(initialPages); - expect(consoleErrorSpy).toBeCalledWith( - "Error in setActiveComponentProps: The active component is a fragment and does not accept props." - ); - }); - - it("logs an error when using setActiveComponentProps before an active component is selected", () => { - const initialPages = { - universal: { - pageName: "universal", - componentTree: [searchBarComponent], - cssImports: [], - filepath: "mock-filepath", - }, - }; - setInitialState({ - pages: initialPages, - activePageName: "universal", - activeComponentUUID: undefined, - pendingChanges, - }); - const newPropValues: PropValues = { - clicked: { - kind: PropValueKind.Literal, - valueType: PropValueType.boolean, - value: true, - }, - }; - const consoleErrorSpy = jest - .spyOn(global.console, "error") - .mockImplementation(); - useStudioStore.getState().pages.setActiveComponentProps(newPropValues); - const actualPages = useStudioStore.getState().pages.pages; - expect(actualPages).toEqual(initialPages); - expect(consoleErrorSpy).toBeCalledWith( - "Error in setActiveComponentProps: No active component selected in store." - ); - }); - - it("returns active component using getActiveComponentState", () => { - const activeComponent = useStudioStore - .getState() - .pages.getActiveComponentState(); - expect(activeComponent).toEqual(resultsComponent); - }); - - it("returns undefined when using getActiveComponentState before an active component is set in store", () => { - setInitialState({ - pages: { - universal: { - componentTree: [searchBarComponent], - cssImports: [], - filepath: "mock-filepath", - }, - }, - activePageName: "universal", - activeComponentUUID: undefined, - pendingChanges, - }); - const activeComponent = useStudioStore - .getState() - .pages.getActiveComponentState(); - expect(activeComponent).toEqual(undefined); - }); - }); }); - -function setInitialState(initialState: PageSliceStates): void { - mockStore({ pages: initialState }); -} From 3342f10996817ce3857184cbf0858ac36597a0c6 Mon Sep 17 00:00:00 2001 From: nmanu1 Date: Wed, 28 Dec 2022 11:44:55 -0500 Subject: [PATCH 4/8] Feedback --- .../studio-plugin/src/ParsingOrchestrator.ts | 6 +- .../src/parsers/getStudioPaths.ts | 4 +- .../studio-plugin/src/types/StudioData.ts | 4 +- .../types/{StudioPaths.ts => UserPaths.ts} | 2 +- packages/studio-plugin/src/types/index.ts | 2 +- packages/studio/src/App.tsx | 4 +- .../{PagesPanel.tsx => ActivePagePanel.tsx} | 9 +-- .../studio/src/components/AddPageButton.tsx | 51 +++++++-------- .../studio/src/components/common/Modal.tsx | 63 ++++++++++++------- .../src/store/slices/createPageSlice.ts | 2 +- .../createPageSlice.activePageActions.test.ts | 2 +- 11 files changed, 85 insertions(+), 64 deletions(-) rename packages/studio-plugin/src/types/{StudioPaths.ts => UserPaths.ts} (93%) rename packages/studio/src/components/{PagesPanel.tsx => ActivePagePanel.tsx} (84%) diff --git a/packages/studio-plugin/src/ParsingOrchestrator.ts b/packages/studio-plugin/src/ParsingOrchestrator.ts index 028152cc0..56e969691 100644 --- a/packages/studio-plugin/src/ParsingOrchestrator.ts +++ b/packages/studio-plugin/src/ParsingOrchestrator.ts @@ -1,5 +1,5 @@ import path from "path"; -import { FileMetadata, PageState, StudioPaths, StudioData } from "./types"; +import { FileMetadata, PageState, UserPaths, StudioData } from "./types"; import fs from "fs"; import ComponentFile from "./sourcefiles/ComponentFile"; import ModuleFile from "./sourcefiles/ModuleFile"; @@ -26,7 +26,7 @@ export default class ParsingOrchestrator { private project: Project; /** All paths are assumed to be absolute. */ - constructor(private paths: StudioPaths) { + constructor(private paths: UserPaths) { this.project = createTsMorphProject(); this.getFileMetadata = this.getFileMetadata.bind(this); this.filepathToFileMetadata = this.setFilepathToFileMetadata(); @@ -47,7 +47,7 @@ export default class ParsingOrchestrator { pageNameToPageState, UUIDToFileMetadata, siteSettings, - studioPaths: this.paths, + userPaths: this.paths, }; } diff --git a/packages/studio-plugin/src/parsers/getStudioPaths.ts b/packages/studio-plugin/src/parsers/getStudioPaths.ts index 7d185d167..65b61ff0f 100644 --- a/packages/studio-plugin/src/parsers/getStudioPaths.ts +++ b/packages/studio-plugin/src/parsers/getStudioPaths.ts @@ -1,5 +1,5 @@ import path from "path"; -import { StudioPaths } from "../types"; +import { UserPaths } from "../types"; /** * Given an absolute path to the user's src folder, determine the filepaths Studio will @@ -7,7 +7,7 @@ import { StudioPaths } from "../types"; * * @param pathToSrc - An absolute path to the src folder */ -export default function getStudioPaths(pathToSrc: string): StudioPaths { +export default function getStudioPaths(pathToSrc: string): UserPaths { return { pages: path.join(pathToSrc, "pages"), modules: path.join(pathToSrc, "modules"), diff --git a/packages/studio-plugin/src/types/StudioData.ts b/packages/studio-plugin/src/types/StudioData.ts index 733aeb62d..dc5fcbf93 100644 --- a/packages/studio-plugin/src/types/StudioData.ts +++ b/packages/studio-plugin/src/types/StudioData.ts @@ -1,11 +1,11 @@ import { SiteSettings } from "../sourcefiles/SiteSettingsFile"; import { ComponentMetadata } from "./ComponentMetadata"; import { PageState } from "./State"; -import { StudioPaths } from "./StudioPaths"; +import { UserPaths } from "./UserPaths"; export interface StudioData { pageNameToPageState: Record; UUIDToFileMetadata: Record; siteSettings?: SiteSettings; - studioPaths: StudioPaths; + userPaths: UserPaths; } diff --git a/packages/studio-plugin/src/types/StudioPaths.ts b/packages/studio-plugin/src/types/UserPaths.ts similarity index 93% rename from packages/studio-plugin/src/types/StudioPaths.ts rename to packages/studio-plugin/src/types/UserPaths.ts index 39de3767b..404b5ad1e 100644 --- a/packages/studio-plugin/src/types/StudioPaths.ts +++ b/packages/studio-plugin/src/types/UserPaths.ts @@ -2,7 +2,7 @@ * Absolute paths for files and directories in the user's file system that are * relevant for Studio. */ -export interface StudioPaths { +export interface UserPaths { /** The absolute path to the directory with the user's components. */ components: string; /** The absolute path to the directory with the user's pages. */ diff --git a/packages/studio-plugin/src/types/index.ts b/packages/studio-plugin/src/types/index.ts index 2288da7ed..d0d300437 100644 --- a/packages/studio-plugin/src/types/index.ts +++ b/packages/studio-plugin/src/types/index.ts @@ -5,4 +5,4 @@ export * from "./ModuleMetadata"; export * from "./FileMetadata"; export * from "./State"; export * from "./StudioData"; -export * from "./StudioPaths"; +export * from "./UserPaths"; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 0ae65d106..80dc943b9 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,5 +1,5 @@ import EditorPanel from "./components/EditorPanel"; -import PagesPanel from "./components/PagesPanel"; +import ActivePagePanel from "./components/ActivePagePanel"; import useStudioStore from "./store/useStudioStore"; export default function App() { @@ -10,7 +10,7 @@ export default function App() { return (
- +
Preview
activeComponentState: {JSON.stringify(activeComponentState, null, 2)}
diff --git a/packages/studio/src/components/PagesPanel.tsx b/packages/studio/src/components/ActivePagePanel.tsx similarity index 84% rename from packages/studio/src/components/PagesPanel.tsx rename to packages/studio/src/components/ActivePagePanel.tsx index 345aaf8e7..7985e9b84 100644 --- a/packages/studio/src/components/PagesPanel.tsx +++ b/packages/studio/src/components/ActivePagePanel.tsx @@ -7,11 +7,12 @@ import classNames from "classnames"; import { useCallback } from "react"; /** - * Renders the left panel of Studio, which lists all pages and displays the - * component tree for the active page. Allows navigation between pages and - * rearranging of components and modules in the component tree. + * Renders the left panel of Studio, which lists all pages, indicates which + * page is active, and displays the component tree for that active page. Allows + * the user to change which page is active and to rearrange the components and + * modules in the component tree of the active page. */ -export default function PagesPanel(): JSX.Element { +export default function ActivePagePanel(): JSX.Element { const { pages, setActivePageName, activePageName } = useStudioStore( (store) => { const { pages, setActivePageName, activePageName } = store.pages; diff --git a/packages/studio/src/components/AddPageButton.tsx b/packages/studio/src/components/AddPageButton.tsx index 11e4b61b1..416a4096b 100644 --- a/packages/studio/src/components/AddPageButton.tsx +++ b/packages/studio/src/components/AddPageButton.tsx @@ -1,16 +1,21 @@ import Modal from "./common/Modal"; import useStudioStore from "../store/useStudioStore"; import { ReactComponent as Plus } from "../icons/plus.svg"; -import { ChangeEvent, useCallback, useState } from "react"; +import { useCallback, useState } from "react"; import path from "path-browserify"; import initialStudioData from "virtual:yext-studio"; +/** + * Renders a button for adding new pages to the store. When the button is + * clicked, a modal is displayed prompting the user for a page name. + */ export default function AddPageButton(): JSX.Element { const addPage = useStudioStore((store) => store.pages.addPage); const [showModal, setShowModal] = useState(false); - const [pageName, setPageName] = useState(""); - const [isValidInput, setIsValidInput] = useState(false); + const [errorMessage, setErrorMessage] = useState( + undefined + ); const handleAddPage = useCallback(() => { setShowModal(true); @@ -18,26 +23,25 @@ export default function AddPageButton(): JSX.Element { const handleModalClose = useCallback(() => { setShowModal(false); - setPageName(""); - setIsValidInput(false); - }, [setShowModal, setPageName, setIsValidInput]); - - const handleModalSave = useCallback(() => { - const pagesPath = initialStudioData.studioPaths.pages; - const filepath = path.join(pagesPath, pageName + ".tsx"); - if (addPage(filepath)) { - handleModalClose(); - } else { - setIsValidInput(false); - } - }, [pageName, addPage, handleModalClose, setIsValidInput]); + setErrorMessage(undefined); + }, [setShowModal, setErrorMessage]); - const handleModalInputChange = useCallback( - (e: ChangeEvent) => { - setPageName(e.target.value); - setIsValidInput(e.target.value.length > 0); + const handleModalSave = useCallback( + (pageName: string) => { + const pagesPath = initialStudioData.userPaths.pages; + const filepath = path.join(pagesPath, pageName + ".tsx"); + if (addPage(filepath)) { + return true; + } else { + if (filepath.startsWith(pagesPath)) { + setErrorMessage("Page name already used."); + } else { + setErrorMessage("Page path is invalid."); + } + return false; + } }, - [setPageName, setIsValidInput] + [addPage, setErrorMessage] ); return ( @@ -47,14 +51,11 @@ export default function AddPageButton(): JSX.Element { ); diff --git a/packages/studio/src/components/common/Modal.tsx b/packages/studio/src/components/common/Modal.tsx index fd68d4a18..e5ca1348a 100644 --- a/packages/studio/src/components/common/Modal.tsx +++ b/packages/studio/src/components/common/Modal.tsx @@ -1,18 +1,15 @@ -import { ChangeEvent } from "react"; +import { ChangeEvent, useCallback, useState } from "react"; import ReactModal from "react-modal"; import { ReactComponent as X } from "../../icons/x.svg"; import classNames from "classnames"; interface ModalProps { isOpen: boolean; - disableSave: boolean; - showErrorMessage: boolean; title: string; description: string; - errorMessage: string; + errorMessage?: string; onClose: () => void; - onSave: () => void; - onInputChange: (e: ChangeEvent) => void; + onSave: (input: string) => boolean; } const customReactModalStyles = { @@ -28,30 +25,51 @@ const customReactModalStyles = { export default function Modal({ isOpen, - disableSave, - showErrorMessage, title, description, errorMessage, onClose, onSave, - onInputChange, }: ModalProps) { + const [isValidInput, setIsValidInput] = useState(false); + const [inputValue, setInputValue] = useState(""); + + const handleClose = useCallback(() => { + setInputValue(""); + setIsValidInput(false); + onClose(); + }, [onClose, setInputValue, setIsValidInput]); + + const handleSave = useCallback(() => { + if (onSave(inputValue)) { + handleClose(); + } else { + setIsValidInput(false); + } + }, [inputValue, onSave, handleClose]); + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + const value = e.target.value.trim(); + setInputValue(value); + setIsValidInput(value.length > 0); + }, + [setInputValue, setIsValidInput] + ); + + const showErrorMessage = !isValidInput && inputValue && errorMessage; const footerClasses = classNames("mt-2 items-center", { "flex justify-between": showErrorMessage, }); - const saveButtonClasses = classNames( - "ml-2 bg-blue-600 py-1 px-3 text-white rounded-md", - { - "bg-gray-400": disableSave, - "bg-blue-600": !disableSave, - } - ); + const saveButtonClasses = classNames("ml-2 py-1 px-3 text-white rounded-md", { + "bg-gray-400": !isValidInput, + "bg-blue-600": isValidInput, + }); return (
{title} -
@@ -68,20 +86,21 @@ export default function Modal({
{showErrorMessage && (
{errorMessage}
)}
- diff --git a/packages/studio/src/store/slices/createPageSlice.ts b/packages/studio/src/store/slices/createPageSlice.ts index 76b8e57e2..9697da3e7 100644 --- a/packages/studio/src/store/slices/createPageSlice.ts +++ b/packages/studio/src/store/slices/createPageSlice.ts @@ -55,7 +55,7 @@ export const createPageSlice: SliceCreator = (set, get) => { console.error("Error adding page: a filepath is required."); return false; } - const pagesPath = initialStudioData.studioPaths.pages; + const pagesPath = initialStudioData.userPaths.pages; if (!path.isAbsolute(filepath) || !filepath.startsWith(pagesPath)) { console.error(`Error adding page: filepath is invalid: ${filepath}`); return false; diff --git a/packages/studio/tests/store/createPageSlice.activePageActions.test.ts b/packages/studio/tests/store/createPageSlice.activePageActions.test.ts index e34bccf5c..73789e4ea 100644 --- a/packages/studio/tests/store/createPageSlice.activePageActions.test.ts +++ b/packages/studio/tests/store/createPageSlice.activePageActions.test.ts @@ -12,7 +12,7 @@ import { jest.mock("virtual:yext-studio", () => { return { pageNameToPageState: {}, - studioPaths: { + userPaths: { pages: __dirname, }, }; From 5a918f238879175fff88a76d0977f923397e29f2 Mon Sep 17 00:00:00 2001 From: nmanu1 Date: Wed, 28 Dec 2022 15:29:57 -0500 Subject: [PATCH 5/8] Nits and mock --- .../studio/__mocks__/virtual:yext-studio.ts | 8 ++++++ .../studio/src/components/ActivePagePanel.tsx | 4 +-- ...tePageSlice.activeComponentActions.test.ts | 3 ++- .../createPageSlice.activePageActions.test.ts | 26 +++++++++---------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/studio/__mocks__/virtual:yext-studio.ts b/packages/studio/__mocks__/virtual:yext-studio.ts index 55f6e3d16..7910933bb 100644 --- a/packages/studio/__mocks__/virtual:yext-studio.ts +++ b/packages/studio/__mocks__/virtual:yext-studio.ts @@ -1,7 +1,15 @@ import { StudioData } from "@yext/studio-plugin"; +import path from "path"; +const mockFilepath = path.join(__dirname, "../tests/__mocks__"); const mockStudioData: StudioData = { pageNameToPageState: {}, UUIDToFileMetadata: {}, + userPaths: { + components: mockFilepath, + pages: mockFilepath, + modules: mockFilepath, + siteSettings: mockFilepath, + }, }; export default mockStudioData; diff --git a/packages/studio/src/components/ActivePagePanel.tsx b/packages/studio/src/components/ActivePagePanel.tsx index 7985e9b84..3b39d3d15 100644 --- a/packages/studio/src/components/ActivePagePanel.tsx +++ b/packages/studio/src/components/ActivePagePanel.tsx @@ -4,7 +4,7 @@ import AddPageButton from "./AddPageButton"; import useStudioStore from "../store/useStudioStore"; import { ReactComponent as Check } from "../icons/check.svg"; import classNames from "classnames"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; /** * Renders the left panel of Studio, which lists all pages, indicates which @@ -19,7 +19,7 @@ export default function ActivePagePanel(): JSX.Element { return { pages, setActivePageName, activePageName }; } ); - const pageNames = Object.keys(pages); + const pageNames = useMemo(() => Object.keys(pages), [pages]); const renderPageList = useCallback( (pageNames: string[]) => { diff --git a/packages/studio/tests/store/createPageSlice.activeComponentActions.test.ts b/packages/studio/tests/store/createPageSlice.activeComponentActions.test.ts index 2ca70704c..8f7f00e20 100644 --- a/packages/studio/tests/store/createPageSlice.activeComponentActions.test.ts +++ b/packages/studio/tests/store/createPageSlice.activeComponentActions.test.ts @@ -11,8 +11,9 @@ import { resultsComponent, fragmentComponent, } from "../__fixtures__/componentStates"; +import { PageSliceStates } from "../../src/store/models/slices/PageSlice"; -const pendingChanges = { +const pendingChanges: PageSliceStates["pendingChanges"] = { pagesToUpdate: new Set(), }; diff --git a/packages/studio/tests/store/createPageSlice.activePageActions.test.ts b/packages/studio/tests/store/createPageSlice.activePageActions.test.ts index 73789e4ea..2e12df2bf 100644 --- a/packages/studio/tests/store/createPageSlice.activePageActions.test.ts +++ b/packages/studio/tests/store/createPageSlice.activePageActions.test.ts @@ -1,6 +1,9 @@ import useStudioStore from "../../src/store/useStudioStore"; import { PageState } from "@yext/studio-plugin"; -import { PagesRecord } from "../../src/store/models/slices/PageSlice"; +import { + PageSliceStates, + PagesRecord, +} from "../../src/store/models/slices/PageSlice"; import path from "path"; import { mockPageSliceStates } from "../__utils__/mockPageSliceState"; import { @@ -9,15 +12,6 @@ import { buttonComponent, } from "../__fixtures__/componentStates"; -jest.mock("virtual:yext-studio", () => { - return { - pageNameToPageState: {}, - userPaths: { - pages: __dirname, - }, - }; -}); - const pages: PagesRecord = { universal: { componentTree: [searchBarComponent], @@ -30,7 +24,7 @@ const pages: PagesRecord = { filepath: "mock-filepath", }, }; -const pendingChanges = { +const pendingChanges: PageSliceStates["pendingChanges"] = { pagesToUpdate: new Set(), }; @@ -151,7 +145,7 @@ describe("PageSlice", () => { }); describe("addPage", () => { - const filepath = path.resolve(__dirname, "./test.tsx"); + const filepath = path.resolve(__dirname, "../__mocks__", "./test.tsx"); it("adds a page to pages", () => { useStudioStore.getState().pages.addPage(filepath); @@ -202,7 +196,7 @@ describe("PageSlice", () => { }); it("gives an error for a filepath outside the allowed path for pages", () => { - const filepath = path.join(__dirname, "../test.tsx"); + const filepath = path.join(__dirname, "../__mocks__", "../test.tsx"); useStudioStore.getState().pages.addPage(filepath); expect(consoleErrorSpy).toHaveBeenCalledTimes(1); expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -211,7 +205,11 @@ describe("PageSlice", () => { }); it("gives an error for a filepath with a page name that already exists", () => { - const filepath = path.join(__dirname, "./universal.tsx"); + const filepath = path.join( + __dirname, + "../__mocks__", + "./universal.tsx" + ); useStudioStore.getState().pages.addPage(filepath); expect(consoleErrorSpy).toHaveBeenCalledTimes(1); expect(consoleErrorSpy).toHaveBeenCalledWith( From ccdfcdc2b5f2cb02d0d7638497c4337622231e4f Mon Sep 17 00:00:00 2001 From: nmanu1 Date: Wed, 28 Dec 2022 16:18:50 -0500 Subject: [PATCH 6/8] Make errorMessage required --- packages/studio/src/components/AddPageButton.tsx | 8 +++----- packages/studio/src/components/common/Modal.tsx | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/components/AddPageButton.tsx b/packages/studio/src/components/AddPageButton.tsx index 416a4096b..3152b897d 100644 --- a/packages/studio/src/components/AddPageButton.tsx +++ b/packages/studio/src/components/AddPageButton.tsx @@ -13,9 +13,8 @@ export default function AddPageButton(): JSX.Element { const addPage = useStudioStore((store) => store.pages.addPage); const [showModal, setShowModal] = useState(false); - const [errorMessage, setErrorMessage] = useState( - undefined - ); + const [errorMessage, setErrorMessage] = + useState("Invalid page name."); const handleAddPage = useCallback(() => { setShowModal(true); @@ -23,8 +22,7 @@ export default function AddPageButton(): JSX.Element { const handleModalClose = useCallback(() => { setShowModal(false); - setErrorMessage(undefined); - }, [setShowModal, setErrorMessage]); + }, [setShowModal]); const handleModalSave = useCallback( (pageName: string) => { diff --git a/packages/studio/src/components/common/Modal.tsx b/packages/studio/src/components/common/Modal.tsx index e5ca1348a..024b3bba0 100644 --- a/packages/studio/src/components/common/Modal.tsx +++ b/packages/studio/src/components/common/Modal.tsx @@ -7,7 +7,7 @@ interface ModalProps { isOpen: boolean; title: string; description: string; - errorMessage?: string; + errorMessage: string; onClose: () => void; onSave: (input: string) => boolean; } @@ -57,7 +57,7 @@ export default function Modal({ [setInputValue, setIsValidInput] ); - const showErrorMessage = !isValidInput && inputValue && errorMessage; + const showErrorMessage = !isValidInput && inputValue; const footerClasses = classNames("mt-2 items-center", { "flex justify-between": showErrorMessage, }); From 4330cf493b807d928f5cdc589ba2e374c61650d1 Mon Sep 17 00:00:00 2001 From: nmanu1 Date: Wed, 28 Dec 2022 19:12:43 -0500 Subject: [PATCH 7/8] Add tests --- .../tests/components/AddPageButton.test.tsx | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 packages/studio/tests/components/AddPageButton.test.tsx diff --git a/packages/studio/tests/components/AddPageButton.test.tsx b/packages/studio/tests/components/AddPageButton.test.tsx new file mode 100644 index 000000000..eaccab86c --- /dev/null +++ b/packages/studio/tests/components/AddPageButton.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import AddPageButton from "../../src/components/AddPageButton"; +import useStudioStore from "../../src/store/useStudioStore"; +import mockStore from "../__utils__/mockStore"; + +jest.mock("../../src/icons/plus.svg", () => { + return { ReactComponent: "svg" }; +}); + +jest.mock("../../src/icons/x.svg", () => { + return { ReactComponent: "svg" }; +}); + +describe("AddPageButton", () => { + beforeEach(() => { + mockStore({ + pages: { + pages: { + universal: { + componentTree: [], + cssImports: [], + filepath: "mock-filepath", + }, + }, + }, + }); + }); + + it("closes the modal when a page name is successfully added", async () => { + const setActivePageNameSpy = jest.spyOn( + useStudioStore.getState().pages, + "setActivePageName" + ); + render(); + const addPageButton = screen.getByRole("button"); + await userEvent.click(addPageButton); + const textbox = screen.getByRole("textbox"); + await userEvent.type(textbox, "test"); + const saveButton = screen.getByText("Save"); + await userEvent.click(saveButton); + expect(setActivePageNameSpy).toBeCalledWith("test"); + expect(screen.queryByText("Save")).toBeNull(); + }); + + it("gives an error if the page name is already used", async () => { + render(); + const addPageButton = screen.getByRole("button"); + await userEvent.click(addPageButton); + const textbox = screen.getByRole("textbox"); + await userEvent.type(textbox, "universal"); + const saveButton = screen.getByText("Save"); + await userEvent.click(saveButton); + expect(screen.getByText("Page name already used.")).toBeDefined(); + }); + + it("gives an error if the page path is invalid", async () => { + render(); + const addPageButton = screen.getByRole("button"); + await userEvent.click(addPageButton); + const textbox = screen.getByRole("textbox"); + await userEvent.type(textbox, "../test"); + const saveButton = screen.getByText("Save"); + await userEvent.click(saveButton); + expect(screen.getByText("Page path is invalid.")).toBeDefined(); + }); +}); From f0fce6a26febd7134f6384a1e1296087978fa1fc Mon Sep 17 00:00:00 2001 From: nmanu1 Date: Thu, 29 Dec 2022 11:11:07 -0500 Subject: [PATCH 8/8] onClose => handleClose --- packages/studio/src/components/AddPageButton.tsx | 2 +- packages/studio/src/components/common/Modal.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/components/AddPageButton.tsx b/packages/studio/src/components/AddPageButton.tsx index 3152b897d..ebee9f74b 100644 --- a/packages/studio/src/components/AddPageButton.tsx +++ b/packages/studio/src/components/AddPageButton.tsx @@ -52,7 +52,7 @@ export default function AddPageButton(): JSX.Element { title="Add Page" description="Give the page a name:" errorMessage={errorMessage} - onClose={handleModalClose} + handleClose={handleModalClose} onSave={handleModalSave} /> diff --git a/packages/studio/src/components/common/Modal.tsx b/packages/studio/src/components/common/Modal.tsx index 024b3bba0..2a92c0824 100644 --- a/packages/studio/src/components/common/Modal.tsx +++ b/packages/studio/src/components/common/Modal.tsx @@ -8,7 +8,7 @@ interface ModalProps { title: string; description: string; errorMessage: string; - onClose: () => void; + handleClose: () => void; onSave: (input: string) => boolean; } @@ -28,7 +28,7 @@ export default function Modal({ title, description, errorMessage, - onClose, + handleClose: handleModalClose, onSave, }: ModalProps) { const [isValidInput, setIsValidInput] = useState(false); @@ -37,8 +37,8 @@ export default function Modal({ const handleClose = useCallback(() => { setInputValue(""); setIsValidInput(false); - onClose(); - }, [onClose, setInputValue, setIsValidInput]); + handleModalClose(); + }, [handleModalClose, setInputValue, setIsValidInput]); const handleSave = useCallback(() => { if (onSave(inputValue)) {