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..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 } from "./types"; +import { FileMetadata, PageState, UserPaths, 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: UserPaths) { this.project = createTsMorphProject(); this.getFileMetadata = this.getFileMetadata.bind(this); this.filepathToFileMetadata = this.setFilepathToFileMetadata(); @@ -55,6 +47,7 @@ export default class ParsingOrchestrator { pageNameToPageState, UUIDToFileMetadata, siteSettings, + userPaths: this.paths, }; } 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..65b61ff0f 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 { UserPaths } 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): UserPaths { 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..dc5fcbf93 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 { UserPaths } from "./UserPaths"; export interface StudioData { pageNameToPageState: Record; UUIDToFileMetadata: Record; siteSettings?: SiteSettings; + userPaths: UserPaths; } diff --git a/packages/studio-plugin/src/types/UserPaths.ts b/packages/studio-plugin/src/types/UserPaths.ts new file mode 100644 index 000000000..404b5ad1e --- /dev/null +++ b/packages/studio-plugin/src/types/UserPaths.ts @@ -0,0 +1,14 @@ +/** + * Absolute paths for files and directories in the user's file system that are + * relevant for Studio. + */ +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. */ + 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-plugin/src/types/index.ts b/packages/studio-plugin/src/types/index.ts index af1090b6f..d0d300437 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 "./UserPaths"; 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/__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/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..80dc943b9 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 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/ActivePagePanel.tsx b/packages/studio/src/components/ActivePagePanel.tsx new file mode 100644 index 000000000..3b39d3d15 --- /dev/null +++ b/packages/studio/src/components/ActivePagePanel.tsx @@ -0,0 +1,67 @@ +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"; +import { useCallback, useMemo } from "react"; + +/** + * 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 ActivePagePanel(): JSX.Element { + const { pages, setActivePageName, activePageName } = useStudioStore( + (store) => { + const { pages, setActivePageName, activePageName } = store.pages; + return { pages, setActivePageName, activePageName }; + } + ); + const pageNames = useMemo(() => Object.keys(pages), [pages]); + + 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 ( +
+
+ Pages + +
+ {renderPageList(pageNames)} + +
Modules
+ +
+ ); +} diff --git a/packages/studio/src/components/AddPageButton.tsx b/packages/studio/src/components/AddPageButton.tsx new file mode 100644 index 000000000..ebee9f74b --- /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 { 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 [errorMessage, setErrorMessage] = + useState("Invalid page name."); + + const handleAddPage = useCallback(() => { + setShowModal(true); + }, [setShowModal]); + + const handleModalClose = useCallback(() => { + setShowModal(false); + }, [setShowModal]); + + 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; + } + }, + [addPage, setErrorMessage] + ); + + return ( + <> + + + + ); +} 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..2a92c0824 --- /dev/null +++ b/packages/studio/src/components/common/Modal.tsx @@ -0,0 +1,111 @@ +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; + title: string; + description: string; + errorMessage: string; + handleClose: () => void; + onSave: (input: string) => boolean; +} + +const customReactModalStyles = { + content: { + top: "50%", + left: "50%", + right: "50%", + bottom: "auto", + marginRight: "-30%", + transform: "translate(-50%, -50%)", + }, +}; + +export default function Modal({ + isOpen, + title, + description, + errorMessage, + handleClose: handleModalClose, + onSave, +}: ModalProps) { + const [isValidInput, setIsValidInput] = useState(false); + const [inputValue, setInputValue] = useState(""); + + const handleClose = useCallback(() => { + setInputValue(""); + setIsValidInput(false); + handleModalClose(); + }, [handleModalClose, 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; + const footerClasses = classNames("mt-2 items-center", { + "flex justify-between": showErrorMessage, + }); + const saveButtonClasses = classNames("ml-2 py-1 px-3 text-white rounded-md", { + "bg-gray-400": !isValidInput, + "bg-blue-600": isValidInput, + }); + + 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..9697da3e7 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.userPaths.pages; + if (!path.isAbsolute(filepath) || !filepath.startsWith(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/__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 cbff170cd..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 }), @@ -25,4 +26,4 @@ export default function mockStoreActiveComponent({ }, }, }); -} \ No newline at end of file +} 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/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(); + }); +}); 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/createPageSlice.activeComponentActions.test.ts b/packages/studio/tests/store/createPageSlice.activeComponentActions.test.ts new file mode 100644 index 000000000..8f7f00e20 --- /dev/null +++ b/packages/studio/tests/store/createPageSlice.activeComponentActions.test.ts @@ -0,0 +1,168 @@ +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"; +import { PageSliceStates } from "../../src/store/models/slices/PageSlice"; + +const pendingChanges: PageSliceStates["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.activePageActions.test.ts b/packages/studio/tests/store/createPageSlice.activePageActions.test.ts new file mode 100644 index 000000000..2e12df2bf --- /dev/null +++ b/packages/studio/tests/store/createPageSlice.activePageActions.test.ts @@ -0,0 +1,270 @@ +import useStudioStore from "../../src/store/useStudioStore"; +import { PageState } from "@yext/studio-plugin"; +import { + PageSliceStates, + PagesRecord, +} from "../../src/store/models/slices/PageSlice"; +import path from "path"; +import { mockPageSliceStates } from "../__utils__/mockPageSliceState"; +import { + searchBarComponent, + resultsComponent, + buttonComponent, +} from "../__fixtures__/componentStates"; + +const pages: PagesRecord = { + universal: { + componentTree: [searchBarComponent], + cssImports: ["index.css"], + filepath: "mock-filepath", + }, + vertical: { + componentTree: [resultsComponent], + cssImports: [], + filepath: "mock-filepath", + }, +}; +const pendingChanges: PageSliceStates["pendingChanges"] = { + pagesToUpdate: new Set(), +}; + +describe("PageSlice", () => { + describe("active page actions", () => { + beforeEach(() => { + mockPageSliceStates({ + pages, + activePageName: "universal", + pendingChanges, + }); + }); + + it("updates activePageName using setActivePageName", () => { + useStudioStore.getState().pages.setActivePageName("vertical"); + const activePageName = useStudioStore.getState().pages.activePageName; + expect(activePageName).toEqual("vertical"); + }); + + it("resets activeComponentUUID when setActivePageName is used", () => { + mockPageSliceStates({ + 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") + .mockImplementation(); + useStudioStore.getState().pages.setActivePageName("location"); + const activePageName = useStudioStore.getState().pages.activePageName; + expect(activePageName).toEqual("universal"); + expect(consoleErrorSpy).toBeCalledWith( + 'Error in setActivePage: Page "location" is not found in Store. Unable to set it as active page.' + ); + }); + + it("updates existing active page's state using setActivePageState", () => { + const newActivePageState: PageState = { + componentTree: [buttonComponent], + cssImports: ["app.css"], + filepath: "mock-filepath", + }; + useStudioStore.getState().pages.setActivePageState(newActivePageState); + const actualPages = useStudioStore.getState().pages.pages; + expect(actualPages).toEqual({ + universal: newActivePageState, + vertical: pages["vertical"], + }); + }); + + 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", () => { + mockPageSliceStates({ + 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" + ); + useStudioStore.getState().pages.setActivePageState(newActivePageState); + expect(useStudioStore.getState().pages.activeComponentUUID).toEqual( + undefined + ); + }); + + it("maintains active component uuid when it's still in page state using setActivePageState", () => { + mockPageSliceStates({ + 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" + ); + useStudioStore.getState().pages.setActivePageState(newActivePageState); + expect(useStudioStore.getState().pages.activeComponentUUID).toEqual( + "searchbar-uuid" + ); + }); + + it("returns active page's state using getActivePageState", () => { + const activePageState = useStudioStore + .getState() + .pages.getActivePageState(); + expect(activePageState).toEqual(pages["universal"]); + }); + + describe("addPage", () => { + const filepath = path.resolve(__dirname, "../__mocks__", "./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, "../__mocks__", "../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, + "../__mocks__", + "./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", () => { + let consoleErrorSpy; + beforeEach(() => { + mockPageSliceStates({ + pages: {}, + activePageName: undefined, + pendingChanges, + }); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + }); + + it("setActivePageState", () => { + useStudioStore.getState().pages.setActivePageState({ + componentTree: [], + cssImports: [], + filepath: "mock-filepath", + }); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Tried to setActivePageState when activePageName was undefined" + ); + }); + + it("getActivePageState", () => { + const nonexistantPage = useStudioStore + .getState() + .pages.getActivePageState(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(0); + expect(nonexistantPage).toBeUndefined(); + }); + + it("getActiveComponentState", () => { + const nonexistantComponentState = useStudioStore + .getState() + .pages.getActiveComponentState(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(0); + expect(nonexistantComponentState).toBeUndefined(); + }); + + it("setActiveComponentProps", () => { + useStudioStore.getState().pages.setActiveComponentProps({}); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Tried to setActiveComponentProps when activePageName was undefined" + ); + }); + }); +}); 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 diff --git a/packages/studio/tests/store/pageSlice.test.ts b/packages/studio/tests/store/pageSlice.test.ts deleted file mode 100644 index 520e9872d..000000000 --- a/packages/studio/tests/store/pageSlice.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -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"; - -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], - cssImports: ["index.css"], - }, - vertical: { - componentTree: [resultsComponent], - cssImports: [], - }, -}; - -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", - }); - }); - - it("updates activePageName using setActivePageName", () => { - useStudioStore.getState().pages.setActivePageName("vertical"); - const activePageName = useStudioStore.getState().pages.activePageName; - expect(activePageName).toEqual("vertical"); - }); - - it("logs an error when using setActivePageName for a page not found in store", () => { - const consoleErrorSpy = jest - .spyOn(global.console, "error") - .mockImplementation(); - useStudioStore.getState().pages.setActivePageName("location"); - const activePageName = useStudioStore.getState().pages.activePageName; - expect(activePageName).toEqual("universal"); - expect(consoleErrorSpy).toBeCalledWith( - 'Error in setActivePage: Page "location" is not found in Store. Unable to set it as active page.' - ); - }); - - it("updates existing active page's state using setActivePageState", () => { - const newActivePageState: PageState = { - componentTree: [buttonComponent], - cssImports: ["app.css"], - }; - useStudioStore.getState().pages.setActivePageState(newActivePageState); - const actualPages = useStudioStore.getState().pages.pages; - expect(actualPages).toEqual({ - universal: newActivePageState, - vertical: pages["vertical"], - }); - }); - - it("resets active component uuid when it's remove from page state using setActivePageState", () => { - setInitialState({ - pages, - activePageName: "universal", - activeComponentUUID: "searchbar-uuid", - }); - const newActivePageState: PageState = { - componentTree: [buttonComponent], - cssImports: ["app.css"], - }; - expect(useStudioStore.getState().pages.activeComponentUUID).toEqual( - "searchbar-uuid" - ); - useStudioStore.getState().pages.setActivePageState(newActivePageState); - expect(useStudioStore.getState().pages.activeComponentUUID).toEqual( - undefined - ); - }); - - it("maintains active component uuid when it's still in page state using setActivePageState", () => { - setInitialState({ - pages, - activePageName: "universal", - activeComponentUUID: "searchbar-uuid", - }); - const newActivePageState: PageState = { - componentTree: [searchBarComponent, buttonComponent], - cssImports: ["app.css"], - }; - expect(useStudioStore.getState().pages.activeComponentUUID).toEqual( - "searchbar-uuid" - ); - useStudioStore.getState().pages.setActivePageState(newActivePageState); - expect(useStudioStore.getState().pages.activeComponentUUID).toEqual( - "searchbar-uuid" - ); - }); - - it("returns active page's state using getActivePageState", () => { - const activePageState = useStudioStore - .getState() - .pages.getActivePageState(); - expect(activePageState).toEqual(pages["universal"]); - }); - }); - - describe("when there is no active page", () => { - let consoleErrorSpy; - beforeEach(() => { - setInitialState({ - pages: {}, - activePageName: undefined, - }); - consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); - }); - - it("setActivePageState", () => { - useStudioStore.getState().pages.setActivePageState({ - componentTree: [], - cssImports: [], - }); - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "Tried to setActivePageState when activePageName was undefined" - ); - }); - - it("getActivePageState", () => { - const nonexistantPage = useStudioStore - .getState() - .pages.getActivePageState(); - expect(consoleErrorSpy).toHaveBeenCalledTimes(0); - expect(nonexistantPage).toBeUndefined(); - }); - - it("getActiveComponentState", () => { - const nonexistantComponentState = useStudioStore - .getState() - .pages.getActiveComponentState(); - expect(consoleErrorSpy).toHaveBeenCalledTimes(0); - expect(nonexistantComponentState).toBeUndefined(); - }); - - it("setActiveComponentProps", () => { - useStudioStore.getState().pages.setActiveComponentProps({}); - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "Tried to setActiveComponentProps when activePageName was undefined" - ); - }); - }); - - describe("active component actions", () => { - beforeEach(() => { - setInitialState({ - pages: { - universal: { - componentTree: [searchBarComponent, resultsComponent], - cssImports: [], - }, - }, - activePageName: "universal", - activeComponentUUID: "results-uuid", - }); - }); - - 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("logs an error when using setActiveComponentProps if the active component is a fragment", () => { - const initialPages = { - universal: { - pageName: "universal", - componentTree: [fragmentComponent, searchBarComponent], - cssImports: [], - }, - }; - setInitialState({ - pages: initialPages, - activePageName: "universal", - activeComponentUUID: fragmentComponent.uuid, - }); - 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: [], - }, - }; - setInitialState({ - pages: initialPages, - activePageName: "universal", - activeComponentUUID: undefined, - }); - 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: [], - }, - }, - activePageName: "universal", - activeComponentUUID: undefined, - }); - const activeComponent = useStudioStore - .getState() - .pages.getActiveComponentState(); - expect(activeComponent).toEqual(undefined); - }); - }); -}); - -function setInitialState(initialState: PageSliceStates): void { - mockStore({ pages: initialState }); -}