diff --git a/plugins/info-cards/.eslintignore b/plugins/info-cards/.eslintignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/plugins/info-cards/.eslintignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/plugins/info-cards/.eslintrc.js b/plugins/info-cards/.eslintrc.js new file mode 100644 index 0000000..3a1250c --- /dev/null +++ b/plugins/info-cards/.eslintrc.js @@ -0,0 +1,30 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: [ + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "standard-with-typescript", + "prettier", + ], + overrides: [], + parserOptions: { + ecmaVersion: "latest", + project: "tsconfig.json", + sourceType: "module", + tsconfigRootDir: __dirname, + }, + plugins: ["react"], + rules: { + // conflicts with no-extra-boolean-cast + "@typescript-eslint/strict-boolean-expressions": "off", + "no-console": ["error", { allow: ["warn", "error"] }], + }, + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/plugins/info-cards/.gitignore b/plugins/info-cards/.gitignore new file mode 100644 index 0000000..4c577a9 --- /dev/null +++ b/plugins/info-cards/.gitignore @@ -0,0 +1,12 @@ +# OSX +*.DS_Store + +# IDEs +.idea +*.iml +.vscode + +# This project +node_modules/ +dist/ +yarn-error.log diff --git a/plugins/info-cards/.prettierignore b/plugins/info-cards/.prettierignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/plugins/info-cards/.prettierignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/plugins/info-cards/README.md b/plugins/info-cards/README.md new file mode 100644 index 0000000..0ba2a0a --- /dev/null +++ b/plugins/info-cards/README.md @@ -0,0 +1,28 @@ +# Example + +Info Cards Plugin is a [Cortex](https://www.cortex.io/) plugin. To see how to run the plugin inside of Cortex, see [our docs](https://docs.cortex.io/docs/plugins). + +### Prerequisites + +Developing and building this plugin requires either [yarn](https://classic.yarnpkg.com/lang/en/docs/install/) or [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm). + +## Getting started + +1. Run `yarn` or `npm install` to download all dependencies +2. Run `yarn build` or `npm run build` to compile the plugin code into `./dist/ui.html` +3. Upload `ui.html` into Cortex on a create or edit plugin page +4. Add or update the code and repeat steps 2-3 as necessary + +### Notable scripts + +The following commands come pre-configured in this repository. You can see all available commands in the `scripts` section of [package.json](./package.json). They can be run with npm via `npm run {script_name}` or with yarn via `yarn {script_name}`, depending on your package manager preference. For instance, the `build` command can be run with `npm run build` or `yarn build`. + +- `build` - compiles the plugin. The compiled code root is `./src/index.tsx` (or as defined by [webpack.config.js](webpack.config.js)) and the output is generated into `dist/ui.html`. +- `test` - runs all tests defined in the repository using [jest](https://jestjs.io/) +- `lint` - runs lint and format checking on the repository using [prettier](https://prettier.io/) and [eslint](https://eslint.org/) +- `lintfix` - runs eslint in fix mode to fix any linting errors that can be fixed automatically +- `formatfix` - runs Prettier in fix mode to fix any formatting errors that can be fixed automatically + +### Available React components + +See available UI components via our [Storybook](https://cortexapps.github.io/plugin-core/). diff --git a/plugins/info-cards/__mocks__/fileMock.js b/plugins/info-cards/__mocks__/fileMock.js new file mode 100644 index 0000000..0a445d0 --- /dev/null +++ b/plugins/info-cards/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = "test-file-stub"; diff --git a/plugins/info-cards/__mocks__/styleMock.js b/plugins/info-cards/__mocks__/styleMock.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/plugins/info-cards/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/plugins/info-cards/babel.config.js b/plugins/info-cards/babel.config.js new file mode 100644 index 0000000..1442fdf --- /dev/null +++ b/plugins/info-cards/babel.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: ["@babel/plugin-syntax-jsx"], + presets: [ + ["@babel/preset-env", { targets: { node: "current" } }], + "@babel/preset-typescript", + ["@babel/preset-react", { runtime: "automatic" }], + ], +}; diff --git a/plugins/info-cards/jest.config.js b/plugins/info-cards/jest.config.js new file mode 100644 index 0000000..db40b4f --- /dev/null +++ b/plugins/info-cards/jest.config.js @@ -0,0 +1,21 @@ +module.exports = { + moduleNameMapper: { + // map static asset imports to a stub file under the assumption they are not important to our tests + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": + "/__mocks__/fileMock.js", + // map style asset imports to a stub file under the assumption they are not important to our tests + "\\.(css|less)$": "/__mocks__/styleMock.js", + "@cortexapps/plugin-core/components": + "/../../node_modules/@cortexapps/plugin-core/dist/components.cjs.js", + "@cortexapps/plugin-core": + "/../../node_modules/@cortexapps/plugin-core/dist/index.cjs.js", + }, + setupFilesAfterEnv: ["/setupTests.ts"], + testEnvironment: "jsdom", + transform: { + "^.+\\.(js|jsx|ts|tsx)$": "babel-jest", + }, + transformIgnorePatterns: [ + "/node_modules/(?!yaml)", // yaml is commonjs evidently + ], +}; diff --git a/plugins/info-cards/package.json b/plugins/info-cards/package.json new file mode 100644 index 0000000..ad4c3a0 --- /dev/null +++ b/plugins/info-cards/package.json @@ -0,0 +1,73 @@ +{ + "name": "info-cards", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.4.9", + "@cortexapps/plugin-core": "^2.1.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@nikolovlazar/chakra-ui-prose": "^1.2.1", + "@uiw/react-codemirror": "^4.23.7", + "dompurify": "^3.2.3", + "framer-motion": "^11.13.5", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "yaml": "^2.6.1" + }, + "devDependencies": { + "@babel/core": "^7.21.3", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/preset-env": "^7.26.0", + "@babel/preset-react": "^7.26.3", + "@babel/preset-typescript": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^14.0.0", + "@types/jest": "^29.5.3", + "@types/react": "^18.0.28", + "@types/react-dom": "^18.0.11", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.55.0", + "babel-jest": "^29.7.0", + "css-loader": "^6.7.3", + "eslint": "^8.0.1", + "eslint-config-prettier": "^8.7.0", + "eslint-config-standard-with-typescript": "^34.0.0", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.6.1", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "html-webpack-plugin": "^5.5.0", + "jest": "^29.6.1", + "jest-environment-jsdom": "^29.5.0", + "npm-run-all": "^4.1.5", + "prettier": "^2.8.4", + "prop-types": "^15.8.1", + "react-dev-utils": "^12.0.1", + "style-loader": "^3.3.1", + "terser-webpack-plugin": "^5.3.7", + "ts-loader": "^9.4.2", + "typescript": "^4.9.5", + "url-loader": "^4.1.1", + "webpack": "^5.76.1", + "webpack-cli": "^5.0.1", + "webpack-dev-server": "^4.15.0" + }, + "scripts": { + "build": "webpack --mode=production", + "clean": "rm -r ./dist", + "dev": "webpack serve --mode=development", + "fix": "run-p formatfix lintfix", + "formatfix": "yarn prettier . --write", + "formatcheck": "yarn prettier . --check", + "lint": "run-p formatcheck lintcheck", + "lintcheck": "yarn eslint src", + "lintfix": "yarn lintcheck --fix", + "test": "jest" + } +} diff --git a/plugins/info-cards/setupTests.ts b/plugins/info-cards/setupTests.ts new file mode 100644 index 0000000..95cd99a --- /dev/null +++ b/plugins/info-cards/setupTests.ts @@ -0,0 +1,57 @@ +import "@testing-library/jest-dom/extend-expect"; + +const mockContext = { + apiBaseUrl: "https://api.cortex.dev", + entity: { + definition: null, + description: null, + groups: null, + name: "Inventory planner", + ownership: { + emails: [ + { + description: null, + email: "nikhil@cortex.io", + inheritance: null, + id: 1, + }, + ], + }, + tag: "inventory-planner", + type: "service", + }, + location: "ENTITY", + user: { + email: "ganesh@cortex.io", + name: "Ganesh Datta", + role: "ADMIN", + }, +}; + +jest.mock("@cortexapps/plugin-core/components", () => { + const originalModule = jest.requireActual( + "@cortexapps/plugin-core/components" + ); + return { + ...originalModule, + usePluginContext: () => { + return mockContext; + }, + PluginProvider: ({ children }) => { + return children; + }, + }; +}); + +jest.mock("@cortexapps/plugin-core", () => { + const originalModule = jest.requireActual("@cortexapps/plugin-core"); + return { + ...originalModule, + CortexApi: { + ...originalModule.CortexApi, + getContext: () => { + return mockContext; + }, + }, + }; +}); diff --git a/plugins/info-cards/src/api/Cortex.ts b/plugins/info-cards/src/api/Cortex.ts new file mode 100644 index 0000000..aac391c --- /dev/null +++ b/plugins/info-cards/src/api/Cortex.ts @@ -0,0 +1,7 @@ +import { CortexApi, type CortexContextResponse } from "@cortexapps/plugin-core"; + +export const getCortexContext = async (): Promise => { + const context = await CortexApi.getContext(); + + return context; +}; diff --git a/plugins/info-cards/src/assets/logo.svg b/plugins/info-cards/src/assets/logo.svg new file mode 100644 index 0000000..2abafcd --- /dev/null +++ b/plugins/info-cards/src/assets/logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/plugins/info-cards/src/baseStyles.css b/plugins/info-cards/src/baseStyles.css new file mode 100644 index 0000000..e9c5e5f --- /dev/null +++ b/plugins/info-cards/src/baseStyles.css @@ -0,0 +1,3 @@ +body { + font: 14px sans-serif; +} diff --git a/plugins/info-cards/src/components/App.test.tsx b/plugins/info-cards/src/components/App.test.tsx new file mode 100644 index 0000000..2ab0045 --- /dev/null +++ b/plugins/info-cards/src/components/App.test.tsx @@ -0,0 +1,108 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import fetchMock from "jest-fetch-mock"; +import App from "./App"; +import { act } from "react-dom/test-utils"; + +describe("App", () => { + beforeEach(() => { + fetchMock.enableMocks(); + fetchMock.resetMocks(); + }); + + it("shows instructions when it's not configured", async () => { + fetchMock.mockResponse(async (req) => { + if ( + req.url === + "https://api.cortex.dev/catalog/info-cards-plugin-config/openapi" + ) { + return { + status: 200, + body: JSON.stringify({ + openapi: "3.0.1", + info: { + title: "Info Cards Plugin", + description: "it is an awesome plugin", + "x-cortex-tag": "info-cards-plugin", + "x-cortex-type": "plugin-configuration", + }, + }), + }; + } + return { + status: 404, + }; + }); + act(() => { + render(); + }); + + await waitFor(() => { + expect( + screen.queryByText( + /To get started, please configure the layout and cards in the editor/ + ) + ).toBeInTheDocument(); + }); + }); + + it("shows content when it's configured", async () => { + fetchMock.mockResponse(async (req) => { + if ( + req.url === + "https://api.cortex.dev/catalog/info-cards-plugin-config/openapi" + ) { + return { + status: 200, + body: JSON.stringify({ + openapi: "3.0.1", + info: { + title: "Info Cards Plugin", + description: "it is an awesome plugin", + "x-cortex-tag": "info-cards-plugin", + "x-cortex-type": "plugin-configuration", + "x-cortex-definition": { + infoRows: [ + { + id: 1734449412873, + cards: [ + { + id: 1734449414053, + rowId: 1734449412873, + title: "meeps", + contentHTML: "

meeps

", + contentType: "HTML", + }, + { + id: 1734459065666, + rowId: 1734449412873, + title: "woot", + contentIFrameURL: "https://example.com", + contentType: "IFrameURL", + }, + ], + }, + ], + }, + }, + }), + }; + } + return { + status: 404, + }; + }); + + act(() => { + render(); + }); + + await waitFor(() => { + const heading = screen.getByRole("heading", { level: 1, name: /meeps/i }); + expect(heading).toBeInTheDocument(); + + const iframe = screen.getByTitle("woot"); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute("src", "https://example.com"); + }); + }); +}); diff --git a/plugins/info-cards/src/components/App.tsx b/plugins/info-cards/src/components/App.tsx new file mode 100644 index 0000000..8389cad --- /dev/null +++ b/plugins/info-cards/src/components/App.tsx @@ -0,0 +1,25 @@ +import type React from "react"; +import { PluginProvider } from "@cortexapps/plugin-core/components"; +import { ChakraProvider } from "@chakra-ui/react"; + +import "../baseStyles.css"; +import ErrorBoundary from "./ErrorBoundary"; +import PluginRoot from "./PluginRoot"; +import theme from "./ui/theme"; + +const App: React.FC = () => { + return ( + + + + + + + + ); +}; + +export default App; diff --git a/plugins/info-cards/src/components/CancelEditModal.tsx b/plugins/info-cards/src/components/CancelEditModal.tsx new file mode 100644 index 0000000..2ee8db8 --- /dev/null +++ b/plugins/info-cards/src/components/CancelEditModal.tsx @@ -0,0 +1,74 @@ +import { useRef } from "react"; + +import { + Button, + useDisclosure, + AlertDialog, + AlertDialogBody, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogContent, + AlertDialogOverlay, + // AlertDialogCloseButton, +} from "@chakra-ui/react"; + +interface CancelEditModalProps { + isModified: boolean; + toggleEditor: () => void; +} + +const CancelEditModal: React.FC = ({ + isModified, + toggleEditor, +}) => { + const { isOpen, onOpen, onClose } = useDisclosure(); + const cancelRef = useRef(null); + + return ( + <> + + + + + + + Discard Changes? + + + + You have unsaved changes. Are you sure you want to close the + editor? + + + + + + + + + + + ); +}; + +export default CancelEditModal; diff --git a/plugins/info-cards/src/components/ConfirmationModal.tsx b/plugins/info-cards/src/components/ConfirmationModal.tsx new file mode 100644 index 0000000..3e9e898 --- /dev/null +++ b/plugins/info-cards/src/components/ConfirmationModal.tsx @@ -0,0 +1,54 @@ +import type React from "react"; +import { + Button, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + Heading, +} from "@chakra-ui/react"; + +interface DeleteInfoRowConfirmationModalProps extends React.PropsWithChildren { + title?: string; + isOpen: boolean; + onClose: () => void; + handleAction: () => void; + actionButtonText?: string; + actionButtonColorScheme?: string; +} + +const ConfirmationModal: React.FC = ({ + isOpen, + onClose, + handleAction, + actionButtonText = "Confirm", + title = "Confirmation Required", + actionButtonColorScheme = "purple", + children, +}) => { + return ( + + + + + {title} + + + {children} + + + + + + + ); +}; + +export default ConfirmationModal; diff --git a/plugins/info-cards/src/components/ErrorBoundary.tsx b/plugins/info-cards/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..f862bd3 --- /dev/null +++ b/plugins/info-cards/src/components/ErrorBoundary.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +interface ErrorBoundaryProps extends React.PropsWithChildren {} + +interface ErrorBoundaryState { + hasError: boolean; +} + +class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + public state: ErrorBoundaryState = { + hasError: false, + }; + + public static getDerivedStateFromError(_: Error): ErrorBoundaryState { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error("Uncaught error:", error, errorInfo); + } + + public render(): React.ReactNode { + if (this.state.hasError) { + return ( +

+ Oops! There was a runtime error. See the console for more details. +

+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/plugins/info-cards/src/components/InfoCard.tsx b/plugins/info-cards/src/components/InfoCard.tsx new file mode 100644 index 0000000..8d02309 --- /dev/null +++ b/plugins/info-cards/src/components/InfoCard.tsx @@ -0,0 +1,63 @@ +import type { InfoCardI } from "../typings"; +import { Box, Card, Heading } from "@chakra-ui/react"; +import DOMPurify from "dompurify"; +import { Prose } from "@nikolovlazar/chakra-ui-prose"; + +interface InfoCardProps { + card: InfoCardI; +} + +export default function InfoCard({ card }: InfoCardProps): JSX.Element { + let sanitizedContent = ""; + if (card.contentType === "HTML" && card.contentHTML) { + sanitizedContent = DOMPurify.sanitize(card.contentHTML); + } + + return ( + + {card.title && card.title.length > 0 && ( + + {card.title} + + )} + + {card.contentType === "IFrameURL" && ( + +