Skip to content

Info cards react query #45

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions plugins/info-cards/README.md
Original file line number Diff line number Diff line change
@@ -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).

Expand All @@ -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/).
2 changes: 2 additions & 0 deletions plugins/info-cards/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 15 additions & 6 deletions plugins/info-cards/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ErrorBoundary>
<PluginProvider>
<ChakraProvider
theme={theme}
toastOptions={{ defaultOptions: { position: "top" } }}
>
<PluginRoot />
</ChakraProvider>
<QueryClientProvider client={queryClient}>
<ChakraProvider
theme={theme}
toastOptions={{ defaultOptions: { position: "top" } }}
>
<PluginRoot />
{/* ReactQueryDevTools will only show in dev server */}
<ReactQueryDevtools initialIsOpen={false} />
</ChakraProvider>
</QueryClientProvider>
</PluginProvider>
</ErrorBoundary>
);
Expand Down
14 changes: 12 additions & 2 deletions plugins/info-cards/src/components/InfoRow.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import type { InfoRowI } from "../typings";
import { Box } from "@chakra-ui/react";
import { Box, theme } from "@chakra-ui/react";
import InfoCard from "./InfoCard";

interface InfoRowProps {
infoRow: InfoRowI;
}
export default function InfoRow({ infoRow }: InfoRowProps): JSX.Element {
return (
<Box display={"flex"} gap={4} width={"full"} justifyContent={"center"}>
<Box
display={"flex"}
gap={4}
width={"full"}
justifyContent={"center"}
style={
infoRow.cards.length < 1
? { height: "1px", backgroundColor: theme.colors.gray[100] }
: {}
}
>
{infoRow.cards.map((card) => (
<InfoCard key={card.id} card={card} />
))}
Expand Down
29 changes: 17 additions & 12 deletions plugins/info-cards/src/components/PluginRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,21 @@ 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);
const [infoRows, setInfoRows] = useState<InfoRowI[]>([]);

const {
isLoading: configIsLoading,
pluginConfig,
savePluginConfig,
} = usePluginConfig();
isFetching: configIsFetching,
isMutating: configIsMutating,
entity: pluginConfig,
updateEntity: savePluginConfig,
} = useEntityDescriptor({
entityTag: "info-cards-plugin-config",
});

const toast = useToast();

Expand All @@ -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<void> => {
try {
await savePluginConfig({
savePluginConfig({
...pluginConfig,
info: {
...pluginConfig?.info,
"x-cortex-definition": {
...(pluginConfig?.info?.["x-cortex-definition"] || {}),
infoRows,
infoRows: [...infoRows],
},
},
});
Expand All @@ -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 <Loader />;
}

const toggleEditor = (): void => {
setIsEditorPage((prev) => !prev);
};

return (
<>
{isModified && (
Expand Down
182 changes: 77 additions & 105 deletions plugins/info-cards/src/hooks.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
refreshPluginConfig: () => void;
isFetching: boolean;
error: unknown;
updateEntity: UseMutationResult<any, Error, any>["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<any | null>(null);

useEffect(() => {
const fetchPluginConfig = async (): Promise<void> => {
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,
};
};
Loading