diff --git a/plugins/info-cards/README.md b/plugins/info-cards/README.md index 0ba2a0a..1c02f1a 100644 --- a/plugins/info-cards/README.md +++ b/plugins/info-cards/README.md @@ -1,4 +1,27 @@ -# Example +# Info Cards Plugin + +The Info Cards plugin provides an easy way to display useful links and information in the global context, with a user-friendly layout editor. + +## Installation + +Install the plugin via the Cortex Plugins Marketplace or manually by adding it to your Cortex instance using the notes under Manual installation below. + +## Configuration + +No manual configuration is required after installation. + +### Saving and Editing Layouts + +- When a card layout is saved for the first time, it will be stored in an entity with the tag `info-cards-plugin-config`. +- To edit an existing layout, click the small pencil icon in the top right corner of the plugin area. + +## Usage + +1. Open the Info Cards plugin from the global context. +2. Add or edit cards using the layout editor. +3. Save your changes to persist the layout. + +# Manual installation 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). @@ -22,7 +45,3 @@ The following commands come pre-configured in this repository. You can see all a - `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/package.json b/plugins/info-cards/package.json index ad4c3a0..6ca5fe2 100644 --- a/plugins/info-cards/package.json +++ b/plugins/info-cards/package.json @@ -11,6 +11,8 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@nikolovlazar/chakra-ui-prose": "^1.2.1", + "@tanstack/react-query": "^5.65.1", + "@tanstack/react-query-devtools": "^5.65.1", "@uiw/react-codemirror": "^4.23.7", "dompurify": "^3.2.3", "framer-motion": "^11.13.5", diff --git a/plugins/info-cards/src/components/App.tsx b/plugins/info-cards/src/components/App.tsx index 8389cad..30ed734 100644 --- a/plugins/info-cards/src/components/App.tsx +++ b/plugins/info-cards/src/components/App.tsx @@ -2,21 +2,30 @@ import type React from "react"; import { PluginProvider } from "@cortexapps/plugin-core/components"; import { ChakraProvider } from "@chakra-ui/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; + import "../baseStyles.css"; import ErrorBoundary from "./ErrorBoundary"; import PluginRoot from "./PluginRoot"; import theme from "./ui/theme"; const App: React.FC = () => { + const queryClient = new QueryClient(); return ( - - - + + + + {/* ReactQueryDevTools will only show in dev server */} + + + ); diff --git a/plugins/info-cards/src/components/InfoRow.tsx b/plugins/info-cards/src/components/InfoRow.tsx index 5554e34..323064f 100644 --- a/plugins/info-cards/src/components/InfoRow.tsx +++ b/plugins/info-cards/src/components/InfoRow.tsx @@ -1,5 +1,5 @@ import type { InfoRowI } from "../typings"; -import { Box } from "@chakra-ui/react"; +import { Box, theme } from "@chakra-ui/react"; import InfoCard from "./InfoCard"; interface InfoRowProps { @@ -7,7 +7,17 @@ interface InfoRowProps { } export default function InfoRow({ infoRow }: InfoRowProps): JSX.Element { return ( - + {infoRow.cards.map((card) => ( ))} diff --git a/plugins/info-cards/src/components/PluginRoot.tsx b/plugins/info-cards/src/components/PluginRoot.tsx index 38ea87b..a739d5a 100644 --- a/plugins/info-cards/src/components/PluginRoot.tsx +++ b/plugins/info-cards/src/components/PluginRoot.tsx @@ -9,7 +9,7 @@ import type { InfoRowI } from "../typings"; import LandingPage from "./LandingPage"; import LayoutBuilder from "./LayoutBuilder"; -import { usePluginConfig } from "../hooks"; +import { useEntityDescriptor } from "../hooks"; export default function PluginRoot(): JSX.Element { const [isEditorPage, setIsEditorPage] = useState(false); @@ -17,9 +17,13 @@ export default function PluginRoot(): JSX.Element { const { isLoading: configIsLoading, - pluginConfig, - savePluginConfig, - } = usePluginConfig(); + isFetching: configIsFetching, + isMutating: configIsMutating, + entity: pluginConfig, + updateEntity: savePluginConfig, + } = useEntityDescriptor({ + entityTag: "info-cards-plugin-config", + }); const toast = useToast(); @@ -37,16 +41,21 @@ export default function PluginRoot(): JSX.Element { return Boolean(isModified); }, [infoRows, pluginConfig]); + const toggleEditor = useCallback(() => { + setInfoRows(pluginConfig?.info?.["x-cortex-definition"]?.infoRows || []); + setIsEditorPage((prev) => !prev); + }, [pluginConfig, setInfoRows, setIsEditorPage]); + const handleSubmit = useCallback(() => { const doSave = async (): Promise => { try { - await savePluginConfig({ + savePluginConfig({ ...pluginConfig, info: { ...pluginConfig?.info, "x-cortex-definition": { ...(pluginConfig?.info?.["x-cortex-definition"] || {}), - infoRows, + infoRows: [...infoRows], }, }, }); @@ -71,16 +80,12 @@ export default function PluginRoot(): JSX.Element { }; void doSave(); - }, [infoRows, pluginConfig, savePluginConfig, toast]); + }, [infoRows, pluginConfig, savePluginConfig, toast, toggleEditor]); - if (configIsLoading) { + if (configIsLoading || configIsFetching || configIsMutating) { return ; } - const toggleEditor = (): void => { - setIsEditorPage((prev) => !prev); - }; - return ( <> {isModified && ( diff --git a/plugins/info-cards/src/hooks.tsx b/plugins/info-cards/src/hooks.tsx index 6075cf1..c50f4b5 100644 --- a/plugins/info-cards/src/hooks.tsx +++ b/plugins/info-cards/src/hooks.tsx @@ -1,126 +1,98 @@ -import { useCallback, useEffect, useState } from "react"; -import YAML from "yaml"; +import { useQueryClient, useQuery, useMutation } from "@tanstack/react-query"; + +import type { UseMutationResult } from "@tanstack/react-query"; import { usePluginContext } from "@cortexapps/plugin-core/components"; -export interface UsePluginConfigReturn { +export interface UseEntityDescriptorProps { + entityTag: string; + mutationMethod?: "PATCH" | "POST"; + onMutateSuccess?: (data: any, variables: any, context?: any) => void; + onMutateError?: (error: Error, variables: any, context?: any) => void; + onMutateSettled?: ( + data: any, + error: Error, + variables: any, + context?: any + ) => void; + onMutate?: (variables: any) => void; +} + +export interface UseEntityDescriptorReturn { + entity: any; isLoading: boolean; - pluginConfig: any | null; - savePluginConfig: (config: any) => Promise; - refreshPluginConfig: () => void; + isFetching: boolean; + error: unknown; + updateEntity: UseMutationResult["mutate"]; + isMutating: boolean; } -export const usePluginConfig = (): UsePluginConfigReturn => { +export const useEntityDescriptor = ({ + entityTag, + mutationMethod = "POST", + onMutateSuccess = () => {}, + onMutateError = () => {}, + onMutateSettled = () => {}, + onMutate = () => {}, +}: UseEntityDescriptorProps): UseEntityDescriptorReturn => { const { apiBaseUrl } = usePluginContext(); - const [refreshCounter, setRefreshCounter] = useState(0); - const [isLoading, setIsLoading] = useState(true); - const [pluginConfig, setPluginConfig] = useState(null); - - useEffect(() => { - const fetchPluginConfig = async (): Promise => { - setIsLoading(true); - setPluginConfig(null); - try { - const response = await fetch( - `${apiBaseUrl}/catalog/info-cards-plugin-config/openapi` - ); - const config = await response.json(); - setPluginConfig(config); - } catch (error) { - console.error(error); - } finally { - setIsLoading(false); - } - }; - void fetchPluginConfig(); - }, [apiBaseUrl, refreshCounter]); - - const savePluginConfig = useCallback( - async (config: any) => { - let existingConfig: any; + const queryClient = useQueryClient(); - // Fetch existing config if it exists - try { - const r = await fetch( - `${apiBaseUrl}/catalog/info-cards-plugin-config/openapi` - ); - if (!r.ok) { - throw new Error("Failed to fetch existing config"); - } - existingConfig = await r.json(); - } catch (error) {} + const query = useQuery({ + queryKey: ["entityDescriptor", entityTag], + queryFn: async () => { + const response = await fetch( + `${apiBaseUrl}/catalog/${entityTag}/openapi` + ); + return await response.json(); + }, + enabled: !!apiBaseUrl, + retry: false, + }); - // Validate the passed in config - if (!config.info?.["x-cortex-definition"]?.infoRows) { - // this should never happen since the plugin should always pass in a valid config - console.error("Invalid config", config); - throw new Error("Invalid config"); + const mutation = useMutation({ + mutationFn: async (data: any) => { + // throw if the data is not an object or data.info is not an object + if (typeof data !== "object" || typeof data.info !== "object") { + throw new Error("Invalid data format"); } - - config.info["x-cortex-tag"] = "info-cards-plugin-config"; - config.info.title = "Info Cards Plugin Configuration"; - config.openapi = "3.0.1"; - - // Preserve the existing x-cortex-type if it exists - config.info["x-cortex-type"] = - existingConfig?.info?.["x-cortex-type"] || "plugin-configuration"; - - // See if the entity type exists, if not create it - try { - const r = await fetch( - `${apiBaseUrl}/catalog/definitions/${ - config.info["x-cortex-type"] as string - }` - ); - if (!r.ok) { - throw new Error("Failed to fetch existing entity type"); - } - } catch (error) { - // Create the entity type - const entityTypeBody = { - iconTag: "bucket", - name: "Plugin Configuration", - schema: { properties: {}, required: [] }, - type: config.info["x-cortex-type"], - }; - const entityTypeResponse = await fetch( - `${apiBaseUrl}/catalog/definitions`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(entityTypeBody), - } - ); - if (!entityTypeResponse.ok) { - throw new Error("Failed to create entity type"); - } + // make sure basic info is set + data.openapi = "3.0.1"; + // don't allow changing the tag + data.info["x-cortex-tag"] = entityTag; + // set a title if it's not set + if (!data.info.title) { + data.info.title = entityTag + .replace(/-/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()); } - - // Save the new config - await fetch(`${apiBaseUrl}/open-api`, { - method: "POST", + const response = await fetch(`${apiBaseUrl}/open-api`, { + method: mutationMethod, headers: { "Content-Type": "application/openapi;charset=utf-8", }, - body: YAML.stringify(config), + body: JSON.stringify(data), }); - - setRefreshCounter((prev) => prev + 1); + return await response.json(); }, - [apiBaseUrl] - ); - - const refreshPluginConfig = useCallback(() => { - setRefreshCounter((prev) => prev + 1); - }, []); + onMutate, + onError: onMutateError, + onSettled: onMutateSettled, + onSuccess: (data, variables, context) => { + void queryClient.invalidateQueries({ + queryKey: ["entityDescriptor", entityTag], + }); + onMutateSuccess(data, variables, context); + }, + }); return { - isLoading, - pluginConfig, - savePluginConfig, - refreshPluginConfig, + entity: query.data, + isLoading: query.isLoading, + isFetching: query.isFetching, + error: query.error, + updateEntity: mutation.mutate, + isMutating: mutation.isPending, }; }; diff --git a/yarn.lock b/yarn.lock index 7a815c1..a7af243 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3742,6 +3742,30 @@ resolved "https://registry.npmjs.org/@swc/types/-/types-0.1.4.tgz" integrity sha512-z/G02d+59gyyUb7KYhKi9jOhicek6QD2oMaotUyG+lUkybpXoV49dY9bj7Ah5Q+y7knK2jU67UTX9FyfGzaxQg== +"@tanstack/query-core@5.65.0": + version "5.65.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.65.0.tgz#6b7c7087a36867361535b613ff39b633808052fd" + integrity sha512-Bnnq/1axf00r2grRT6gUyIkZRKzhHs+p4DijrCQ3wMlA3D3TTT71gtaSLtqnzGddj73/7X5JDGyjiSLdjvQN4w== + +"@tanstack/query-devtools@5.65.0": + version "5.65.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.65.0.tgz#37da5e911543b4f6d98b9a04369eab0de6044ba1" + integrity sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg== + +"@tanstack/react-query-devtools@^5.65.1": + version "5.65.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.65.1.tgz#375ba44398076436f9c82f5ae46f0a3d47397db6" + integrity sha512-PKUBz7+FAP3eI1zoWrP5vxNQXs+elPz3u/3cILGhNZl2dufgbU9OJRpbC+BAptLXTsGxTwkAlrWBIZbD/c7CDw== + dependencies: + "@tanstack/query-devtools" "5.65.0" + +"@tanstack/react-query@^5.65.1": + version "5.65.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.65.1.tgz#84f120f7cf7398626c991ccef4557bd4d780fe37" + integrity sha512-BSpjo4RQdJ75Mw3pqM1AJYNhanNxJE3ct7RmCZUAv9cUJg/Qmonzc/Xy2kKXeQA1InuKATSuc6pOZciWOF8TYQ== + dependencies: + "@tanstack/query-core" "5.65.0" + "@testing-library/dom@^9.0.0": version "9.3.1" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz"